Jelajahi Sumber

feat(webui): Chat 接线会话记忆 thread — 新会话 + 历史会话

记忆 P1 后端就绪后把 thread 暴露到 Chat 页:

- threadIdRef 持有当前对话 thread;每条消息 stream body 带 thread_id
  (首条为空→后端新建),从 started 事件回填,后续消息复用 → 连续
  对话第二句自动带前情
- 切换 agent 自动重置 thread(不同 agent 会话不混)
- 顶部「新会话」:清 thread + 清消息 + 清 continue 上下文,开始无
  前情的干净对话
- 顶部「历史」下拉:列该 agent 的 thread(标题+消息数),点击拉
  thread 内 runs 渲染成历史气泡并接上该 thread 继续聊
- agents.ts 加 threads/threadRuns/archiveThread 三个 client

tsc 0 错误、webui build 通过;后端 thread 端点已有 9 测试覆盖。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
kenny67nju 2 bulan lalu
induk
melakukan
19e332702e
2 mengubah file dengan 104 tambahan dan 0 penghapusan
  1. 9 0
      webui/src/api/agents.ts
  2. 95 0
      webui/src/pages/Chat.tsx

+ 9 - 0
webui/src/api/agents.ts

@@ -80,6 +80,15 @@ export interface RecallEntry {
 export const agentsApi = {
   list: () => api.get<{ agents: Agent[] }>('/agents'),
   get: (id: string) => api.get<Agent>(`/agents/${id}`),
+  // 会话记忆 thread
+  threads: (agentId: string) =>
+    api.get<{ threads: { id: string; title: string; run_count: number; updated_at: string }[] }>(
+      `/agents/${agentId}/threads`),
+  threadRuns: (agentId: string, threadId: string) =>
+    api.get<{ runs: { id: string; input: string; output: string; status: string; workspace_path: string; created_at: string }[] }>(
+      `/agents/${agentId}/threads/${threadId}/runs`),
+  archiveThread: (agentId: string, threadId: string) =>
+    api.post<{ ok: boolean }>(`/agents/${agentId}/threads/${threadId}/archive`),
   create: (body: {
     name: string
     description: string

+ 95 - 0
webui/src/pages/Chat.tsx

@@ -5,7 +5,9 @@ import {
   ArrowLeft, Send, Square, ChevronDown, ChevronRight,
   Bot, User, Clock, Brain, Folder, Download, Trash2, X,
   Save, RotateCcw, History, Key, BookMarked, LayoutDashboard,
+  MessageSquarePlus,
 } from 'lucide-react'
+import toast from 'react-hot-toast'
 import { agentsApi, type WorkspaceFile, type MemoryEntry, type CoreMemory, type RecallEntry } from '../api/agents'
 import { knowledgeApi } from '../api/knowledge'
 import { useAppStore } from '../store/app'
@@ -1173,6 +1175,51 @@ export default function Chat() {
   const [continueFromPath, setContinueFromPath] = useState<string | null>(null)
   const continueRunIdRef  = useRef<string | null>(null)
   const continuePathRef   = useRef<string | null>(null)
+  // 会话记忆:当前对话 thread。每条消息带上,后端据此注入前情。
+  // 切换 agent 或点「新建会话」时清空,由本次 run 的 started 事件回填。
+  const threadIdRef = useRef<string | null>(null)
+  const [historyOpen, setHistoryOpen] = useState(false)
+
+  // 切换 agent → 重置 thread(不同 agent 的会话不混)
+  useEffect(() => { threadIdRef.current = null }, [agentId])
+
+  // 历史会话列表(下拉打开时拉取)
+  const { data: threadsData, refetch: refetchThreads } = useQuery({
+    queryKey: ['threads', agentId],
+    queryFn: () => agentsApi.threads(agentId!),
+    enabled: !!agentId && historyOpen,
+  })
+
+  // 新建会话:清空 thread + 清空消息(产物 continue 上下文也一并清)
+  const startNewThread = useCallback(() => {
+    threadIdRef.current = null
+    continueRunIdRef.current = null
+    continuePathRef.current = null
+    setContinueFromRunId(null)
+    setContinueFromPath(null)
+    setMessages([])
+    setHistoryOpen(false)
+  }, [])
+
+  // 载入一条历史会话:拉 thread 内 runs 渲染成气泡,并接上该 thread
+  const loadThread = useCallback(async (threadId: string) => {
+    if (!agentId) return
+    try {
+      const res = await agentsApi.threadRuns(agentId, threadId)
+      const msgs: Message[] = []
+      for (const r of res.runs) {
+        msgs.push({ id: r.id + '_u', role: 'user', text: r.input, status: 'done', steps: [], turns: [] })
+        msgs.push({ id: r.id + '_a', role: 'assistant', text: r.output || '(无输出)',
+                    status: r.status === 'failed' ? 'error' : 'done',
+                    runId: r.id, workspacePath: r.workspace_path || undefined, steps: [], turns: [] })
+      }
+      setMessages(msgs)
+      threadIdRef.current = threadId
+      setHistoryOpen(false)
+    } catch (e) {
+      toast.error('载入会话失败')
+    }
+  }, [agentId])
 
   // Mode selector for continue-this-run flows.
   //   iterate (default): full pipeline, new cycle_N+1
@@ -1267,6 +1314,8 @@ export default function Chat() {
           // Only meaningful when continuing — fresh runs always run as iterate
           mode: ctxRunId ? ctxMode : 'iterate',
           target_subagent: ctxRunId && ctxMode === 'edit' ? ctxTarget : '',
+          // 会话记忆:带上当前 thread(首条消息为空 → 后端新建,started 回填)
+          thread_id: threadIdRef.current ?? '',
         }),
         signal: abortCtrl.signal,
       })
@@ -1352,6 +1401,9 @@ export default function Chat() {
                 m.id === assistantId ? { ...m, runId: rid } : m,
               ))
             }
