feat(web): 用小队子页面展示思考与工具过程
气泡只聚合过程摘要并链到 hash 子页面;成员编制来自只读 squad 端点,思考与工具默认折叠。
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+42
-2
@@ -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<SubagentsTarget | null>(null);
|
||||
const [docVisibilityModalOpen, setDocVisibilityModalOpen] = useState(false);
|
||||
const [targetDocForVisibility, setTargetDocForVisibility] = useState<DeliverableItem | null>(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 (
|
||||
<OnDemand
|
||||
label="小队过程"
|
||||
load={loadSubagentsView}
|
||||
componentProps={{
|
||||
runId: subagentsTarget.runId,
|
||||
sessionId: subagentsTarget.sessionId,
|
||||
roleId: subagentsTarget.roleId,
|
||||
onExit: () => {
|
||||
setView('workbench');
|
||||
const next = `${window.location.pathname}${window.location.search}`;
|
||||
window.history.replaceState(null, '', next);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 全屏系统管理台:与工作台并列的顶层视图,不经过 AppShell
|
||||
if (view === 'admin') {
|
||||
return (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<SquadProjection> {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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<TaskRunCardProps> = ({ message }) => {
|
||||
<span style={{ wordBreak: 'break-word' }}>{artifactBits.join(' · ')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.taskRunId && message.sessionId && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="task-run-card-subagents"
|
||||
onClick={() => {
|
||||
window.location.hash = subagentsHash({
|
||||
sessionId: message.sessionId,
|
||||
runId: message.taskRunId || '',
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
border: 0,
|
||||
borderTop: '1px solid var(--border)',
|
||||
background: 'transparent',
|
||||
padding: '8px 12px',
|
||||
fontSize: '12px',
|
||||
color: 'var(--brand-500)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{isLive ? '查看小队过程 →' : summarizeProcess(message.processItems) || '查看小队过程 →'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -43,7 +43,7 @@ export interface WorkbenchViewProps {
|
||||
currentDocRole: UserRole;
|
||||
setRightsModalOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setUserSettingsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setView: (view: 'workbench' | 'admin') => void;
|
||||
setView: (view: 'workbench' | 'admin' | 'subagents') => void;
|
||||
setProjectMembersModalOpen: Dispatch<SetStateAction<boolean>>;
|
||||
currentWfState: WorkbenchWorkflowState;
|
||||
workflowGovernanceEnabled: boolean;
|
||||
|
||||
@@ -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 (
|
||||
<div data-testid={props.testId} style={{ border: '1px solid var(--border)', borderRadius: 8, marginBottom: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
background: 'transparent',
|
||||
border: 0,
|
||||
padding: '8px 10px',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
color: 'var(--text)',
|
||||
}}
|
||||
>
|
||||
{props.summary}
|
||||
</button>
|
||||
{open ? <div style={{ padding: '0 10px 10px', fontSize: 13 }}>{props.children}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<CollapsibleBlock testId="process-step-output" summary={`产出 · ${title}`}>
|
||||
<div dangerouslySetInnerHTML={{ __html: renderBlockMarkdown(item.text) }} />
|
||||
</CollapsibleBlock>
|
||||
);
|
||||
}
|
||||
@@ -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, /思考 · /);
|
||||
});
|
||||
@@ -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 (
|
||||
<div data-testid="subagents-view" style={{ minHeight: '100vh', background: 'var(--bg, #f8fafc)', color: 'var(--text, #0f172a)' }}>
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', gap: 12, padding: '12px 16px', borderBottom: '1px solid var(--border, #e2e8f0)' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700 }}>小队过程 · {props.title || '任务'}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint, #64748b)' }}>run:{runLabel}</div>
|
||||
</div>
|
||||
<button type="button" onClick={props.onExit}>返回对话</button>
|
||||
</header>
|
||||
{props.notice ? <p role="status" style={{ margin: '8px 16px' }}>{props.notice}</p> : null}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '240px 1fr', minHeight: 'calc(100vh - 120px)' }}>
|
||||
<aside data-testid="subagents-members" style={{ borderRight: '1px solid var(--border, #e2e8f0)', padding: 8 }}>
|
||||
{props.members.length === 0 ? <p>还没有小队编制</p> : null}
|
||||
{props.members.map((item) => {
|
||||
const status = memberRunStatus(item.role, props.phases);
|
||||
const active = item.role === selected;
|
||||
return (
|
||||
<button
|
||||
key={item.role}
|
||||
type="button"
|
||||
data-testid="subagents-member"
|
||||
data-role={item.role}
|
||||
aria-pressed={active}
|
||||
onClick={() => props.onSelectRole(item.role)}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
marginBottom: 6,
|
||||
padding: 8,
|
||||
borderRadius: 8,
|
||||
border: active ? '1px solid var(--brand-500, #2563eb)' : '1px solid transparent',
|
||||
background: active ? 'rgba(37, 99, 235, 0.08)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 650 }}>{item.name || item.role}</div>
|
||||
<div style={{ fontSize: 12 }}>{STATUS_LABEL[status]}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</aside>
|
||||
<section data-testid="subagents-detail" style={{ padding: 12 }}>
|
||||
{member ? (
|
||||
<p style={{ marginTop: 0 }}>
|
||||
{member.name} · {member.role}
|
||||
{member.responsibilities?.length ? ` · ${member.responsibilities.join('、')}` : ''}
|
||||
</p>
|
||||
) : (
|
||||
<p>选择一名专员查看过程</p>
|
||||
)}
|
||||
{visible.length === 0 ? <p>还没有过程记录</p> : null}
|
||||
{visible.map((item) => {
|
||||
if (item.category === 'tool_call') return <ToolCallCard key={item.key} item={item} />;
|
||||
if (item.category === 'step_output') return <ProcessStepOutput key={item.key} item={item} />;
|
||||
if (item.category === 'agent_thought') return <ThinkingBlock key={item.key} item={item} />;
|
||||
return <ProcessStepOutput key={item.key} item={item} />;
|
||||
})}
|
||||
</section>
|
||||
</div>
|
||||
<ol data-testid="subagents-timeline" style={{ listStyle: 'none', margin: 0, padding: '8px 16px', borderTop: '1px solid var(--border, #e2e8f0)', fontSize: 12 }}>
|
||||
{props.phases.map((phase) => (
|
||||
<li key={phase.step}>{phase.icon} {phase.label}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function itemsForLayout(items: Record<string, ProcessItem>): ProcessItem[] {
|
||||
return processItemsOf(items);
|
||||
}
|
||||
@@ -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<SquadMemberDTO[]>([]);
|
||||
const [title, setTitle] = useState('');
|
||||
const [notice, setNotice] = useState<string | undefined>();
|
||||
|
||||
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 (
|
||||
<SubagentsLayout
|
||||
title={title}
|
||||
runId={props.runId}
|
||||
members={members}
|
||||
items={itemsForLayout(stream.items)}
|
||||
phases={stream.phases}
|
||||
selectedRole={props.roleId}
|
||||
onSelectRole={selectRole}
|
||||
onExit={props.onExit}
|
||||
notice={streamNotice}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<CollapsibleBlock
|
||||
testId="thinking-block"
|
||||
defaultOpen={item.status === 'streaming'}
|
||||
summary={summaryOf(item)}
|
||||
>
|
||||
<div dangerouslySetInnerHTML={{ __html: renderBlockMarkdown(item.text) }} />
|
||||
</CollapsibleBlock>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<CollapsibleBlock testId="tool-call-card" summary={`工具 ${name} · ${statusLabel(status)}${duration}`}>
|
||||
{body ? <div dangerouslySetInnerHTML={{ __html: renderBlockMarkdown(body) }} /> : <p>无入参或结果</p>}
|
||||
</CollapsibleBlock>
|
||||
);
|
||||
}
|
||||
@@ -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: '法规' });
|
||||
});
|
||||
|
||||
|
||||
@@ -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 元素的锚点高亮脉冲
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
}
|
||||
@@ -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({}));
|
||||
});
|
||||
@@ -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<ProcessCategory>(['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<string, ProcessItem>,
|
||||
ev?: DetailEvent | null,
|
||||
now = Date.now(),
|
||||
): Record<string, ProcessItem> {
|
||||
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<string, ProcessItem> | 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<string, ProcessItem> | 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<string, ProcessItem> | null): string {
|
||||
return processItemsOf(items)
|
||||
.map((item) => `${item.key}:${item.status}:${item.text.length}:${item.tool?.status ?? ''}`)
|
||||
.join('|');
|
||||
}
|
||||
@@ -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 <script>\n```json\n{"a":1}\n```');
|
||||
assert.doesNotMatch(html, /<script>/);
|
||||
assert.match(html, /<script>/);
|
||||
assert.match(html, /<pre><code class="lang-json">/);
|
||||
assert.match(html, /"a": 1/);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/** 工具入参/结果用的轻量块级渲染:转义优先,只识别 fenced code。不引入新依赖。 */
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function fenceBody(lang: string, body: string): string {
|
||||
let text = body.replace(/\n$/, '');
|
||||
if (lang === 'json') {
|
||||
try {
|
||||
text = JSON.stringify(JSON.parse(text), null, 2);
|
||||
} catch {
|
||||
// 保持原文,仍然转义。
|
||||
}
|
||||
}
|
||||
const klass = lang ? ` class="lang-${escapeHtml(lang)}"` : '';
|
||||
return `<pre><code${klass}>${escapeHtml(text)}</code></pre>`;
|
||||
}
|
||||
|
||||
export function renderBlockMarkdown(source: string): string {
|
||||
const text = source ?? '';
|
||||
const parts: string[] = [];
|
||||
const re = /```([a-zA-Z0-9_-]*)\n([\s\S]*?)```/g;
|
||||
let last = 0;
|
||||
for (const match of text.matchAll(re)) {
|
||||
const index = match.index ?? 0;
|
||||
if (index > last) parts.push(`<p>${escapeHtml(text.slice(last, index)).replace(/\n/g, '<br>')}</p>`);
|
||||
parts.push(fenceBody(match[1] || '', match[2] || ''));
|
||||
last = index + match[0].length;
|
||||
}
|
||||
if (last < text.length) {
|
||||
parts.push(`<p>${escapeHtml(text.slice(last)).replace(/\n/g, '<br>')}</p>`);
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
saveSessionDraft,
|
||||
} from '../data/storage';
|
||||
import { enrichMessagesWithCategories } from '../engine/categoryHelper';
|
||||
import { processStamp } from '../engine/processEvent';
|
||||
import {
|
||||
filterPendingLiveMessages,
|
||||
isLiveMessageInHistory,
|
||||
@@ -529,6 +530,7 @@ export function useChatWindow({
|
||||
liveM.status !== m.status ||
|
||||
liveM.livePhaseLabel !== m.livePhaseLabel ||
|
||||
(liveM.phaseEvents?.length || 0) !== (m.phaseEvents?.length || 0) ||
|
||||
processStamp(liveM.processItems) !== processStamp(m.processItems) ||
|
||||
liveM.errorInfo !== m.errorInfo ||
|
||||
liveM.serverId !== m.serverId)
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { StreamEvent } from '../api/chatStream';
|
||||
import { readTaskRunSSEOnce, subscribeTaskRunEvents } from '../api/taskRuns';
|
||||
import { applyPhaseEvent } from '../engine/phaseEventMapper';
|
||||
import { applyDetailEvent, type ProcessItem } from '../engine/processEvent';
|
||||
import type { PhaseEvent } from '../types/workbench';
|
||||
|
||||
export type RunProcessStatus = 'loading' | 'live' | 'done' | 'error';
|
||||
|
||||
/** 子页面自持订阅:先重放已落库事件,未终态再续订 live。 */
|
||||
export function useRunProcessStream(runId: string): {
|
||||
items: Record<string, ProcessItem>;
|
||||
phases: PhaseEvent[];
|
||||
status: RunProcessStatus;
|
||||
} {
|
||||
const [items, setItems] = useState<Record<string, ProcessItem>>({});
|
||||
const [phases, setPhases] = useState<PhaseEvent[]>([]);
|
||||
const [status, setStatus] = useState<RunProcessStatus>('loading');
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
let itemState: Record<string, ProcessItem> = {};
|
||||
let phaseState: PhaseEvent[] = [];
|
||||
let terminal = false;
|
||||
const onEvent = (ev: StreamEvent) => {
|
||||
if (ev.type === 'detail') {
|
||||
itemState = applyDetailEvent(itemState, ev.detail);
|
||||
setItems(itemState);
|
||||
} else if (ev.type === 'phase') {
|
||||
phaseState = applyPhaseEvent(phaseState, ev.phase);
|
||||
setPhases(phaseState);
|
||||
} else if (ev.type === 'done' || ev.type === 'error') {
|
||||
terminal = true;
|
||||
}
|
||||
};
|
||||
void (async () => {
|
||||
try {
|
||||
const replay = await readTaskRunSSEOnce(runId, 0, onEvent, controller.signal, { replayOnly: true });
|
||||
if (controller.signal.aborted) return;
|
||||
if (replay.terminal || terminal) {
|
||||
setStatus('done');
|
||||
return;
|
||||
}
|
||||
setStatus('live');
|
||||
await subscribeTaskRunEvents(
|
||||
runId,
|
||||
{ onEvent, afterSeq: replay.lastSeq },
|
||||
controller.signal,
|
||||
);
|
||||
if (!controller.signal.aborted) setStatus('done');
|
||||
} catch {
|
||||
if (!controller.signal.aborted) setStatus('error');
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [runId]);
|
||||
|
||||
return { items, phases, status };
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { listProjectWorkflowDocuments } from '../api/projectDocuments';
|
||||
import { createTaskRun, getTaskRun, resumeTaskRun, subscribeTaskRunEvents } from '../api/taskRuns';
|
||||
import { buildTaskRequest, streamTask } from '../api/taskStream';
|
||||
import { applyPhaseEvent } from '../engine/phaseEventMapper';
|
||||
import { applyDetailEvent } from '../engine/processEvent';
|
||||
import { resolveTaskTemplateForMessage, wrongWorkflowGuidance } from '../engine/templateMatch';
|
||||
import { GOVERNANCE_BLOCKED_HINT, isBlockedGovernanceStatus } from '../lib/blockedGovernanceCopy';
|
||||
import {
|
||||
@@ -479,6 +480,14 @@ export function useTaskRunAttempts(input: TaskRunAttemptsInput) {
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 'detail': {
|
||||
// 过程帧只进 processItems,不拼进气泡正文。
|
||||
patchAsst((m) => ({
|
||||
status: m.status === 'pending' ? 'streaming_status' : m.status,
|
||||
processItems: applyDetailEvent(m.processItems || {}, ev.detail),
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 'status': {
|
||||
if (ev.status?.message) {
|
||||
patchAsst({ status: 'streaming_status', livePhaseLabel: ev.status.message });
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { defaultMemberRole, memberRunStatus } from './squadMembers';
|
||||
import type { PhaseEvent } from '../types/workbench';
|
||||
|
||||
const phases: PhaseEvent[] = [
|
||||
{ step: 'step_executing#1', icon: '⚙️', label: '法规', status: 'completed', role: 'regulatory' },
|
||||
{ step: 'step_executing#2', icon: '⚙️', label: '撰写', status: 'running', role: 'writer' },
|
||||
];
|
||||
|
||||
test('memberRunStatus prefers running over older completed', () => {
|
||||
assert.equal(memberRunStatus('writer', phases), 'running');
|
||||
assert.equal(memberRunStatus('regulatory', phases), 'completed');
|
||||
assert.equal(memberRunStatus('leader', phases), 'waiting');
|
||||
});
|
||||
|
||||
test('defaultMemberRole selects the running member', () => {
|
||||
assert.equal(
|
||||
defaultMemberRole([{ role: 'regulatory' }, { role: 'writer' }, { role: 'leader' }], phases),
|
||||
'writer',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { PhaseEvent } from '../types/workbench';
|
||||
|
||||
export type MemberRunStatus = 'waiting' | 'running' | 'completed' | 'failed';
|
||||
|
||||
export function memberRunStatus(role: string, phases: PhaseEvent[]): MemberRunStatus {
|
||||
const related = phases.filter((phase) => phase.role === role);
|
||||
if (related.some((phase) => phase.status === 'running')) return 'running';
|
||||
if (related.some((phase) => phase.status === 'failed')) return 'failed';
|
||||
if (related.some((phase) => phase.status === 'completed')) return 'completed';
|
||||
return 'waiting';
|
||||
}
|
||||
|
||||
/** 未指定 role 时:当前执行中,否则最近完成,否则编制第一人。 */
|
||||
export function defaultMemberRole(
|
||||
members: { role: string }[],
|
||||
phases: PhaseEvent[],
|
||||
): string | undefined {
|
||||
const running = members.find((member) => memberRunStatus(member.role, phases) === 'running');
|
||||
if (running) return running.role;
|
||||
for (let i = members.length - 1; i >= 0; i -= 1) {
|
||||
if (memberRunStatus(members[i].role, phases) === 'completed') return members[i].role;
|
||||
}
|
||||
return members[0]?.role;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ProcessItem } from '../engine/processEvent';
|
||||
import { DocLifecycleState, UserRole } from './docgov';
|
||||
import { DocVisibilityRule } from './rbac';
|
||||
import { MessageContentKind, MessageExportCapability } from './replyExport';
|
||||
@@ -57,6 +58,8 @@ export interface PhaseEvent {
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
/** 后端 phase.message 原文(刷新快照 / 时间线当前行展开用) */
|
||||
detail?: string;
|
||||
/** 专员 role(step_executing 等按成员归集状态) */
|
||||
role?: string;
|
||||
}
|
||||
|
||||
/** 服务端写入 chat_messages.metadata.task_phase_snapshot 的折叠进度快照 */
|
||||
@@ -218,6 +221,11 @@ export interface ChatMessage {
|
||||
branchTag?: string;
|
||||
timestamp: string;
|
||||
phaseEvents?: PhaseEvent[];
|
||||
/**
|
||||
* 过程 detail 聚合(思考/工具/步骤产出)。只在气泡摘要与小队子页面使用,
|
||||
* 不得拼进 content。
|
||||
*/
|
||||
processItems?: Record<string, ProcessItem>;
|
||||
/** 任务总耗时(毫秒);done 事件或 snapshot.duration_ms */
|
||||
taskDurationMs?: number;
|
||||
/** 历史消息带来的 phase 快照标记(有则刷新不必再 replay) */
|
||||
|
||||
Reference in New Issue
Block a user