|
|
@@ -4,7 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
|
import {
|
|
|
ArrowLeft, Send, Square, ChevronDown, ChevronRight,
|
|
|
Bot, User, Clock, Brain, Folder, Download, Trash2, X,
|
|
|
- Save, RotateCcw, History, Key, BookMarked,
|
|
|
+ Save, RotateCcw, History, Key, BookMarked, LayoutDashboard,
|
|
|
} from 'lucide-react'
|
|
|
import { agentsApi, type WorkspaceFile, type MemoryEntry, type CoreMemory, type RecallEntry } from '../api/agents'
|
|
|
import { knowledgeApi } from '../api/knowledge'
|
|
|
@@ -18,8 +18,9 @@ type Role = 'user' | 'assistant'
|
|
|
type MsgStatus = 'streaming' | 'done' | 'error'
|
|
|
|
|
|
interface ThinkStep {
|
|
|
- type: 'think' | 'tool_call' | 'tool_result' | 'error'
|
|
|
+ type: 'think' | 'tool_call' | 'tool_result' | 'error' | 'think_chunk' | 'think_start'
|
|
|
content: string
|
|
|
+ tool?: string
|
|
|
}
|
|
|
|
|
|
interface Message {
|
|
|
@@ -35,9 +36,17 @@ interface Message {
|
|
|
// ── Step block ────────────────────────────────────────────────────
|
|
|
|
|
|
function StepBlock({ step }: { step: ThinkStep }) {
|
|
|
- const [open, setOpen] = useState(false)
|
|
|
- const icons = { think: '💭', tool_call: '🔧', tool_result: '✅', error: '❌' }
|
|
|
- const labels = { think: '推理', tool_call: '工具调用', tool_result: '工具结果', error: '错误' }
|
|
|
+ const [open, setOpen] = useState(step.type === 'think') // think blocks open by default
|
|
|
+ const icons: Record<string, string> = {
|
|
|
+ think: '💭', tool_call: '🔧', tool_result: '✅', error: '❌', think_chunk: '✨',
|
|
|
+ }
|
|
|
+ const labels: Record<string, string> = {
|
|
|
+ think: '推理过程', tool_call: '工具调用', tool_result: '工具结果', error: '错误', think_chunk: '流式输出',
|
|
|
+ }
|
|
|
+ // For tool events show tool name in header
|
|
|
+ const headerLabel = step.tool
|
|
|
+ ? `${labels[step.type]} · ${step.tool}`
|
|
|
+ : labels[step.type]
|
|
|
|
|
|
return (
|
|
|
<div className="border border-gray-100 rounded-lg overflow-hidden text-xs">
|
|
|
@@ -45,12 +54,12 @@ function StepBlock({ step }: { step: ThinkStep }) {
|
|
|
onClick={() => setOpen(o => !o)}
|
|
|
className="flex items-center gap-2 w-full px-3 py-2 bg-gray-50 hover:bg-gray-100 text-gray-600 transition-colors"
|
|
|
>
|
|
|
- <span>{icons[step.type]}</span>
|
|
|
- <span className="font-medium">{labels[step.type]}</span>
|
|
|
+ <span>{icons[step.type] ?? '📌'}</span>
|
|
|
+ <span className="font-medium">{headerLabel}</span>
|
|
|
{open ? <ChevronDown size={12} className="ml-auto" /> : <ChevronRight size={12} className="ml-auto" />}
|
|
|
</button>
|
|
|
{open && (
|
|
|
- <pre className="px-3 py-2 text-gray-700 whitespace-pre-wrap break-words font-mono leading-relaxed bg-white max-h-48 overflow-auto">
|
|
|
+ <pre className="px-3 py-2 text-gray-700 whitespace-pre-wrap break-words font-mono leading-relaxed bg-white max-h-64 overflow-auto">
|
|
|
{step.content}
|
|
|
</pre>
|
|
|
)}
|
|
|
@@ -130,7 +139,13 @@ function WorkspacePanel({ agentId, runId, apiKey }: { agentId: string; runId: st
|
|
|
|
|
|
// ── Message bubble ────────────────────────────────────────────────
|
|
|
|
|
|
-function MessageBubble({ msg, agentId, apiKey }: { msg: Message; agentId: string; apiKey: string }) {
|
|
|
+function MessageBubble({
|
|
|
+ msg, agentId, apiKey,
|
|
|
+ onContinue,
|
|
|
+}: {
|
|
|
+ msg: Message; agentId: string; apiKey: string
|
|
|
+ onContinue?: (runId: string, workspacePath: string) => void
|
|
|
+}) {
|
|
|
const isUser = msg.role === 'user'
|
|
|
|
|
|
return (
|
|
|
@@ -165,9 +180,22 @@ function MessageBubble({ msg, agentId, apiKey }: { msg: Message; agentId: string
|
|
|
</div>
|
|
|
)}
|
|
|
|
|
|
- {/* Workspace file panel — appears below assistant messages with workspace */}
|
|
|
+ {/* Workspace file panel + Continue button */}
|
|
|
{!isUser && msg.status === 'done' && msg.runId && (
|
|
|
- <WorkspacePanel agentId={agentId} runId={msg.runId} apiKey={apiKey} />
|
|
|
+ <div className="space-y-1 w-full">
|
|
|
+ <WorkspacePanel agentId={agentId} runId={msg.runId} apiKey={apiKey} />
|
|
|
+ {onContinue && (
|
|
|
+ <button
|
|
|
+ onClick={() => onContinue(msg.runId!, msg.workspacePath ?? '')}
|
|
|
+ className="flex items-center gap-1.5 text-xs text-indigo-600 hover:text-indigo-800 bg-indigo-50 hover:bg-indigo-100 border border-indigo-200 rounded-lg px-3 py-1.5 transition-colors"
|
|
|
+ title={`在此 run 的工作区基础上继续提问\n${msg.workspacePath ?? ''}`}
|
|
|
+ >
|
|
|
+ <RotateCcw size={11} />
|
|
|
+ 继续此 run
|
|
|
+ <span className="text-indigo-400 font-mono">{msg.runId?.slice(-8)}</span>
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
)}
|
|
|
</div>
|
|
|
</div>
|
|
|
@@ -532,10 +560,54 @@ export default function Chat() {
|
|
|
ta.style.height = `${Math.min(ta.scrollHeight, 160)}px`
|
|
|
}, [input])
|
|
|
|
|
|
+ // Track which run to continue from (useRef avoids stale-closure in useCallback)
|
|
|
+ const [continueFromRunId, setContinueFromRunId] = useState<string | null>(null)
|
|
|
+ const [continueFromPath, setContinueFromPath] = useState<string | null>(null)
|
|
|
+ const continueRunIdRef = useRef<string | null>(null)
|
|
|
+ const continuePathRef = useRef<string | null>(null)
|
|
|
+
|
|
|
+ // Mode selector for continue-this-run flows.
|
|
|
+ // iterate (default): full pipeline, new cycle_N+1
|
|
|
+ // edit: call ONE sub-agent on the same cycle
|
|
|
+ // chat: read-only Q&A, no sub-agents
|
|
|
+ type Mode = 'iterate' | 'edit' | 'chat'
|
|
|
+ const [mode, setMode] = useState<Mode>('iterate')
|
|
|
+ const [targetSubagent, setTargetSubagent] = useState<string>('')
|
|
|
+ const modeRef = useRef<Mode>('iterate')
|
|
|
+ const targetSubagentRef = useRef<string>('')
|
|
|
+
|
|
|
+ // Available sub-agents read straight from the agent's config — backend
|
|
|
+ // accepts both camelCase `subAgents` and snake_case `sub_agents`.
|
|
|
+ const subAgents: string[] = (() => {
|
|
|
+ const cfg = (agent?.config ?? {}) as Record<string, unknown>
|
|
|
+ const dict = (cfg.subAgents ?? cfg.sub_agents ?? {}) as Record<string, { tool?: string; tool_name?: string }>
|
|
|
+ const out: string[] = []
|
|
|
+ for (const [name, def] of Object.entries(dict)) {
|
|
|
+ const toolName = (def?.tool || def?.tool_name) ?? `call_${name.replace('-agent', '')}`
|
|
|
+ if (!out.includes(toolName)) out.push(toolName)
|
|
|
+ }
|
|
|
+ return out
|
|
|
+ })()
|
|
|
+
|
|
|
const sendMessage = useCallback(async () => {
|
|
|
if (!input.trim() || streaming || !agentId) return
|
|
|
|
|
|
const userText = input.trim()
|
|
|
+ // Read from refs — always current, no stale-closure issue
|
|
|
+ const ctxRunId = continueRunIdRef.current
|
|
|
+ const ctxPath = continuePathRef.current
|
|
|
+ const ctxMode = modeRef.current
|
|
|
+ const ctxTarget = targetSubagentRef.current
|
|
|
+ // Clear continue context after capturing
|
|
|
+ continueRunIdRef.current = null
|
|
|
+ continuePathRef.current = null
|
|
|
+ modeRef.current = 'iterate'
|
|
|
+ targetSubagentRef.current = ''
|
|
|
+ setContinueFromRunId(null)
|
|
|
+ setContinueFromPath(null)
|
|
|
+ setMode('iterate')
|
|
|
+ setTargetSubagent('')
|
|
|
+
|
|
|
setInput('')
|
|
|
setMessages(prev => [
|
|
|
...prev,
|
|
|
@@ -551,25 +623,71 @@ export default function Chat() {
|
|
|
|
|
|
let cancelled = false
|
|
|
let finalText = ''
|
|
|
+ let idleTimer: ReturnType<typeof setInterval> | null = null
|
|
|
+ let currentRunId: string | null = null // captured from the SSE 'started' event
|
|
|
+ const abortCtrl = new AbortController()
|
|
|
|
|
|
try {
|
|
|
const res = await fetch(`/api/v1/agents/${agentId}/run/stream`, {
|
|
|
method: 'POST',
|
|
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
|
|
- body: JSON.stringify({ input: userText, parameters: {}, context: {} }),
|
|
|
+ body: JSON.stringify({
|
|
|
+ input: userText,
|
|
|
+ parameters: {},
|
|
|
+ context: ctxRunId ? { run_id: ctxRunId, workspace_path: ctxPath } : {},
|
|
|
+ // Only meaningful when continuing — fresh runs always run as iterate
|
|
|
+ mode: ctxRunId ? ctxMode : 'iterate',
|
|
|
+ target_subagent: ctxRunId && ctxMode === 'edit' ? ctxTarget : '',
|
|
|
+ }),
|
|
|
+ signal: abortCtrl.signal,
|
|
|
})
|
|
|
|
|
|
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`)
|
|
|
|
|
|
- abortRef.current = () => { cancelled = true }
|
|
|
-
|
|
|
const reader = res.body.getReader()
|
|
|
const decoder = new TextDecoder()
|
|
|
let buffer = ''
|
|
|
|
|
|
+ // Stop button: setting `cancelled` alone isn't enough — reader.read()
|
|
|
+ // can park forever while the backend is still computing (no token to
|
|
|
+ // wake it). We also call reader.cancel() to release the read and
|
|
|
+ // abortCtrl.abort() to tear down the underlying connection so the
|
|
|
+ // backend sees the client disconnect.
|
|
|
+ //
|
|
|
+ // For TRUE cancellation we also POST /runs/<id>/cancel — without
|
|
|
+ // that the agent's background thread keeps spawning claude
|
|
|
+ // subprocesses even after the SSE connection is dead. We fire the
|
|
|
+ // POST in the background (no await) so the UI unblocks immediately.
|
|
|
+ abortRef.current = () => {
|
|
|
+ cancelled = true
|
|
|
+ if (currentRunId) {
|
|
|
+ fetch(`/api/v1/agents/${agentId}/runs/${currentRunId}/cancel`, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { Authorization: `Bearer ${apiKey}` },
|
|
|
+ }).catch(() => { /* nop — best-effort */ })
|
|
|
+ }
|
|
|
+ try { reader.cancel('user stop') } catch { /* nop */ }
|
|
|
+ try { abortCtrl.abort() } catch { /* nop */ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Idle-timeout watchdog: if no SSE chunk arrives for this long, assume
|
|
|
+ // the upstream connection is dead (e.g. backend restarted mid-stream).
|
|
|
+ // Without this the reader.read() can park forever and freeze the input
|
|
|
+ // box until the user hard-refreshes the page.
|
|
|
+ const IDLE_TIMEOUT_MS = 90_000
|
|
|
+ let lastChunkAt = Date.now()
|
|
|
+ idleTimer = setInterval(() => {
|
|
|
+ if (Date.now() - lastChunkAt > IDLE_TIMEOUT_MS) {
|
|
|
+ cancelled = true
|
|
|
+ try { reader.cancel('idle timeout') } catch { /* nop */ }
|
|
|
+ try { abortCtrl.abort() } catch { /* nop */ }
|
|
|
+ }
|
|
|
+ }, 5_000)
|
|
|
+
|
|
|
while (!cancelled) {
|
|
|
const { done, value } = await reader.read()
|
|
|
if (done) break
|
|
|
+ lastChunkAt = Date.now()
|
|
|
|
|
|
buffer += decoder.decode(value, { stream: true })
|
|
|
const parts = buffer.split('\n\n')
|
|
|
@@ -589,26 +707,130 @@ export default function Chat() {
|
|
|
let payload: Record<string, unknown>
|
|
|
try { payload = JSON.parse(dataLine) } catch { continue }
|
|
|
|
|
|
- if (eventType === 'answer' || eventType === 'done') {
|
|
|
+ if (eventType === 'started') {
|
|
|
+ // Backend emits this immediately after creating the run row,
|
|
|
+ // long before any work happens. Capture run_id so the Stop
|
|
|
+ // button can call /cancel even if the user clicks before any
|
|
|
+ // other event arrives.
|
|
|
+ const rid = payload.run_id as string | undefined
|
|
|
+ if (rid) currentRunId = rid
|
|
|
+
|
|
|
+ } else if (eventType === 'answer') {
|
|
|
+ // answer event: backend sends {content, step, tool, duration_ms}
|
|
|
+ const text = (payload.content ?? payload.output ?? payload.message ?? '') as string
|
|
|
+ if (text) finalText = text
|
|
|
+
|
|
|
+ } else if (eventType === 'cancelled') {
|
|
|
+ // Backend confirmed the run was cancelled. Mark the bubble.
|
|
|
+ setMessages(prev => prev.map(m =>
|
|
|
+ m.id === assistantId
|
|
|
+ ? { ...m, status: 'done', text: m.text || '(已取消)' }
|
|
|
+ : m,
|
|
|
+ ))
|
|
|
+ qc.invalidateQueries({ queryKey: ['runs', agentId] })
|
|
|
+
|
|
|
+ } else if (eventType === 'done') {
|
|
|
const text = (payload.output ?? payload.message ?? '') as string
|
|
|
if (text) finalText = text
|
|
|
- if (eventType === 'done') {
|
|
|
- const runId = payload.run_id as string | undefined
|
|
|
- const workspacePath = (payload.workspace_path as string) || undefined
|
|
|
+ const runId = payload.run_id as string | undefined
|
|
|
+ const workspacePath = (payload.workspace_path as string) || undefined
|
|
|
+ setMessages(prev => prev.map(m =>
|
|
|
+ m.id === assistantId
|
|
|
+ ? { ...m, text: finalText || m.text, status: 'done', runId, workspacePath }
|
|
|
+ : m,
|
|
|
+ ))
|
|
|
+ qc.invalidateQueries({ queryKey: ['runs', agentId] })
|
|
|
+ break
|
|
|
+
|
|
|
+ } else if (eventType === 'think_start') {
|
|
|
+ // Immediately show "thinking…" in the bubble before LLM call returns
|
|
|
+ const content = (payload.content as string) || '正在推理…'
|
|
|
+ setMessages(prev => prev.map(m =>
|
|
|
+ m.id === assistantId ? { ...m, text: `⏳ ${content}` } : m,
|
|
|
+ ))
|
|
|
+
|
|
|
+ } else if (eventType === 'think') {
|
|
|
+ // LLM call done — parse JSON tool call and show human-readable summary in bubble
|
|
|
+ const content = (payload.content as string) || JSON.stringify(payload, null, 2)
|
|
|
+ // Try to render JSON tool call as readable text
|
|
|
+ let bubbleText = content
|
|
|
+ try {
|
|
|
+ // Strip markdown code fences if present
|
|
|
+ const cleaned = content.replace(/^```(?:json)?\s*/m, '').replace(/```\s*$/m, '').trim()
|
|
|
+ const obj = JSON.parse(cleaned)
|
|
|
+ if (obj.tool) {
|
|
|
+ const TOOL_NAMES: Record<string, string> = {
|
|
|
+ call_physlit: '📚 文献调研',
|
|
|
+ call_physsim: '🔬 数值模拟',
|
|
|
+ call_physanalyst: '📊 数据分析',
|
|
|
+ call_physwriter: '✍️ 论文写作',
|
|
|
+ terminate: '✅ 完成',
|
|
|
+ }
|
|
|
+ const label = TOOL_NAMES[obj.tool] || `🔧 ${obj.tool}`
|
|
|
+ const inputPreview = typeof obj.input === 'string'
|
|
|
+ ? obj.input.slice(0, 100)
|
|
|
+ : JSON.stringify(obj.input).slice(0, 100)
|
|
|
+ bubbleText = `${label}\n${inputPreview}${inputPreview.length >= 100 ? '…' : ''}`
|
|
|
+ }
|
|
|
+ } catch { /* not JSON, show raw */ }
|
|
|
+ setMessages(prev => prev.map(m =>
|
|
|
+ m.id === assistantId
|
|
|
+ ? { ...m, text: bubbleText, steps: [...m.steps, { type: 'think', content }] }
|
|
|
+ : m,
|
|
|
+ ))
|
|
|
+
|
|
|
+ } else if (eventType === 'think_chunk') {
|
|
|
+ // Streaming text chunks — accumulate into message bubble
|
|
|
+ const chunk = (payload.content as string) || ''
|
|
|
+ if (chunk) {
|
|
|
setMessages(prev => prev.map(m =>
|
|
|
m.id === assistantId
|
|
|
- ? { ...m, text: finalText || m.text, status: 'done', runId, workspacePath }
|
|
|
+ ? { ...m, text: (m.text || '') + chunk }
|
|
|
: m,
|
|
|
))
|
|
|
- // Invalidate runs cache so history is fresh on next load
|
|
|
- qc.invalidateQueries({ queryKey: ['runs', agentId] })
|
|
|
- break
|
|
|
}
|
|
|
- } else if (['think', 'tool_call', 'tool_result', 'error'].includes(eventType)) {
|
|
|
- const content = JSON.stringify(payload, null, 2)
|
|
|
+
|
|
|
+ } else if (eventType === 'tool_call') {
|
|
|
+ // Show tool call immediately in bubble
|
|
|
+ const tool = (payload.tool as string) || '工具'
|
|
|
+ const content = (payload.content as string) || ''
|
|
|
+ const TOOL_NAMES: Record<string, string> = {
|
|
|
+ call_physlit: '📚 文献调研', call_physsim: '🔬 数值模拟',
|
|
|
+ call_physanalyst: '📊 数据分析', call_physwriter: '✍️ 论文写作',
|
|
|
+ terminate: '✅ 完成', Bash: '💻 执行命令', WriteFile: '📝 写入文件',
|
|
|
+ ReadFile: '📖 读取文件',
|
|
|
+ }
|
|
|
+ const label = TOOL_NAMES[tool] || `🔧 ${tool}`
|
|
|
+ const preview = content.slice(0, 80) + (content.length > 80 ? '…' : '')
|
|
|
setMessages(prev => prev.map(m =>
|
|
|
m.id === assistantId
|
|
|
- ? { ...m, steps: [...m.steps, { type: eventType as ThinkStep['type'], content }] }
|
|
|
+ ? {
|
|
|
+ ...m,
|
|
|
+ text: `${label}\n${preview}`,
|
|
|
+ steps: [...m.steps, { type: 'tool_call', content, tool }],
|
|
|
+ }
|
|
|
+ : m,
|
|
|
+ ))
|
|
|
+
|
|
|
+ } else if (eventType === 'tool_result') {
|
|
|
+ const content = (payload.content as string) || JSON.stringify(payload, null, 2)
|
|
|
+ const tool = (payload.tool as string) || undefined
|
|
|
+ const preview = content.slice(0, 60) + (content.length > 60 ? '…' : '')
|
|
|
+ setMessages(prev => prev.map(m =>
|
|
|
+ m.id === assistantId
|
|
|
+ ? {
|
|
|
+ ...m,
|
|
|
+ text: `✅ 返回结果: ${preview}`,
|
|
|
+ steps: [...m.steps, { type: 'tool_result', content, tool }],
|
|
|
+ }
|
|
|
+ : m,
|
|
|
+ ))
|
|
|
+
|
|
|
+ } else if (eventType === 'error') {
|
|
|
+ const content = (payload.content as string) || JSON.stringify(payload, null, 2)
|
|
|
+ setMessages(prev => prev.map(m =>
|
|
|
+ m.id === assistantId
|
|
|
+ ? { ...m, steps: [...m.steps, { type: 'error', content }] }
|
|
|
: m,
|
|
|
))
|
|
|
}
|
|
|
@@ -621,17 +843,38 @@ export default function Chat() {
|
|
|
))
|
|
|
}
|
|
|
} catch (e) {
|
|
|
+ // User-initiated stop / abort throws AbortError — treat as graceful
|
|
|
+ // stop, not as an error red bubble.
|
|
|
+ const err = e as Error
|
|
|
+ const isAbort = err.name === 'AbortError' || cancelled
|
|
|
setMessages(prev => prev.map(m =>
|
|
|
m.id === assistantId
|
|
|
- ? { ...m, status: 'error', text: `错误:${(e as Error).message}` }
|
|
|
+ ? isAbort
|
|
|
+ ? { ...m, status: 'done', text: m.text || '(已停止)' }
|
|
|
+ : { ...m, status: 'error', text: `错误:${err.message}` }
|
|
|
: m,
|
|
|
))
|
|
|
} finally {
|
|
|
+ if (idleTimer) clearInterval(idleTimer)
|
|
|
setStreaming(false)
|
|
|
abortRef.current = null
|
|
|
}
|
|
|
}, [input, streaming, agentId, apiKey, qc])
|
|
|
|
|
|
+ // Belt-and-suspenders safety net: if the SSE stream silently drops (eg. the
|
|
|
+ // backend was restarted), the page would otherwise leave `streaming` stuck
|
|
|
+ // at true and the input box disabled. Pressing Escape now force-unsticks.
|
|
|
+ useEffect(() => {
|
|
|
+ function onKey(e: KeyboardEvent) {
|
|
|
+ if (e.key === 'Escape' && streaming) {
|
|
|
+ abortRef.current?.()
|
|
|
+ setStreaming(false)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ window.addEventListener('keydown', onKey)
|
|
|
+ return () => window.removeEventListener('keydown', onKey)
|
|
|
+ }, [streaming])
|
|
|
+
|
|
|
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
|
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage() }
|
|
|
}
|
|
|
@@ -669,6 +912,15 @@ export default function Chat() {
|
|
|
))}
|
|
|
</div>
|
|
|
</div>
|
|
|
+ {/* Workspace link */}
|
|
|
+ <button
|
|
|
+ onClick={() => navigate(`/agents/${agentId}/workspace`)}
|
|
|
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-gray-100 text-gray-600 hover:bg-gray-200 transition-colors"
|
|
|
+ title="查看运行工作区"
|
|
|
+ >
|
|
|
+ <LayoutDashboard size={13} />
|
|
|
+ 工作区
|
|
|
+ </button>
|
|
|
{/* Memory toggle */}
|
|
|
<button
|
|
|
onClick={() => setMemoryOpen(o => !o)}
|
|
|
@@ -735,6 +987,13 @@ export default function Chat() {
|
|
|
msg={msg}
|
|
|
agentId={agentId!}
|
|
|
apiKey={apiKey ?? ''}
|
|
|
+ onContinue={(runId, workspacePath) => {
|
|
|
+ continueRunIdRef.current = runId
|
|
|
+ continuePathRef.current = workspacePath
|
|
|
+ setContinueFromRunId(runId)
|
|
|
+ setContinueFromPath(workspacePath)
|
|
|
+ textareaRef.current?.focus()
|
|
|
+ }}
|
|
|
/>
|
|
|
))}
|
|
|
<div ref={bottomRef} />
|
|
|
@@ -746,18 +1005,158 @@ export default function Chat() {
|
|
|
)}
|
|
|
</div>
|
|
|
|
|
|
+ {/* Continue-from-run banner — pinned above input so it's always visible
|
|
|
+ right before the user sends. Distinguishes "continue this run"
|
|
|
+ from "start a fresh conversation". */}
|
|
|
+ {continueFromRunId && (() => {
|
|
|
+ const prevRun = runsData?.runs?.find(r => r.id === continueFromRunId)
|
|
|
+ const prevInputPreview = prevRun?.input?.slice(0, 120) ?? ''
|
|
|
+ return (
|
|
|
+ <div className="px-6 pt-3 bg-white shrink-0">
|
|
|
+ <div className="max-w-4xl mx-auto rounded-xl border border-indigo-300 bg-indigo-50/70 overflow-hidden">
|
|
|
+ <div className="flex items-center gap-2 px-4 py-2 bg-indigo-100/60 border-b border-indigo-200">
|
|
|
+ <RotateCcw size={13} className="text-indigo-600 shrink-0" />
|
|
|
+ <span className="text-xs font-semibold text-indigo-900">
|
|
|
+ 继续模式 — 下一条消息会在此 run 的工作区基础上执行
|
|
|
+ </span>
|
|
|
+ <button
|
|
|
+ onClick={() => {
|
|
|
+ continueRunIdRef.current = null
|
|
|
+ continuePathRef.current = null
|
|
|
+ setContinueFromRunId(null)
|
|
|
+ setContinueFromPath(null)
|
|
|
+ }}
|
|
|
+ className="ml-auto flex items-center gap-1 text-xs text-indigo-600 hover:text-indigo-900 hover:bg-indigo-200/60 px-2 py-0.5 rounded transition-colors"
|
|
|
+ title="取消继续,下一条作为全新对话"
|
|
|
+ >
|
|
|
+ <X size={12} />
|
|
|
+ 改为新对话
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <div className="px-4 py-2.5 text-xs text-indigo-900/90 space-y-1.5">
|
|
|
+ <div className="flex items-baseline gap-2">
|
|
|
+ <span className="font-medium text-indigo-700/70 shrink-0 w-16">Run ID</span>
|
|
|
+ <code className="font-mono text-indigo-900 bg-white/80 px-1.5 py-0.5 rounded border border-indigo-200">
|
|
|
+ {continueFromRunId.slice(-12)}
|
|
|
+ </code>
|
|
|
+ </div>
|
|
|
+ {continueFromPath && (
|
|
|
+ <div className="flex items-baseline gap-2">
|
|
|
+ <span className="font-medium text-indigo-700/70 shrink-0 w-16">工作区</span>
|
|
|
+ <code
|
|
|
+ className="font-mono text-indigo-900/90 truncate flex-1"
|
|
|
+ title={continueFromPath}
|
|
|
+ >
|
|
|
+ {continueFromPath}
|
|
|
+ </code>
|
|
|
+ <button
|
|
|
+ onClick={() => navigator.clipboard?.writeText(continueFromPath)}
|
|
|
+ className="text-indigo-500 hover:text-indigo-700 shrink-0 text-xs underline-offset-2 hover:underline"
|
|
|
+ title="复制路径"
|
|
|
+ >
|
|
|
+ 复制
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ {prevInputPreview && (
|
|
|
+ <div className="flex items-baseline gap-2">
|
|
|
+ <span className="font-medium text-indigo-700/70 shrink-0 w-16">上一轮</span>
|
|
|
+ <span className="text-indigo-800/80 line-clamp-2 leading-snug">
|
|
|
+ {prevInputPreview}
|
|
|
+ {(prevRun?.input?.length ?? 0) > 120 ? '…' : ''}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Mode selector — chat / edit / iterate */}
|
|
|
+ <div className="pt-1 border-t border-indigo-200/60 mt-1">
|
|
|
+ <div className="flex items-baseline gap-2">
|
|
|
+ <span className="font-medium text-indigo-700/70 shrink-0 w-16">模式</span>
|
|
|
+ <div className="flex gap-1 flex-wrap">
|
|
|
+ {([
|
|
|
+ { id: 'chat', icon: '💬', label: 'Chat', desc: '只读问答,< 2 min' },
|
|
|
+ { id: 'edit', icon: '✏️', label: 'Edit', desc: '调一个 sub-agent,~10 min' },
|
|
|
+ { id: 'iterate', icon: '🔄', label: 'Iterate', desc: '新 cycle,~1-2 h' },
|
|
|
+ ] as { id: Mode; icon: string; label: string; desc: string }[]).map(opt => (
|
|
|
+ <button
|
|
|
+ key={opt.id}
|
|
|
+ onClick={() => {
|
|
|
+ modeRef.current = opt.id
|
|
|
+ setMode(opt.id)
|
|
|
+ if (opt.id !== 'edit') {
|
|
|
+ targetSubagentRef.current = ''
|
|
|
+ setTargetSubagent('')
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ className={clsx(
|
|
|
+ 'flex items-center gap-1 text-xs px-2 py-0.5 rounded border transition-colors',
|
|
|
+ mode === opt.id
|
|
|
+ ? 'bg-indigo-600 text-white border-indigo-600 shadow-sm'
|
|
|
+ : 'bg-white text-indigo-700 border-indigo-200 hover:border-indigo-400'
|
|
|
+ )}
|
|
|
+ title={opt.desc}
|
|
|
+ >
|
|
|
+ <span>{opt.icon}</span>
|
|
|
+ <span className="font-medium">{opt.label}</span>
|
|
|
+ </button>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ {/* Edit-mode sub-agent picker */}
|
|
|
+ {mode === 'edit' && (
|
|
|
+ <div className="flex items-baseline gap-2 mt-1.5">
|
|
|
+ <span className="font-medium text-indigo-700/70 shrink-0 w-16">调用</span>
|
|
|
+ <select
|
|
|
+ value={targetSubagent}
|
|
|
+ onChange={e => {
|
|
|
+ targetSubagentRef.current = e.target.value
|
|
|
+ setTargetSubagent(e.target.value)
|
|
|
+ }}
|
|
|
+ className="text-xs bg-white border border-indigo-200 rounded px-2 py-0.5 focus:outline-none focus:border-indigo-500"
|
|
|
+ >
|
|
|
+ <option value="">选择一个 sub-agent…</option>
|
|
|
+ {subAgents.map(sa => (
|
|
|
+ <option key={sa} value={sa}>{sa}</option>
|
|
|
+ ))}
|
|
|
+ </select>
|
|
|
+ {subAgents.length === 0 && (
|
|
|
+ <span className="text-xs text-indigo-500/70">
|
|
|
+ (此 agent 未配置 sub-agents — Edit 模式不可用)
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+ })()}
|
|
|
+
|
|
|
{/* Input bar */}
|
|
|
- <div className="px-6 py-4 border-t border-gray-200 bg-white shrink-0">
|
|
|
+ <div className={clsx(
|
|
|
+ 'px-6 py-4 border-t bg-white shrink-0 transition-colors',
|
|
|
+ continueFromRunId ? 'border-indigo-200' : 'border-gray-200',
|
|
|
+ )}>
|
|
|
<div className="flex items-end gap-3 max-w-4xl mx-auto">
|
|
|
<textarea
|
|
|
ref={textareaRef}
|
|
|
value={input}
|
|
|
onChange={e => setInput(e.target.value)}
|
|
|
onKeyDown={handleKeyDown}
|
|
|
- placeholder="发消息… (Enter 发送,Shift+Enter 换行)"
|
|
|
+ placeholder={
|
|
|
+ continueFromRunId
|
|
|
+ ? '继续此 run — 输入下一步指令…(Enter 发送)'
|
|
|
+ : '发消息… (Enter 发送,Shift+Enter 换行)'
|
|
|
+ }
|
|
|
rows={1}
|
|
|
disabled={streaming}
|
|
|
- className="flex-1 resize-none rounded-xl border border-gray-300 px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:bg-gray-50 max-h-40 leading-relaxed"
|
|
|
+ className={clsx(
|
|
|
+ 'flex-1 resize-none rounded-xl border px-4 py-3 text-sm focus:outline-none focus:ring-2 disabled:bg-gray-50 max-h-40 leading-relaxed transition-colors',
|
|
|
+ continueFromRunId
|
|
|
+ ? 'border-indigo-400 focus:ring-indigo-500 bg-indigo-50/30'
|
|
|
+ : 'border-gray-300 focus:ring-indigo-500',
|
|
|
+ )}
|
|
|
/>
|
|
|
{streaming ? (
|
|
|
<button
|
|
|
@@ -770,11 +1169,29 @@ export default function Chat() {
|
|
|
) : (
|
|
|
<button
|
|
|
onClick={sendMessage}
|
|
|
- disabled={!input.trim()}
|
|
|
- className="flex items-center gap-1.5 px-4 py-3 bg-indigo-600 text-white rounded-xl hover:bg-indigo-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors text-sm font-medium"
|
|
|
+ disabled={
|
|
|
+ !input.trim() ||
|
|
|
+ // Edit mode requires a chosen sub-agent.
|
|
|
+ (!!continueFromRunId && mode === 'edit' && !targetSubagent)
|
|
|
+ }
|
|
|
+ className={clsx(
|
|
|
+ 'flex items-center gap-1.5 px-4 py-3 text-white rounded-xl disabled:opacity-40 disabled:cursor-not-allowed transition-colors text-sm font-medium',
|
|
|
+ continueFromRunId
|
|
|
+ ? 'bg-indigo-700 hover:bg-indigo-800 ring-2 ring-indigo-300'
|
|
|
+ : 'bg-indigo-600 hover:bg-indigo-700',
|
|
|
+ )}
|
|
|
+ title={
|
|
|
+ continueFromRunId
|
|
|
+ ? (mode === 'edit' && !targetSubagent
|
|
|
+ ? '请先选择要调用的 sub-agent'
|
|
|
+ : `继续 run ${continueFromRunId.slice(-8)} — 模式: ${mode}`)
|
|
|
+ : '发送'
|
|
|
+ }
|
|
|
>
|
|
|
<Send size={14} />
|
|
|
- 发送
|
|
|
+ {continueFromRunId
|
|
|
+ ? (mode === 'chat' ? '💬 问' : mode === 'edit' ? '✏️ 改' : '🔄 继续')
|
|
|
+ : '发送'}
|
|
|
</button>
|
|
|
)}
|
|
|
</div>
|