💻 源码
import { useCallback, useEffect, useMemo, useState } from 'react'
import { ConceptPanel } from './components/ConceptPanel'
import { ComparePanel } from './components/ComparePanel'
import { ReActChain } from './components/ReActChain'
import { DEMO_QUESTIONS } from './data/questions'
import { plainChat, DashScopeError } from './lib/dashscope'
import { groupStepsIntoRounds } from './lib/rounds'
import { runReAct } from './lib/react-loop'
import type { ReActRound, ReActStep } from './types'

const API_KEY_STORAGE = 'dashscope_api_key'

export default function App() {
  const [apiKey, setApiKey] = useState('')
  const [question, setQuestion] = useState(DEMO_QUESTIONS[0].text)
  const [reactSteps, setReactSteps] = useState<ReActStep[]>([])
  const [plainSteps, setPlainSteps] = useState<ReActStep[]>([])
  const [plainAnswer, setPlainAnswer] = useState('')
  const [reactAnswer, setReactAnswer] = useState('')
  const [toolCallCount, setToolCallCount] = useState(0)
  const [error, setError] = useState('')
  const [reactLoading, setReactLoading] = useState(false)
  const [plainLoading, setPlainLoading] = useState(false)
  const [compareMode, setCompareMode] = useState(true)

  const reactRounds: ReActRound[] = useMemo(
    () => groupStepsIntoRounds(reactSteps),
    [reactSteps],
  )

  const activeQuestion = useMemo(
    () => DEMO_QUESTIONS.find((q) => q.text === question),
    [question],
  )

  useEffect(() => {
    const saved = sessionStorage.getItem(API_KEY_STORAGE)
    if (saved) setApiKey(saved)
  }, [])

  const saveApiKey = (key: string) => {
    setApiKey(key)
    sessionStorage.setItem(API_KEY_STORAGE, key)
  }

  const runDemo = useCallback(async () => {
    if (!apiKey.trim()) {
      setError('请先输入 DashScope API Key')
      return
    }
    if (!question.trim()) {
      setError('请输入问题')
      return
    }

    setError('')
    setReactSteps([])
    setPlainSteps([])
    setPlainAnswer('')
    setReactAnswer('')
    setToolCallCount(0)

    setReactLoading(true)
    if (compareMode) setPlainLoading(true)

    const q = question.trim()
    const key = apiKey.trim()

    const reactPromise = runReAct(key, q, (step) => {
      setReactSteps((prev) => [...prev, step])
    })

    const plainPromise = compareMode
      ? plainChat(key, q, (step) => {
          setPlainSteps((prev) => [...prev, step])
        })
      : Promise.resolve({ steps: [], finalAnswer: '' })

    try {
      const [reactResult, plainResult] = await Promise.all([reactPromise, plainPromise])
      setReactAnswer(reactResult.finalAnswer)
      setToolCallCount(reactResult.toolCallCount)
      if (compareMode) {
        setPlainAnswer(plainResult.finalAnswer)
        setPlainSteps(plainResult.steps)
      }
    } catch (err) {
      const msg = err instanceof DashScopeError ? err.message : '运行失败,请检查网络和 API Key'
      setError(msg)
    } finally {
      setReactLoading(false)
      setPlainLoading(false)
    }
  }, [apiKey, question, compareMode])

  return (
    <div className="app">
      <header className="header">
        <div className="header-inner">
          <div className="header-brand">
            <span className="logo">ReAct</span>
            <div>
              <h1>大模型推理与行动演示</h1>
              <p>Thought → Action → Observation → Answer</p>
            </div>
          </div>
          <div className="api-key-box">
            <label htmlFor="api-key">DashScope API Key</label>
            <input
              id="api-key"
              type="password"
              placeholder="sk-xxxxxxxx(仅保存在浏览器会话中)"
              value={apiKey}
              onChange={(e) => saveApiKey(e.target.value)}
            />
          </div>
        </div>
      </header>

      <main className="main">
        <aside className="sidebar">
          <ConceptPanel />
        </aside>

        <section className="workspace">
          <div className="control-panel">
            <h2>互动演示</h2>

            <div className="preset-questions">
              <span className="label">示例问题(推荐先选带「需工具」的)</span>
              <div className="preset-grid">
                {DEMO_QUESTIONS.map((q) => (
                  <button
                    key={q.id}
                    type="button"
                    className={`preset-btn ${question === q.text ? 'active' : ''}`}
                    onClick={() => setQuestion(q.text)}
                    title={q.hint}
                  >
                    {q.text}
                    {q.needsTool && <span className="tag">需工具</span>}
                  </button>
                ))}
              </div>
            </div>

            <div className="question-input">
              <label htmlFor="question">自定义问题</label>
              <textarea
                id="question"
                rows={2}
                value={question}
                onChange={(e) => setQuestion(e.target.value)}
                placeholder="输入你的问题…"
              />
            </div>

            <div className="control-actions">
              <label className="toggle">
                <input
                  type="checkbox"
                  checked={compareMode}
                  onChange={(e) => setCompareMode(e.target.checked)}
                />
                对比模式(左右并列展示过程差异)
              </label>
              <button
                type="button"
                className="run-btn"
                onClick={runDemo}
                disabled={reactLoading || plainLoading}
              >
                {reactLoading ? '推理中…' : '▶ 运行演示'}
              </button>
            </div>

            {error && <div className="error-banner">{error}</div>}
          </div>

          {compareMode ? (
            <ComparePanel
              plainSteps={plainSteps}
              reactRounds={reactRounds}
              reactSteps={reactSteps}
              plainAnswer={plainAnswer}
              reactAnswer={reactAnswer}
              toolCallCount={toolCallCount}
              plainLoading={plainLoading}
              reactLoading={reactLoading}
              needsTool={activeQuestion?.needsTool}
            />
          ) : (
            <div className="compare-section solo">
              <div className="compare-card compare-with">
                <div className="compare-header">
                  <span className="compare-badge badge-ok">ReAct + 工具</span>
                  <h3>ReAct 推理链(实时)· {toolCallCount} 次工具调用</h3>
                </div>
                <ReActChain
                  rounds={reactRounds}
                  inputStep={reactSteps.find((s) => s.type === 'input')}
                  answerStep={reactSteps.find((s) => s.type === 'answer')}
                  loading={reactLoading}
                />
              </div>
            </div>
          )}
        </section>
      </main>

      <footer className="footer">
        <p>教学演示 · DashScope qwen-plus · 工具数据来自 Open-Meteo / Frankfurter</p>
      </footer>
    </div>
  )
}