+            // 会话记忆:回填后端解析/新建的 thread_id,后续消息复用
+            const tid = payload.thread_id as string | undefined
+            if (tid) threadIdRef.current = tid
 
           } else if (eventType === 'answer') {
             // answer event: backend sends {content, step, tool, duration_ms}
@@ -1605,6 +1657,49 @@ export default function Chat() {
             ))}
           </div>
         </div>
+        {/* 会话记忆:新建会话 + 历史会话 */}
+        <button
+          onClick={startNewThread}
+          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="开始一段新对话(不带前情记忆)"
+        >
+          <MessageSquarePlus size={13} />
+          新会话
+        </button>
+        <div className="relative">
+          <button
+            onClick={() => { setHistoryOpen(o => !o); if (!historyOpen) refetchThreads() }}
+            className={clsx(
+              'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors',
+              historyOpen ? 'bg-indigo-100 text-indigo-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200',
+            )}
+            title="历史会话"
+          >
+            <History size={13} />
+            历史
+          </button>
+          {historyOpen && (
+            <div className="absolute right-0 top-full mt-1 w-72 max-h-80 overflow-y-auto bg-white border border-gray-200 rounded-xl shadow-lg z-30">
+              {(threadsData?.threads ?? []).length === 0 ? (
+                <p className="text-xs text-gray-400 px-4 py-3">暂无历史会话</p>
+              ) : (
+                (threadsData?.threads ?? []).map(t => (
+                  <button
+                    key={t.id}
+                    onClick={() => loadThread(t.id)}
+                    className={clsx(
+                      'w-full text-left px-4 py-2.5 hover:bg-gray-50 border-b border-gray-50 last:border-0',
+                      threadIdRef.current === t.id && 'bg-indigo-50',
+                    )}
+                  >
+                    <p className="text-xs font-medium text-gray-800 truncate">{t.title || '未命名会话'}</p>
+                    <p className="text-[10px] text-gray-400 mt-0.5">{t.run_count} 条消息</p>
+                  </button>
+                ))
+              )}
+            </div>
+          )}
+        </div>
         {/* Workspace link */}
         <button
           onClick={() => navigate(`/agents/${agentId}/workspace`)}