refactor: Agent 配置硬编码化、压缩系统不可变重构、前端组件化与安全硬化
- AgentConfig: 移除 10+ 个环境变量读取,仅保留 TOKEN_SOFT/HARD_LIMIT 两个
可调参数,context_char_limit 替换为统一的 token_soft_limit 阈值
- compact: find_safe_cut_point 重写为 HashSet O(n) 算法,
micro_compact 改为不可变风格,compress_context 签名升级为
token_soft_limit + max_messages 双参数,新增 COMPACTION_OUTPUT_RESERVE
- modes: ModeConfig.max_steps/tool_timeout_secs 去 Optional 化,
Deep Research 步数 16→100,Literature Reader 步数 6→25
- dashboard: 提取 AgentMarkdown/ThoughtCard/ToolCallCard/AnswerCard/
SubAgentContainer 等共享组件,ResearchAgentPanel 大幅瘦身,
交互卡片重构为 console-panel 紧凑风格
- security: 移除 HERMES_YOLO_MODE、AGENT_BLOCK_NETWORK 开关、
AGENT_CHECKPOINT_ENABLED 开关,关键安全机制强制启用
This commit is contained in:
parent
85b6429c30
commit
b11b8ad015
25
.env.example
25
.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)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
29
dashboard/src/components/agent/AgentMarkdown.tsx
Normal file
29
dashboard/src/components/agent/AgentMarkdown.tsx
Normal file
@ -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 (
|
||||
<div className={className}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkMath, remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, safeSchema], rehypeKatex]}
|
||||
>
|
||||
{preprocessMath(children)}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
dashboard/src/components/agent/AnswerCard.tsx
Normal file
34
dashboard/src/components/agent/AnswerCard.tsx
Normal file
@ -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 (
|
||||
<div className="relative space-y-2">
|
||||
<div
|
||||
className={`absolute -left-[21px] top-3 w-2.5 h-2.5 rounded-full border-2 border-white ${colorScheme.answer}`}
|
||||
/>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="text-[10px] font-bold text-slate-400 px-1 flex items-center gap-1 mb-1">
|
||||
<CheckCircle2
|
||||
className={`w-3.5 h-3.5 text-emerald-500 ${isStreaming ? 'animate-pulse' : ''}`}
|
||||
/>
|
||||
<span>结论</span>
|
||||
</span>
|
||||
<div className="w-full rounded-2xl px-5 py-4 text-xs leading-relaxed font-semibold shadow-xs border bg-white text-slate-800 border-slate-200 prose prose-sm max-w-none select-text prose-headings:text-slate-900 prose-headings:font-bold prose-strong:text-slate-900 prose-code:text-sky-700 prose-img:rounded-lg">
|
||||
<AgentMarkdown>
|
||||
{content}
|
||||
</AgentMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
182
dashboard/src/components/agent/SubAgentContainer.tsx
Normal file
182
dashboard/src/components/agent/SubAgentContainer.tsx
Normal file
@ -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<string, boolean>;
|
||||
expandedArgs: Record<string, boolean>;
|
||||
expandedResults: Record<string, boolean>;
|
||||
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 (
|
||||
<div className="relative space-y-2 select-none">
|
||||
<div
|
||||
className={`absolute -left-[21px] top-3 w-2.5 h-2.5 rounded-full border-2 border-white ${
|
||||
parentStreaming ? 'bg-violet-500 animate-pulse' : 'bg-violet-400'
|
||||
}`}
|
||||
/>
|
||||
<div className="border border-violet-200 bg-violet-50/30 rounded-xl shadow-2xs overflow-hidden">
|
||||
{/* 头部 */}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center justify-between px-3.5 py-2.5 cursor-pointer hover:bg-violet-50/80 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Network
|
||||
className={`w-4 h-4 ${
|
||||
parentStreaming ? 'text-violet-500 animate-pulse' : 'text-violet-400'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-[10px] font-extrabold text-violet-700 tracking-wider uppercase">
|
||||
{parentStreaming ? '子代理执行中...' : '子代理完成'}
|
||||
</span>
|
||||
{isComplete && (
|
||||
<span className="text-[9px] font-bold text-violet-500 ml-1 flex items-center gap-0.5">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
{stepCount} 步骤 · {toolCount} 工具
|
||||
</span>
|
||||
)}
|
||||
{parentStreaming && <Loader className="w-3 h-3 text-violet-500 animate-spin" />}
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-violet-600 hover:text-violet-800 shrink-0">
|
||||
{isCollapsed ? '展开' : '收起'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* 折叠态摘要 */}
|
||||
{isCollapsed && summary && (
|
||||
<div className="px-3.5 pb-3 border-t border-violet-100/50">
|
||||
<p className="text-[11px] text-violet-600 font-medium leading-relaxed line-clamp-2 mt-2">
|
||||
{summary.length > 200 ? summary.slice(0, 200) + '...' : summary}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{isCollapsed && !summary && parentStreaming && (
|
||||
<div className="px-3.5 pb-3 border-t border-violet-100/50">
|
||||
<p className="text-[11px] text-violet-400 italic font-medium mt-2">
|
||||
子代理正在收集信息...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 展开态:嵌套时间线 */}
|
||||
{!isCollapsed && (
|
||||
<div className="border-t border-violet-100/50 px-3.5 py-3">
|
||||
{children.length === 0 && parentStreaming && (
|
||||
<div className="flex items-center gap-2 text-violet-400 py-2">
|
||||
<Loader className="w-3 h-3 animate-spin" />
|
||||
<span className="text-[10px] font-bold">子代理初始化中...</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="pl-4 border-l border-violet-100 space-y-3">
|
||||
{children.map((child, childIdx) =>
|
||||
renderNestedTimelineItem(
|
||||
child,
|
||||
childIdx,
|
||||
parentStreaming,
|
||||
expandedThoughts,
|
||||
expandedArgs,
|
||||
expandedResults,
|
||||
onToggleThought,
|
||||
onToggleArgs,
|
||||
onToggleResult,
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 子代理内部时间线条目渲染
|
||||
function renderNestedTimelineItem(
|
||||
item: TimelineItem,
|
||||
idx: number,
|
||||
isStreaming: boolean,
|
||||
expandedThoughts: Record<string, boolean>,
|
||||
expandedArgs: Record<string, boolean>,
|
||||
expandedResults: Record<string, boolean>,
|
||||
onToggleThought: (key: string) => void,
|
||||
onToggleArgs: (tcId: string) => void,
|
||||
onToggleResult: (tcId: string) => void,
|
||||
) {
|
||||
switch (item.type) {
|
||||
case 'thought': {
|
||||
const thoughtKey = `sub-thought-${item.step}`;
|
||||
return (
|
||||
<ThoughtCard
|
||||
key={`${thoughtKey}-${idx}`}
|
||||
step={item.step}
|
||||
content={item.content}
|
||||
isStreaming={isStreaming}
|
||||
colorScheme={SUBAGENT_COLORS}
|
||||
isExpanded={expandedThoughts[thoughtKey] === true}
|
||||
onToggle={() => onToggleThought(thoughtKey)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'tool_call': {
|
||||
const tcId = item.id;
|
||||
return (
|
||||
<ToolCallCard
|
||||
key={`tc-${tcId}-${idx}`}
|
||||
step={item.step}
|
||||
name={item.name}
|
||||
arguments={item.arguments}
|
||||
result={item.result}
|
||||
isStreaming={isStreaming}
|
||||
colorScheme={SUBAGENT_COLORS}
|
||||
isArgsExpanded={expandedArgs[tcId] === true}
|
||||
isResultExpanded={expandedResults[tcId] === true}
|
||||
onToggleArgs={() => onToggleArgs(tcId)}
|
||||
onToggleResult={() => onToggleResult(tcId)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'answer':
|
||||
return (
|
||||
<AnswerCard
|
||||
key={`answer-${idx}`}
|
||||
content={item.content}
|
||||
colorScheme={SUBAGENT_COLORS}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
71
dashboard/src/components/agent/ThoughtCard.tsx
Normal file
71
dashboard/src/components/agent/ThoughtCard.tsx
Normal file
@ -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 (
|
||||
<div className="relative space-y-2 select-none">
|
||||
<div
|
||||
className={`absolute -left-[21px] top-3 w-2.5 h-2.5 rounded-full border-2 border-white ${dotColor}`}
|
||||
/>
|
||||
<div className={`space-y-1.5 rounded-xl p-3.5 select-text ${bgClass}`}>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center justify-between text-left cursor-pointer"
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-1.5 text-[10px] font-extrabold tracking-wider uppercase ${textClass}`}
|
||||
>
|
||||
<Brain className="w-3.5 h-3.5" />
|
||||
<span>分析与推理 (Thought)</span>
|
||||
<span className={`text-[9px] font-mono ${mutedClass}`}>
|
||||
· Step {step}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`text-[10px] font-bold shrink-0 ${hoverClass}`}>
|
||||
{isExpanded ? '收起' : '展开'}
|
||||
</span>
|
||||
</button>
|
||||
{isExpanded ? (
|
||||
<p className="text-slate-700 font-medium leading-relaxed font-sans whitespace-pre-wrap text-xs">
|
||||
{content}
|
||||
</p>
|
||||
) : (
|
||||
<p className={`text-[11px] italic font-medium leading-relaxed ${previewClass}`}>
|
||||
{preview}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
dashboard/src/components/agent/ToolCallCard.tsx
Normal file
113
dashboard/src/components/agent/ToolCallCard.tsx
Normal file
@ -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 (
|
||||
<div className="relative space-y-2 select-none">
|
||||
<div
|
||||
className={`absolute -left-[21px] top-3 w-2.5 h-2.5 rounded-full border-2 border-white ${dotColor}`}
|
||||
/>
|
||||
<div className="space-y-2 border border-slate-100 bg-white rounded-xl p-3.5 shadow-2xs">
|
||||
{/* 工具名称 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-extrabold text-sky-700 tracking-wider uppercase">
|
||||
<Settings
|
||||
className={`w-3.5 h-3.5 ${hasResult ? 'text-slate-500' : 'text-sky-500 animate-spin'}`}
|
||||
/>
|
||||
<span>调用工具: {getToolDisplayName(name)}</span>
|
||||
<span className="text-[9px] text-slate-400 font-mono font-medium">
|
||||
· Step {step}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggleArgs}
|
||||
className="text-[10px] font-bold text-sky-600 hover:text-sky-800 hover:underline cursor-pointer flex items-center gap-0.5"
|
||||
>
|
||||
{isArgsExpanded ? '收起参数' : '展开参数'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 参数 */}
|
||||
{isArgsExpanded && (
|
||||
<div className="font-mono text-[10px] bg-slate-50 border border-slate-200 rounded-lg p-2.5 text-slate-650 overflow-x-auto whitespace-pre-wrap max-w-full leading-relaxed select-text">
|
||||
{JSON.stringify(args, null, 2)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 结果 */}
|
||||
{hasResult && (
|
||||
<div className="space-y-1.5 border-t border-slate-100 pt-2.5 mt-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-extrabold text-emerald-700 tracking-wider uppercase">
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
<span>观测与反馈 (Observation)</span>
|
||||
{result!.isError && (
|
||||
<span className="px-1.5 py-0.2 rounded bg-rose-50 text-rose-700 border border-rose-100 text-[9px]">
|
||||
错误返回
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggleResult}
|
||||
className="text-[10px] font-bold text-sky-600 hover:text-sky-800 hover:underline cursor-pointer flex items-center gap-0.5"
|
||||
>
|
||||
{isResultExpanded ? '收起结果' : '展开结果'}
|
||||
</button>
|
||||
</div>
|
||||
{isResultExpanded ? (
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className="text-xs bg-slate-50 border border-slate-200 rounded-lg p-3 text-slate-700 overflow-auto select-text leading-relaxed max-h-[60vh] prose prose-sm max-w-none prose-headings:text-slate-800 prose-code:text-sky-700 prose-pre:bg-slate-100 prose-pre:text-xs prose-img:rounded-lg"
|
||||
>
|
||||
<AgentMarkdown>
|
||||
{result!.output}
|
||||
</AgentMarkdown>
|
||||
<div ref={chatEndRef} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[10px] text-slate-400 italic font-medium">
|
||||
结果已截断 (共 {result!.output.length} 字符)。点击右侧展开。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
dashboard/src/components/agent/constants.ts
Normal file
36
dashboard/src/components/agent/constants.ts
Normal file
@ -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',
|
||||
};
|
||||
23
dashboard/src/components/agent/index.ts
Normal file
23
dashboard/src/components/agent/index.ts
Normal file
@ -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';
|
||||
247
dashboard/src/components/agent/subagentRouting.ts
Normal file
247
dashboard/src/components/agent/subagentRouting.ts
Normal file
@ -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;
|
||||
}
|
||||
50
dashboard/src/components/agent/toolDisplayNames.ts
Normal file
50
dashboard/src/components/agent/toolDisplayNames.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
54
dashboard/src/components/agent/types.ts
Normal file
54
dashboard/src/components/agent/types.ts
Normal file
@ -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;
|
||||
}
|
||||
143
dashboard/src/components/agent/useAgentSSE.ts
Normal file
143
dashboard/src/components/agent/useAgentSSE.ts
Normal file
@ -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<string | null>;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
export function useAgentSSE(): UseAgentSSEReturn {
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
}, []);
|
||||
|
||||
const send = useCallback(
|
||||
async (params: AgentSSEParams, handlers: SSEEventHandlers): Promise<string | null> => {
|
||||
setStreaming(true);
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const body: Record<string, any> = {
|
||||
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 };
|
||||
}
|
||||
44
dashboard/src/components/agent/useAutoScroll.ts
Normal file
44
dashboard/src/components/agent/useAutoScroll.ts
Normal file
@ -0,0 +1,44 @@
|
||||
// dashboard/src/components/agent/useAutoScroll.ts
|
||||
// 共享自动滚动 hook:检测是否贴底,仅在贴底时自动滚动
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
|
||||
interface UseAutoScrollReturn {
|
||||
chatEndRef: React.RefObject<HTMLDivElement | null>;
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
shouldAutoScroll: boolean;
|
||||
setShouldAutoScroll: (v: boolean) => void;
|
||||
scrollToBottom: (behavior?: ScrollBehavior) => void;
|
||||
handleScroll: () => void;
|
||||
}
|
||||
|
||||
export function useAutoScroll(deps: any[]): UseAutoScrollReturn {
|
||||
const chatEndRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
className={`w-full flex items-center rounded-lg text-xs font-semibold tracking-wider transition-all duration-300 border border-transparent text-red-600 hover:bg-red-50 hover:text-red-700 cursor-pointer ${
|
||||
className={`w-full flex items-center rounded-lg text-xs font-semibold tracking-wider transition-all duration-300 border border-transparent text-slate-500 hover:bg-rose-50/50 hover:text-rose-600 cursor-pointer group ${
|
||||
isCollapsed ? 'px-2 py-2.5 justify-center' : 'px-3 py-2.5'
|
||||
}`}
|
||||
title={isCollapsed ? "退出登录" : undefined}
|
||||
>
|
||||
<LogOut className="w-4 h-4 shrink-0 text-red-500" />
|
||||
<LogOut className="w-4 h-4 shrink-0 text-slate-400 group-hover:text-rose-500 transition-colors" />
|
||||
<span
|
||||
className={`truncate transition-all duration-300 origin-left ${
|
||||
isCollapsed
|
||||
|
||||
@ -1,14 +1,15 @@
|
||||
// dashboard/src/features/agent/AskUserQuestionCard.tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { MessageCircle, Send, X, Loader, CheckSquare, Square } from 'lucide-react';
|
||||
import { MessageCircle, Send, Loader, CheckSquare, Square } from 'lucide-react';
|
||||
import type { PendingQuestion } from '../../types';
|
||||
|
||||
interface AskUserQuestionCardProps {
|
||||
onAnswered?: () => void;
|
||||
onQuestionCountChange?: (count: number) => void;
|
||||
}
|
||||
|
||||
export function AskUserQuestionCard({ onAnswered }: AskUserQuestionCardProps) {
|
||||
export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUserQuestionCardProps) {
|
||||
const [pendingQuestions, setPendingQuestions] = useState<PendingQuestion[]>([]);
|
||||
const [answers, setAnswers] = useState<Record<string, string[]>>({});
|
||||
const [freeText, setFreeText] = useState<Record<string, string>>({});
|
||||
@ -24,7 +25,6 @@ export function AskUserQuestionCard({ onAnswered }: AskUserQuestionCardProps) {
|
||||
try {
|
||||
const res = await axios.get<PendingQuestion[]>('/api/chat/questions');
|
||||
if (!cancelled) {
|
||||
// 安全解析:确保返回的是数组
|
||||
const data = Array.isArray(res.data) ? res.data : [];
|
||||
setPendingQuestions(data);
|
||||
// 自动展开新问题
|
||||
@ -51,7 +51,11 @@ export function AskUserQuestionCard({ onAnswered }: AskUserQuestionCardProps) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 切换选项:使用 option.label 而非整个对象
|
||||
// 当待处理问题数量改变时,报告给父容器
|
||||
useEffect(() => {
|
||||
onQuestionCountChange?.(pendingQuestions.length);
|
||||
}, [pendingQuestions.length, onQuestionCountChange]);
|
||||
|
||||
const toggleOption = (questionId: string, optionLabel: string, multiSelect: boolean) => {
|
||||
setAnswers(prev => {
|
||||
const current = prev[questionId] || [];
|
||||
@ -117,9 +121,8 @@ export function AskUserQuestionCard({ onAnswered }: AskUserQuestionCardProps) {
|
||||
if (pendingQuestions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
{pendingQuestions.map(q => {
|
||||
// 防御:确保必填字段存在
|
||||
if (!q || !q.question_id) return null;
|
||||
|
||||
const isExpanded = expanded[q.question_id] !== false;
|
||||
@ -131,62 +134,43 @@ export function AskUserQuestionCard({ onAnswered }: AskUserQuestionCardProps) {
|
||||
return (
|
||||
<div
|
||||
key={q.question_id}
|
||||
className="bg-amber-50 border-2 border-amber-300 rounded-xl shadow-lg overflow-hidden transition-all"
|
||||
className="console-panel rounded-xl p-3 shadow-lg border-amber-200/80 bg-amber-50/45 transition-all pointer-events-auto text-xs"
|
||||
>
|
||||
{/* 头部 — 显示 header 标签和问题摘要 */}
|
||||
<button
|
||||
onClick={() => setExpanded(prev => ({ ...prev, [q.question_id]: !prev[q.question_id] }))}
|
||||
className="w-full flex items-center justify-between px-4 py-3 bg-amber-100/50 hover:bg-amber-100 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-left min-w-0">
|
||||
<MessageCircle className="w-4 h-4 text-amber-600 shrink-0" />
|
||||
<span className="px-1.5 py-0.5 rounded bg-amber-200 border border-amber-300 text-[10px] font-extrabold text-amber-800 shrink-0">
|
||||
{/* 标题栏 - 头部与问题放在同一行,可省略独立的问题文本框 */}
|
||||
<div className="mb-2 flex items-center justify-between border-b border-amber-100/40 pb-1.5 min-w-0">
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1 mr-3">
|
||||
<MessageCircle className="h-3.5 w-3.5 text-amber-550 shrink-0" />
|
||||
<span className="px-1.5 py-0.2 rounded bg-amber-100 border border-amber-200 text-[9px] font-extrabold text-amber-800 shrink-0">
|
||||
{q.header || '提问'}
|
||||
</span>
|
||||
{!isExpanded && (
|
||||
<span className="text-[10px] text-amber-700 font-medium truncate">
|
||||
{q.question || ''}
|
||||
</span>
|
||||
<span className="font-extrabold text-slate-800 text-[11px] truncate flex-1" title={q.question || ''}>
|
||||
{q.question || ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{options.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(prev => ({ ...prev, [q.question_id]: !prev[q.question_id] }))}
|
||||
className="text-[10px] font-bold text-sky-600 hover:text-sky-850 cursor-pointer select-none"
|
||||
>
|
||||
{isExpanded ? '收起选项' : '展开选项'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{!isExpanded && (
|
||||
<span className="text-[9px] text-amber-500 font-bold px-1.5 py-0.5 bg-amber-100 rounded">
|
||||
点击展开
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); dismissQuestion(q.question_id); }}
|
||||
className="p-1 rounded-md text-amber-400 hover:text-amber-600 hover:bg-amber-200 transition-colors cursor-pointer"
|
||||
title="忽略此问题"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
{/* 内容区 */}
|
||||
{isExpanded && (
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
{/* 问题文本 */}
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] font-bold text-amber-700 uppercase tracking-wider">
|
||||
问题
|
||||
</span>
|
||||
<p className="text-xs text-slate-800 font-semibold leading-relaxed bg-white rounded-lg p-3 border border-amber-200">
|
||||
{q.question || ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 选项列表 — 每个选项是 {label, description} 对象 */}
|
||||
<div className="space-y-2.5">
|
||||
{/* 选项列表 */}
|
||||
{options.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
|
||||
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-wider block">
|
||||
{multiSelect ? '可多选' : '请选择一项'}
|
||||
</span>
|
||||
<div className="space-y-1.5">
|
||||
{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
|
||||
? <CheckSquare className="w-3.5 h-3.5 text-sky-600 shrink-0 mt-0.5" />
|
||||
: <Square className="w-3.5 h-3.5 text-slate-400 shrink-0 mt-0.5" />
|
||||
? <CheckSquare className="w-3.5 h-3.5 text-sky-600 shrink-0" />
|
||||
: <Square className="w-3.5 h-3.5 text-slate-450 shrink-0" />
|
||||
) : (
|
||||
<div className={`w-3.5 h-3.5 rounded-full border-2 shrink-0 mt-0.5 ${
|
||||
selected ? 'border-sky-600 bg-sky-600' : 'border-slate-300'
|
||||
<div className={`w-3.5 h-3.5 rounded-full border-2 shrink-0 ${
|
||||
selected ? 'border-sky-600 bg-sky-600' : 'border-slate-350'
|
||||
}`}>
|
||||
{selected && (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-white" />
|
||||
<div className="w-1 h-1 rounded-full bg-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* label + description */}
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-slate-800">{label}</div>
|
||||
|
||||
{/* 将 label 与 desc 合并在同一行呈现以压缩高度 */}
|
||||
<div className="min-w-0 flex-1 flex items-baseline gap-1.5 truncate">
|
||||
<span className="font-extrabold text-slate-800 text-[11px] shrink-0 leading-normal">{label}</span>
|
||||
{desc && (
|
||||
<div className="text-[10px] text-slate-500 mt-0.5 leading-relaxed">
|
||||
{desc}
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 font-medium truncate leading-normal" title={desc}>
|
||||
— {desc}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
@ -235,42 +220,39 @@ export function AskUserQuestionCard({ onAnswered }: AskUserQuestionCardProps) {
|
||||
)}
|
||||
|
||||
{/* 自由文本 */}
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
|
||||
补充说明(可选)
|
||||
</span>
|
||||
<div className="w-full">
|
||||
<textarea
|
||||
value={freeText[q.question_id] || ''}
|
||||
onChange={(e) => setFreeText(prev => ({ ...prev, [q.question_id]: e.target.value }))}
|
||||
disabled={isSubmitting}
|
||||
placeholder="输入您的补充说明..."
|
||||
rows={2}
|
||||
className="w-full bg-white border border-slate-200 rounded-lg px-3 py-2 text-xs text-slate-800 placeholder-slate-400 focus:outline-none focus:border-sky-300 focus:ring-1 focus:ring-sky-500/20 resize-none disabled:opacity-50"
|
||||
placeholder="补充说明(可选)..."
|
||||
rows={1}
|
||||
className="w-full bg-white border border-slate-200/60 rounded-lg px-2.5 py-1.5 text-[11px] text-slate-800 placeholder-slate-400 focus:outline-none focus:border-sky-300 focus:ring-1 focus:ring-sky-500/10 resize-none disabled:opacity-50 min-h-[32px] max-h-24 leading-normal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 错误提示(按问题 ID 隔离) */}
|
||||
{/* 错误提示 */}
|
||||
{qError && (
|
||||
<div className="text-[10px] text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2 font-medium">
|
||||
<div className="text-[10px] text-red-700 bg-red-50 border border-red-200 rounded-lg px-2.5 py-1.5 font-bold">
|
||||
{qError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
onClick={() => handleSubmit(q.question_id)}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 bg-amber-600 hover:bg-amber-700 text-white rounded-lg py-2.5 text-xs font-bold transition-colors cursor-pointer disabled:opacity-50"
|
||||
className="flex-1 flex items-center justify-center gap-1.5 bg-emerald-600 hover:bg-emerald-700 border border-emerald-600 hover:border-emerald-750 text-white rounded-lg py-1.5 text-[11px] font-extrabold transition-all cursor-pointer disabled:opacity-50 hover:scale-102 active:scale-98"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader className="w-3.5 h-3.5 animate-spin" />
|
||||
<Loader className="w-3 h-3 animate-spin" />
|
||||
<span>提交中...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
<Send className="w-3 h-3" />
|
||||
<span>提交回答</span>
|
||||
</>
|
||||
)}
|
||||
@ -278,7 +260,7 @@ export function AskUserQuestionCard({ onAnswered }: AskUserQuestionCardProps) {
|
||||
<button
|
||||
onClick={() => dismissQuestion(q.question_id)}
|
||||
disabled={isSubmitting}
|
||||
className="px-4 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg text-xs font-bold transition-colors cursor-pointer disabled:opacity-50"
|
||||
className="px-3 bg-red-50 hover:bg-red-100 text-red-700 border border-red-200 hover:border-red-300 rounded-lg text-[11px] font-extrabold transition-all cursor-pointer disabled:opacity-50 hover:scale-102 active:scale-98"
|
||||
>
|
||||
忽略
|
||||
</button>
|
||||
@ -291,3 +273,4 @@ export function AskUserQuestionCard({ onAnswered }: AskUserQuestionCardProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -6,13 +6,15 @@ import type { PendingPermissionRequest } from '../../types';
|
||||
|
||||
interface PermissionRequestCardProps {
|
||||
sessionId: string;
|
||||
onRequestCountChange?: (count: number) => void;
|
||||
}
|
||||
|
||||
/** 显示待处理的工具执行权限请求卡片 */
|
||||
export function PermissionRequestCard({ sessionId }: PermissionRequestCardProps) {
|
||||
export function PermissionRequestCard({ sessionId, onRequestCountChange }: PermissionRequestCardProps) {
|
||||
const [pending, setPending] = useState<PendingPermissionRequest[]>([]);
|
||||
const [submitting, setSubmitting] = useState<Record<string, boolean>>({});
|
||||
const [responses, setResponses] = useState<Record<string, 'allow' | 'deny' | null>>({});
|
||||
const [expandedParams, setExpandedParams] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 轮询待处理权限请求
|
||||
useEffect(() => {
|
||||
@ -67,63 +69,93 @@ export function PermissionRequestCard({ sessionId }: PermissionRequestCardProps)
|
||||
// 不显示已处理的请求
|
||||
const activeRequests = pending.filter(p => !responses[p.tool_call_id]);
|
||||
|
||||
useEffect(() => {
|
||||
onRequestCountChange?.(activeRequests.length);
|
||||
}, [activeRequests.length, onRequestCountChange]);
|
||||
|
||||
const toggleParams = (id: string) => {
|
||||
setExpandedParams(prev => ({ ...prev, [id]: !prev[id] }));
|
||||
};
|
||||
|
||||
if (activeRequests.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{activeRequests.map(req => (
|
||||
<div
|
||||
key={req.permission_id}
|
||||
className="rounded-lg border border-amber-500/30 bg-amber-950/20 p-4"
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-amber-400" />
|
||||
<span className="font-semibold text-amber-300">权限确认</span>
|
||||
<code className="rounded bg-amber-900/40 px-1.5 py-0.5 text-xs text-amber-200">
|
||||
{req.tool_name}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{activeRequests.map(req => {
|
||||
const hasArgs = Object.keys(req.arguments || {}).length > 0;
|
||||
const showArgs = expandedParams[req.permission_id] || false;
|
||||
|
||||
<p className="mb-3 text-sm text-amber-200/80">{req.message}</p>
|
||||
|
||||
{/* 显示工具参数的简化预览 */}
|
||||
{Object.keys(req.arguments).length > 0 && (
|
||||
<pre className="mb-3 max-h-24 overflow-auto rounded bg-black/30 p-2 text-xs text-amber-300/60">
|
||||
{JSON.stringify(req.arguments, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => respond(req.tool_call_id, true, false)}
|
||||
disabled={submitting[req.tool_call_id]}
|
||||
className="flex items-center gap-1 rounded bg-emerald-600 px-3 py-1.5 text-sm text-white transition hover:bg-emerald-500 disabled:opacity-50"
|
||||
>
|
||||
{submitting[req.tool_call_id] ? (
|
||||
<Loader className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Check className="h-4 w-4" />
|
||||
return (
|
||||
<div
|
||||
key={req.permission_id}
|
||||
className="console-panel rounded-xl p-3 shadow-lg border-amber-200/80 bg-amber-50/45 transition-all pointer-events-auto text-xs"
|
||||
>
|
||||
{/* 标题与控制栏 */}
|
||||
<div className="mb-2 flex items-center justify-between border-b border-amber-100/40 pb-1.5">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<Shield className="h-3.5 w-3.5 text-amber-550 shrink-0" />
|
||||
<span className="font-extrabold text-slate-800 tracking-wide text-[11px] shrink-0">授权确认</span>
|
||||
<code className="rounded bg-amber-100/70 border border-amber-200/80 px-1.5 py-0.2 text-[9px] font-mono font-bold text-amber-800 truncate" title={req.tool_name}>
|
||||
{req.tool_name}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{hasArgs && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleParams(req.permission_id)}
|
||||
className="text-[10px] font-bold text-sky-600 hover:text-sky-850 cursor-pointer select-none"
|
||||
>
|
||||
{showArgs ? '隐藏参数' : '参数详情'}
|
||||
</button>
|
||||
)}
|
||||
Allow
|
||||
</button>
|
||||
<button
|
||||
onClick={() => respond(req.tool_call_id, true, true)}
|
||||
disabled={submitting[req.tool_call_id]}
|
||||
className="flex items-center gap-1 rounded bg-emerald-700 px-3 py-1.5 text-sm text-emerald-200 transition hover:bg-emerald-600 disabled:opacity-50"
|
||||
>
|
||||
Always Allow
|
||||
</button>
|
||||
<button
|
||||
onClick={() => respond(req.tool_call_id, false, false)}
|
||||
disabled={submitting[req.tool_call_id]}
|
||||
className="flex items-center gap-1 rounded bg-red-600 px-3 py-1.5 text-sm text-white transition hover:bg-red-500 disabled:opacity-50"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 警告消息 */}
|
||||
<p className="mb-2 text-[11px] font-semibold text-slate-650 leading-relaxed">{req.message}</p>
|
||||
|
||||
{/* 显示工具参数的简化预览 - 按需展开 */}
|
||||
{hasArgs && showArgs && (
|
||||
<pre className="mb-2.5 max-h-24 overflow-auto rounded-lg bg-slate-50 border border-slate-200/50 p-2 text-[9px] font-mono text-slate-600 scrollbar-thin">
|
||||
{JSON.stringify(req.arguments, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{/* 操作按钮栏 - 更加紧凑 */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
onClick={() => respond(req.tool_call_id, true, false)}
|
||||
disabled={submitting[req.tool_call_id]}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-md text-[10px] font-extrabold transition-all shadow-3xs cursor-pointer bg-emerald-600 hover:bg-emerald-700 text-white disabled:opacity-50 hover:scale-102 active:scale-98"
|
||||
>
|
||||
{submitting[req.tool_call_id] ? (
|
||||
<Loader className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Check className="h-3 w-3" />
|
||||
)}
|
||||
Allow
|
||||
</button>
|
||||
<button
|
||||
onClick={() => respond(req.tool_call_id, true, true)}
|
||||
disabled={submitting[req.tool_call_id]}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-md text-[10px] font-extrabold transition-all shadow-3xs cursor-pointer bg-sky-600 hover:bg-sky-700 text-white disabled:opacity-50 hover:scale-102 active:scale-98"
|
||||
>
|
||||
Always Allow
|
||||
</button>
|
||||
<button
|
||||
onClick={() => respond(req.tool_call_id, false, false)}
|
||||
disabled={submitting[req.tool_call_id]}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-md text-[10px] font-extrabold transition-all cursor-pointer bg-red-50 hover:bg-red-100 text-red-700 border border-red-200 hover:border-red-300 disabled:opacity-50 hover:scale-102 active:scale-98"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,17 +1,18 @@
|
||||
// dashboard/src/features/reader/AIAssistantPanel.tsx
|
||||
// 文献 AI 问答助手 — 使用共享 AgentMarkdown / useAgentSSE / 子代理路由 / 渲染组件
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
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, { defaultSchema } from 'rehype-sanitize';
|
||||
import 'katex/dist/katex.min.css';
|
||||
import {
|
||||
Send, Loader, Sparkles, X, BookOpen, AlertCircle, Compass,
|
||||
Brain, Settings, ChevronDown, ChevronUp, AlertTriangle, Square
|
||||
import {
|
||||
Send, Loader, X, BookOpen, AlertCircle, Compass,
|
||||
Brain, Square, Paperclip,
|
||||
} from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
AgentMarkdown, useAgentSSE, useAutoScroll,
|
||||
ThoughtCard, ToolCallCard, SubAgentContainer,
|
||||
PARENT_COLORS,
|
||||
routeThought, routeToolCall, routeToolResult, routeTextDelta,
|
||||
} from '../../components/agent';
|
||||
import type { SSEEventHandlers, TimelineItem } from '../../components/agent';
|
||||
|
||||
interface RetrievalSource {
|
||||
bibcode: string;
|
||||
@ -20,402 +21,278 @@ interface RetrievalSource {
|
||||
distance: number;
|
||||
}
|
||||
|
||||
interface AgentStep {
|
||||
step: number;
|
||||
type: 'thought' | 'tool_call' | 'tool_result' | 'error';
|
||||
label: string;
|
||||
detail?: string;
|
||||
isError?: boolean;
|
||||
isFinished?: boolean;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
sender: 'user' | 'ai';
|
||||
text: string;
|
||||
sources?: RetrievalSource[];
|
||||
imageUrl?: string;
|
||||
steps?: AgentStep[];
|
||||
timeline: TimelineItem[];
|
||||
}
|
||||
|
||||
interface AIAssistantPanelProps {
|
||||
bibcode: string;
|
||||
onClose: () => void;
|
||||
onJumpToSource: (bibcode: string, paragraphIndex: number) => void;
|
||||
pendingFigure?: { path: string; url: string } | null;
|
||||
onClearPendingFigure?: () => void;
|
||||
}
|
||||
|
||||
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'
|
||||
]),
|
||||
};
|
||||
|
||||
const SUGGESTED_QUESTIONS = [
|
||||
"对比我馆藏的文献中,针对热亚矮星双星在共同包层抛射过程中恒星风流失速率的各种主流观点差异。",
|
||||
"有哪些文献提及了脉动白矮星的非径向振动模?",
|
||||
"简述目前文献中关于 Gaia DR3 视差零点改正的处理方法。",
|
||||
"文献库中关于双星合并前奏(Precursor)观测特征 of 论述有哪些?"
|
||||
'对比我馆藏的文献中,针对热亚矮星双星在共同包层抛射过程中恒星风流失速率的各种主流观点差异。',
|
||||
'有哪些文献提及了脉动白矮星的非径向振动模?',
|
||||
'简述目前文献中关于 Gaia DR3 视差零点改正的处理方法。',
|
||||
'文献库中关于双星合并前奏(Precursor)观测特征 of 论述有哪些?',
|
||||
];
|
||||
|
||||
function getToolDisplayName(name: string): string {
|
||||
switch (name) {
|
||||
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 '语义库检索 (RAG)';
|
||||
case 'query_target': return '查询天体物理参数';
|
||||
default: return name;
|
||||
}
|
||||
}
|
||||
|
||||
export function AIAssistantPanel({
|
||||
bibcode,
|
||||
onClose,
|
||||
onJumpToSource,
|
||||
pendingFigure,
|
||||
onClearPendingFigure
|
||||
}: AIAssistantPanelProps) {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
|
||||
// 步骤展开状态
|
||||
const [stepsExpanded, setStepsExpanded] = useState<Record<number, boolean>>({});
|
||||
|
||||
const chatEndRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [shouldAutoScroll, setShouldAutoScroll] = useState(true);
|
||||
// 折叠状态(共享 ThoughtCard / ToolCallCard / SubAgentContainer 的展开控制)
|
||||
const [expandedThoughts, setExpandedThoughts] = useState<Record<string, boolean>>({});
|
||||
const [expandedArgs, setExpandedArgs] = useState<Record<string, boolean>>({});
|
||||
const [expandedResults, setExpandedResults] = useState<Record<string, boolean>>({});
|
||||
const [collapsedSubAgents, setCollapsedSubAgents] = useState<Record<string, boolean>>({});
|
||||
|
||||
const scrollToBottom = () => {
|
||||
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
const [pendingImage, setPendingImage] = useState<{
|
||||
data?: string; path?: string; mime_type: string; name: string;
|
||||
} | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { streaming, send, stop: sseStop } = useAgentSSE();
|
||||
const { chatEndRef, scrollContainerRef, setShouldAutoScroll, handleScroll } =
|
||||
useAutoScroll([messages, streaming]);
|
||||
|
||||
// ── 折叠控制 ──
|
||||
const toggleThought = (key: string) =>
|
||||
setExpandedThoughts(prev => ({ ...prev, [key]: !prev[key] }));
|
||||
const toggleArgs = (tcId: string) =>
|
||||
setExpandedArgs(prev => ({ ...prev, [tcId]: !prev[tcId] }));
|
||||
const toggleResult = (tcId: string) =>
|
||||
setExpandedResults(prev => ({ ...prev, [tcId]: !prev[tcId] }));
|
||||
const toggleSubAgent = (id: string) =>
|
||||
setCollapsedSubAgents(prev => ({ ...prev, [id]: !prev[id] }));
|
||||
|
||||
// ── 图片处理 ──
|
||||
const attachImage = (file: File) => {
|
||||
if (!file.type.startsWith('image/')) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const result = e.target?.result as string;
|
||||
const commaIdx = result.indexOf(',');
|
||||
if (commaIdx === -1) return;
|
||||
setPendingImage({
|
||||
data: result.substring(commaIdx + 1),
|
||||
mime_type: file.type,
|
||||
name: file.name,
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldAutoScroll) {
|
||||
scrollToBottom();
|
||||
const handlePaste = (e: React.ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.startsWith('image/')) {
|
||||
e.preventDefault();
|
||||
const file = items[i].getAsFile();
|
||||
if (file) attachImage(file);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [messages, loading, shouldAutoScroll]);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!scrollContainerRef.current) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current;
|
||||
// 距离底部 50 像素以内视为贴紧底部
|
||||
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
|
||||
setShouldAutoScroll(isAtBottom);
|
||||
};
|
||||
|
||||
// 重置会话(当 bibcode 切换时)
|
||||
// ── bibcode 切换重置 ──
|
||||
useEffect(() => {
|
||||
setMessages([]);
|
||||
setSessionId(null);
|
||||
setError(null);
|
||||
setStepsExpanded({});
|
||||
setExpandedThoughts({});
|
||||
setExpandedArgs({});
|
||||
setExpandedResults({});
|
||||
setCollapsedSubAgents({});
|
||||
setPendingImage(null);
|
||||
}, [bibcode]);
|
||||
|
||||
// 手动停止智能体执行
|
||||
const handleStop = async () => {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
await axios.post(`/api/chat/sessions/${sessionId}/stop`);
|
||||
} catch (e) {
|
||||
console.error('停止智能体执行失败:', e);
|
||||
sseStop();
|
||||
if (sessionId) {
|
||||
try { await axios.post(`/api/chat/sessions/${sessionId}/stop`); } catch { /* noop */ }
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async (questionText: string) => {
|
||||
if (!questionText.trim() || loading) return;
|
||||
if ((!questionText.trim() && !pendingImage) || streaming) return;
|
||||
|
||||
const figure = pendingFigure;
|
||||
const figure = pendingImage;
|
||||
setError(null);
|
||||
setPendingImage(null);
|
||||
|
||||
const userMsg: Message = {
|
||||
sender: 'user',
|
||||
text: questionText,
|
||||
imageUrl: figure?.url || undefined
|
||||
timeline: [],
|
||||
imageUrl: figure
|
||||
? (figure.path
|
||||
? `/api/files/${figure.path}`
|
||||
: `data:${figure.mime_type};base64,${figure.data}`)
|
||||
: undefined,
|
||||
};
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
setInput('');
|
||||
setLoading(true);
|
||||
setShouldAutoScroll(true);
|
||||
|
||||
if (onClearPendingFigure) {
|
||||
onClearPendingFigure();
|
||||
}
|
||||
// AI 消息占位符(用 TimelineItem[] 替代旧 AgentStep[])
|
||||
const aiMsg: Message = { sender: 'ai', text: '', timeline: [], sources: [] };
|
||||
setMessages(prev => [...prev, aiMsg]);
|
||||
|
||||
// AI 初始消息占位符
|
||||
const aiMsgPlaceholder: Message = {
|
||||
sender: 'ai',
|
||||
text: '',
|
||||
steps: [],
|
||||
sources: []
|
||||
// ── SSE 事件处理器(使用共享子代理路由)──
|
||||
const handlers: SSEEventHandlers = {
|
||||
onSession: (sid) => {
|
||||
if (!sessionId) setSessionId(sid);
|
||||
},
|
||||
onThought: (step, content) => {
|
||||
setMessages(prev => updateAiTimeline(prev, (timeline) =>
|
||||
routeThought(timeline, step, content),
|
||||
));
|
||||
},
|
||||
onToolCall: (step, _id, name, args) => {
|
||||
setMessages(prev => updateAiTimeline(prev, (timeline) =>
|
||||
routeToolCall(timeline, step, _id, name, args),
|
||||
));
|
||||
},
|
||||
onToolResult: (_step, tcId, name, _output, isError, metadata) => {
|
||||
setExpandedResults(prev => ({ ...prev, [tcId]: true }));
|
||||
setMessages(prev => {
|
||||
if (prev.length === 0) return prev;
|
||||
const last = prev[prev.length - 1];
|
||||
if (last.sender !== 'ai') return prev;
|
||||
const newTimeline = routeToolResult(last.timeline, tcId, name, _output, isError);
|
||||
// RAG 来源收集
|
||||
let sources = [...(last.sources || [])];
|
||||
if (name === 'rag_search' && metadata?.sources) {
|
||||
for (const s of metadata.sources) {
|
||||
if (!sources.some(
|
||||
c => c.bibcode === s.bibcode && c.paragraph_index === s.paragraph_index,
|
||||
)) {
|
||||
sources.push({
|
||||
bibcode: s.bibcode, paragraph_index: s.paragraph_index,
|
||||
content: s.preview || '', distance: s.distance,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...prev.slice(0, -1), { ...last, timeline: newTimeline, sources }];
|
||||
});
|
||||
},
|
||||
onTextDelta: (content, toolCallId) => {
|
||||
if (toolCallId) {
|
||||
setExpandedResults(prev => ({ ...prev, [toolCallId]: true }));
|
||||
}
|
||||
setMessages(prev => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last?.sender !== 'ai') return prev;
|
||||
const { timeline, answerDelta } = routeTextDelta(last.timeline, content, toolCallId);
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{ ...last, timeline, text: last.text + answerDelta },
|
||||
];
|
||||
});
|
||||
},
|
||||
onError: (msg) => {
|
||||
setError(msg);
|
||||
},
|
||||
};
|
||||
setMessages(prev => [...prev, aiMsgPlaceholder]);
|
||||
|
||||
try {
|
||||
// 如果有待处理的图片,先获取并转为 base64
|
||||
let imagePayload: { data: string; mime_type: string } | undefined = undefined;
|
||||
if (figure) {
|
||||
try {
|
||||
const imgResponse = await fetch(figure.url);
|
||||
if (!imgResponse.ok) {
|
||||
throw new Error(`Failed to fetch image: ${imgResponse.statusText}`);
|
||||
}
|
||||
const blob = await imgResponse.blob();
|
||||
|
||||
const base64Res = await new Promise<{ data: string; mimeType: string }>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(blob);
|
||||
reader.onloadend = () => {
|
||||
const result = reader.result as string;
|
||||
const commaIdx = result.indexOf(',');
|
||||
if (commaIdx === -1) {
|
||||
reject(new Error('Invalid data URL'));
|
||||
return;
|
||||
}
|
||||
const data = result.substring(commaIdx + 1);
|
||||
const match = result.match(/^data:([^;]+);base64,/);
|
||||
const mimeType = match ? match[1] : blob.type || 'image/png';
|
||||
resolve({ data, mimeType });
|
||||
};
|
||||
reader.onerror = reject;
|
||||
});
|
||||
|
||||
imagePayload = {
|
||||
data: base64Res.data,
|
||||
mime_type: base64Res.mimeType
|
||||
};
|
||||
} catch (imgErr) {
|
||||
console.error('Failed to process image attachment:', imgErr);
|
||||
setError('获取或处理选中的图表失败,请重试');
|
||||
setMessages(prev => {
|
||||
const next = [...prev];
|
||||
// 移除用户和 AI 占位消息
|
||||
if (next.length >= 2) {
|
||||
return next.slice(0, -2);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const contextPrefix = `[当前正在阅读文献: ${bibcode}] `;
|
||||
const requestQuery = questionText.includes(bibcode)
|
||||
? questionText : `${contextPrefix}${questionText}`;
|
||||
|
||||
// 调用智能体流式对话接口(带文献上下文提示,确保 Agent 优先分析当前阅读的文献)
|
||||
const contextPrefix = `[当前正在阅读文献: ${bibcode}] `;
|
||||
const requestQuery = questionText.includes(bibcode) ? questionText : `${contextPrefix}${questionText}`;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/chat/agent', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
question: requestQuery,
|
||||
session_id: sessionId,
|
||||
mode: 'literature-reader',
|
||||
image: imagePayload,
|
||||
}),
|
||||
});
|
||||
|
||||
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 = '';
|
||||
|
||||
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) continue;
|
||||
|
||||
if (trimmed.startsWith('data:')) {
|
||||
const dataStr = trimmed.slice(5).trim();
|
||||
if (!dataStr) continue;
|
||||
|
||||
try {
|
||||
const event = JSON.parse(dataStr);
|
||||
|
||||
switch (event.type) {
|
||||
case 'session':
|
||||
if (!sessionId) {
|
||||
setSessionId(event.session_id);
|
||||
}
|
||||
break;
|
||||
case 'thought':
|
||||
setMessages(prev => {
|
||||
const next = [...prev];
|
||||
const last = next[next.length - 1];
|
||||
if (last && last.sender === 'ai') {
|
||||
if (!last.steps) last.steps = [];
|
||||
let stepObj = last.steps.find(s => s.step === event.step && s.type === 'thought');
|
||||
if (!stepObj) {
|
||||
stepObj = { step: event.step, type: 'thought', label: '思考推理中' };
|
||||
last.steps.push(stepObj);
|
||||
}
|
||||
stepObj.detail = event.content;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
break;
|
||||
case 'tool_call':
|
||||
setMessages(prev => {
|
||||
const next = [...prev];
|
||||
const last = next[next.length - 1];
|
||||
if (last && last.sender === 'ai') {
|
||||
if (!last.steps) last.steps = [];
|
||||
let stepObj = last.steps.find(s => s.step === event.step && s.type === 'tool_call');
|
||||
if (!stepObj) {
|
||||
stepObj = { step: event.step, type: 'tool_call', label: `调用: ${getToolDisplayName(event.name)}`, isFinished: false };
|
||||
last.steps.push(stepObj);
|
||||
}
|
||||
stepObj.detail = typeof event.arguments === 'string' ? event.arguments : JSON.stringify(event.arguments);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
break;
|
||||
case 'tool_result':
|
||||
setMessages(prev => {
|
||||
const next = [...prev];
|
||||
const last = next[next.length - 1];
|
||||
if (last && last.sender === 'ai') {
|
||||
if (!last.steps) last.steps = [];
|
||||
const stepObj = last.steps.find(s => s.step === event.step && s.type === 'tool_call');
|
||||
if (stepObj) {
|
||||
stepObj.label = `调用: ${getToolDisplayName(event.name)} (已获取观测数据)`;
|
||||
stepObj.isError = event.is_error;
|
||||
stepObj.isFinished = true;
|
||||
}
|
||||
|
||||
// 提取 RAG 检索到的参考来源文献,并在 sources 中进行去重和追加
|
||||
if (event.name === 'rag_search' && event.metadata && event.metadata.sources) {
|
||||
const newSources: RetrievalSource[] = event.metadata.sources.map((s: any) => ({
|
||||
bibcode: s.bibcode,
|
||||
paragraph_index: s.paragraph_index,
|
||||
content: s.preview || '',
|
||||
distance: s.distance,
|
||||
}));
|
||||
|
||||
const existing = last.sources || [];
|
||||
const combined = [...existing];
|
||||
|
||||
for (const src of newSources) {
|
||||
if (!combined.some(c => c.bibcode === src.bibcode && c.paragraph_index === src.paragraph_index)) {
|
||||
combined.push(src);
|
||||
}
|
||||
}
|
||||
last.sources = combined;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
break;
|
||||
case 'text_delta':
|
||||
setMessages(prev => {
|
||||
const next = [...prev];
|
||||
const last = next[next.length - 1];
|
||||
if (last && last.sender === 'ai') {
|
||||
last.text += event.content;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
break;
|
||||
case 'error':
|
||||
setMessages(prev => {
|
||||
const next = [...prev];
|
||||
const last = next[next.length - 1];
|
||||
if (last && last.sender === 'ai') {
|
||||
if (!last.steps) last.steps = [];
|
||||
last.steps.push({
|
||||
step: 99,
|
||||
type: 'error',
|
||||
label: `错误返回`,
|
||||
detail: event.message
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析流式 SSE 失败:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
|
||||
} catch (streamErr) {
|
||||
console.error('智能体流式对话失败:', streamErr);
|
||||
setError('智能体流式对话失败,请稍后重试');
|
||||
setMessages(prev => {
|
||||
const next = [...prev];
|
||||
if (next.length > 0 && next[next.length - 1].sender === 'ai' && !next[next.length - 1].text) {
|
||||
next.pop();
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('问答请求失败:', err);
|
||||
setError(err.response?.data?.error || err.message || '网络请求错误,请稍后重试');
|
||||
// 如果报错,清空多余的消息占位符
|
||||
setMessages(prev => {
|
||||
const next = [...prev];
|
||||
if (next.length > 0 && next[next.length - 1].sender === 'ai' && !next[next.length - 1].text) {
|
||||
next.pop();
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setLoading(false);
|
||||
let imagePayload: { data: string; mime_type: string } | undefined;
|
||||
if (figure?.data) {
|
||||
imagePayload = { data: figure.data, mime_type: figure.mime_type };
|
||||
}
|
||||
|
||||
await send(
|
||||
{
|
||||
question: requestQuery,
|
||||
sessionId,
|
||||
mode: 'literature-reader',
|
||||
image: imagePayload
|
||||
? { data: imagePayload.data, mime_type: imagePayload.mime_type }
|
||||
: undefined,
|
||||
},
|
||||
handlers,
|
||||
);
|
||||
};
|
||||
|
||||
const toggleSteps = (msgIdx: number) => {
|
||||
setStepsExpanded(prev => ({
|
||||
...prev,
|
||||
[msgIdx]: !prev[msgIdx]
|
||||
}));
|
||||
// ── 时间线条目渲染(委托到共享组件)──
|
||||
const renderTimelineItem = (item: TimelineItem, idx: number, isStreaming: boolean) => {
|
||||
switch (item.type) {
|
||||
case 'subagent_container':
|
||||
return (
|
||||
<SubAgentContainer
|
||||
key={`subagent-${item.id}-${idx}`}
|
||||
status={item.status}
|
||||
children={item.children}
|
||||
summary={item.summary}
|
||||
isStreaming={isStreaming}
|
||||
isCollapsed={collapsedSubAgents[item.id] !== false}
|
||||
onToggle={() => toggleSubAgent(item.id)}
|
||||
expandedThoughts={expandedThoughts}
|
||||
expandedArgs={expandedArgs}
|
||||
expandedResults={expandedResults}
|
||||
onToggleThought={toggleThought}
|
||||
onToggleArgs={toggleArgs}
|
||||
onToggleResult={toggleResult}
|
||||
/>
|
||||
);
|
||||
case 'thought': {
|
||||
const thoughtKey = `ai-thought-${item.step}-${idx}`;
|
||||
return (
|
||||
<ThoughtCard
|
||||
key={thoughtKey}
|
||||
step={item.step}
|
||||
content={item.content}
|
||||
isStreaming={isStreaming}
|
||||
colorScheme={PARENT_COLORS}
|
||||
isExpanded={expandedThoughts[thoughtKey] === true}
|
||||
onToggle={() => toggleThought(thoughtKey)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'tool_call': {
|
||||
const tcId = item.id;
|
||||
return (
|
||||
<ToolCallCard
|
||||
key={`tc-${tcId}-${idx}`}
|
||||
step={item.step}
|
||||
name={item.name}
|
||||
arguments={item.arguments}
|
||||
result={item.result}
|
||||
isStreaming={isStreaming}
|
||||
colorScheme={PARENT_COLORS}
|
||||
isArgsExpanded={expandedArgs[tcId] === true}
|
||||
isResultExpanded={expandedResults[tcId] === true}
|
||||
onToggleArgs={() => toggleArgs(tcId)}
|
||||
onToggleResult={() => toggleResult(tcId)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'answer':
|
||||
return null; // finalAnswer 单独渲染在气泡内
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="console-panel rounded-xl border border-slate-200 bg-slate-50 flex flex-col overflow-hidden relative shadow-sm h-full">
|
||||
{/* 头部面板 */}
|
||||
<div className="px-4 py-3.5 border-b border-slate-200 flex items-center justify-between bg-white shrink-0">
|
||||
<div className="flex items-center gap-1.5 select-none">
|
||||
<Sparkles className="w-4 h-4 text-sky-600 animate-pulse" />
|
||||
<span className="text-xs font-bold text-slate-800">科研智能问答助手</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-slate-600 transition-colors cursor-pointer">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 对话消息区 */}
|
||||
<div
|
||||
<div className="flex flex-col overflow-hidden h-full">
|
||||
{/* 消息区 */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto p-4 pb-28 space-y-4 min-h-0"
|
||||
@ -429,10 +306,10 @@ export function AIAssistantPanel({
|
||||
专注文献精读与理解的 AI 助手。支持语义检索、逐段解读、公式分析,严格只读模式保障文献安全。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 推荐提示词 */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-[10px] font-bold text-slate-400 tracking-wider uppercase">推荐学术提问:</span>
|
||||
<span className="text-[10px] font-bold text-slate-400 tracking-wider uppercase">
|
||||
推荐学术提问:
|
||||
</span>
|
||||
<div className="space-y-2">
|
||||
{SUGGESTED_QUESTIONS.map((q, idx) => (
|
||||
<button
|
||||
@ -448,9 +325,12 @@ export function AIAssistantPanel({
|
||||
</div>
|
||||
) : (
|
||||
messages.map((msg, index) => (
|
||||
<div key={index} className={`flex flex-col ${msg.sender === 'user' ? 'items-end' : 'items-start'} space-y-1.5`}>
|
||||
<div
|
||||
key={index}
|
||||
className={`flex flex-col ${msg.sender === 'user' ? 'items-end' : 'items-start'} space-y-1.5`}
|
||||
>
|
||||
<span className="text-[10px] font-bold text-slate-400 px-1">
|
||||
{msg.sender === 'user' ? '我' : '智能科研助手'}
|
||||
{msg.sender === 'user' ? '我' : '天文科研助手'}
|
||||
</span>
|
||||
<div
|
||||
className={`max-w-[95%] rounded-xl px-4 py-3 text-xs leading-relaxed font-medium shadow-sm border ${
|
||||
@ -462,73 +342,51 @@ export function AIAssistantPanel({
|
||||
{msg.sender === 'user' ? (
|
||||
<div className="space-y-2">
|
||||
{msg.imageUrl && (
|
||||
<img src={msg.imageUrl} alt="Attached Figure" className="max-w-40 max-h-40 object-contain rounded-md border border-sky-500/30" />
|
||||
<img
|
||||
src={msg.imageUrl}
|
||||
alt="Attached Figure"
|
||||
className="max-w-40 max-h-40 object-contain rounded-md border border-sky-500/30"
|
||||
/>
|
||||
)}
|
||||
<p className="whitespace-pre-wrap">{msg.text}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2.5">
|
||||
{/* Collapsible Steps list */}
|
||||
{msg.steps && msg.steps.length > 0 && (
|
||||
{/* 时间线条目(思考/工具调用/子代理容器)*/}
|
||||
{msg.timeline.length > 0 && (
|
||||
<div className="border border-slate-100 bg-slate-50/50 rounded-lg p-2 select-none">
|
||||
<button
|
||||
onClick={() => toggleSteps(index)}
|
||||
className="flex items-center justify-between w-full text-left text-[10px] font-bold text-slate-500 hover:text-slate-700 transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
<Brain className="w-3.5 h-3.5 text-purple-500" />
|
||||
<span>推理与工具链展开 ({msg.steps.length} 步)</span>
|
||||
</span>
|
||||
{stepsExpanded[index] ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
|
||||
{stepsExpanded[index] && (
|
||||
<div className="mt-2 pl-2.5 border-l border-slate-200 space-y-1.5 transition-all">
|
||||
{msg.steps.map((step, sIdx) => (
|
||||
<div key={sIdx} className="text-[9px] text-slate-655 flex flex-col gap-0.5">
|
||||
<span className="font-bold flex items-center gap-1">
|
||||
{step.type === 'thought' ? (
|
||||
<Brain className="w-3 h-3 text-purple-500 shrink-0" />
|
||||
) : step.type === 'error' ? (
|
||||
<AlertTriangle className="w-3 h-3 text-rose-500 shrink-0" />
|
||||
) : (
|
||||
<Settings className={`w-3 h-3 text-sky-600 shrink-0 ${step.isFinished ? '' : 'animate-spin'}`} />
|
||||
)}
|
||||
<span>{step.label}</span>
|
||||
</span>
|
||||
{step.detail && (
|
||||
<span className="font-mono text-[9px] opacity-75 whitespace-pre-wrap block bg-white px-1.5 py-0.5 rounded border border-slate-100 max-h-24 overflow-y-auto">
|
||||
{step.detail}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[10px] font-bold text-purple-500 flex items-center gap-1 mb-2 px-1">
|
||||
<Brain className="w-3.5 h-3.5" />
|
||||
<span>推理与工具链 ({msg.timeline.length} 项)</span>
|
||||
</div>
|
||||
<div className="pl-4 border-l border-slate-200 space-y-2">
|
||||
{msg.timeline.map((item, tIdx) =>
|
||||
renderTimelineItem(item, tIdx, !!streaming),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Final output text */}
|
||||
{/* 最终回答 */}
|
||||
{msg.text ? (
|
||||
<div className="prose prose-sm max-w-none text-slate-800 leading-relaxed prose-headings:text-slate-900 prose-headings:font-bold prose-strong:text-slate-900 prose-code:text-sky-700 prose-img:rounded-lg">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkMath, remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, safeSchema], rehypeKatex]}
|
||||
>
|
||||
{msg.text}
|
||||
</ReactMarkdown>
|
||||
<AgentMarkdown>{msg.text}</AgentMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
!loading && <span className="text-slate-400 italic">正在构建最终结论...</span>
|
||||
!streaming && (
|
||||
<span className="text-slate-400 italic">正在构建最终结论...</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 关联来源展示 */}
|
||||
{/* 关联来源 */}
|
||||
{msg.sender === 'ai' && msg.sources && msg.sources.length > 0 && (
|
||||
<div className="w-full mt-2 pl-2 space-y-1.5 border-l-2 border-slate-200">
|
||||
<span className="text-[9px] font-bold text-slate-400 select-none">参考来源文献(点击跳转):</span>
|
||||
<span className="text-[9px] font-bold text-slate-400 select-none">
|
||||
参考来源文献(点击跳转):
|
||||
</span>
|
||||
<div className="grid grid-cols-1 gap-1.5 w-[95%]">
|
||||
{msg.sources.map((src, sIdx) => (
|
||||
<button
|
||||
@ -540,7 +398,9 @@ export function AIAssistantPanel({
|
||||
<div className="flex items-center gap-1.5 truncate mr-2">
|
||||
<BookOpen className="w-3 h-3 text-sky-600 shrink-0" />
|
||||
<span className="font-bold text-slate-700 truncate">{src.bibcode}</span>
|
||||
<span className="text-slate-400 text-[9px] font-bold">§{src.paragraph_index + 1}</span>
|
||||
<span className="text-slate-400 text-[9px] font-bold">
|
||||
§{src.paragraph_index + 1}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-slate-400 text-[9px] shrink-0 font-medium">
|
||||
距: {src.distance.toFixed(3)}
|
||||
@ -554,7 +414,7 @@ export function AIAssistantPanel({
|
||||
))
|
||||
)}
|
||||
|
||||
{loading && messages.length > 0 && !messages[messages.length - 1].text && (
|
||||
{streaming && messages.length > 0 && !messages[messages.length - 1].text && (
|
||||
<div className="flex items-center space-x-2 text-slate-500 pl-2">
|
||||
<Loader className="w-4 h-4 animate-spin text-sky-600" />
|
||||
<span className="text-[10px] font-bold">文献检索及推理结论构建中...</span>
|
||||
@ -574,31 +434,7 @@ export function AIAssistantPanel({
|
||||
<div ref={chatEndRef} />
|
||||
</div>
|
||||
|
||||
{/* 待提问图片预览 */}
|
||||
{pendingFigure && (
|
||||
<div className="px-3 py-2 bg-slate-100 border-t border-slate-200 flex items-center justify-between shrink-0 select-none">
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={pendingFigure.url}
|
||||
alt="Preview"
|
||||
className="w-10 h-10 object-cover rounded-md border border-slate-300"
|
||||
/>
|
||||
<div className="text-[10px] text-slate-550">
|
||||
<span className="font-bold text-slate-700 block">已选中图表插图</span>
|
||||
<span className="font-mono text-slate-400 truncate max-w-48 block">{pendingFigure.path.split('/').pop()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearPendingFigure}
|
||||
className="text-slate-400 hover:text-slate-655 p-1 cursor-pointer"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 底栏输入区 */}
|
||||
{/* 底栏输入 */}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
@ -606,7 +442,7 @@ export function AIAssistantPanel({
|
||||
}}
|
||||
className="absolute bottom-0 left-0 right-0 p-3 bg-transparent pointer-events-none shrink-0"
|
||||
>
|
||||
<div className="bg-slate-50 border border-slate-200/60 rounded-2xl p-2.5 focus-within:bg-white focus-within:border-sky-500 focus-within:ring-1 focus-within:ring-sky-500/10 transition-all relative pointer-events-auto shadow-md">
|
||||
<div className="bg-slate-50 border border-slate-200/60 rounded-2xl p-2.5 focus-within:bg-white focus-within:border-sky-500 focus-within:ring-1 focus-within:ring-sky-500/10 transition-all relative pointer-events-auto shadow-md flex flex-col gap-2">
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
@ -616,33 +452,98 @@ export function AIAssistantPanel({
|
||||
handleSend(input);
|
||||
}
|
||||
}}
|
||||
disabled={loading}
|
||||
placeholder={pendingFigure ? "针对选中图表提问..." : "向 AI 馆藏助手提问..."}
|
||||
onPaste={handlePaste}
|
||||
disabled={streaming}
|
||||
placeholder={pendingImage ? '针对选中图表提问...' : '向 AI 馆藏助手提问...'}
|
||||
rows={2}
|
||||
className="w-full bg-transparent resize-none border-none outline-none focus:outline-none focus:ring-0 text-xs text-slate-900 placeholder-slate-400 pr-8 leading-relaxed font-semibold min-h-[36px] max-h-32"
|
||||
className="w-full bg-transparent resize-none border-none outline-none focus:outline-none focus:ring-0 text-xs text-slate-900 placeholder-slate-400 leading-relaxed font-semibold min-h-[36px] max-h-32 pb-1"
|
||||
/>
|
||||
<div className="absolute right-2 bottom-2">
|
||||
{loading ? (
|
||||
<div className="flex justify-between items-center mt-1.5 pt-1.5 border-t border-slate-200/40">
|
||||
<div className="flex gap-2 items-center min-w-0">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) attachImage(file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStop}
|
||||
className="p-1.5 rounded-lg bg-rose-600 hover:bg-rose-700 text-white transition-colors cursor-pointer flex items-center justify-center shadow-xs"
|
||||
title="手动停止执行"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={streaming}
|
||||
title="上传或粘贴图片(也可直接 Ctrl+V 粘贴)"
|
||||
className={`p-1 rounded-md border text-[10px] font-bold transition-all cursor-pointer flex items-center justify-center shrink-0 ${
|
||||
pendingImage
|
||||
? 'bg-sky-50 border-sky-300 text-sky-700'
|
||||
: 'bg-white border-slate-200 text-slate-400 hover:text-sky-600 hover:border-sky-200'
|
||||
} disabled:opacity-60`}
|
||||
>
|
||||
<Square className="w-3.5 h-3.5 fill-white" />
|
||||
<Paperclip className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim()}
|
||||
className="p-1.5 rounded-lg bg-sky-600 hover:bg-sky-700 text-white disabled:bg-slate-100 disabled:text-slate-350 transition-colors cursor-pointer flex items-center justify-center shadow-xs"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{pendingImage && (
|
||||
<div className="relative inline-block ml-1 shrink-0 group select-none">
|
||||
<img
|
||||
src={
|
||||
pendingImage.path
|
||||
? `/api/files/${pendingImage.path}`
|
||||
: `data:${pendingImage.mime_type};base64,${pendingImage.data}`
|
||||
}
|
||||
alt="Preview"
|
||||
className="w-5 h-5 object-cover rounded border border-slate-300 shadow-2xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingImage(null)}
|
||||
className="absolute -top-1 -right-1 bg-slate-500/80 hover:bg-slate-700 text-white rounded-full w-3 h-3 shadow-xs transition-colors cursor-pointer flex items-center justify-center"
|
||||
title="移除图片"
|
||||
>
|
||||
<X className="w-2 h-2" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{streaming ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStop}
|
||||
className="p-1 rounded-lg bg-slate-800 hover:bg-slate-950 text-white transition-colors cursor-pointer flex items-center justify-center shadow-xs"
|
||||
title="手动停止执行"
|
||||
>
|
||||
<Square className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim() && !pendingImage}
|
||||
className="p-1 px-2.5 rounded-lg bg-sky-600 hover:bg-sky-700 text-white disabled:bg-slate-100 disabled:text-slate-350 transition-colors cursor-pointer flex items-center justify-center gap-1 shadow-xs text-[10px] font-bold"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
<span>发送</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── helpers ──
|
||||
|
||||
// 更新最后一条 AI 消息的 timeline
|
||||
function updateAiTimeline(
|
||||
prev: Message[],
|
||||
fn: (timeline: TimelineItem[]) => TimelineItem[],
|
||||
): Message[] {
|
||||
if (prev.length === 0) return prev;
|
||||
const last = prev[prev.length - 1];
|
||||
if (last.sender !== 'ai') return prev;
|
||||
return [...prev.slice(0, -1), { ...last, timeline: fn(last.timeline) }];
|
||||
}
|
||||
|
||||
|
||||
@ -916,7 +916,6 @@ export function ReaderPanel({
|
||||
<div className="flex-1 overflow-hidden h-full">
|
||||
<AIAssistantPanel
|
||||
bibcode={selectedPaper.bibcode}
|
||||
onClose={() => setShowNotesPanel(false)}
|
||||
onJumpToSource={onJumpToSource}
|
||||
/>
|
||||
</div>
|
||||
|
||||
14
dashboard/src/utils/preprocess.ts
Normal file
14
dashboard/src/utils/preprocess.ts
Normal file
@ -0,0 +1,14 @@
|
||||
// dashboard/src/utils/preprocess.ts
|
||||
|
||||
/**
|
||||
* Preprocesses LaTeX math delimiters in model/tool outputs to ensure Compatibility with remark-math.
|
||||
* Converts \( ... \) to $ ... $ and \[ ... \] to $$ ... $$.
|
||||
*/
|
||||
export const preprocessMath = (text: string): string => {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.replace(/\\\[/g, () => '$$\n')
|
||||
.replace(/\\\]/g, () => '\n$$')
|
||||
.replace(/\\\(/g, () => '$')
|
||||
.replace(/\\\)/g, () => '$');
|
||||
};
|
||||
@ -142,7 +142,7 @@ graph LR
|
||||
| 层 | 触发条件 | 算法 | API 调用 | 关键参数 |
|
||||
|:---|:---|:---|:---|:---|
|
||||
| **snip** (L0) | 消息数 > `MAX_MESSAGES` (默认 50) | `find_safe_cut_point` 切中间段 → 插入占位消息 | 否 | HEAD_KEEP=3, tail_keep=47 |
|
||||
| **micro** (L1) | L0 后仍超 `context_char_limit × 1.5` | 工具结果 → `[Previous: used {tool_name}]` | 否 | keep_recent=8 |
|
||||
| **micro** (L1) | L0 后仍超 `token_soft_limit` | 工具结果 → `[Previous: used {tool_name}]` | 否 | keep_recent=8 |
|
||||
| **auto** (L2) | L1 后仍超限 | LLM 生成中文摘要替换历史 | 是 | 摘要 ≤500 字 |
|
||||
| **aggressive_micro** (L3) | L2 后仍超限 | 同 L1 但仅保留最近 2 条工具结果 | 否 | keep_recent=2 |
|
||||
| **identity** (L4) | 压缩后消息 ≤4 条 | 注入 `[身份确认]` 块 | 否 | 天体物理学研究助手身份 |
|
||||
@ -351,7 +351,7 @@ Session → Thought → (ToolCall ↔ ToolResult)* → TextDelta → Usage → D
|
||||
| `AGENT_MAX_STEPS` | 8 | ReAct 最大迭代步数 |
|
||||
| `AGENT_TOOL_TIMEOUT_SECS` | 120 | 工具执行超时(秒) |
|
||||
| `AGENT_MAX_TOOL_OUTPUT_CHARS` | 4000 | 工具输出截断长度(字符) |
|
||||
| `AGENT_CONTEXT_CHAR_LIMIT` | 16000 | 上下文压缩触发点(字符估算) |
|
||||
| `AGENT_TOKEN_SOFT_LIMIT` | 32000 | 各压缩层统一触发阈值(token) |
|
||||
| `AGENT_TOKEN_SOFT_LIMIT` | 32000 | Token 预算软限制(触发 nudging) |
|
||||
| `AGENT_TOKEN_HARD_LIMIT` | 40000 | Token 预算硬限制(触发强制动作) |
|
||||
| `AGENT_MAX_MESSAGES` | 50 | snip_compact 触发阈值(消息数) |
|
||||
|
||||
@ -11,7 +11,7 @@ Agent 系统的所有可配置参数,按子系统分类。
|
||||
| `AGENT_MAX_STEPS` | 8 | usize | 单轮对话中最大 ReAct 迭代步数。到达后强制终止,注入"请根据已有信息直接给出最终答案"消息,调用 `final_answer_without_tools()` 不带工具生成最终回答。 |
|
||||
| `AGENT_TOOL_TIMEOUT_SECS` | 120 | u64 | 单个工具调用的超时时间(秒)。`read_file` / `grep_files` 等快速工具通常 <1s;`download_paper` 可能需要 30-60s。超时后返回 `ToolOutput::error`,不终止整个 turn。 |
|
||||
| `AGENT_MAX_TOOL_OUTPUT_CHARS` | 4000 | usize | 工具输出截断字符数。超过此值的输出被截断并在尾部附加 `[已截断,原始 N 字符]`。大文件的完整内容可通过 `maybe_persist_tool_result()` 写入磁盘,返回文件路径指针。 |
|
||||
| `AGENT_CONTEXT_CHAR_LIMIT` | 16000 | usize | 上下文字符估算上限。用于 `rough_estimate_tokens` 与 micro_compact 层的触发判断。仅在 LLM 不返回精确 token 计数时作为后备。 |
|
||||
| `AGENT_TOKEN_SOFT_LIMIT` | 32000 | usize | 各压缩层的统一触发阈值(token)。替代旧版 `AGENT_CONTEXT_CHAR_LIMIT`。用于 micro_compact / auto_compact 层的触发判断及 TokenBudget 软限制。 |
|
||||
| `AGENT_TOKEN_SOFT_LIMIT` | 32000 | usize | Token 预算软限制。达到 80% 时注入 💡 "Token 预算提示";达到 100% 时注入 🟡 "Token 预算警告";同时作为自动压缩的触发阈值(`estimated_tokens > soft_limit`)。 |
|
||||
| `AGENT_TOKEN_HARD_LIMIT` | 40000 | usize | Token 预算硬限制。达到时注入 🔴 "已耗尽" 消息,强制要求模型立即给出最终答案。error recovery escalate 步骤可临时提升此值到 64,000。 |
|
||||
| `AGENT_MAX_MESSAGES` | 50 | usize | snip_compact (Layer 0) 的触发阈值。消息数超过此值时截断中间段:保留前 3 条 + 后 47 条,中间替换为含工具名称列表的占位消息。 |
|
||||
@ -128,7 +128,7 @@ Agent 系统的所有可配置参数,按子系统分类。
|
||||
| `src/lib.rs` (Config) | `LLM_API_KEY`, `LLM_API_BASE`, `LLM_MODEL`, `LLM_FALLBACK_MODEL`, `LLM_FALLBACK_CHAIN`, `EMBEDDING_*`, `ADS_API_KEY`, `DATABASE_URL`, `LIBRARY_DIR`, `SKILLS_DIR`, `PORT`, `QINIU_*`, `MINERU_*` |
|
||||
| `src/main.rs` | `EMBEDDING_DIM` |
|
||||
| `src/services/logging.rs` | `LOG_LEVEL`, `LOG_FORMAT`, `LOG_OUTPUTS`, `LOG_DIR` |
|
||||
| `src/agent/runtime/mod.rs` (AgentConfig) | `AGENT_MAX_STEPS`, `AGENT_TOOL_TIMEOUT_SECS`, `AGENT_MAX_TOOL_OUTPUT_CHARS`, `AGENT_CONTEXT_CHAR_LIMIT`, `AGENT_TOKEN_SOFT_LIMIT`, `AGENT_TOKEN_HARD_LIMIT`, `AGENT_MAX_MESSAGES` |
|
||||
| `src/agent/runtime/mod.rs` (AgentConfig) | `AGENT_MAX_STEPS`, `AGENT_TOOL_TIMEOUT_SECS`, `AGENT_MAX_TOOL_OUTPUT_CHARS`, `AGENT_TOKEN_SOFT_LIMIT`, `AGENT_TOKEN_HARD_LIMIT`, `AGENT_MAX_MESSAGES` |
|
||||
| `src/agent/runtime/mod.rs` (call_llm_with_recovery) | `LLM_FALLBACK_MODEL` (原 `FALLBACK_MODEL`), `LLM_FALLBACK_CHAIN` (P3) |
|
||||
| `src/agent/runtime/mod.rs` (AgentRuntime) | `AGENT_COORDINATOR_MAX_WORKERS`, `AGENT_COORDINATOR_WORKER_TIMEOUT` (P2) |
|
||||
| `src/agent/compact.rs` (extract_memories_from_compaction) | `EXTRACT_MEMORY_ENABLED` (P3 压缩记忆桥接) |
|
||||
|
||||
@ -171,12 +171,19 @@ let est_tokens: usize = messages
|
||||
.iter()
|
||||
.map(|m| m.content.as_ref().map_or(0, |c| c.len()) + 4)
|
||||
.sum();
|
||||
if est_tokens > self.config.context_char_limit * 3 / 2 {
|
||||
compact::compress_context(&mut messages, llm, config.context_char_limit, "subagent").await;
|
||||
if est_tokens > self.config.token_soft_limit * 3 / 2 {
|
||||
compact::compress_context(
|
||||
&mut messages,
|
||||
llm,
|
||||
config.token_soft_limit,
|
||||
config.max_messages,
|
||||
"subagent",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
```
|
||||
|
||||
- 触发阈值:`context_char_limit * 1.5`(默认 ~24000 字符)
|
||||
- 触发阈值:`token_soft_limit * 1.5`(默认 ~48000 tokens)
|
||||
- 压缩方式:调用 `compact::compress_context`(LLM 摘要压缩)
|
||||
- 标识来源:`"subagent"`,区别于 `"lead"` / `"teammate"` / `"background"`
|
||||
|
||||
|
||||
@ -81,7 +81,7 @@ src/
|
||||
| `SKILLS_DIR` | `./skills` | Agent Skills 目录 |
|
||||
| `PORT` | `8000` | 服务端口 |
|
||||
|
||||
Agent 调优参数:`AGENT_MAX_STEPS` (8)、`AGENT_TOOL_TIMEOUT_SECS` (120)、`AGENT_CONTEXT_CHAR_LIMIT` (16000)。
|
||||
Agent 调优参数:`AGENT_MAX_STEPS` (8)、`AGENT_TOOL_TIMEOUT_SECS` (120)、`AGENT_TOKEN_SOFT_LIMIT` (32000)、`AGENT_TOKEN_HARD_LIMIT` (40000)。
|
||||
|
||||
## 代码规范
|
||||
|
||||
|
||||
@ -29,8 +29,13 @@ static COMPACTING: AtomicBool = AtomicBool::new(false);
|
||||
use crate::clients::llm::{ChatMessage, LlmClient, MessageRole};
|
||||
|
||||
/// 找到安全的上下文切割点,确保不会切断 tool_call / tool_result 配对。
|
||||
/// 从末尾向前扫描,如果候选切割点的第一条要保留的消息是 tool 角色,
|
||||
/// 则向前追溯到对应的 assistant(tool_calls) 消息一并保留。
|
||||
///
|
||||
/// 从目标切割点向前扫描:如果保留区中存在 tool_result,则
|
||||
/// 将其对应的 assistant(tool_calls) 也纳入保留区,保证配对完整。
|
||||
///
|
||||
/// 返回 0 表示消息数不足以进行切割(调用方应跳过压缩)。
|
||||
///
|
||||
/// 时间复杂度: O(n),单次后向扫描,使用 HashSet 追踪已覆盖的 tool_call_id。
|
||||
pub fn find_safe_cut_point(messages: &[ChatMessage], desired_keep: usize) -> usize {
|
||||
if messages.len() <= desired_keep {
|
||||
return 0; // 全部保留,无需切割
|
||||
@ -43,53 +48,41 @@ pub fn find_safe_cut_point(messages: &[ChatMessage], desired_keep: usize) -> usi
|
||||
cut = 1;
|
||||
}
|
||||
|
||||
// 如果切割点落在一个 tool 消息上,向前扩展以包含其对应的 assistant(tool_calls)
|
||||
loop {
|
||||
if cut >= messages.len() {
|
||||
cut = messages.len() - 1;
|
||||
break;
|
||||
}
|
||||
|
||||
let first_kept = &messages[cut];
|
||||
|
||||
if first_kept.role == MessageRole::Tool {
|
||||
let mut found_pair = false;
|
||||
for j in (0..cut).rev() {
|
||||
if messages[j].role == MessageRole::Assistant && messages[j].tool_calls.is_some() {
|
||||
cut = j;
|
||||
found_pair = true;
|
||||
break;
|
||||
}
|
||||
// 收集保留区 [cut..] 中所有 tool_result 对应的 tool_call_id
|
||||
let mut covered_ids: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
||||
for msg in &messages[cut..] {
|
||||
if msg.role == MessageRole::Tool {
|
||||
if let Some(ref id) = msg.tool_call_id {
|
||||
covered_ids.insert(id.as_str());
|
||||
}
|
||||
if !found_pair {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查切割点之前没有孤立的 assistant(tool_calls)。
|
||||
// 从后往前扫描:每找到一个孤立的 assistant,将 cut 移到该位置并继续向前检查。
|
||||
let mut search = cut;
|
||||
while search > 0 {
|
||||
let mut found = false;
|
||||
for j in (0..search).rev() {
|
||||
if messages[j].role == MessageRole::Assistant && messages[j].tool_calls.is_some() {
|
||||
let has_tool_result = messages[j + 1..search]
|
||||
.iter()
|
||||
.any(|m| m.role == MessageRole::Tool);
|
||||
if !has_tool_result {
|
||||
cut = j;
|
||||
search = j;
|
||||
found = true;
|
||||
// 从切割点向前扫描:如果某个 assistant(tool_calls) 的 tool_result
|
||||
// 在保留区([cut..])中,则将该 assistant 也移入保留区以保证配对完整。
|
||||
// 注意:仅使用初始 covered_ids(来自保留区),不将前向扫描中遇到的
|
||||
// Tool 消息加入 covered_ids(这些 Tool 在摘要区而非保留区,不应触发迁移)。
|
||||
let mut i = cut.saturating_sub(1);
|
||||
loop {
|
||||
if messages[i].role == MessageRole::Assistant {
|
||||
if let Some(ref tcs) = messages[i].tool_calls {
|
||||
let has_result_in_keep = tcs.iter().any(|tc| covered_ids.contains(tc.id.as_str()));
|
||||
if has_result_in_keep {
|
||||
// 该 assistant 的 tool_result 位于保留区 → 将 assistant 也纳入保留区
|
||||
cut = i;
|
||||
// 将 assistant 的 tool_call_id 标记为已覆盖,
|
||||
// 以便更早的依赖链也能被正确识别
|
||||
for tc in tcs {
|
||||
covered_ids.insert(tc.id.as_str());
|
||||
}
|
||||
}
|
||||
break; // 只处理最近的一个,继续向前
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
|
||||
if i == 0 {
|
||||
break;
|
||||
}
|
||||
i -= 1;
|
||||
}
|
||||
|
||||
cut
|
||||
@ -191,34 +184,60 @@ fn find_tool_name_for_call_id(messages: &[ChatMessage], tool_call_id: &str) -> O
|
||||
|
||||
/// 轻量级压缩:将较早的工具结果替换为简短占位符,释放上下文空间。
|
||||
/// 保留最近 `keep_recent` 条工具结果不变。
|
||||
/// P0 改进:使用 `[Previous: used {tool_name}]` 替代字符预览,节省 ~80 tokens/条。
|
||||
pub fn micro_compact(messages: &mut [ChatMessage], keep_recent: usize) {
|
||||
let tool_info: Vec<(usize, String, String)> = messages
|
||||
///
|
||||
/// 不可变风格:接收引用,返回新的消息列表。
|
||||
/// 使用 `[Previous: used {tool_name}]` 替代字符预览,节省 ~80 tokens/条。
|
||||
pub fn micro_compact(messages: &[ChatMessage], keep_recent: usize) -> Vec<ChatMessage> {
|
||||
// 收集所有工具消息的索引、call_id 和工具名
|
||||
let tool_positions: Vec<(usize, &str)> = messages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, m)| {
|
||||
if m.role == MessageRole::Tool {
|
||||
let call_id = m.tool_call_id.clone().unwrap_or_default();
|
||||
// 先查找工具名称(此时 messages 是不可变借用)
|
||||
let tool_name = find_tool_name_for_call_id(messages, &call_id)
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
Some((i, call_id, tool_name))
|
||||
m.tool_call_id.as_deref().map(|id| (i, id))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let compact_count = tool_info.len().saturating_sub(keep_recent);
|
||||
let compact_count = tool_positions.len().saturating_sub(keep_recent);
|
||||
if compact_count == 0 {
|
||||
return;
|
||||
return messages.to_vec();
|
||||
}
|
||||
|
||||
for (idx, _tool_id, tool_name) in tool_info.iter().take(compact_count) {
|
||||
if let Some(msg) = messages.get_mut(*idx) {
|
||||
msg.content = Some(format!("[Previous: used {}]", tool_name));
|
||||
}
|
||||
}
|
||||
// 构建需要压缩的索引集合(前 compact_count 个工具消息)
|
||||
let compact_indices: std::collections::HashSet<usize> = tool_positions
|
||||
.iter()
|
||||
.take(compact_count)
|
||||
.map(|(i, _)| *i)
|
||||
.collect();
|
||||
|
||||
// 为需要压缩的工具消息查询工具名
|
||||
let tool_name_map: std::collections::HashMap<usize, String> = tool_positions
|
||||
.iter()
|
||||
.take(compact_count)
|
||||
.map(|(i, call_id)| {
|
||||
let name = find_tool_name_for_call_id(messages, call_id)
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
(*i, name)
|
||||
})
|
||||
.collect();
|
||||
|
||||
messages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, msg)| {
|
||||
if compact_indices.contains(&i) {
|
||||
let tool_name = tool_name_map.get(&i).cloned().unwrap_or_default();
|
||||
let mut new_msg = msg.clone();
|
||||
new_msg.content = Some(format!("[Previous: used {}]", tool_name));
|
||||
new_msg
|
||||
} else {
|
||||
msg.clone()
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 粗略估算消息列表的 token 数(用作首次调用的近似值)。
|
||||
@ -350,27 +369,40 @@ async fn generate_summary(
|
||||
}
|
||||
|
||||
/// 多层回退压缩:snip → micro → auto → aggressive_micro → identity inject
|
||||
///
|
||||
/// `token_soft_limit`: 来自 TokenBudget 的软限制,用于统一各层的触发阈值。
|
||||
/// `max_messages`: 来自 AgentConfig 的消息数上限,用于 Layer 0 snip 触发。
|
||||
/// `output_reserve`: 为压缩 LLM 调用预留的输出 token 空间。
|
||||
async fn compress_with_fallback(
|
||||
messages: &mut Vec<ChatMessage>,
|
||||
llm: &LlmClient,
|
||||
context_char_limit: usize,
|
||||
token_soft_limit: usize,
|
||||
max_messages: usize,
|
||||
output_reserve: usize,
|
||||
) {
|
||||
// Layer 0: snip_compact(零 API 调用,消息数超过 MAX_MESSAGES 时截断中间段)
|
||||
snip_compact(messages, MAX_MESSAGES);
|
||||
// Layer 0: snip_compact(零 API 调用,消息数超过阈值时截断中间段)
|
||||
snip_compact(messages, max_messages);
|
||||
|
||||
// Layer 1: micro_compact(保留最近 8 条工具结果)
|
||||
micro_compact(messages, 8);
|
||||
if rough_estimate_tokens(messages) < (context_char_limit * 3 / 2) {
|
||||
*messages = micro_compact(messages, 8);
|
||||
if rough_estimate_tokens(messages) < token_soft_limit {
|
||||
return;
|
||||
}
|
||||
|
||||
// Layer 2: auto_compact(LLM 摘要,含迭代融合)
|
||||
// 确保有足够的输出空间给压缩 LLM 调用
|
||||
let effective_limit = token_soft_limit.saturating_sub(output_reserve);
|
||||
if rough_estimate_tokens(messages) < effective_limit {
|
||||
return; // 虽然在软限制之上,但没有足够的输出空间,跳过 LLM 压缩
|
||||
}
|
||||
|
||||
let system_msg = messages.first().cloned();
|
||||
let cut_point = find_safe_cut_point(messages, 10);
|
||||
let to_summarize = &messages[1..cut_point];
|
||||
if to_summarize.is_empty() {
|
||||
// cut_point 为 0 表示消息数不足,无需切割
|
||||
if cut_point <= 1 {
|
||||
return;
|
||||
}
|
||||
let to_summarize = &messages[1..cut_point];
|
||||
|
||||
// ── 迭代摘要融合 ──
|
||||
// 在切割前提取已有摘要,传给 LLM 以实现迭代融合
|
||||
@ -390,9 +422,9 @@ async fn compress_with_fallback(
|
||||
messages.extend(recent);
|
||||
|
||||
// Layer 3: 如果摘要后仍然超限,激进 micro_compact(仅保留 2 条)
|
||||
if rough_estimate_tokens(messages) >= (context_char_limit * 3 / 2) {
|
||||
if rough_estimate_tokens(messages) >= token_soft_limit {
|
||||
warn!("[Compact] LLM 摘要后仍超限,执行激进压缩 (keep_recent=2)");
|
||||
micro_compact(messages, 2);
|
||||
*messages = micro_compact(messages, 2);
|
||||
}
|
||||
|
||||
// Layer 4: 注入身份确认块
|
||||
@ -408,13 +440,25 @@ async fn compress_with_fallback(
|
||||
/// 上下文压缩:保存 transcript → 多层回退压缩。
|
||||
/// 保留系统提示 + 最近的完整 tool-call/tool-result 配对。
|
||||
/// 使用 LLM 摘要较旧的对话历史,在 token 超限时触发。
|
||||
///
|
||||
/// `token_soft_limit`: TokenBudget 软限制,各压缩层的统一触发阈值。
|
||||
/// `max_messages`: 消息数上限(来自 AgentConfig.max_messages)。
|
||||
pub async fn compress_context(
|
||||
messages: &mut Vec<ChatMessage>,
|
||||
llm: &LlmClient,
|
||||
context_char_limit: usize,
|
||||
token_soft_limit: usize,
|
||||
max_messages: usize,
|
||||
session_id: &str,
|
||||
) {
|
||||
compress_context_with_hooks(messages, llm, context_char_limit, session_id, None).await;
|
||||
compress_context_with_hooks(
|
||||
messages,
|
||||
llm,
|
||||
token_soft_limit,
|
||||
max_messages,
|
||||
session_id,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// 带 Hook 的上下文压缩变体。如果提供了 HookRegistry,会在压缩前后触发事件。
|
||||
@ -422,14 +466,16 @@ pub async fn compress_context(
|
||||
pub async fn compress_context_with_hooks(
|
||||
messages: &mut Vec<ChatMessage>,
|
||||
llm: &LlmClient,
|
||||
context_char_limit: usize,
|
||||
token_soft_limit: usize,
|
||||
max_messages: usize,
|
||||
session_id: &str,
|
||||
hook_registry: Option<&HookRegistry>,
|
||||
) {
|
||||
compress_context_with_hooks_and_log(
|
||||
messages,
|
||||
llm,
|
||||
context_char_limit,
|
||||
token_soft_limit,
|
||||
max_messages,
|
||||
session_id,
|
||||
hook_registry,
|
||||
None,
|
||||
@ -437,12 +483,20 @@ pub async fn compress_context_with_hooks(
|
||||
.await;
|
||||
}
|
||||
|
||||
/// 输出预留 token 数 — 压缩 LLM 生成摘要时需要消耗输出 token。
|
||||
/// 如果不预留,当上下文接近窗口上限时压缩调用本身会因输出空间不足而失败。
|
||||
pub const COMPACTION_OUTPUT_RESERVE: usize = 4000;
|
||||
|
||||
/// 带 Hook 和 CollapseLog 的上下文压缩变体。
|
||||
/// 压缩后自动记录 commit 到 CollapseLog,并在溢出时注入合并摘要。
|
||||
///
|
||||
/// `token_soft_limit`: TokenBudget 软限制,统一各压缩层的触发阈值。
|
||||
/// `max_messages`: 消息数上限,用于 Layer 0 snip 触发。
|
||||
pub async fn compress_context_with_hooks_and_log(
|
||||
messages: &mut Vec<ChatMessage>,
|
||||
llm: &LlmClient,
|
||||
context_char_limit: usize,
|
||||
token_soft_limit: usize,
|
||||
max_messages: usize,
|
||||
session_id: &str,
|
||||
hook_registry: Option<&HookRegistry>,
|
||||
collapse_log: Option<&std::sync::Mutex<CollapseLog>>,
|
||||
@ -472,7 +526,14 @@ pub async fn compress_context_with_hooks_and_log(
|
||||
}
|
||||
|
||||
// 执行多层回退压缩(注:完整会话历史已在 agent_messages 表中持久化,无需额外 transcript 快照)
|
||||
compress_with_fallback(messages, llm, context_char_limit).await;
|
||||
compress_with_fallback(
|
||||
messages,
|
||||
llm,
|
||||
token_soft_limit,
|
||||
max_messages,
|
||||
COMPACTION_OUTPUT_RESERVE,
|
||||
)
|
||||
.await;
|
||||
|
||||
let after_count = messages.len();
|
||||
let method = if after_count < before_count / 2 {
|
||||
@ -649,7 +710,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_micro_compact_placeholder_format() {
|
||||
let mut messages = vec![
|
||||
let messages = vec![
|
||||
ChatMessage::system("You are a helpful assistant."),
|
||||
ChatMessage::user("Hello"),
|
||||
ChatMessage::assistant_with_tool_calls(
|
||||
@ -674,7 +735,7 @@ mod tests {
|
||||
ChatMessage::assistant("Here are the results..."),
|
||||
];
|
||||
|
||||
micro_compact(&mut messages, 0);
|
||||
let messages = micro_compact(&messages, 0);
|
||||
|
||||
// 工具结果应该被压缩为 [Previous: used search_papers]
|
||||
let tool_msg = &messages[3];
|
||||
@ -699,7 +760,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_micro_compact_preserves_recent_results() {
|
||||
let mut messages = vec![
|
||||
let messages = vec![
|
||||
ChatMessage::system("You are a helpful assistant."),
|
||||
ChatMessage::user("Search for papers"),
|
||||
ChatMessage::assistant_with_tool_calls(
|
||||
@ -742,7 +803,7 @@ mod tests {
|
||||
},
|
||||
];
|
||||
|
||||
micro_compact(&mut messages, 1);
|
||||
let messages = micro_compact(&messages, 1);
|
||||
|
||||
// 第一个工具结果应该被压缩
|
||||
let compressed = &messages[3];
|
||||
|
||||
@ -42,9 +42,9 @@ pub const DEEP_RESEARCH_MODE: AgentMode = AgentMode {
|
||||
|
||||
// 配置:更多步数、启用思考、加载科研权限
|
||||
mode_config: ModeConfig {
|
||||
max_steps: Some(16),
|
||||
max_steps: 100,
|
||||
enable_thinking: Some(true),
|
||||
tool_timeout_secs: Some(180),
|
||||
tool_timeout_secs: 180,
|
||||
permission_profile: Some("research"),
|
||||
},
|
||||
};
|
||||
|
||||
@ -18,11 +18,11 @@ pub const DEFAULT_MODE: AgentMode = AgentMode {
|
||||
// 全量工具
|
||||
tool_set: ToolSet::All,
|
||||
|
||||
// 不覆盖任何默认配置(全部继承 AgentConfig::default())
|
||||
// 默认配置
|
||||
mode_config: ModeConfig {
|
||||
max_steps: None,
|
||||
max_steps: 8,
|
||||
enable_thinking: None,
|
||||
tool_timeout_secs: None,
|
||||
tool_timeout_secs: 120,
|
||||
permission_profile: None,
|
||||
},
|
||||
};
|
||||
|
||||
@ -62,9 +62,9 @@ pub const LITERATURE_READER_MODE: AgentMode = AgentMode {
|
||||
|
||||
// 配置:较少步数、强制只读权限
|
||||
mode_config: ModeConfig {
|
||||
max_steps: Some(6),
|
||||
max_steps: 25,
|
||||
enable_thinking: Some(false), // 文献阅读不需要思考模式,节省 token
|
||||
tool_timeout_secs: Some(120),
|
||||
tool_timeout_secs: 120,
|
||||
permission_profile: Some("readonly"),
|
||||
},
|
||||
};
|
||||
|
||||
@ -38,12 +38,12 @@ pub enum ToolSet {
|
||||
/// 所有字段为 `None` 时表示不覆盖,使用 AgentConfig 的默认值。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ModeConfig {
|
||||
/// 最大推理步数(None = 使用 AgentConfig 默认值 8)
|
||||
pub max_steps: Option<usize>,
|
||||
/// 最大推理步数
|
||||
pub max_steps: usize,
|
||||
/// 是否强制启用思考模式(None = 用户可覆盖)
|
||||
pub enable_thinking: Option<bool>,
|
||||
/// 工具执行超时秒数(None = 使用 AgentConfig 默认值 120)
|
||||
pub tool_timeout_secs: Option<u64>,
|
||||
/// 工具执行超时秒数
|
||||
pub tool_timeout_secs: u64,
|
||||
/// 权限档案名称,对应 permission_profile::load_profile 的参数
|
||||
/// (None = 不额外加载权限档案)
|
||||
pub permission_profile: Option<&'static str>,
|
||||
@ -234,9 +234,9 @@ mod tests {
|
||||
#[test]
|
||||
fn test_mode_config_default_all_none() {
|
||||
let config = ModeConfig::default();
|
||||
assert!(config.max_steps.is_none());
|
||||
assert_eq!(config.max_steps, 0); // usize::default()
|
||||
assert_eq!(config.tool_timeout_secs, 0); // u64::default()
|
||||
assert!(config.enable_thinking.is_none());
|
||||
assert!(config.tool_timeout_secs.is_none());
|
||||
assert!(config.permission_profile.is_none());
|
||||
}
|
||||
|
||||
@ -253,7 +253,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_deep_research_mode_config() {
|
||||
let mode = &DEEP_RESEARCH_MODE;
|
||||
assert_eq!(mode.mode_config.max_steps, Some(16));
|
||||
assert_eq!(mode.mode_config.max_steps, 100);
|
||||
assert_eq!(mode.mode_config.enable_thinking, Some(true));
|
||||
assert_eq!(mode.mode_config.permission_profile, Some("research"));
|
||||
}
|
||||
|
||||
@ -151,15 +151,8 @@ impl CheckpointManager {
|
||||
CheckpointManager {
|
||||
enabled: enabled && inner.repo.is_some(),
|
||||
repo_path: store_path,
|
||||
max_snapshots: std::env::var("AGENT_CHECKPOINT_MAX_SNAPSHOTS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(10),
|
||||
max_file_size: std::env::var("AGENT_CHECKPOINT_MAX_FILE_SIZE_MB")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<usize>().ok())
|
||||
.map(|mb| mb * 1024 * 1024)
|
||||
.unwrap_or(10 * 1024 * 1024), // 10 MB
|
||||
max_snapshots: 10,
|
||||
max_file_size: 10 * 1024 * 1024, // 10 MB
|
||||
inner: Mutex::new(inner),
|
||||
}
|
||||
}
|
||||
|
||||
@ -285,24 +285,9 @@ impl HardlineResult {
|
||||
///
|
||||
/// 执行反规避标准化后再匹配,返回第一个命中的模式。
|
||||
///
|
||||
/// 注意:此函数在模块导入时冻结 `HERMES_YOLO_MODE`,
|
||||
/// 确保运行时无法通过设置环境变量绕过 hardline 检查。
|
||||
pub fn check_command(raw_command: &str) -> HardlineResult {
|
||||
let normalized = normalize_command(raw_command);
|
||||
|
||||
// ── YOLO 模式冻结 ──
|
||||
// YOLO 模式在首次调用时从环境变量读取并缓存,
|
||||
// 后续设置环境变量不会生效(防止注入攻击)。
|
||||
static YOLO_MODE_FROZEN: LazyLock<bool> = LazyLock::new(|| {
|
||||
let val = std::env::var("HERMES_YOLO_MODE")
|
||||
.unwrap_or_default()
|
||||
.to_lowercase();
|
||||
val == "1" || val == "true" || val == "yes" || val == "on"
|
||||
});
|
||||
|
||||
// Hardline 即使在 YOLO 模式下也不可绕过
|
||||
let _yolo = *YOLO_MODE_FROZEN;
|
||||
|
||||
let matches: Vec<usize> = HARDLINE_REGEX_SET
|
||||
.matches(&normalized)
|
||||
.into_iter()
|
||||
|
||||
@ -66,9 +66,7 @@ pub struct AgentConfig {
|
||||
pub tool_timeout_secs: u64,
|
||||
/// 工具输出最大字符数
|
||||
pub max_tool_output_chars: usize,
|
||||
/// 上下文 Token 估算上限(触发自动摘要压缩)
|
||||
pub context_char_limit: usize,
|
||||
/// Token 预算软限制(触发 nudging 提醒)
|
||||
/// Token 预算软限制 — 各压缩层统一触发阈值
|
||||
pub token_soft_limit: usize,
|
||||
/// Token 预算硬限制(触发强制动作)
|
||||
pub token_hard_limit: usize,
|
||||
@ -99,80 +97,32 @@ pub struct AgentConfig {
|
||||
impl AgentConfig {
|
||||
/// 从环境变量加载配置,缺失时使用默认值。
|
||||
pub fn from_env_optional() -> Self {
|
||||
let mut config = AgentConfig {
|
||||
max_steps: std::env::var("AGENT_MAX_STEPS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(8),
|
||||
AgentConfig {
|
||||
max_steps: 8,
|
||||
duplicate_call_threshold: 3,
|
||||
tool_timeout_secs: std::env::var("AGENT_TOOL_TIMEOUT_SECS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(120),
|
||||
max_tool_output_chars: std::env::var("AGENT_MAX_TOOL_OUTPUT_CHARS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(4000),
|
||||
context_char_limit: std::env::var("AGENT_CONTEXT_CHAR_LIMIT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(16000),
|
||||
tool_timeout_secs: 120,
|
||||
max_tool_output_chars: 4000,
|
||||
token_soft_limit: std::env::var("AGENT_TOKEN_SOFT_LIMIT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(32000),
|
||||
.unwrap_or(80000),
|
||||
token_hard_limit: std::env::var("AGENT_TOKEN_HARD_LIMIT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(40000),
|
||||
max_messages: std::env::var("AGENT_MAX_MESSAGES")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(50),
|
||||
.unwrap_or(100000),
|
||||
max_messages: 50,
|
||||
enable_thinking: false,
|
||||
permission_deny_rules: parse_comma_list("AGENT_PERMISSIONS_DENY"),
|
||||
permission_allow_rules: parse_comma_list("AGENT_PERMISSIONS_ALLOW"),
|
||||
permission_ask_rules: parse_comma_list("AGENT_PERMISSIONS_ASK"),
|
||||
permission_mode: std::env::var("AGENT_PERMISSION_MODE")
|
||||
.unwrap_or_else(|_| "default".to_string()),
|
||||
denial_max_consecutive: std::env::var("AGENT_DENIAL_MAX_CONSECUTIVE")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(3),
|
||||
denial_max_total: std::env::var("AGENT_DENIAL_MAX_TOTAL")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(20),
|
||||
denial_max_consecutive: 3,
|
||||
denial_max_total: 20,
|
||||
additional_allowed_dirs: parse_comma_list("AGENT_ADDITIONAL_DIRS"),
|
||||
subagent_allowed_tools: parse_comma_list("AGENT_SUBAGENT_ALLOWED_TOOLS"),
|
||||
mode: std::env::var("AGENT_MODE").unwrap_or_else(|_| "default".to_string()),
|
||||
};
|
||||
|
||||
// 加载权限档案(AGENT_PERMISSION_PROFILE),追加到现有规则
|
||||
let profile_name = std::env::var("AGENT_PERMISSION_PROFILE").unwrap_or_default();
|
||||
if !profile_name.is_empty() {
|
||||
if let Some(profile) = permission_profile::load_profile(&profile_name) {
|
||||
info!(
|
||||
"[AgentConfig] 加载权限档案: {} — {}",
|
||||
profile.name, profile.description
|
||||
);
|
||||
permission_profile::apply_profile_to_config(
|
||||
&profile,
|
||||
&mut config.permission_deny_rules,
|
||||
&mut config.permission_allow_rules,
|
||||
&mut config.permission_ask_rules,
|
||||
&mut config.permission_mode,
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
"[AgentConfig] 未知的权限档案: {}(可用: {:?})",
|
||||
profile_name,
|
||||
permission_profile::list_available_profiles()
|
||||
);
|
||||
}
|
||||
mode: "default".to_string(),
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
}
|
||||
|
||||
@ -380,10 +330,7 @@ impl AgentRuntime {
|
||||
apply_mode_tool_filter(&mut tool_registry, mode);
|
||||
|
||||
// 初始化 checkpoint 管理器
|
||||
let checkpoint_enabled = std::env::var("AGENT_CHECKPOINT_ENABLED")
|
||||
.unwrap_or_else(|_| "true".to_string())
|
||||
.to_lowercase()
|
||||
!= "false";
|
||||
let checkpoint_enabled = true;
|
||||
let checkpoint_store = app_state.config.library_dir.join("..").join(".checkpoints");
|
||||
let checkpoint_manager = Arc::new(checkpoint::CheckpointManager::new(
|
||||
std::fs::canonicalize(&checkpoint_store).unwrap_or(checkpoint_store),
|
||||
@ -455,10 +402,7 @@ impl AgentRuntime {
|
||||
apply_mode_tool_filter(&mut tool_registry, mode);
|
||||
|
||||
// 初始化 checkpoint 管理器
|
||||
let checkpoint_enabled = std::env::var("AGENT_CHECKPOINT_ENABLED")
|
||||
.unwrap_or_else(|_| "true".to_string())
|
||||
.to_lowercase()
|
||||
!= "false";
|
||||
let checkpoint_enabled = true;
|
||||
let checkpoint_store = app_state.config.library_dir.join("..").join(".checkpoints");
|
||||
let checkpoint_manager = Arc::new(checkpoint::CheckpointManager::new(
|
||||
std::fs::canonicalize(&checkpoint_store).unwrap_or(checkpoint_store),
|
||||
@ -561,7 +505,8 @@ impl AgentRuntime {
|
||||
compact::compress_context_with_hooks_and_log(
|
||||
messages,
|
||||
llm,
|
||||
self.config.context_char_limit,
|
||||
self.config.token_soft_limit,
|
||||
self.config.max_messages,
|
||||
session_id,
|
||||
Some(hook_registry),
|
||||
Some(&self.collapse_log),
|
||||
@ -799,7 +744,7 @@ impl AgentRuntime {
|
||||
None => compact::rough_estimate_tokens(messages),
|
||||
};
|
||||
|
||||
// 使用 token 预算的软限制作为压缩触发点(而非粗糙的 context_char_limit * 1.5)
|
||||
// 使用 TokenBudget 软限制作为压缩触发点
|
||||
let token_limit = token_budget.soft_limit;
|
||||
|
||||
let mut did_compress = false;
|
||||
@ -1418,14 +1363,15 @@ impl AgentRuntime {
|
||||
error_recovery::RecoveryStep::AggressiveCompact => {
|
||||
info!("[AgentRuntime] 错误恢复: 激进压缩 (snip + micro with keep_recent=2)");
|
||||
compact::snip_compact(messages, self.config.max_messages);
|
||||
compact::micro_compact(messages, 2);
|
||||
*messages = compact::micro_compact(messages, 2);
|
||||
}
|
||||
error_recovery::RecoveryStep::ReactiveCompact => {
|
||||
info!("[AgentRuntime] 错误恢复: LLM 摘要压缩");
|
||||
compact::compress_context(
|
||||
messages,
|
||||
llm,
|
||||
self.config.context_char_limit,
|
||||
self.config.token_soft_limit,
|
||||
self.config.max_messages,
|
||||
session_id,
|
||||
)
|
||||
.await;
|
||||
@ -1846,15 +1792,11 @@ impl AgentRuntime {
|
||||
///
|
||||
/// 仅覆盖 mode_config 中 Some 的字段,None 保持原值不变。
|
||||
fn apply_mode_config(config: &mut AgentConfig, mode: &AgentMode) {
|
||||
if let Some(max_steps) = mode.mode_config.max_steps {
|
||||
config.max_steps = max_steps;
|
||||
}
|
||||
config.max_steps = mode.mode_config.max_steps;
|
||||
if let Some(enable_thinking) = mode.mode_config.enable_thinking {
|
||||
config.enable_thinking = enable_thinking;
|
||||
}
|
||||
if let Some(tool_timeout_secs) = mode.mode_config.tool_timeout_secs {
|
||||
config.tool_timeout_secs = tool_timeout_secs;
|
||||
}
|
||||
config.tool_timeout_secs = mode.mode_config.tool_timeout_secs;
|
||||
|
||||
// 加载权限档案(模式绑定的 permission_profile)
|
||||
if let Some(profile_name) = mode.mode_config.permission_profile {
|
||||
|
||||
@ -296,7 +296,7 @@ impl SubAgentRunner {
|
||||
.iter()
|
||||
.map(|m| m.content.as_ref().map_or(0, |c| c.len()) + 4)
|
||||
.sum();
|
||||
if est_tokens > self.config.context_char_limit * 3 / 2 {
|
||||
if est_tokens > self.config.token_soft_limit * 3 / 2 {
|
||||
info!(
|
||||
"[SubAgent] 上下文超限 (est. {} tokens),触发压缩",
|
||||
est_tokens
|
||||
@ -304,7 +304,8 @@ impl SubAgentRunner {
|
||||
compact::compress_context_with_hooks(
|
||||
&mut messages,
|
||||
llm,
|
||||
self.config.context_char_limit,
|
||||
self.config.token_soft_limit,
|
||||
self.config.max_messages,
|
||||
subagent_name,
|
||||
self.hook_registry.as_ref().map(|a| a.as_ref()),
|
||||
)
|
||||
|
||||
@ -179,8 +179,15 @@ async fn run_teammate_react_turn(
|
||||
.iter()
|
||||
.map(|m| m.content.as_ref().map_or(0, |c| c.len()) + 4)
|
||||
.sum();
|
||||
if est_tokens > config.context_char_limit * 3 / 2 {
|
||||
compact::compress_context(messages, llm, config.context_char_limit, "teammate").await;
|
||||
if est_tokens > config.token_soft_limit * 3 / 2 {
|
||||
compact::compress_context(
|
||||
messages,
|
||||
llm,
|
||||
config.token_soft_limit,
|
||||
config.max_messages,
|
||||
"teammate",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// LLM 流式调用
|
||||
|
||||
@ -276,16 +276,14 @@ fn extract_first_command_word(command: &str) -> String {
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// 网络访问命令 — 当 `AGENT_BLOCK_NETWORK=true`(默认)时被禁止。
|
||||
/// 网络访问命令 — 始终被禁止。
|
||||
const NETWORK_COMMANDS: &[&str] = &[
|
||||
"curl", "wget", "nc", "netcat", "socat", "ncat", "ftp", "tftp",
|
||||
];
|
||||
|
||||
/// 检查是否启用网络访问限制(默认启用)。
|
||||
/// 检查是否启用网络访问限制(始终启用)。
|
||||
fn is_network_blocked() -> bool {
|
||||
std::env::var("AGENT_BLOCK_NETWORK")
|
||||
.map(|v| v != "false" && v != "0")
|
||||
.unwrap_or(true)
|
||||
true
|
||||
}
|
||||
|
||||
/// 检查命令首词是否在安全白名单中。
|
||||
@ -550,20 +548,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_network_commands_blocking() {
|
||||
// 此测试统一验证网络命令的阻止/放行逻辑,
|
||||
// 避免多个测试并行修改 AGENT_BLOCK_NETWORK 导致竞争。
|
||||
std::env::remove_var("AGENT_BLOCK_NETWORK");
|
||||
// 默认阻止
|
||||
// 网络命令始终被阻止。
|
||||
assert!(validate_bash_command("curl https://example.com").is_some());
|
||||
assert!(validate_bash_command("wget https://example.com/file").is_some());
|
||||
// 显式 true
|
||||
std::env::set_var("AGENT_BLOCK_NETWORK", "true");
|
||||
assert!(validate_bash_command("nc -l 1234").is_some());
|
||||
// 设为 false 放行
|
||||
std::env::set_var("AGENT_BLOCK_NETWORK", "false");
|
||||
assert!(validate_bash_command("curl https://example.com").is_none());
|
||||
assert!(validate_bash_command("wget https://example.com/file").is_none());
|
||||
std::env::remove_var("AGENT_BLOCK_NETWORK");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -51,9 +51,7 @@ impl Config {
|
||||
let llm_api_base =
|
||||
env::var("LLM_API_BASE").unwrap_or_else(|_| "https://api.openai.com/v1".to_string());
|
||||
let llm_model = env::var("LLM_MODEL").unwrap_or_else(|_| "gpt-4o-mini".to_string());
|
||||
let llm_fallback_model = env::var("LLM_FALLBACK_MODEL")
|
||||
.or_else(|_| env::var("FALLBACK_MODEL"))
|
||||
.unwrap_or_default();
|
||||
let llm_fallback_model = env::var("LLM_FALLBACK_MODEL").unwrap_or_default();
|
||||
let llm_fallback_chain = env::var("LLM_FALLBACK_CHAIN")
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
|
||||
Loading…
Reference in New Issue
Block a user