diff --git a/.env.example b/.env.example index f227bd5..29c505f 100644 --- a/.env.example +++ b/.env.example @@ -94,21 +94,10 @@ LOG_DIR=./logs # 6. Agent 运行时参数 # ───────────────────────────────────────────────────────────────────────────── -# 最大 ReAct 推理步数(默认 8) -# AGENT_MAX_STEPS=8 -# 单个工具执行超时秒数(默认 120) -# AGENT_TOOL_TIMEOUT_SECS=120 -# 工具输出截断字符数(默认 4000) -# AGENT_MAX_TOOL_OUTPUT_CHARS=4000 -# 上下文触发压缩的字符阈值(默认 16000) -# AGENT_CONTEXT_CHAR_LIMIT=16000 -# Token 软限制:触发渐进式提醒(默认 32000) -# AGENT_TOKEN_SOFT_LIMIT=32000 -# Token 硬限制:触发强制压缩或终止(默认 40000) -# AGENT_TOKEN_HARD_LIMIT=40000 -# 触发 snip 压缩的最大消息数(默认 50) -# AGENT_MAX_MESSAGES=50 - +# Token 软限制:统一压缩触发阈值(默认 80000) +# AGENT_TOKEN_SOFT_LIMIT=80000 +# Token 硬限制:触发强制压缩或终止(默认 100000) +# AGENT_TOKEN_HARD_LIMIT=100000 # ── 权限系统 ── # Agent 工具权限规则(逗号分隔,支持内容级匹配 "ToolName(pattern)") @@ -134,6 +123,12 @@ LOG_DIR=./logs # 询问规则(逗号分隔,需用户确认) # AGENT_PERMISSIONS_ASK=run_bash,file_write,file_edit,download_paper +# 附加文件访问目录(逗号分隔,扩展 Agent 工作沙箱范围,默认空=仅工作目录) +# AGENT_ADDITIONAL_DIRS=/data,/tmp/astro + +# 子代理工具白名单(逗号分隔工具名,空=继承父代理全部工具,可选) +# AGENT_SUBAGENT_ALLOWED_TOOLS=read_file,grep_files,search_papers + # ───────────────────────────────────────────────────────────────────────────── # 7. 自动记忆提取(实验性功能,同时控制压缩记忆桥接 P3) # ───────────────────────────────────────────────────────────────────────────── diff --git a/CLAUDE.md b/CLAUDE.md index 4ad0a94..bfd3b91 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,7 +134,7 @@ The agent (`src/agent/`) implements a **ReAct** (Thought → Action → Observat 11. **Teams** (`team/`): File-based inbox directory per session for lead/teammate message passing 12. **Background tasks** (`background.rs`): Slow ops (download, parse) can run async; results inject via `BgNotificationQueue` before next LLM call -Environment variables for agent tuning: `AGENT_MAX_STEPS` (default 8), `AGENT_TOOL_TIMEOUT_SECS` (default 120), `AGENT_MAX_TOOL_OUTPUT_CHARS` (default 4000), `AGENT_CONTEXT_CHAR_LIMIT` (default 16000), `AGENT_TOKEN_SOFT_LIMIT` / `AGENT_TOKEN_HARD_LIMIT`. +Environment variables for agent tuning: `AGENT_TOKEN_SOFT_LIMIT` (default 80000) / `AGENT_TOKEN_HARD_LIMIT` (default 100000). ### Database diff --git a/dashboard/src/components/agent/AgentMarkdown.tsx b/dashboard/src/components/agent/AgentMarkdown.tsx new file mode 100644 index 0000000..6e7a7aa --- /dev/null +++ b/dashboard/src/components/agent/AgentMarkdown.tsx @@ -0,0 +1,29 @@ +// dashboard/src/components/agent/AgentMarkdown.tsx +// 统一的 Markdown 渲染器:KaTeX 数学公式 + GFM + sanitize +import ReactMarkdown from 'react-markdown'; +import remarkMath from 'remark-math'; +import remarkGfm from 'remark-gfm'; +import rehypeRaw from 'rehype-raw'; +import rehypeKatex from 'rehype-katex'; +import rehypeSanitize from 'rehype-sanitize'; +import 'katex/dist/katex.min.css'; +import { preprocessMath } from '../../utils/preprocess'; +import { safeSchema } from './constants'; + +interface AgentMarkdownProps { + children: string; + className?: string; +} + +export function AgentMarkdown({ children, className }: AgentMarkdownProps) { + return ( +
+ + {preprocessMath(children)} + +
+ ); +} diff --git a/dashboard/src/components/agent/AnswerCard.tsx b/dashboard/src/components/agent/AnswerCard.tsx new file mode 100644 index 0000000..f86b99d --- /dev/null +++ b/dashboard/src/components/agent/AnswerCard.tsx @@ -0,0 +1,34 @@ +// dashboard/src/components/agent/AnswerCard.tsx +// 最终答案卡片:展示 Agent 的最终回答 +import { CheckCircle2 } from 'lucide-react'; +import type { ColorScheme } from './constants'; +import { AgentMarkdown } from './AgentMarkdown'; + +interface AnswerCardProps { + content: string; + colorScheme: ColorScheme; + isStreaming?: boolean; +} + +export function AnswerCard({ content, colorScheme, isStreaming }: AnswerCardProps) { + return ( +
+
+
+ + + 结论 + +
+ + {content} + +
+
+
+ ); +} diff --git a/dashboard/src/components/agent/SubAgentContainer.tsx b/dashboard/src/components/agent/SubAgentContainer.tsx new file mode 100644 index 0000000..f01671a --- /dev/null +++ b/dashboard/src/components/agent/SubAgentContainer.tsx @@ -0,0 +1,182 @@ +// dashboard/src/components/agent/SubAgentContainer.tsx +// 子代理容器:展示子代理的执行过程(可折叠,含嵌套时间线) +import { Network, CheckCircle2, Loader } from 'lucide-react'; +import type { TimelineItem } from './types'; +import { SUBAGENT_COLORS } from './constants'; +import { ThoughtCard } from './ThoughtCard'; +import { ToolCallCard } from './ToolCallCard'; +import { AnswerCard } from './AnswerCard'; + +interface SubAgentContainerProps { + status: 'streaming' | 'complete'; + children: TimelineItem[]; + summary?: string; + isStreaming: boolean; + isCollapsed: boolean; + onToggle: () => void; + // 展开/折叠状态回调 + expandedThoughts: Record; + expandedArgs: Record; + expandedResults: Record; + onToggleThought: (key: string) => void; + onToggleArgs: (tcId: string) => void; + onToggleResult: (tcId: string) => void; +} + +export function SubAgentContainer({ + status, + children, + summary, + isStreaming: parentStreaming, + isCollapsed, + onToggle, + expandedThoughts, + expandedArgs, + expandedResults, + onToggleThought, + onToggleArgs, + onToggleResult, +}: SubAgentContainerProps) { + const isComplete = status === 'complete'; + const stepCount = children.length; + const toolCount = children.filter(c => c.type === 'tool_call').length; + + return ( +
+
+
+ {/* 头部 */} + + + {/* 折叠态摘要 */} + {isCollapsed && summary && ( +
+

+ {summary.length > 200 ? summary.slice(0, 200) + '...' : summary} +

+
+ )} + {isCollapsed && !summary && parentStreaming && ( +
+

+ 子代理正在收集信息... +

+
+ )} + + {/* 展开态:嵌套时间线 */} + {!isCollapsed && ( +
+ {children.length === 0 && parentStreaming && ( +
+ + 子代理初始化中... +
+ )} +
+ {children.map((child, childIdx) => + renderNestedTimelineItem( + child, + childIdx, + parentStreaming, + expandedThoughts, + expandedArgs, + expandedResults, + onToggleThought, + onToggleArgs, + onToggleResult, + ) + )} +
+
+ )} +
+
+ ); +} + +// 子代理内部时间线条目渲染 +function renderNestedTimelineItem( + item: TimelineItem, + idx: number, + isStreaming: boolean, + expandedThoughts: Record, + expandedArgs: Record, + expandedResults: Record, + onToggleThought: (key: string) => void, + onToggleArgs: (tcId: string) => void, + onToggleResult: (tcId: string) => void, +) { + switch (item.type) { + case 'thought': { + const thoughtKey = `sub-thought-${item.step}`; + return ( + onToggleThought(thoughtKey)} + /> + ); + } + case 'tool_call': { + const tcId = item.id; + return ( + onToggleArgs(tcId)} + onToggleResult={() => onToggleResult(tcId)} + /> + ); + } + case 'answer': + return ( + + ); + default: + return null; + } +} diff --git a/dashboard/src/components/agent/ThoughtCard.tsx b/dashboard/src/components/agent/ThoughtCard.tsx new file mode 100644 index 0000000..990b216 --- /dev/null +++ b/dashboard/src/components/agent/ThoughtCard.tsx @@ -0,0 +1,71 @@ +// dashboard/src/components/agent/ThoughtCard.tsx +// 思考卡片:展示 Agent 的推理过程(默认折叠,可展开) +import { Brain } from 'lucide-react'; +import type { ColorScheme } from './constants'; + +interface ThoughtCardProps { + step: number; + content: string; + isStreaming: boolean; + colorScheme: ColorScheme; + isExpanded: boolean; + onToggle: () => void; +} + +export function ThoughtCard({ + step, + content, + isStreaming, + colorScheme, + isExpanded, + onToggle, +}: ThoughtCardProps) { + const preview = content.length > 120 ? content.slice(0, 120) + '…' : content; + const dotColor = isStreaming ? colorScheme.thought.active : colorScheme.thought.idle; + + // 从 colorScheme 推导包裹色(用于容器背景) + const isSub = colorScheme.thought.idle.includes('violet'); + const bgClass = isSub + ? 'bg-violet-50/40 border border-violet-100/50' + : 'bg-purple-50/40 border border-purple-100/50'; + const textClass = isSub ? 'text-violet-600' : 'text-purple-600'; + const previewClass = isSub ? 'text-violet-400' : 'text-purple-400'; + const hoverClass = isSub ? 'text-violet-500 hover:text-violet-700' : 'text-purple-500 hover:text-purple-700'; + const mutedClass = isSub ? 'text-violet-400' : 'text-purple-400'; + + return ( +
+
+
+ + {isExpanded ? ( +

+ {content} +

+ ) : ( +

+ {preview} +

+ )} +
+
+ ); +} diff --git a/dashboard/src/components/agent/ToolCallCard.tsx b/dashboard/src/components/agent/ToolCallCard.tsx new file mode 100644 index 0000000..2abf5b4 --- /dev/null +++ b/dashboard/src/components/agent/ToolCallCard.tsx @@ -0,0 +1,113 @@ +// dashboard/src/components/agent/ToolCallCard.tsx +// 工具调用卡片:展示参数和结果,皆可独立折叠 +import { Settings, Eye } from 'lucide-react'; +import type { ColorScheme } from './constants'; +import { getToolDisplayName } from './toolDisplayNames'; +import { AgentMarkdown } from './AgentMarkdown'; +import { useAutoScroll } from './useAutoScroll'; + +interface ToolCallCardProps { + step: number; + name: string; + arguments: any; + result?: { output: string; isError: boolean }; + isStreaming: boolean; + colorScheme: ColorScheme; + isArgsExpanded: boolean; + isResultExpanded: boolean; + onToggleArgs: () => void; + onToggleResult: () => void; +} + +export function ToolCallCard({ + step, + name, + arguments: args, + result, + isStreaming, + colorScheme, + isArgsExpanded, + isResultExpanded, + onToggleArgs, + onToggleResult, +}: ToolCallCardProps) { + const hasResult = !!result; + const dotColor = isStreaming ? colorScheme.tool_call.active : colorScheme.tool_call.idle; + const { chatEndRef, scrollContainerRef, handleScroll } = useAutoScroll( + result ? [result.output] : [], + ); + + return ( +
+
+
+ {/* 工具名称 */} +
+
+ + 调用工具: {getToolDisplayName(name)} + + · Step {step} + +
+ +
+ + {/* 参数 */} + {isArgsExpanded && ( +
+ {JSON.stringify(args, null, 2)} +
+ )} + + {/* 结果 */} + {hasResult && ( +
+
+
+ + 观测与反馈 (Observation) + {result!.isError && ( + + 错误返回 + + )} +
+ +
+ {isResultExpanded ? ( +
+ + {result!.output} + +
+
+ ) : ( +
+ 结果已截断 (共 {result!.output.length} 字符)。点击右侧展开。 +
+ )} +
+ )} +
+
+ ); +} diff --git a/dashboard/src/components/agent/constants.ts b/dashboard/src/components/agent/constants.ts new file mode 100644 index 0000000..05cee03 --- /dev/null +++ b/dashboard/src/components/agent/constants.ts @@ -0,0 +1,36 @@ +// dashboard/src/components/agent/constants.ts +// Agent 渲染共享常量:safeSchema、颜色方案等 +import { defaultSchema } from 'rehype-sanitize'; + +export const safeSchema = { + ...defaultSchema, + attributes: { + ...defaultSchema.attributes, + '*': (defaultSchema.attributes?.['*'] || []).concat([ + 'className', 'style', 'mathvariant', 'display', + ]), + }, + tagNames: (defaultSchema.tagNames || []).concat([ + 'math', 'mrow', 'mi', 'mo', 'mn', 'msup', 'msub', 'msubsup', + 'mfrac', 'mover', 'munder', 'munderover', 'mspace', 'mtext', 'annotation', + ]), +}; + +// 时间线颜色方案 +export interface ColorScheme { + thought: { active: string; idle: string }; + tool_call: { active: string; idle: string }; + answer: string; +} + +export const PARENT_COLORS: ColorScheme = { + thought: { active: 'bg-purple-400 animate-pulse', idle: 'bg-purple-300' }, + tool_call: { active: 'bg-sky-500 animate-pulse', idle: 'bg-sky-400' }, + answer: 'bg-emerald-400', +}; + +export const SUBAGENT_COLORS: ColorScheme = { + thought: { active: 'bg-violet-400 animate-pulse', idle: 'bg-violet-300' }, + tool_call: { active: 'bg-violet-500 animate-pulse', idle: 'bg-violet-400' }, + answer: 'bg-violet-400', +}; diff --git a/dashboard/src/components/agent/index.ts b/dashboard/src/components/agent/index.ts new file mode 100644 index 0000000..5197402 --- /dev/null +++ b/dashboard/src/components/agent/index.ts @@ -0,0 +1,23 @@ +// dashboard/src/components/agent/index.ts +// Agent 渲染共享模块 barrel export +export { safeSchema, PARENT_COLORS, SUBAGENT_COLORS } from './constants'; +export type { ColorScheme } from './constants'; +export { getToolDisplayName } from './toolDisplayNames'; +export { AgentMarkdown } from './AgentMarkdown'; +export { ThoughtCard } from './ThoughtCard'; +export { ToolCallCard } from './ToolCallCard'; +export { AnswerCard } from './AnswerCard'; +export { SubAgentContainer } from './SubAgentContainer'; +export { useAgentSSE } from './useAgentSSE'; +export { useAutoScroll } from './useAutoScroll'; +export { + findStreamingSubAgent, + routeThought, + routeToolCall, + routeToolResult, + routeTextDelta, + isSubagentThought, + isSubagentContainerCall, + isSubagentChildCall, +} from './subagentRouting'; +export type { TimelineItem, SSEEventHandlers, AgentSSEParams } from './types'; diff --git a/dashboard/src/components/agent/subagentRouting.ts b/dashboard/src/components/agent/subagentRouting.ts new file mode 100644 index 0000000..7de388b --- /dev/null +++ b/dashboard/src/components/agent/subagentRouting.ts @@ -0,0 +1,247 @@ +// dashboard/src/components/agent/subagentRouting.ts +// 子代理路由逻辑(纯函数,TimelineItem[] 操作) +// ResearchAgentPanel 和 AIAssistantPanel 共用 +import type { TimelineItem } from './types'; + +// ── 前缀检测 ── + +export const SUBAGENT_CONTAINER_NAMES = ['subagent', 'delegate_research']; + +export function isSubagentThought(content: string): boolean { + return content.startsWith('[子代理] '); +} + +export function cleanSubagentThought(content: string): string { + return content.slice(6); // 去掉 "[子代理] " 前缀 +} + +export function isSubagentContainerCall(name: string): boolean { + return SUBAGENT_CONTAINER_NAMES.includes(name); +} + +export function isSubagentChildCall(name: string): boolean { + return name.startsWith('[sub] '); +} + +export function cleanSubagentChildName(name: string): string { + return name.slice(6); +} + +export function isSubagentChildResult(name: string): boolean { + return name.startsWith('[sub] '); +} + +// ── 容器查找 ── + +// 找到最后一个 streaming 状态的 subagent_container 索引 +export function findStreamingSubAgent(timeline: TimelineItem[]): number { + for (let i = timeline.length - 1; i >= 0; i--) { + const t = timeline[i]; + if (t.type === 'subagent_container' && t.status === 'streaming') { + return i; + } + } + return -1; +} + +// ── 操作函数:接受 timeline → 返回 new timeline(不可变风格)── + +// 路由 thought 事件 +export function routeThought( + timeline: TimelineItem[], + step: number, + content: string, +): TimelineItem[] { + // 子代理思考 → 路由到活跃容器 + if (isSubagentThought(content)) { + const subContent = cleanSubagentThought(content); + const saIdx = findStreamingSubAgent(timeline); + if (saIdx >= 0) { + return updateContainerChild(timeline, saIdx, (children) => { + const existing = children.find(c => c.type === 'thought' && c.step === step); + if (existing) { + return children.map(c => + c.type === 'thought' && c.step === step + ? { ...c, content: subContent } as TimelineItem + : c, + ); + } + return [...children, { type: 'thought', step, content: subContent }]; + }); + } + // 无活跃容器 → fallback 为父代理 thought + return upsertParentThought(timeline, step, subContent); + } + // 父代理思考 + return upsertParentThought(timeline, step, content); +} + +// 路由 tool_call 事件 +export function routeToolCall( + timeline: TimelineItem[], + step: number, + id: string, + name: string, + args: any, +): TimelineItem[] { + // 创建 subagent_container + if (isSubagentContainerCall(name)) { + const existing = timeline.find( + t => t.type === 'subagent_container' && t.id === id, + ); + if (existing) return timeline; + return [ + ...timeline, + { + type: 'subagent_container' as const, + id, + status: 'streaming' as const, + children: [], + }, + ]; + } + + // 子代理工具 → 路由到活跃容器 + if (isSubagentChildCall(name)) { + const cleanName = cleanSubagentChildName(name); + const saIdx = findStreamingSubAgent(timeline); + if (saIdx >= 0) { + return updateContainerChild(timeline, saIdx, (children) => { + const dup = children.find( + c => c.type === 'tool_call' && 'id' in c && c.id === id, + ); + if (dup) return children; + return [ + ...children, + { type: 'tool_call' as const, step, id, name: cleanName, arguments: args }, + ]; + }); + } + // 无活跃容器 → fallback + return upsertParentToolCall(timeline, step, id, cleanName, args); + } + + // 父代理工具调用 + return upsertParentToolCall(timeline, step, id, name, args); +} + +// 路由 tool_result 事件 +export function routeToolResult( + timeline: TimelineItem[], + toolCallId: string, + name: string, + output: string, + isError: boolean, +): TimelineItem[] { + // 匹配 subagent_container id → 完成容器 + const saCompleteIdx = timeline.findIndex( + t => t.type === 'subagent_container' && t.id === toolCallId, + ); + if (saCompleteIdx >= 0) { + const newTimeline = [...timeline]; + const container = newTimeline[saCompleteIdx] as Extract< + TimelineItem, + { type: 'subagent_container' } + >; + newTimeline[saCompleteIdx] = { + ...container, + status: 'complete' as const, + summary: output, + }; + return newTimeline; + } + + // 子代理工具结果 → 路由到活跃容器 + if (isSubagentChildResult(name)) { + const saIdx = findStreamingSubAgent(timeline); + if (saIdx >= 0) { + return updateContainerChild(timeline, saIdx, (children) => + children.map(c => { + if (c.type === 'tool_call' && c.id === toolCallId && !c.result) { + return { ...c, result: { output, isError } }; + } + return c; + }), + ); + } + } + + // 父代理工具结果 + return timeline.map(t => { + if (t.type === 'tool_call' && t.id === toolCallId) { + return { ...t, result: { output, isError } }; + } + return t; + }); +} + +// 路由 text_delta 事件(可携带 tool_call_id 流式输出到工具结果) +export function routeTextDelta( + timeline: TimelineItem[], + content: string, + toolCallId?: string, +): { timeline: TimelineItem[]; answerDelta: string } { + if (toolCallId) { + return { + timeline: timeline.map(t => { + if (t.type === 'tool_call' && t.id === toolCallId) { + const prevOutput = t.result?.output || ''; + return { + ...t, + result: { output: prevOutput + content, isError: false }, + }; + } + return t; + }), + answerDelta: '', + }; + } + return { timeline, answerDelta: content }; +} + +// ── 内部 helpers ── + +function upsertParentThought( + timeline: TimelineItem[], + step: number, + content: string, +): TimelineItem[] { + const existing = timeline.find(t => t.type === 'thought' && t.step === step); + if (existing) { + return timeline.map(t => + t.type === 'thought' && t.step === step ? { ...t, content } : t, + ); + } + return [...timeline, { type: 'thought', step, content }]; +} + +function upsertParentToolCall( + timeline: TimelineItem[], + step: number, + id: string, + name: string, + args: any, +): TimelineItem[] { + const existing = timeline.find( + t => t.type === 'tool_call' && 'id' in t && t.id === id, + ); + if (existing) return timeline; + return [...timeline, { type: 'tool_call' as const, step, id, name, arguments: args }]; +} + +function updateContainerChild( + timeline: TimelineItem[], + containerIdx: number, + updateFn: (children: TimelineItem[]) => TimelineItem[], +): TimelineItem[] { + const newTimeline = [...timeline]; + const container = newTimeline[containerIdx] as Extract< + TimelineItem, + { type: 'subagent_container' } + >; + newTimeline[containerIdx] = { + ...container, + children: updateFn(container.children), + }; + return newTimeline; +} diff --git a/dashboard/src/components/agent/toolDisplayNames.ts b/dashboard/src/components/agent/toolDisplayNames.ts new file mode 100644 index 0000000..61c0c95 --- /dev/null +++ b/dashboard/src/components/agent/toolDisplayNames.ts @@ -0,0 +1,50 @@ +// dashboard/src/components/agent/toolDisplayNames.ts +// 工具名称 → 人类可读中文名称(所有面板共用) + +export function getToolDisplayName(name: string): string { + switch (name) { + // 文件系统工具 + case 'read_file': return '读取文件内容'; + case 'grep_files': return '正则搜索文件'; + case 'glob_files': return '通配符匹配文件'; + case 'run_bash': return '执行 Shell 命令'; + case 'file_write': return '写入文件'; + case 'file_edit': return '精确编辑文件'; + // 天文科研工具 + case 'search_papers': return '检索 ADS/arXiv 文献'; + case 'get_paper_metadata': return '获取文献详细元数据'; + case 'download_paper': return '下载文献全文资源'; + case 'parse_paper': return '结构化解析文献内容'; + case 'get_paper_content': return '获取文献全文内容'; + case 'rag_search': return '检索馆藏知识库'; + case 'query_target': return '查询天体物理参数 (CDS)'; + case 'save_note': return '保存文献手札'; + // Agent 控制工具 + case 'todo_write': return '管理任务列表'; + case 'compress_context': return '压缩上下文窗口'; + case 'load_skill': return '加载专家技能'; + case 'subagent': return '派发子代理'; + case 'delegate_research': return '派发子代理'; + case 'ask_user': return '向用户提问'; + // 记忆系统 + case 'save_memory': return '保存项目记忆'; + case 'load_memory': return '读取项目记忆'; + // 图片分析 + case 'analyze_image': return '分析图像内容'; + // 后台任务 + case 'bg_task_run': return '启动后台任务'; + case 'bg_task_check': return '检查后台任务状态'; + // 团队协作 + case 'spawn_teammate': return '创建团队成员'; + case 'send_teammate_message': return '发送团队成员消息'; + case 'team_broadcast': return '团队广播消息'; + case 'check_team_inbox': return '检查团队收件箱'; + default: { + // 子代理工具名带 [sub] 前缀 + if (name.startsWith('[sub] ')) { + return `子代理: ${getToolDisplayName(name.slice(6))}`; + } + return name; + } + } +} diff --git a/dashboard/src/components/agent/types.ts b/dashboard/src/components/agent/types.ts new file mode 100644 index 0000000..5edc647 --- /dev/null +++ b/dashboard/src/components/agent/types.ts @@ -0,0 +1,54 @@ +// dashboard/src/components/agent/types.ts +// Agent 渲染共享类型定义 + +// 时间线条目类型 +export type TimelineItem = + | { type: 'thought'; step: number; content: string } + | { + type: 'tool_call'; + step: number; + id: string; + name: string; + arguments: any; + result?: { output: string; isError: boolean }; + } + | { type: 'answer'; content: string } + | { + type: 'subagent_container'; + id: string; + status: 'streaming' | 'complete'; + children: TimelineItem[]; + summary?: string; + }; + +// SSE 事件处理器接口 — 每个面板自行实现状态更新 +export interface SSEEventHandlers { + onSession?: (sessionId: string, title?: string) => void; + onThought?: (step: number, content: string) => void; + onToolCall?: (step: number, id: string, name: string, args: any) => void; + onToolResult?: ( + step: number, + toolCallId: string, + name: string, + output: string, + isError: boolean, + metadata?: any, + ) => void; + onTextDelta?: (content: string, toolCallId?: string) => void; + onUsage?: (usage: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }) => void; + onError?: (message: string) => void; + onDone?: () => void; +} + +// send() 参数 +export interface AgentSSEParams { + question: string; + sessionId: string | null; + mode?: string; + thinking?: boolean; + image?: { data?: string; path?: string; mime_type: string } | null; +} diff --git a/dashboard/src/components/agent/useAgentSSE.ts b/dashboard/src/components/agent/useAgentSSE.ts new file mode 100644 index 0000000..b03fc41 --- /dev/null +++ b/dashboard/src/components/agent/useAgentSSE.ts @@ -0,0 +1,143 @@ +// dashboard/src/components/agent/useAgentSSE.ts +// 共享 SSE 流处理 hook:解析 /api/chat/agent 的 SSE 事件并回调 +import { useState, useRef, useCallback } from 'react'; +import type { SSEEventHandlers, AgentSSEParams } from './types'; + +interface UseAgentSSEReturn { + streaming: boolean; + send: (params: AgentSSEParams, handlers: SSEEventHandlers) => Promise; + stop: () => void; +} + +export function useAgentSSE(): UseAgentSSEReturn { + const [streaming, setStreaming] = useState(false); + const abortRef = useRef(null); + + const stop = useCallback(() => { + abortRef.current?.abort(); + }, []); + + const send = useCallback( + async (params: AgentSSEParams, handlers: SSEEventHandlers): Promise => { + setStreaming(true); + const controller = new AbortController(); + abortRef.current = controller; + + try { + const body: Record = { + question: params.question, + session_id: params.sessionId, + mode: params.mode || 'default', + thinking: params.thinking || false, + }; + + if (params.image) { + body.image = params.image.path + ? { path: params.image.path, mime_type: params.image.mime_type } + : { data: params.image.data, mime_type: params.image.mime_type }; + } + + const response = await fetch('/api/chat/agent', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const reader = response.body?.getReader(); + if (!reader) { + throw new Error('ReadableStream not supported'); + } + + const decoder = new TextDecoder('utf-8'); + let buffer = ''; + let resolvedSessionId: string | null = null; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || !trimmed.startsWith('data:')) continue; + + const dataStr = trimmed.slice(5).trim(); + if (!dataStr) continue; + + try { + const event = JSON.parse(dataStr); + + switch (event.type) { + case 'session': + resolvedSessionId = event.session_id; + handlers.onSession?.(event.session_id, event.title); + break; + case 'thought': + handlers.onThought?.(event.step, event.content); + break; + case 'tool_call': + handlers.onToolCall?.( + event.step, + event.id, + event.name, + event.arguments, + ); + break; + case 'tool_result': + handlers.onToolResult?.( + event.step, + event.tool_call_id, + event.name, + event.output, + event.is_error, + event.metadata, + ); + break; + case 'text_delta': + handlers.onTextDelta?.( + event.content, + event.tool_call_id, + ); + break; + case 'usage': + handlers.onUsage?.(event); + break; + case 'error': + handlers.onError?.(event.message); + break; + case 'done': + handlers.onDone?.(); + break; + } + } catch { + console.error('解析 SSE 数据包失败:', trimmed); + } + } + } + + setStreaming(false); + return resolvedSessionId; + } catch (e: any) { + if (e.name === 'AbortError') { + setStreaming(false); + return null; + } + console.error('智能体对话请求失败:', e); + handlers.onError?.(e.message || '网络连接错误,请稍后重试'); + setStreaming(false); + return null; + } + }, + [], + ); + + return { streaming, send, stop }; +} diff --git a/dashboard/src/components/agent/useAutoScroll.ts b/dashboard/src/components/agent/useAutoScroll.ts new file mode 100644 index 0000000..317f1b6 --- /dev/null +++ b/dashboard/src/components/agent/useAutoScroll.ts @@ -0,0 +1,44 @@ +// dashboard/src/components/agent/useAutoScroll.ts +// 共享自动滚动 hook:检测是否贴底,仅在贴底时自动滚动 +import { useState, useRef, useEffect, useCallback } from 'react'; + +interface UseAutoScrollReturn { + chatEndRef: React.RefObject; + scrollContainerRef: React.RefObject; + shouldAutoScroll: boolean; + setShouldAutoScroll: (v: boolean) => void; + scrollToBottom: (behavior?: ScrollBehavior) => void; + handleScroll: () => void; +} + +export function useAutoScroll(deps: any[]): UseAutoScrollReturn { + const chatEndRef = useRef(null); + const scrollContainerRef = useRef(null); + const [shouldAutoScroll, setShouldAutoScroll] = useState(true); + + const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => { + chatEndRef.current?.scrollIntoView({ behavior }); + }, []); + + useEffect(() => { + if (shouldAutoScroll) { + scrollToBottom(); + } + }, deps); + + const handleScroll = useCallback(() => { + const el = scrollContainerRef.current; + if (!el) return; + const { scrollTop, scrollHeight, clientHeight } = el; + setShouldAutoScroll(scrollHeight - scrollTop - clientHeight < 50); + }, []); + + return { + chatEndRef, + scrollContainerRef, + shouldAutoScroll, + setShouldAutoScroll, + scrollToBottom, + handleScroll, + }; +} diff --git a/dashboard/src/components/layout/Sidebar.tsx b/dashboard/src/components/layout/Sidebar.tsx index a0e8717..0646009 100644 --- a/dashboard/src/components/layout/Sidebar.tsx +++ b/dashboard/src/components/layout/Sidebar.tsx @@ -94,10 +94,10 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations, {[ { id: 'search', label: '统一检索', icon: Search }, { id: 'library', label: '馆藏管理', icon: Library }, - { id: 'sync', label: '批量任务', icon: RefreshCw }, { id: 'reader', label: '双语阅读', icon: BookOpen }, - { id: 'agent', label: '智能科研', icon: Sparkles }, { id: 'citation', label: '引用星系', icon: GitFork }, + { id: 'sync', label: '批量任务', icon: RefreshCw }, + { id: 'agent', label: '智能科研', icon: Sparkles }, ].map((tab: { id: string; label: string; icon: any; disabled?: boolean }) => { const Icon = tab.icon; const isActive = activeTab === tab.id; @@ -213,12 +213,12 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations, )}
-
- {!isExpanded && ( - - 点击展开 - - )} - -
- +
- {/* 内容 */} + {/* 内容区 */} {isExpanded && ( -
- {/* 问题文本 */} -
- - 问题 - -

- {q.question || ''} -

-
- - {/* 选项列表 — 每个选项是 {label, description} 对象 */} +
+ {/* 选项列表 */} {options.length > 0 && (
- + {multiSelect ? '可多选' : '请选择一项'}
{options.map((option, idx) => { - // 防御:确保 option 是有效对象 const label = typeof option?.label === 'string' ? option.label : String(option); const desc = typeof option?.description === 'string' ? option.description : ''; const selected = (answers[q.question_id] || []).includes(label); @@ -196,35 +180,36 @@ export function AskUserQuestionCard({ onAnswered }: AskUserQuestionCardProps) { key={idx} onClick={() => toggleOption(q.question_id, label, multiSelect)} disabled={isSubmitting} - className={`w-full text-left px-3 py-2.5 rounded-lg border text-xs font-medium transition-all cursor-pointer flex items-start gap-2.5 ${ + className={`w-full text-left px-2.5 py-1.5 rounded-lg border text-[11px] font-semibold transition-all cursor-pointer flex items-center gap-2 ${ selected - ? 'bg-sky-50 border-sky-300 text-sky-800' - : 'bg-white border-slate-200 text-slate-700 hover:border-sky-200 hover:bg-sky-50/50' + ? 'bg-sky-50/70 border-sky-300 text-sky-850 shadow-3xs' + : 'bg-white border-slate-200/60 text-slate-700 hover:border-sky-200 hover:bg-sky-50/50' } disabled:opacity-50`} > {/* 选择指示器 */} {multiSelect ? ( selected - ? - : + ? + : ) : ( -
{selected && (
-
+
)}
)} - {/* label + description */} -
-
{label}
+ + {/* 将 label 与 desc 合并在同一行呈现以压缩高度 */} +
+ {label} {desc && ( -
- {desc} -
+ + — {desc} + )}
@@ -235,42 +220,39 @@ export function AskUserQuestionCard({ onAnswered }: AskUserQuestionCardProps) { )} {/* 自由文本 */} -
- - 补充说明(可选) - +