diff --git a/internal/server/server.go b/internal/server/server.go index a6059857..0fdbc5d2 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -232,6 +232,7 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("POST /api/v1/tasks/runs", s.handleCreateTaskRun) mux.HandleFunc("GET /api/v1/tasks/runs/{run_id}", s.handleGetTaskRun) mux.HandleFunc("GET /api/v1/tasks/runs/{run_id}/events", s.handleTaskRunEvents) + mux.HandleFunc("GET /api/v1/tasks/runs/{run_id}/squad", s.handleGetTaskRunSquad) mux.HandleFunc("POST /api/v1/tasks/runs/{run_id}/cancel", s.handleCancelTaskRun) mux.HandleFunc("POST /api/v1/tasks/runs/{run_id}/resume", s.handleResumeTaskRun) mux.HandleFunc("POST /api/v1/admin/"+workspace.WorkspacesDirName+"/reconcile", s.handleReconcileWorkspace) diff --git a/internal/server/task_run_handlers.go b/internal/server/task_run_handlers.go index e2dc98bc..27a7303a 100644 --- a/internal/server/task_run_handlers.go +++ b/internal/server/task_run_handlers.go @@ -630,6 +630,48 @@ func (s *Server) handleGetTaskRun(w http.ResponseWriter, r *http.Request) { writeJSONResponse(w, http.StatusOK, s.buildTaskRunResponse(rec)) } +// handleGetTaskRunSquad 只读投影工作区 squad.json,供小队过程子页面左栏。 +func (s *Server) handleGetTaskRunSquad(w http.ResponseWriter, r *http.Request) { + user, ok := s.requirePrincipal(w, r) + if !ok { + return + } + runID := r.PathValue("run_id") + if runID == "" { + writeSafeError(w, r, http.StatusBadRequest, "run_id required") + return + } + if !s.assertTaskRunOwnedByUser(w, r.Context(), runID, user) { + return + } + rec, err := s.taskRuns().GetRun(runID) + if err != nil { + writeDomainError(w, r, http.StatusNotFound, err) + return + } + if rec == nil || strings.TrimSpace(rec.WorkspaceID) == "" || s.resolver() == nil { + writeSafeError(w, r, http.StatusNotFound, "workspace not found") + return + } + dir, err := s.resolver().ResolveStoredWorkDir(rec.WorkspaceID) + if err != nil { + writeSafeError(w, r, http.StatusNotFound, "workspace not found") + return + } + proj, err := squad.ReadSquadProjection(dir) + if err != nil { + writeSafeError(w, r, http.StatusNotFound, "squad not found") + return + } + if proj.TaskKey == "" { + proj.TaskKey = rec.TaskKey + } + if proj.Title == "" { + proj.Title = rec.TaskKey + } + writeJSONResponse(w, http.StatusOK, proj) +} + func (s *Server) handleResumeTaskRun(w http.ResponseWriter, r *http.Request) { user, ok := s.requirePrincipal(w, r) if !ok { diff --git a/internal/squad/squad_projection.go b/internal/squad/squad_projection.go new file mode 100644 index 00000000..ccdab454 --- /dev/null +++ b/internal/squad/squad_projection.go @@ -0,0 +1,60 @@ +package squad + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// SquadMemberView 是子页面左栏的只读编制投影(不含模型名与工具权限)。 +type SquadMemberView struct { + Role string `json:"role"` + Name string `json:"name"` + Responsibilities []string `json:"responsibilities"` + Source string `json:"source,omitempty"` +} + +// SquadProjection GET /tasks/runs/{id}/squad 的响应体。 +type SquadProjection struct { + Members []SquadMemberView `json:"members"` + TaskKey string `json:"task_key"` + Title string `json:"title"` +} + +// ReadSquadProjection 读取工作区 squad.json(及可选 workspace.json 标题)。 +// 缺少 squad.json 时返回错误,由调用方决定 404。 +func ReadSquadProjection(workDir string) (SquadProjection, error) { + data, err := os.ReadFile(filepath.Join(workDir, "squad.json")) + if err != nil { + return SquadProjection{}, err + } + var cfg SquadConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return SquadProjection{}, err + } + members := make([]SquadMemberView, 0, len(cfg.Agents)) + for _, agent := range cfg.Agents { + resp := agent.Responsibilities + if resp == nil { + resp = []string{} + } + members = append(members, SquadMemberView{ + Role: agent.Role, + Name: agent.Name, + Responsibilities: resp, + Source: string(agent.Source), + }) + } + title := cfg.SquadName + taskKey := "" + if metaRaw, err := os.ReadFile(filepath.Join(workDir, "workspace.json")); err == nil { + var meta WorkspaceMetadata + if json.Unmarshal(metaRaw, &meta) == nil { + if meta.StageTitle != "" { + title = meta.StageTitle + } + taskKey = meta.TaskKey + } + } + return SquadProjection{Members: members, TaskKey: taskKey, Title: title}, nil +} diff --git a/internal/squad/squad_projection_test.go b/internal/squad/squad_projection_test.go new file mode 100644 index 00000000..57ab1402 --- /dev/null +++ b/internal/squad/squad_projection_test.go @@ -0,0 +1,38 @@ +package squad + +import ( + "os" + "path/filepath" + "testing" +) + +func TestReadSquadProjection(t *testing.T) { + dir := t.TempDir() + squadJSON := `{ + "squad_name": "立项小队", + "version": "1", + "agents": [ + {"role": "regulatory", "name": "法规专员", "source": "standard", "responsibilities": ["筛法规"]} + ] + }` + if err := os.WriteFile(filepath.Join(dir, "squad.json"), []byte(squadJSON), 0o644); err != nil { + t.Fatal(err) + } + ws := `{"task_key":"intake","stage_title":"用户需求收集"}` + if err := os.WriteFile(filepath.Join(dir, "workspace.json"), []byte(ws), 0o644); err != nil { + t.Fatal(err) + } + got, err := ReadSquadProjection(dir) + if err != nil { + t.Fatal(err) + } + if got.TaskKey != "intake" || got.Title != "用户需求收集" { + t.Fatalf("meta: %+v", got) + } + if len(got.Members) != 1 || got.Members[0].Role != "regulatory" || got.Members[0].Name != "法规专员" { + t.Fatalf("members: %+v", got.Members) + } + if _, err := ReadSquadProjection(t.TempDir()); err == nil { + t.Fatal("missing squad.json must error") + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 94dbae78..699b4e34 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -112,7 +112,7 @@ import { RequestEpoch, shareInflight } from './data/initFanout'; import { resolveSessionProjectId, } from './lib/sessionAdapter'; -import { navigateToAnchor, parseAnchorHash } from './engine/anchorNavigator'; +import { navigateToAnchor, parseAnchorHash, parseSubagentsHash, type SubagentsTarget } from './engine/anchorNavigator'; import { useUnreadMarks } from './hooks/useUnreadMarks'; import { fetchChatCategories, fetchChatDates } from './api/chatWindow'; import { ChatCategoryStat, ChatDateStat, QuotedMessage } from './types/workbench'; @@ -129,6 +129,8 @@ export { const loadAdminConsoleView = () => import('./components/admin/console/AdminConsoleView').then((m) => ({ default: m.AdminConsoleView })); +const loadSubagentsView = () => + import('./components/subagents/SubagentsView').then((m) => ({ default: m.SubagentsView })); export default function App() { // Wave 2:启动时幂等剔除 localStorage 中的 demo seed 残留(须先于 load*) purgeDemoSeedsFromLocalStorage(); @@ -323,7 +325,8 @@ export default function App() { const [rightsModalOpen, setRightsModalOpen] = useState(false); const [userSettingsOpen, setUserSettingsOpen] = useState(false); // 顶层视图切换:单屏工作台 / 全屏系统管理台(仅管理员可进入) - const [view, setView] = useState<'workbench' | 'admin'>('workbench'); + const [view, setView] = useState<'workbench' | 'admin' | 'subagents'>('workbench'); + const [subagentsTarget, setSubagentsTarget] = useState(null); const [docVisibilityModalOpen, setDocVisibilityModalOpen] = useState(false); const [targetDocForVisibility, setTargetDocForVisibility] = useState(null); @@ -1125,6 +1128,16 @@ export default function App() { useEffect(() => { const handleHashChange = () => { if (sessionValidatedRef.current !== true) return; + const sub = parseSubagentsHash(window.location.hash); + if (sub) { + setSubagentsTarget(sub); + setView('subagents'); + if (sub.sessionId && sub.sessionId !== activeSessionIdRef.current) { + setActiveSessionId(sub.sessionId); + } + return; + } + setView((current) => (current === 'subagents' ? 'workbench' : current)); const parsed = parseAnchorHash(window.location.hash); if (!parsed) return; const currentActive = activeSessionIdRef.current; @@ -1152,6 +1165,14 @@ export default function App() { return () => window.removeEventListener('hashchange', handleHashChange); }, []); // 仅在挂载时监听 hashchange,避免 activeSessionId 变更时因残留 hash 产生回弹 + useEffect(() => { + if (sessionValidated !== true) return; + const sub = parseSubagentsHash(window.location.hash); + if (!sub) return; + setSubagentsTarget(sub); + setView('subagents'); + }, [sessionValidated]); + // 引用消息交互 const handleQuoteMessage = (message: ChatMessage) => { let msgId = message.serverId; @@ -1582,6 +1603,25 @@ export default function App() { ); } + if (view === 'subagents' && subagentsTarget) { + return ( + { + setView('workbench'); + const next = `${window.location.pathname}${window.location.search}`; + window.history.replaceState(null, '', next); + }, + }} + /> + ); + } + // 全屏系统管理台:与工作台并列的顶层视图,不经过 AppShell if (view === 'admin') { return ( diff --git a/web/src/api/chatStream.ts b/web/src/api/chatStream.ts index 70932fd6..d393aded 100644 --- a/web/src/api/chatStream.ts +++ b/web/src/api/chatStream.ts @@ -18,6 +18,24 @@ export type StreamEvent = { status?: { message?: string; intent?: string }; delta?: string; error?: string; + detail?: { + category?: string; + step_id?: string; + role?: string; + title?: string; + content?: string; + collapsible?: boolean; + item_id?: string; + delta?: string; + done?: boolean; + tool?: { + name?: string; + status?: string; + input?: string; + output?: string; + duration_ms?: number; + }; + }; deliverable?: { task_key?: string; title?: string; diff --git a/web/src/api/taskRuns.ts b/web/src/api/taskRuns.ts index 9dd2583b..b0536e34 100644 --- a/web/src/api/taskRuns.ts +++ b/web/src/api/taskRuns.ts @@ -195,3 +195,26 @@ export async function getTaskRun(runId: string) { } return res.json(); } + +export type SquadMemberDTO = { + role: string; + name: string; + responsibilities: string[]; + source?: string; +}; + +export type SquadProjection = { + members: SquadMemberDTO[]; + task_key: string; + title: string; +}; + +export async function getTaskRunSquad(runId: string): Promise { + const res = await apiFetch(`/api/v1/tasks/runs/${encodeURIComponent(runId)}/squad`, { + headers: getBearerAuthHeaders(), + }); + if (!res.ok) { + throw new Error(`get task run squad failed: HTTP ${res.status}`); + } + return res.json(); +} diff --git a/web/src/components/chat/TaskRunCard.tsx b/web/src/components/chat/TaskRunCard.tsx index 0128516b..589494f4 100644 --- a/web/src/components/chat/TaskRunCard.tsx +++ b/web/src/components/chat/TaskRunCard.tsx @@ -9,6 +9,8 @@ import { runningPhaseDetail, type TaskRunCardBadgeTone, } from '../../lib/taskRunCard'; +import { subagentsHash } from '../../engine/anchorNavigator'; +import { summarizeProcess } from '../../engine/processEvent'; export interface TaskRunCardProps { message: ChatMessage; @@ -274,6 +276,33 @@ export const TaskRunCard: React.FC = ({ message }) => { {artifactBits.join(' · ')} )} + + {message.taskRunId && message.sessionId && ( + + )} ); }; diff --git a/web/src/components/layout/WorkbenchView.tsx b/web/src/components/layout/WorkbenchView.tsx index e2ece52a..77b27623 100644 --- a/web/src/components/layout/WorkbenchView.tsx +++ b/web/src/components/layout/WorkbenchView.tsx @@ -43,7 +43,7 @@ export interface WorkbenchViewProps { currentDocRole: UserRole; setRightsModalOpen: Dispatch>; setUserSettingsOpen: Dispatch>; - setView: (view: 'workbench' | 'admin') => void; + setView: (view: 'workbench' | 'admin' | 'subagents') => void; setProjectMembersModalOpen: Dispatch>; currentWfState: WorkbenchWorkflowState; workflowGovernanceEnabled: boolean; diff --git a/web/src/components/subagents/CollapsibleBlock.tsx b/web/src/components/subagents/CollapsibleBlock.tsx new file mode 100644 index 00000000..9c535f8e --- /dev/null +++ b/web/src/components/subagents/CollapsibleBlock.tsx @@ -0,0 +1,32 @@ +import { useState, type ReactNode } from 'react'; + +export function CollapsibleBlock(props: { + summary: string; + children: ReactNode; + defaultOpen?: boolean; + testId?: string; +}) { + const [open, setOpen] = useState(props.defaultOpen ?? false); + return ( +
+ + {open ?
{props.children}
: null} +
+ ); +} diff --git a/web/src/components/subagents/ProcessStepOutput.tsx b/web/src/components/subagents/ProcessStepOutput.tsx new file mode 100644 index 00000000..35865236 --- /dev/null +++ b/web/src/components/subagents/ProcessStepOutput.tsx @@ -0,0 +1,12 @@ +import type { ProcessItem } from '../../engine/processEvent'; +import { renderBlockMarkdown } from '../../engine/renderBlockMarkdown'; +import { CollapsibleBlock } from './CollapsibleBlock'; + +export function ProcessStepOutput({ item }: { item: ProcessItem }) { + const title = item.title || '步骤产出'; + return ( + +
+ + ); +} diff --git a/web/src/components/subagents/SubagentsLayout.test.ts b/web/src/components/subagents/SubagentsLayout.test.ts new file mode 100644 index 00000000..d446c8fb --- /dev/null +++ b/web/src/components/subagents/SubagentsLayout.test.ts @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { SubagentsLayout } from './SubagentsLayout'; + +test('SubagentsLayout shows the running member and folded thought summary', () => { + const html = renderToStaticMarkup( + React.createElement(SubagentsLayout, { + title: '用户需求收集', + runId: 'run-abcdef123456', + members: [ + { role: 'regulatory', name: '法规专员', responsibilities: ['筛法规'] }, + { role: 'writer', name: '撰写专员', responsibilities: ['成稿'] }, + ], + phases: [ + { step: 'step_executing#2', icon: '⚙️', label: '撰写中', status: 'running', role: 'writer' }, + ], + items: [ + { + key: 'tasks/02/thought-1', + category: 'agent_thought', + role: 'writer', + stepId: 'tasks/02', + text: '先核对素材再写用户需求表的适用范围说明', + status: 'done', + updatedAt: 1, + }, + { + key: 'tasks/01/thought-9', + category: 'agent_thought', + role: 'regulatory', + stepId: 'tasks/01', + text: '法规侧不应出现在撰写栏', + status: 'done', + updatedAt: 2, + }, + ], + onSelectRole: () => undefined, + onExit: () => undefined, + }), + ); + assert.match(html, /data-testid="subagents-view"/); + assert.match(html, /撰写专员/); + assert.match(html, /先核对素材/); + assert.doesNotMatch(html, /法规侧不应出现/); + assert.match(html, /返回对话/); + assert.match(html, /思考 · /); +}); diff --git a/web/src/components/subagents/SubagentsLayout.tsx b/web/src/components/subagents/SubagentsLayout.tsx new file mode 100644 index 00000000..73861a48 --- /dev/null +++ b/web/src/components/subagents/SubagentsLayout.tsx @@ -0,0 +1,104 @@ +import type { SquadMemberDTO } from '../../api/taskRuns'; +import { processItemsOf, type ProcessItem } from '../../engine/processEvent'; +import { defaultMemberRole, memberRunStatus } from '../../lib/squadMembers'; +import type { PhaseEvent } from '../../types/workbench'; +import { ProcessStepOutput } from './ProcessStepOutput'; +import { ThinkingBlock } from './ThinkingBlock'; +import { ToolCallCard } from './ToolCallCard'; + +const STATUS_LABEL = { + waiting: '等待', + running: '执行中', + completed: '完成', + failed: '异常', +} as const; + +export function SubagentsLayout(props: { + title: string; + runId: string; + members: SquadMemberDTO[]; + items: ProcessItem[]; + phases: PhaseEvent[]; + selectedRole?: string; + onSelectRole: (role: string) => void; + onExit: () => void; + notice?: string; +}) { + const selected = props.selectedRole && props.members.some((member) => member.role === props.selectedRole) + ? props.selectedRole + : defaultMemberRole(props.members, props.phases); + const member = props.members.find((item) => item.role === selected); + const visible = props.items.filter((item) => item.role === selected || (!item.role && !selected)); + const runLabel = props.runId.length > 12 ? `${props.runId.slice(0, 8)}…` : props.runId; + + return ( +
+
+
+
小队过程 · {props.title || '任务'}
+
run:{runLabel}
+
+ +
+ {props.notice ?

{props.notice}

: null} +
+ +
+ {member ? ( +

+ {member.name} · {member.role} + {member.responsibilities?.length ? ` · ${member.responsibilities.join('、')}` : ''} +

+ ) : ( +

选择一名专员查看过程

+ )} + {visible.length === 0 ?

还没有过程记录

: null} + {visible.map((item) => { + if (item.category === 'tool_call') return ; + if (item.category === 'step_output') return ; + if (item.category === 'agent_thought') return ; + return ; + })} +
+
+
    + {props.phases.map((phase) => ( +
  1. {phase.icon} {phase.label}
  2. + ))} +
+
+ ); +} + +export function itemsForLayout(items: Record): ProcessItem[] { + return processItemsOf(items); +} diff --git a/web/src/components/subagents/SubagentsView.tsx b/web/src/components/subagents/SubagentsView.tsx new file mode 100644 index 00000000..8da68dcc --- /dev/null +++ b/web/src/components/subagents/SubagentsView.tsx @@ -0,0 +1,55 @@ +import { useEffect, useState } from 'react'; +import { getTaskRunSquad, type SquadMemberDTO } from '../../api/taskRuns'; +import { subagentsHash } from '../../engine/anchorNavigator'; +import { useRunProcessStream } from '../../hooks/useRunProcessStream'; +import { itemsForLayout, SubagentsLayout } from './SubagentsLayout'; + +export function SubagentsView(props: { + runId: string; + sessionId: string; + roleId?: string; + onExit: () => void; +}) { + const stream = useRunProcessStream(props.runId); + const [members, setMembers] = useState([]); + const [title, setTitle] = useState(''); + const [notice, setNotice] = useState(); + + useEffect(() => { + let cancelled = false; + void getTaskRunSquad(props.runId) + .then((squad) => { + if (cancelled) return; + setMembers(squad.members || []); + setTitle(squad.title || squad.task_key || ''); + setNotice(undefined); + }) + .catch(() => { + if (!cancelled) setNotice('暂时读不到小队编制,过程事件仍会显示。'); + }); + return () => { + cancelled = true; + }; + }, [props.runId]); + + const selectRole = (role: string) => { + const next = subagentsHash({ sessionId: props.sessionId, runId: props.runId, roleId: role }); + if (window.location.hash !== next) window.location.hash = next; + }; + + const streamNotice = stream.status === 'error' ? '过程流中断,可返回对话后重新进入。' : notice; + + return ( + + ); +} diff --git a/web/src/components/subagents/ThinkingBlock.tsx b/web/src/components/subagents/ThinkingBlock.tsx new file mode 100644 index 00000000..ff7cc694 --- /dev/null +++ b/web/src/components/subagents/ThinkingBlock.tsx @@ -0,0 +1,22 @@ +import type { ProcessItem } from '../../engine/processEvent'; +import { renderBlockMarkdown } from '../../engine/renderBlockMarkdown'; +import { CollapsibleBlock } from './CollapsibleBlock'; + +function summaryOf(item: ProcessItem): string { + const head = item.text.replace(/\s+/g, ' ').trim().slice(0, 40); + const extra = item.text.length > 40 ? '…' : ''; + const state = item.status === 'streaming' ? '思考中' : item.status === 'error' ? '思考中断' : '思考'; + return `${state} · ${head}${extra}(${item.text.length} 字)`; +} + +export function ThinkingBlock({ item }: { item: ProcessItem }) { + return ( + +
+ + ); +} diff --git a/web/src/components/subagents/ToolCallCard.tsx b/web/src/components/subagents/ToolCallCard.tsx new file mode 100644 index 00000000..8d99aba8 --- /dev/null +++ b/web/src/components/subagents/ToolCallCard.tsx @@ -0,0 +1,24 @@ +import type { ProcessItem } from '../../engine/processEvent'; +import { renderBlockMarkdown } from '../../engine/renderBlockMarkdown'; +import { CollapsibleBlock } from './CollapsibleBlock'; + +function statusLabel(status: string): string { + if (status === 'succeeded') return '完成'; + if (status === 'failed') return '失败'; + return '调用中'; +} + +export function ToolCallCard({ item }: { item: ProcessItem }) { + const tool = item.tool; + const name = tool?.name || item.title || '工具'; + const status = tool?.status || (item.status === 'error' ? 'failed' : 'calling'); + const duration = tool?.durationMs != null ? ` · ${tool.durationMs}ms` : ''; + const body = [tool?.input ? `入参\n\`\`\`json\n${tool.input}\n\`\`\`` : '', tool?.output ? `结果\n\`\`\`\n${tool.output}\n\`\`\`` : ''] + .filter(Boolean) + .join('\n\n'); + return ( + + {body ?
:

无入参或结果

} + + ); +} diff --git a/web/src/engine/anchorNavigator.test.ts b/web/src/engine/anchorNavigator.test.ts index 0ca100d6..bc82c6b5 100644 --- a/web/src/engine/anchorNavigator.test.ts +++ b/web/src/engine/anchorNavigator.test.ts @@ -4,7 +4,9 @@ import { parseAnchorHash, formatAnchorHash, navigateToAnchor, + parseSubagentsHash, resolveTurnAnchorId, + subagentsHash, } from './anchorNavigator'; test('parseAnchorHash: 正确解析标准深链 hash', () => { @@ -153,3 +155,19 @@ test('resolveTurnAnchorId: 方案 A 轮次归一化测试', () => { assert.equal(resolveTurnAnchorId(102, []), 102, 'empty messages returns raw id'); }); +test('parseSubagentsHash 解析小队过程深链且不误伤消息锚点', () => { + assert.equal(parseAnchorHash('#/s/sess/run/run-1/agents'), null); + assert.deepEqual(parseSubagentsHash('#/s/sess/run/run-1/agents'), { + sessionId: 'sess', + runId: 'run-1', + roleId: undefined, + }); + assert.deepEqual(parseSubagentsHash('#/s/sess/run/run-1/agents/regulatory'), { + sessionId: 'sess', + runId: 'run-1', + roleId: 'regulatory', + }); + const hashed = subagentsHash({ sessionId: 'a/b', runId: 'run 1', roleId: '法规' }); + assert.deepEqual(parseSubagentsHash(hashed), { sessionId: 'a/b', runId: 'run 1', roleId: '法规' }); +}); + diff --git a/web/src/engine/anchorNavigator.ts b/web/src/engine/anchorNavigator.ts index 335afc4a..8cdec77d 100644 --- a/web/src/engine/anchorNavigator.ts +++ b/web/src/engine/anchorNavigator.ts @@ -110,6 +110,33 @@ export function parseAnchorHash(hash: string): AnchorTarget | null { return { sessionId, messageId }; } +export type SubagentsTarget = { + sessionId: string; + runId: string; + roleId?: string; +}; + +/** + * 小队过程子页面深链:#/s/{sessionId}/run/{runId}/agents[/{roleId}] + * 与消息锚点 #/s/{sessionId}/m/{messageId} 互斥。 + */ +export function parseSubagentsHash(hash: string): SubagentsTarget | null { + if (!hash) return null; + const cleaned = hash.startsWith('#') ? hash.substring(1) : hash; + const match = cleaned.match(/^\/s\/([^/]+)\/run\/([^/]+)\/agents(?:\/([^/]+))?$/); + if (!match) return null; + const sessionId = decodeURIComponent(match[1]); + const runId = decodeURIComponent(match[2]); + if (!sessionId || !runId) return null; + const roleId = match[3] ? decodeURIComponent(match[3]) : undefined; + return { sessionId, runId, roleId }; +} + +export function subagentsHash(target: SubagentsTarget): string { + const base = `#/s/${encodeURIComponent(target.sessionId)}/run/${encodeURIComponent(target.runId)}/agents`; + return target.roleId ? `${base}/${encodeURIComponent(target.roleId)}` : base; +} + /** * 触发 DOM 元素的锚点高亮脉冲 */ diff --git a/web/src/engine/messageIdentity.ts b/web/src/engine/messageIdentity.ts index 885d5975..1ded23ec 100644 --- a/web/src/engine/messageIdentity.ts +++ b/web/src/engine/messageIdentity.ts @@ -114,6 +114,10 @@ export function mergeLiveOverHistory( clientTurnId: live.clientTurnId ?? history.clientTurnId, clientMessageId: live.clientMessageId ?? history.clientMessageId, phaseEvents: live.phaseEvents?.length ? live.phaseEvents : history.phaseEvents, + processItems: + live.processItems && Object.keys(live.processItems).length > 0 + ? live.processItems + : history.processItems, livePhaseLabel: preferHistoryTerminal ? undefined : live.livePhaseLabel ?? history.livePhaseLabel, diff --git a/web/src/engine/phaseEventMapper.ts b/web/src/engine/phaseEventMapper.ts index 2bf5c611..5a79927c 100644 --- a/web/src/engine/phaseEventMapper.ts +++ b/web/src/engine/phaseEventMapper.ts @@ -83,17 +83,19 @@ export function applyPhaseEvent(strip: PhaseEvent[], phase?: PhaseEventPayload): const base = detail || meta.label; const label = decorateLabel(base, phase); const key = stepKeyOf(phase); + const role = phase.role?.trim() || undefined; // 已存在的条目:刷新文案;非失败事件再次到达时,failed 条目恢复 running(重试/续跑重新执行该步骤) const existingIdx = strip.findIndex((e) => e.step === key); if (existingIdx >= 0) { return strip.map((e, i) => { if (i !== existingIdx) return e; + const nextRole = role || e.role; if (e.status === 'failed' && phase.phase_id !== 'step_failed') { - return { ...e, label, detail, status: 'running' as const }; + return { ...e, label, detail, role: nextRole, status: 'running' as const }; } // 已收口条目被重复事件刷新文案时,保持 label 与 status 语义一致。 - return settleStep({ ...e, label, detail }); + return settleStep({ ...e, label, detail, role: nextRole }); }); } @@ -101,10 +103,12 @@ export function applyPhaseEvent(strip: PhaseEvent[], phase?: PhaseEventPayload): if (phase.phase_id === 'step_failed') { if (strip.some((e) => e.status === 'running')) { return strip.map((e) => - e.status === 'running' ? { ...e, status: 'failed' as const, detail: detail ?? e.detail } : e, + e.status === 'running' + ? { ...e, status: 'failed' as const, detail: detail ?? e.detail, role: role || e.role } + : e, ); } - return [...strip, { step: key, icon: meta.icon, label, detail, status: 'failed' as const }]; + return [...strip, { step: key, icon: meta.icon, label, detail, role, status: 'failed' as const }]; } // 收口型 phase:running 条目置 completed,不追加新节点 @@ -118,6 +122,6 @@ export function applyPhaseEvent(strip: PhaseEvent[], phase?: PhaseEventPayload): ); return [ ...settled, - { step: key, icon: meta.icon, label, detail, status: 'running' as const }, + { step: key, icon: meta.icon, label, detail, role, status: 'running' as const }, ]; } \ No newline at end of file diff --git a/web/src/engine/processEvent.test.ts b/web/src/engine/processEvent.test.ts new file mode 100644 index 00000000..7ce0b2d9 --- /dev/null +++ b/web/src/engine/processEvent.test.ts @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { applyDetailEvent, processStamp, summarizeProcess } from './processEvent'; + +test('applyDetailEvent appends reasoning deltas then closes on done', () => { + let items = applyDetailEvent({}, { + category: 'agent_thought', + step_id: 'tasks/01', + role: 'regulatory', + item_id: 'thought-1', + delta: '先看', + }, 1); + items = applyDetailEvent(items, { + category: 'agent_thought', + step_id: 'tasks/01', + item_id: 'thought-1', + delta: '法规', + }, 2); + assert.equal(items['tasks/01/thought-1'].text, '先看法规'); + assert.equal(items['tasks/01/thought-1'].status, 'streaming'); + items = applyDetailEvent(items, { + category: 'agent_thought', + step_id: 'tasks/01', + item_id: 'thought-1', + done: true, + }, 3); + assert.equal(items['tasks/01/thought-1'].status, 'done'); + assert.equal(items['tasks/01/thought-1'].text, '先看法规'); +}); + +test('content snapshot replaces accumulated delta', () => { + let items = applyDetailEvent({}, { + category: 'agent_thought', + item_id: 't', + delta: '旧', + }, 1); + items = applyDetailEvent(items, { + category: 'agent_thought', + item_id: 't', + content: '对账后的全文', + }, 2); + assert.equal(items['/t'].text, '对账后的全文'); +}); + +test('tool frames merge status without dropping the name', () => { + let items = applyDetailEvent({}, { + category: 'tool_call', + role: 'writer', + step_id: 'tasks/02', + item_id: 'call-1', + tool: { name: 'read_file', status: 'calling', input: 'output.md' }, + }, 1); + items = applyDetailEvent(items, { + category: 'tool_call', + item_id: 'call-1', + step_id: 'tasks/02', + tool: { status: 'failed', output: 'missing' }, + done: true, + }, 2); + const tool = items['tasks/02/call-1'].tool; + assert.equal(tool?.name, 'read_file'); + assert.equal(tool?.status, 'failed'); + assert.equal(tool?.input, 'output.md'); + assert.equal(tool?.output, 'missing'); + assert.equal(items['tasks/02/call-1'].status, 'error'); +}); + +test('summarizeProcess counts roles tools and thoughts', () => { + const items = applyDetailEvent( + applyDetailEvent({}, { + category: 'agent_thought', + role: 'regulatory', + item_id: 'a', + delta: '想', + }, 1), + { + category: 'tool_call', + role: 'writer', + item_id: 'b', + tool: { name: 'write_file', status: 'succeeded' }, + }, + 2, + ); + assert.equal(summarizeProcess(items), '2 名专员 · 1 次工具调用 · 1 段推演'); + assert.notEqual(processStamp(items), processStamp({})); +}); diff --git a/web/src/engine/processEvent.ts b/web/src/engine/processEvent.ts new file mode 100644 index 00000000..5c50e0d5 --- /dev/null +++ b/web/src/engine/processEvent.ts @@ -0,0 +1,133 @@ +/** 小队过程 detail 帧的纯函数聚合。气泡与子页面共用,不写进消息正文。 */ + +export type ProcessCategory = 'agent_thought' | 'tool_call' | 'step_output' | 'log'; + +export type ProcessItemStatus = 'streaming' | 'done' | 'error'; + +export type ProcessTool = { + name: string; + status: string; + input?: string; + output?: string; + durationMs?: number; +}; + +export type ProcessItem = { + key: string; + category: ProcessCategory; + role: string; + stepId: string; + title?: string; + text: string; + status: ProcessItemStatus; + tool?: ProcessTool; + updatedAt: number; +}; + +export type DetailEvent = { + category?: string; + step_id?: string; + role?: string; + title?: string; + content?: string; + collapsible?: boolean; + item_id?: string; + delta?: string; + done?: boolean; + tool?: { + name?: string; + status?: string; + input?: string; + output?: string; + duration_ms?: number; + }; +}; + +const CATEGORIES = new Set(['agent_thought', 'tool_call', 'step_output', 'log']); + +function asCategory(raw?: string): ProcessCategory { + if (raw && CATEGORIES.has(raw as ProcessCategory)) return raw as ProcessCategory; + return 'log'; +} + +function mergeTool(prev: ProcessTool | undefined, incoming: DetailEvent['tool']): ProcessTool | undefined { + if (!incoming && !prev) return undefined; + return { + name: incoming?.name || prev?.name || '', + status: incoming?.status || prev?.status || 'calling', + input: incoming?.input ?? prev?.input, + output: incoming?.output ?? prev?.output, + durationMs: incoming?.duration_ms ?? prev?.durationMs, + }; +} + +function nextStatus(prev: ProcessItemStatus | undefined, ev: DetailEvent, tool?: ProcessTool): ProcessItemStatus { + if (tool?.status === 'failed') return 'error'; + if (ev.done || tool?.status === 'succeeded') return 'done'; + if (ev.delta || ev.content) return prev === 'done' || prev === 'error' ? prev : 'streaming'; + return prev ?? 'streaming'; +} + +/** + * 把一条 detail 帧折叠进过程项表。 + * delta 追加;content 是快照对账(覆盖);done / tool 成功收口。 + */ +export function applyDetailEvent( + items: Record, + ev?: DetailEvent | null, + now = Date.now(), +): Record { + if (!ev?.category && !ev?.item_id && !ev?.delta && !ev?.content) return items; + const stepId = ev.step_id || ''; + const itemId = ev.item_id || ev.title || ev.category || 'item'; + const key = `${stepId}/${itemId}`; + const prev = items[key]; + let text = prev?.text ?? ''; + if (typeof ev.content === 'string' && ev.content.length > 0) { + text = ev.content; + } else if (ev.delta) { + text += ev.delta; + } + const tool = mergeTool(prev?.tool, ev.tool); + const status = nextStatus(prev?.status, ev, tool); + return { + ...items, + [key]: { + key, + category: asCategory(ev.category || prev?.category), + role: ev.role || prev?.role || '', + stepId, + title: ev.title || prev?.title, + text, + status, + tool, + updatedAt: now, + }, + }; +} + +export function processItemsOf(items?: Record | null): ProcessItem[] { + if (!items) return []; + return Object.values(items).sort((a, b) => a.updatedAt - b.updatedAt || a.key.localeCompare(b.key)); +} + +/** 气泡 L3 摘要:专员数 · 工具次数 · 推演段数。空则返回空串。 */ +export function summarizeProcess(items?: Record | null): string { + const list = processItemsOf(items); + if (list.length === 0) return ''; + const roles = new Set(list.map((item) => item.role).filter(Boolean)); + const tools = list.filter((item) => item.category === 'tool_call').length; + const thoughts = list.filter((item) => item.category === 'agent_thought').length; + const parts: string[] = []; + if (roles.size > 0) parts.push(`${roles.size} 名专员`); + if (tools > 0) parts.push(`${tools} 次工具调用`); + if (thoughts > 0) parts.push(`${thoughts} 段推演`); + return parts.join(' · '); +} + +/** 供消息合并判断过程项是否变化(不计时间戳)。 */ +export function processStamp(items?: Record | null): string { + return processItemsOf(items) + .map((item) => `${item.key}:${item.status}:${item.text.length}:${item.tool?.status ?? ''}`) + .join('|'); +} diff --git a/web/src/engine/renderBlockMarkdown.test.ts b/web/src/engine/renderBlockMarkdown.test.ts new file mode 100644 index 00000000..39d66134 --- /dev/null +++ b/web/src/engine/renderBlockMarkdown.test.ts @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { renderBlockMarkdown } from './renderBlockMarkdown'; + +test('renderBlockMarkdown escapes html and pretty-prints json fences', () => { + const html = renderBlockMarkdown('see