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>
)
}