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:
Asfmq 2026-06-25 00:49:45 +08:00
parent 85b6429c30
commit b11b8ad015
37 changed files with 1981 additions and 1475 deletions

View File

@ -94,21 +94,10 @@ LOG_DIR=./logs
# 6. Agent 运行时参数 # 6. Agent 运行时参数
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# 最大 ReAct 推理步数(默认 8 # Token 软限制:统一压缩触发阈值(默认 80000
# AGENT_MAX_STEPS=8 # AGENT_TOKEN_SOFT_LIMIT=80000
# 单个工具执行超时秒数(默认 120 # Token 硬限制:触发强制压缩或终止(默认 100000
# AGENT_TOOL_TIMEOUT_SECS=120 # AGENT_TOKEN_HARD_LIMIT=100000
# 工具输出截断字符数(默认 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
# ── 权限系统 ── # ── 权限系统 ──
# Agent 工具权限规则(逗号分隔,支持内容级匹配 "ToolName(pattern)" # Agent 工具权限规则(逗号分隔,支持内容级匹配 "ToolName(pattern)"
@ -134,6 +123,12 @@ LOG_DIR=./logs
# 询问规则(逗号分隔,需用户确认) # 询问规则(逗号分隔,需用户确认)
# AGENT_PERMISSIONS_ASK=run_bash,file_write,file_edit,download_paper # 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 # 7. 自动记忆提取(实验性功能,同时控制压缩记忆桥接 P3
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────

View File

@ -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 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 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 ### Database

View 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>
);
}

View 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>
);
}

View 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;
}
}

View 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>
);
}

View 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>
);
}

View 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',
};

View 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';

View 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;
}

View 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;
}
}
}

View 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;
}

View 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 };
}

View 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,
};
}

View File

@ -94,10 +94,10 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations,
{[ {[
{ id: 'search', label: '统一检索', icon: Search }, { id: 'search', label: '统一检索', icon: Search },
{ id: 'library', label: '馆藏管理', icon: Library }, { id: 'library', label: '馆藏管理', icon: Library },
{ id: 'sync', label: '批量任务', icon: RefreshCw },
{ id: 'reader', label: '双语阅读', icon: BookOpen }, { id: 'reader', label: '双语阅读', icon: BookOpen },
{ id: 'agent', label: '智能科研', icon: Sparkles },
{ id: 'citation', label: '引用星系', icon: GitFork }, { 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 }) => { ].map((tab: { id: string; label: string; icon: any; disabled?: boolean }) => {
const Icon = tab.icon; const Icon = tab.icon;
const isActive = activeTab === tab.id; const isActive = activeTab === tab.id;
@ -213,12 +213,12 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations,
<button <button
type="button" type="button"
onClick={onLogout} 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' isCollapsed ? 'px-2 py-2.5 justify-center' : 'px-3 py-2.5'
}`} }`}
title={isCollapsed ? "退出登录" : undefined} 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 <span
className={`truncate transition-all duration-300 origin-left ${ className={`truncate transition-all duration-300 origin-left ${
isCollapsed isCollapsed

View File

@ -1,14 +1,15 @@
// dashboard/src/features/agent/AskUserQuestionCard.tsx // dashboard/src/features/agent/AskUserQuestionCard.tsx
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import axios from 'axios'; 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'; import type { PendingQuestion } from '../../types';
interface AskUserQuestionCardProps { interface AskUserQuestionCardProps {
onAnswered?: () => void; onAnswered?: () => void;
onQuestionCountChange?: (count: number) => void;
} }
export function AskUserQuestionCard({ onAnswered }: AskUserQuestionCardProps) { export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUserQuestionCardProps) {
const [pendingQuestions, setPendingQuestions] = useState<PendingQuestion[]>([]); const [pendingQuestions, setPendingQuestions] = useState<PendingQuestion[]>([]);
const [answers, setAnswers] = useState<Record<string, string[]>>({}); const [answers, setAnswers] = useState<Record<string, string[]>>({});
const [freeText, setFreeText] = useState<Record<string, string>>({}); const [freeText, setFreeText] = useState<Record<string, string>>({});
@ -24,7 +25,6 @@ export function AskUserQuestionCard({ onAnswered }: AskUserQuestionCardProps) {
try { try {
const res = await axios.get<PendingQuestion[]>('/api/chat/questions'); const res = await axios.get<PendingQuestion[]>('/api/chat/questions');
if (!cancelled) { if (!cancelled) {
// 安全解析:确保返回的是数组
const data = Array.isArray(res.data) ? res.data : []; const data = Array.isArray(res.data) ? res.data : [];
setPendingQuestions(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) => { const toggleOption = (questionId: string, optionLabel: string, multiSelect: boolean) => {
setAnswers(prev => { setAnswers(prev => {
const current = prev[questionId] || []; const current = prev[questionId] || [];
@ -117,9 +121,8 @@ export function AskUserQuestionCard({ onAnswered }: AskUserQuestionCardProps) {
if (pendingQuestions.length === 0) return null; if (pendingQuestions.length === 0) return null;
return ( return (
<div className="space-y-3"> <div className="flex flex-col gap-2">
{pendingQuestions.map(q => { {pendingQuestions.map(q => {
// 防御:确保必填字段存在
if (!q || !q.question_id) return null; if (!q || !q.question_id) return null;
const isExpanded = expanded[q.question_id] !== false; const isExpanded = expanded[q.question_id] !== false;
@ -131,62 +134,43 @@ export function AskUserQuestionCard({ onAnswered }: AskUserQuestionCardProps) {
return ( return (
<div <div
key={q.question_id} 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 <div className="mb-2 flex items-center justify-between border-b border-amber-100/40 pb-1.5 min-w-0">
onClick={() => setExpanded(prev => ({ ...prev, [q.question_id]: !prev[q.question_id] }))} <div className="flex items-center gap-1.5 min-w-0 flex-1 mr-3">
className="w-full flex items-center justify-between px-4 py-3 bg-amber-100/50 hover:bg-amber-100 transition-colors cursor-pointer" <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">
<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">
{q.header || '提问'} {q.header || '提问'}
</span> </span>
{!isExpanded && ( <span className="font-extrabold text-slate-800 text-[11px] truncate flex-1" title={q.question || ''}>
<span className="text-[10px] text-amber-700 font-medium truncate"> {q.question || ''}
{q.question || ''} </span>
</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>
<div className="flex items-center gap-1 shrink-0"> </div>
{!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>
{/* 内容 */} {/* 内容区 */}
{isExpanded && ( {isExpanded && (
<div className="px-4 py-3 space-y-3"> <div className="space-y-2.5">
{/* 问题文本 */} {/* 选项列表 */}
<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} 对象 */}
{options.length > 0 && ( {options.length > 0 && (
<div className="space-y-1.5"> <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 ? '可多选' : '请选择一项'} {multiSelect ? '可多选' : '请选择一项'}
</span> </span>
<div className="space-y-1.5"> <div className="space-y-1.5">
{options.map((option, idx) => { {options.map((option, idx) => {
// 防御:确保 option 是有效对象
const label = typeof option?.label === 'string' ? option.label : String(option); const label = typeof option?.label === 'string' ? option.label : String(option);
const desc = typeof option?.description === 'string' ? option.description : ''; const desc = typeof option?.description === 'string' ? option.description : '';
const selected = (answers[q.question_id] || []).includes(label); const selected = (answers[q.question_id] || []).includes(label);
@ -196,35 +180,36 @@ export function AskUserQuestionCard({ onAnswered }: AskUserQuestionCardProps) {
key={idx} key={idx}
onClick={() => toggleOption(q.question_id, label, multiSelect)} onClick={() => toggleOption(q.question_id, label, multiSelect)}
disabled={isSubmitting} 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 selected
? 'bg-sky-50 border-sky-300 text-sky-800' ? 'bg-sky-50/70 border-sky-300 text-sky-850 shadow-3xs'
: 'bg-white border-slate-200 text-slate-700 hover:border-sky-200 hover:bg-sky-50/50' : 'bg-white border-slate-200/60 text-slate-700 hover:border-sky-200 hover:bg-sky-50/50'
} disabled:opacity-50`} } disabled:opacity-50`}
> >
{/* 选择指示器 */} {/* 选择指示器 */}
{multiSelect ? ( {multiSelect ? (
selected selected
? <CheckSquare className="w-3.5 h-3.5 text-sky-600 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-400 shrink-0 mt-0.5" /> : <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 ${ <div className={`w-3.5 h-3.5 rounded-full border-2 shrink-0 ${
selected ? 'border-sky-600 bg-sky-600' : 'border-slate-300' selected ? 'border-sky-600 bg-sky-600' : 'border-slate-350'
}`}> }`}>
{selected && ( {selected && (
<div className="w-full h-full flex items-center justify-center"> <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>
)} )}
</div> </div>
)} )}
{/* label + description */}
<div className="min-w-0"> {/* 将 label 与 desc 合并在同一行呈现以压缩高度 */}
<div className="font-semibold text-slate-800">{label}</div> <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 && ( {desc && (
<div className="text-[10px] text-slate-500 mt-0.5 leading-relaxed"> <span className="text-[10px] text-slate-500 font-medium truncate leading-normal" title={desc}>
{desc} {desc}
</div> </span>
)} )}
</div> </div>
</button> </button>
@ -235,42 +220,39 @@ export function AskUserQuestionCard({ onAnswered }: AskUserQuestionCardProps) {
)} )}
{/* 自由文本 */} {/* 自由文本 */}
<div className="space-y-1.5"> <div className="w-full">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
</span>
<textarea <textarea
value={freeText[q.question_id] || ''} value={freeText[q.question_id] || ''}
onChange={(e) => setFreeText(prev => ({ ...prev, [q.question_id]: e.target.value }))} onChange={(e) => setFreeText(prev => ({ ...prev, [q.question_id]: e.target.value }))}
disabled={isSubmitting} disabled={isSubmitting}
placeholder="输入您的补充说明..." placeholder="补充说明(可选)..."
rows={2} rows={1}
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" 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> </div>
{/* 错误提示(按问题 ID 隔离) */} {/* 错误提示 */}
{qError && ( {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} {qError}
</div> </div>
)} )}
{/* 提交按钮 */} {/* 提交按钮 */}
<div className="flex gap-2 pt-1"> <div className="flex gap-1.5">
<button <button
onClick={() => handleSubmit(q.question_id)} onClick={() => handleSubmit(q.question_id)}
disabled={isSubmitting} 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 ? ( {isSubmitting ? (
<> <>
<Loader className="w-3.5 h-3.5 animate-spin" /> <Loader className="w-3 h-3 animate-spin" />
<span>...</span> <span>...</span>
</> </>
) : ( ) : (
<> <>
<Send className="w-3.5 h-3.5" /> <Send className="w-3 h-3" />
<span></span> <span></span>
</> </>
)} )}
@ -278,7 +260,7 @@ export function AskUserQuestionCard({ onAnswered }: AskUserQuestionCardProps) {
<button <button
onClick={() => dismissQuestion(q.question_id)} onClick={() => dismissQuestion(q.question_id)}
disabled={isSubmitting} 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> </button>
@ -291,3 +273,4 @@ export function AskUserQuestionCard({ onAnswered }: AskUserQuestionCardProps) {
</div> </div>
); );
} }

View File

@ -6,13 +6,15 @@ import type { PendingPermissionRequest } from '../../types';
interface PermissionRequestCardProps { interface PermissionRequestCardProps {
sessionId: string; sessionId: string;
onRequestCountChange?: (count: number) => void;
} }
/** 显示待处理的工具执行权限请求卡片 */ /** 显示待处理的工具执行权限请求卡片 */
export function PermissionRequestCard({ sessionId }: PermissionRequestCardProps) { export function PermissionRequestCard({ sessionId, onRequestCountChange }: PermissionRequestCardProps) {
const [pending, setPending] = useState<PendingPermissionRequest[]>([]); const [pending, setPending] = useState<PendingPermissionRequest[]>([]);
const [submitting, setSubmitting] = useState<Record<string, boolean>>({}); const [submitting, setSubmitting] = useState<Record<string, boolean>>({});
const [responses, setResponses] = useState<Record<string, 'allow' | 'deny' | null>>({}); const [responses, setResponses] = useState<Record<string, 'allow' | 'deny' | null>>({});
const [expandedParams, setExpandedParams] = useState<Record<string, boolean>>({});
// 轮询待处理权限请求 // 轮询待处理权限请求
useEffect(() => { useEffect(() => {
@ -67,63 +69,93 @@ export function PermissionRequestCard({ sessionId }: PermissionRequestCardProps)
// 不显示已处理的请求 // 不显示已处理的请求
const activeRequests = pending.filter(p => !responses[p.tool_call_id]); 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; if (activeRequests.length === 0) return null;
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-2">
{activeRequests.map(req => ( {activeRequests.map(req => {
<div const hasArgs = Object.keys(req.arguments || {}).length > 0;
key={req.permission_id} const showArgs = expandedParams[req.permission_id] || false;
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>
<p className="mb-3 text-sm text-amber-200/80">{req.message}</p> 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 && (
{Object.keys(req.arguments).length > 0 && ( <button
<pre className="mb-3 max-h-24 overflow-auto rounded bg-black/30 p-2 text-xs text-amber-300/60"> type="button"
{JSON.stringify(req.arguments, null, 2)} onClick={() => toggleParams(req.permission_id)}
</pre> className="text-[10px] font-bold text-sky-600 hover:text-sky-850 cursor-pointer select-none"
)} >
{showArgs ? '隐藏参数' : '参数详情'}
<div className="flex gap-2"> </button>
<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" />
)} )}
Allow </div>
</button>
<button {/* 警告消息 */}
onClick={() => respond(req.tool_call_id, true, true)} <p className="mb-2 text-[11px] font-semibold text-slate-650 leading-relaxed">{req.message}</p>
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" {/* 显示工具参数的简化预览 - 按需展开 */}
> {hasArgs && showArgs && (
Always Allow <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">
</button> {JSON.stringify(req.arguments, null, 2)}
<button </pre>
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" {/* 操作按钮栏 - 更加紧凑 */}
> <div className="flex flex-wrap gap-1.5">
<X className="h-4 w-4" /> <button
Deny onClick={() => respond(req.tool_call_id, true, false)}
</button> 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> );
))} })}
</div> </div>
); );
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,17 +1,18 @@
// dashboard/src/features/reader/AIAssistantPanel.tsx // dashboard/src/features/reader/AIAssistantPanel.tsx
// 文献 AI 问答助手 — 使用共享 AgentMarkdown / useAgentSSE / 子代理路由 / 渲染组件
import { useState, useRef, useEffect } from 'react'; 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 { import {
Send, Loader, Sparkles, X, BookOpen, AlertCircle, Compass, Send, Loader, X, BookOpen, AlertCircle, Compass,
Brain, Settings, ChevronDown, ChevronUp, AlertTriangle, Square Brain, Square, Paperclip,
} from 'lucide-react'; } from 'lucide-react';
import axios from 'axios'; 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 { interface RetrievalSource {
bibcode: string; bibcode: string;
@ -20,401 +21,277 @@ interface RetrievalSource {
distance: number; distance: number;
} }
interface AgentStep {
step: number;
type: 'thought' | 'tool_call' | 'tool_result' | 'error';
label: string;
detail?: string;
isError?: boolean;
isFinished?: boolean;
}
interface Message { interface Message {
sender: 'user' | 'ai'; sender: 'user' | 'ai';
text: string; text: string;
sources?: RetrievalSource[]; sources?: RetrievalSource[];
imageUrl?: string; imageUrl?: string;
steps?: AgentStep[]; timeline: TimelineItem[];
} }
interface AIAssistantPanelProps { interface AIAssistantPanelProps {
bibcode: string; bibcode: string;
onClose: () => void;
onJumpToSource: (bibcode: string, paragraphIndex: number) => 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 = [ const SUGGESTED_QUESTIONS = [
"对比我馆藏的文献中,针对热亚矮星双星在共同包层抛射过程中恒星风流失速率的各种主流观点差异。", '对比我馆藏的文献中,针对热亚矮星双星在共同包层抛射过程中恒星风流失速率的各种主流观点差异。',
"有哪些文献提及了脉动白矮星的非径向振动模?", '有哪些文献提及了脉动白矮星的非径向振动模?',
"简述目前文献中关于 Gaia DR3 视差零点改正的处理方法。", '简述目前文献中关于 Gaia DR3 视差零点改正的处理方法。',
"文献库中关于双星合并前奏Precursor观测特征 of 论述有哪些?" '文献库中关于双星合并前奏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({ export function AIAssistantPanel({
bibcode, bibcode,
onClose,
onJumpToSource, onJumpToSource,
pendingFigure,
onClearPendingFigure
}: AIAssistantPanelProps) { }: AIAssistantPanelProps) {
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [sessionId, setSessionId] = useState<string | null>(null); const [sessionId, setSessionId] = useState<string | null>(null);
// 步骤展开状态 // 折叠状态(共享 ThoughtCard / ToolCallCard / SubAgentContainer 的展开控制)
const [stepsExpanded, setStepsExpanded] = useState<Record<number, boolean>>({}); 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 chatEndRef = useRef<HTMLDivElement>(null); const [pendingImage, setPendingImage] = useState<{
const scrollContainerRef = useRef<HTMLDivElement>(null); data?: string; path?: string; mime_type: string; name: string;
const [shouldAutoScroll, setShouldAutoScroll] = useState(true); } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const scrollToBottom = () => { const { streaming, send, stop: sseStop } = useAgentSSE();
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }); 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(() => { const handlePaste = (e: React.ClipboardEvent) => {
if (shouldAutoScroll) { const items = e.clipboardData?.items;
scrollToBottom(); 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(() => { useEffect(() => {
setMessages([]); setMessages([]);
setSessionId(null); setSessionId(null);
setError(null); setError(null);
setStepsExpanded({}); setExpandedThoughts({});
setExpandedArgs({});
setExpandedResults({});
setCollapsedSubAgents({});
setPendingImage(null);
}, [bibcode]); }, [bibcode]);
// 手动停止智能体执行
const handleStop = async () => { const handleStop = async () => {
if (!sessionId) return; sseStop();
try { if (sessionId) {
await axios.post(`/api/chat/sessions/${sessionId}/stop`); try { await axios.post(`/api/chat/sessions/${sessionId}/stop`); } catch { /* noop */ }
} catch (e) {
console.error('停止智能体执行失败:', e);
} }
}; };
const handleSend = async (questionText: string) => { const handleSend = async (questionText: string) => {
if (!questionText.trim() || loading) return; if ((!questionText.trim() && !pendingImage) || streaming) return;
const figure = pendingFigure; const figure = pendingImage;
setError(null); setError(null);
setPendingImage(null);
const userMsg: Message = { const userMsg: Message = {
sender: 'user', sender: 'user',
text: questionText, 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]); setMessages(prev => [...prev, userMsg]);
setInput(''); setInput('');
setLoading(true);
setShouldAutoScroll(true); setShouldAutoScroll(true);
if (onClearPendingFigure) { // AI 消息占位符(用 TimelineItem[] 替代旧 AgentStep[]
onClearPendingFigure(); const aiMsg: Message = { sender: 'ai', text: '', timeline: [], sources: [] };
} setMessages(prev => [...prev, aiMsg]);
// AI 初始消息占位符 // ── SSE 事件处理器(使用共享子代理路由)──
const aiMsgPlaceholder: Message = { const handlers: SSEEventHandlers = {
sender: 'ai', onSession: (sid) => {
text: '', if (!sessionId) setSessionId(sid);
steps: [], },
sources: [] 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 { const contextPrefix = `[当前正在阅读文献: ${bibcode}] `;
// 如果有待处理的图片,先获取并转为 base64 const requestQuery = questionText.includes(bibcode)
let imagePayload: { data: string; mime_type: string } | undefined = undefined; ? questionText : `${contextPrefix}${questionText}`;
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) => { let imagePayload: { data: string; mime_type: string } | undefined;
const reader = new FileReader(); if (figure?.data) {
reader.readAsDataURL(blob); imagePayload = { data: figure.data, mime_type: figure.mime_type };
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;
}
}
// 调用智能体流式对话接口(带文献上下文提示,确保 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);
} }
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 => ({ const renderTimelineItem = (item: TimelineItem, idx: number, isStreaming: boolean) => {
...prev, switch (item.type) {
[msgIdx]: !prev[msgIdx] 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 ( 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="flex flex-col overflow-hidden 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
ref={scrollContainerRef} ref={scrollContainerRef}
onScroll={handleScroll} onScroll={handleScroll}
@ -429,10 +306,10 @@ export function AIAssistantPanel({
AI AI
</p> </p>
</div> </div>
{/* 推荐提示词 */}
<div className="space-y-2"> <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"> <div className="space-y-2">
{SUGGESTED_QUESTIONS.map((q, idx) => ( {SUGGESTED_QUESTIONS.map((q, idx) => (
<button <button
@ -448,9 +325,12 @@ export function AIAssistantPanel({
</div> </div>
) : ( ) : (
messages.map((msg, index) => ( 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"> <span className="text-[10px] font-bold text-slate-400 px-1">
{msg.sender === 'user' ? '我' : '智能科研助手'} {msg.sender === 'user' ? '我' : '天文科研助手'}
</span> </span>
<div <div
className={`max-w-[95%] rounded-xl px-4 py-3 text-xs leading-relaxed font-medium shadow-sm border ${ 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' ? ( {msg.sender === 'user' ? (
<div className="space-y-2"> <div className="space-y-2">
{msg.imageUrl && ( {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> <p className="whitespace-pre-wrap">{msg.text}</p>
</div> </div>
) : ( ) : (
<div className="space-y-2.5"> <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"> <div className="border border-slate-100 bg-slate-50/50 rounded-lg p-2 select-none">
<button <div className="text-[10px] font-bold text-purple-500 flex items-center gap-1 mb-2 px-1">
onClick={() => toggleSteps(index)} <Brain className="w-3.5 h-3.5" />
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> ({msg.timeline.length} )</span>
> </div>
<span className="flex items-center gap-1"> <div className="pl-4 border-l border-slate-200 space-y-2">
<Brain className="w-3.5 h-3.5 text-purple-500" /> {msg.timeline.map((item, tIdx) =>
<span> ({msg.steps.length} )</span> renderTimelineItem(item, tIdx, !!streaming),
</span> )}
{stepsExpanded[index] ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />} </div>
</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> </div>
)} )}
{/* Final output text */} {/* 最终回答 */}
{msg.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"> <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 <AgentMarkdown>{msg.text}</AgentMarkdown>
remarkPlugins={[remarkMath, remarkGfm]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, safeSchema], rehypeKatex]}
>
{msg.text}
</ReactMarkdown>
</div> </div>
) : ( ) : (
!loading && <span className="text-slate-400 italic">...</span> !streaming && (
<span className="text-slate-400 italic">...</span>
)
)} )}
</div> </div>
)} )}
</div> </div>
{/* 关联来源展示 */} {/* 关联来源 */}
{msg.sender === 'ai' && msg.sources && msg.sources.length > 0 && ( {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"> <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%]"> <div className="grid grid-cols-1 gap-1.5 w-[95%]">
{msg.sources.map((src, sIdx) => ( {msg.sources.map((src, sIdx) => (
<button <button
@ -540,7 +398,9 @@ export function AIAssistantPanel({
<div className="flex items-center gap-1.5 truncate mr-2"> <div className="flex items-center gap-1.5 truncate mr-2">
<BookOpen className="w-3 h-3 text-sky-600 shrink-0" /> <BookOpen className="w-3 h-3 text-sky-600 shrink-0" />
<span className="font-bold text-slate-700 truncate">{src.bibcode}</span> <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> </div>
<span className="text-slate-400 text-[9px] shrink-0 font-medium"> <span className="text-slate-400 text-[9px] shrink-0 font-medium">
: {src.distance.toFixed(3)} : {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"> <div className="flex items-center space-x-2 text-slate-500 pl-2">
<Loader className="w-4 h-4 animate-spin text-sky-600" /> <Loader className="w-4 h-4 animate-spin text-sky-600" />
<span className="text-[10px] font-bold">...</span> <span className="text-[10px] font-bold">...</span>
@ -574,31 +434,7 @@ export function AIAssistantPanel({
<div ref={chatEndRef} /> <div ref={chatEndRef} />
</div> </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 <form
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); 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" 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 <textarea
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
@ -616,33 +452,98 @@ export function AIAssistantPanel({
handleSend(input); handleSend(input);
} }
}} }}
disabled={loading} onPaste={handlePaste}
placeholder={pendingFigure ? "针对选中图表提问..." : "向 AI 馆藏助手提问..."} disabled={streaming}
placeholder={pendingImage ? '针对选中图表提问...' : '向 AI 馆藏助手提问...'}
rows={2} 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"> <div className="flex justify-between items-center mt-1.5 pt-1.5 border-t border-slate-200/40">
{loading ? ( <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 <button
type="button" type="button"
onClick={handleStop} onClick={() => fileInputRef.current?.click()}
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" disabled={streaming}
title="手动停止执行" 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>
) : ( {pendingImage && (
<button <div className="relative inline-block ml-1 shrink-0 group select-none">
type="submit" <img
disabled={!input.trim()} src={
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" pendingImage.path
> ? `/api/files/${pendingImage.path}`
<Send className="w-3.5 h-3.5" /> : `data:${pendingImage.mime_type};base64,${pendingImage.data}`
</button> }
)} 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>
</div> </div>
</form> </form>
</div> </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) }];
}

View File

@ -916,7 +916,6 @@ export function ReaderPanel({
<div className="flex-1 overflow-hidden h-full"> <div className="flex-1 overflow-hidden h-full">
<AIAssistantPanel <AIAssistantPanel
bibcode={selectedPaper.bibcode} bibcode={selectedPaper.bibcode}
onClose={() => setShowNotesPanel(false)}
onJumpToSource={onJumpToSource} onJumpToSource={onJumpToSource}
/> />
</div> </div>

View 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, () => '$');
};

View File

@ -142,7 +142,7 @@ graph LR
| 层 | 触发条件 | 算法 | API 调用 | 关键参数 | | 层 | 触发条件 | 算法 | API 调用 | 关键参数 |
|:---|:---|:---|:---|:---| |:---|:---|:---|:---|:---|
| **snip** (L0) | 消息数 > `MAX_MESSAGES` (默认 50) | `find_safe_cut_point` 切中间段 → 插入占位消息 | 否 | HEAD_KEEP=3, tail_keep=47 | | **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 字 | | **auto** (L2) | L1 后仍超限 | LLM 生成中文摘要替换历史 | 是 | 摘要 ≤500 字 |
| **aggressive_micro** (L3) | L2 后仍超限 | 同 L1 但仅保留最近 2 条工具结果 | 否 | keep_recent=2 | | **aggressive_micro** (L3) | L2 后仍超限 | 同 L1 但仅保留最近 2 条工具结果 | 否 | keep_recent=2 |
| **identity** (L4) | 压缩后消息 ≤4 条 | 注入 `[身份确认]` 块 | 否 | 天体物理学研究助手身份 | | **identity** (L4) | 压缩后消息 ≤4 条 | 注入 `[身份确认]` 块 | 否 | 天体物理学研究助手身份 |
@ -351,7 +351,7 @@ Session → Thought → (ToolCall ↔ ToolResult)* → TextDelta → Usage → D
| `AGENT_MAX_STEPS` | 8 | ReAct 最大迭代步数 | | `AGENT_MAX_STEPS` | 8 | ReAct 最大迭代步数 |
| `AGENT_TOOL_TIMEOUT_SECS` | 120 | 工具执行超时(秒) | | `AGENT_TOOL_TIMEOUT_SECS` | 120 | 工具执行超时(秒) |
| `AGENT_MAX_TOOL_OUTPUT_CHARS` | 4000 | 工具输出截断长度(字符) | | `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_SOFT_LIMIT` | 32000 | Token 预算软限制(触发 nudging |
| `AGENT_TOKEN_HARD_LIMIT` | 40000 | Token 预算硬限制(触发强制动作) | | `AGENT_TOKEN_HARD_LIMIT` | 40000 | Token 预算硬限制(触发强制动作) |
| `AGENT_MAX_MESSAGES` | 50 | snip_compact 触发阈值(消息数) | | `AGENT_MAX_MESSAGES` | 50 | snip_compact 触发阈值(消息数) |

View File

@ -11,7 +11,7 @@ Agent 系统的所有可配置参数,按子系统分类。
| `AGENT_MAX_STEPS` | 8 | usize | 单轮对话中最大 ReAct 迭代步数。到达后强制终止,注入"请根据已有信息直接给出最终答案"消息,调用 `final_answer_without_tools()` 不带工具生成最终回答。 | | `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_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_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_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_TOKEN_HARD_LIMIT` | 40000 | usize | Token 预算硬限制。达到时注入 🔴 "已耗尽" 消息强制要求模型立即给出最终答案。error recovery escalate 步骤可临时提升此值到 64,000。 |
| `AGENT_MAX_MESSAGES` | 50 | usize | snip_compact (Layer 0) 的触发阈值。消息数超过此值时截断中间段:保留前 3 条 + 后 47 条,中间替换为含工具名称列表的占位消息。 | | `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/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/main.rs` | `EMBEDDING_DIM` |
| `src/services/logging.rs` | `LOG_LEVEL`, `LOG_FORMAT`, `LOG_OUTPUTS`, `LOG_DIR` | | `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` (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/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 压缩记忆桥接) | | `src/agent/compact.rs` (extract_memories_from_compaction) | `EXTRACT_MEMORY_ENABLED` (P3 压缩记忆桥接) |

View File

@ -171,12 +171,19 @@ let est_tokens: usize = messages
.iter() .iter()
.map(|m| m.content.as_ref().map_or(0, |c| c.len()) + 4) .map(|m| m.content.as_ref().map_or(0, |c| c.len()) + 4)
.sum(); .sum();
if est_tokens > self.config.context_char_limit * 3 / 2 { if est_tokens > self.config.token_soft_limit * 3 / 2 {
compact::compress_context(&mut messages, llm, config.context_char_limit, "subagent").await; 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 摘要压缩) - 压缩方式:调用 `compact::compress_context`LLM 摘要压缩)
- 标识来源:`"subagent"`,区别于 `"lead"` / `"teammate"` / `"background"` - 标识来源:`"subagent"`,区别于 `"lead"` / `"teammate"` / `"background"`

View File

@ -81,7 +81,7 @@ src/
| `SKILLS_DIR` | `./skills` | Agent Skills 目录 | | `SKILLS_DIR` | `./skills` | Agent Skills 目录 |
| `PORT` | `8000` | 服务端口 | | `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)。
## 代码规范 ## 代码规范

View File

@ -29,8 +29,13 @@ static COMPACTING: AtomicBool = AtomicBool::new(false);
use crate::clients::llm::{ChatMessage, LlmClient, MessageRole}; use crate::clients::llm::{ChatMessage, LlmClient, MessageRole};
/// 找到安全的上下文切割点,确保不会切断 tool_call / tool_result 配对。 /// 找到安全的上下文切割点,确保不会切断 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 { pub fn find_safe_cut_point(messages: &[ChatMessage], desired_keep: usize) -> usize {
if messages.len() <= desired_keep { if messages.len() <= desired_keep {
return 0; // 全部保留,无需切割 return 0; // 全部保留,无需切割
@ -43,53 +48,41 @@ pub fn find_safe_cut_point(messages: &[ChatMessage], desired_keep: usize) -> usi
cut = 1; cut = 1;
} }
// 如果切割点落在一个 tool 消息上,向前扩展以包含其对应的 assistant(tool_calls) // 收集保留区 [cut..] 中所有 tool_result 对应的 tool_call_id
loop { let mut covered_ids: std::collections::HashSet<&str> = std::collections::HashSet::new();
if cut >= messages.len() { for msg in &messages[cut..] {
cut = messages.len() - 1; if msg.role == MessageRole::Tool {
break; if let Some(ref id) = msg.tool_call_id {
} covered_ids.insert(id.as_str());
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;
}
} }
if !found_pair {
break;
}
} else {
break;
} }
} }
// 检查切割点之前没有孤立的 assistant(tool_calls)。 // 从切割点向前扫描:如果某个 assistant(tool_calls) 的 tool_result
// 从后往前扫描:每找到一个孤立的 assistant将 cut 移到该位置并继续向前检查。 // 在保留区([cut..])中,则将该 assistant 也移入保留区以保证配对完整。
let mut search = cut; // 注意:仅使用初始 covered_ids来自保留区不将前向扫描中遇到的
while search > 0 { // Tool 消息加入 covered_ids这些 Tool 在摘要区而非保留区,不应触发迁移)。
let mut found = false; let mut i = cut.saturating_sub(1);
for j in (0..search).rev() { loop {
if messages[j].role == MessageRole::Assistant && messages[j].tool_calls.is_some() { if messages[i].role == MessageRole::Assistant {
let has_tool_result = messages[j + 1..search] if let Some(ref tcs) = messages[i].tool_calls {
.iter() let has_result_in_keep = tcs.iter().any(|tc| covered_ids.contains(tc.id.as_str()));
.any(|m| m.role == MessageRole::Tool); if has_result_in_keep {
if !has_tool_result { // 该 assistant 的 tool_result 位于保留区 → 将 assistant 也纳入保留区
cut = j; cut = i;
search = j; // 将 assistant 的 tool_call_id 标记为已覆盖,
found = true; // 以便更早的依赖链也能被正确识别
for tc in tcs {
covered_ids.insert(tc.id.as_str());
}
} }
break; // 只处理最近的一个,继续向前
} }
} }
if !found {
if i == 0 {
break; break;
} }
i -= 1;
} }
cut cut
@ -191,34 +184,60 @@ fn find_tool_name_for_call_id(messages: &[ChatMessage], tool_call_id: &str) -> O
/// 轻量级压缩:将较早的工具结果替换为简短占位符,释放上下文空间。 /// 轻量级压缩:将较早的工具结果替换为简短占位符,释放上下文空间。
/// 保留最近 `keep_recent` 条工具结果不变。 /// 保留最近 `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() .iter()
.enumerate() .enumerate()
.filter_map(|(i, m)| { .filter_map(|(i, m)| {
if m.role == MessageRole::Tool { if m.role == MessageRole::Tool {
let call_id = m.tool_call_id.clone().unwrap_or_default(); m.tool_call_id.as_deref().map(|id| (i, id))
// 先查找工具名称(此时 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))
} else { } else {
None None
} }
}) })
.collect(); .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 { if compact_count == 0 {
return; return messages.to_vec();
} }
for (idx, _tool_id, tool_name) in tool_info.iter().take(compact_count) { // 构建需要压缩的索引集合(前 compact_count 个工具消息)
if let Some(msg) = messages.get_mut(*idx) { let compact_indices: std::collections::HashSet<usize> = tool_positions
msg.content = Some(format!("[Previous: used {}]", tool_name)); .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 数(用作首次调用的近似值)。 /// 粗略估算消息列表的 token 数(用作首次调用的近似值)。
@ -350,27 +369,40 @@ async fn generate_summary(
} }
/// 多层回退压缩snip → micro → auto → aggressive_micro → identity inject /// 多层回退压缩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( async fn compress_with_fallback(
messages: &mut Vec<ChatMessage>, messages: &mut Vec<ChatMessage>,
llm: &LlmClient, llm: &LlmClient,
context_char_limit: usize, token_soft_limit: usize,
max_messages: usize,
output_reserve: usize,
) { ) {
// Layer 0: snip_compact零 API 调用,消息数超过 MAX_MESSAGES 时截断中间段) // Layer 0: snip_compact零 API 调用,消息数超过阈值时截断中间段)
snip_compact(messages, MAX_MESSAGES); snip_compact(messages, max_messages);
// Layer 1: micro_compact保留最近 8 条工具结果) // Layer 1: micro_compact保留最近 8 条工具结果)
micro_compact(messages, 8); *messages = micro_compact(messages, 8);
if rough_estimate_tokens(messages) < (context_char_limit * 3 / 2) { if rough_estimate_tokens(messages) < token_soft_limit {
return; return;
} }
// Layer 2: auto_compactLLM 摘要,含迭代融合) // Layer 2: auto_compactLLM 摘要,含迭代融合)
// 确保有足够的输出空间给压缩 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 system_msg = messages.first().cloned();
let cut_point = find_safe_cut_point(messages, 10); let cut_point = find_safe_cut_point(messages, 10);
let to_summarize = &messages[1..cut_point]; // cut_point 为 0 表示消息数不足,无需切割
if to_summarize.is_empty() { if cut_point <= 1 {
return; return;
} }
let to_summarize = &messages[1..cut_point];
// ── 迭代摘要融合 ── // ── 迭代摘要融合 ──
// 在切割前提取已有摘要,传给 LLM 以实现迭代融合 // 在切割前提取已有摘要,传给 LLM 以实现迭代融合
@ -390,9 +422,9 @@ async fn compress_with_fallback(
messages.extend(recent); messages.extend(recent);
// Layer 3: 如果摘要后仍然超限,激进 micro_compact仅保留 2 条) // 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)"); warn!("[Compact] LLM 摘要后仍超限,执行激进压缩 (keep_recent=2)");
micro_compact(messages, 2); *messages = micro_compact(messages, 2);
} }
// Layer 4: 注入身份确认块 // Layer 4: 注入身份确认块
@ -408,13 +440,25 @@ async fn compress_with_fallback(
/// 上下文压缩:保存 transcript → 多层回退压缩。 /// 上下文压缩:保存 transcript → 多层回退压缩。
/// 保留系统提示 + 最近的完整 tool-call/tool-result 配对。 /// 保留系统提示 + 最近的完整 tool-call/tool-result 配对。
/// 使用 LLM 摘要较旧的对话历史,在 token 超限时触发。 /// 使用 LLM 摘要较旧的对话历史,在 token 超限时触发。
///
/// `token_soft_limit`: TokenBudget 软限制,各压缩层的统一触发阈值。
/// `max_messages`: 消息数上限(来自 AgentConfig.max_messages
pub async fn compress_context( pub async fn compress_context(
messages: &mut Vec<ChatMessage>, messages: &mut Vec<ChatMessage>,
llm: &LlmClient, llm: &LlmClient,
context_char_limit: usize, token_soft_limit: usize,
max_messages: usize,
session_id: &str, 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会在压缩前后触发事件。 /// 带 Hook 的上下文压缩变体。如果提供了 HookRegistry会在压缩前后触发事件。
@ -422,14 +466,16 @@ pub async fn compress_context(
pub async fn compress_context_with_hooks( pub async fn compress_context_with_hooks(
messages: &mut Vec<ChatMessage>, messages: &mut Vec<ChatMessage>,
llm: &LlmClient, llm: &LlmClient,
context_char_limit: usize, token_soft_limit: usize,
max_messages: usize,
session_id: &str, session_id: &str,
hook_registry: Option<&HookRegistry>, hook_registry: Option<&HookRegistry>,
) { ) {
compress_context_with_hooks_and_log( compress_context_with_hooks_and_log(
messages, messages,
llm, llm,
context_char_limit, token_soft_limit,
max_messages,
session_id, session_id,
hook_registry, hook_registry,
None, None,
@ -437,12 +483,20 @@ pub async fn compress_context_with_hooks(
.await; .await;
} }
/// 输出预留 token 数 — 压缩 LLM 生成摘要时需要消耗输出 token。
/// 如果不预留,当上下文接近窗口上限时压缩调用本身会因输出空间不足而失败。
pub const COMPACTION_OUTPUT_RESERVE: usize = 4000;
/// 带 Hook 和 CollapseLog 的上下文压缩变体。 /// 带 Hook 和 CollapseLog 的上下文压缩变体。
/// 压缩后自动记录 commit 到 CollapseLog并在溢出时注入合并摘要。 /// 压缩后自动记录 commit 到 CollapseLog并在溢出时注入合并摘要。
///
/// `token_soft_limit`: TokenBudget 软限制,统一各压缩层的触发阈值。
/// `max_messages`: 消息数上限,用于 Layer 0 snip 触发。
pub async fn compress_context_with_hooks_and_log( pub async fn compress_context_with_hooks_and_log(
messages: &mut Vec<ChatMessage>, messages: &mut Vec<ChatMessage>,
llm: &LlmClient, llm: &LlmClient,
context_char_limit: usize, token_soft_limit: usize,
max_messages: usize,
session_id: &str, session_id: &str,
hook_registry: Option<&HookRegistry>, hook_registry: Option<&HookRegistry>,
collapse_log: Option<&std::sync::Mutex<CollapseLog>>, collapse_log: Option<&std::sync::Mutex<CollapseLog>>,
@ -472,7 +526,14 @@ pub async fn compress_context_with_hooks_and_log(
} }
// 执行多层回退压缩(注:完整会话历史已在 agent_messages 表中持久化,无需额外 transcript 快照) // 执行多层回退压缩(注:完整会话历史已在 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 after_count = messages.len();
let method = if after_count < before_count / 2 { let method = if after_count < before_count / 2 {
@ -649,7 +710,7 @@ mod tests {
#[test] #[test]
fn test_micro_compact_placeholder_format() { fn test_micro_compact_placeholder_format() {
let mut messages = vec![ let messages = vec![
ChatMessage::system("You are a helpful assistant."), ChatMessage::system("You are a helpful assistant."),
ChatMessage::user("Hello"), ChatMessage::user("Hello"),
ChatMessage::assistant_with_tool_calls( ChatMessage::assistant_with_tool_calls(
@ -674,7 +735,7 @@ mod tests {
ChatMessage::assistant("Here are the results..."), ChatMessage::assistant("Here are the results..."),
]; ];
micro_compact(&mut messages, 0); let messages = micro_compact(&messages, 0);
// 工具结果应该被压缩为 [Previous: used search_papers] // 工具结果应该被压缩为 [Previous: used search_papers]
let tool_msg = &messages[3]; let tool_msg = &messages[3];
@ -699,7 +760,7 @@ mod tests {
#[test] #[test]
fn test_micro_compact_preserves_recent_results() { fn test_micro_compact_preserves_recent_results() {
let mut messages = vec![ let messages = vec![
ChatMessage::system("You are a helpful assistant."), ChatMessage::system("You are a helpful assistant."),
ChatMessage::user("Search for papers"), ChatMessage::user("Search for papers"),
ChatMessage::assistant_with_tool_calls( 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]; let compressed = &messages[3];

View File

@ -42,9 +42,9 @@ pub const DEEP_RESEARCH_MODE: AgentMode = AgentMode {
// 配置:更多步数、启用思考、加载科研权限 // 配置:更多步数、启用思考、加载科研权限
mode_config: ModeConfig { mode_config: ModeConfig {
max_steps: Some(16), max_steps: 100,
enable_thinking: Some(true), enable_thinking: Some(true),
tool_timeout_secs: Some(180), tool_timeout_secs: 180,
permission_profile: Some("research"), permission_profile: Some("research"),
}, },
}; };

View File

@ -18,11 +18,11 @@ pub const DEFAULT_MODE: AgentMode = AgentMode {
// 全量工具 // 全量工具
tool_set: ToolSet::All, tool_set: ToolSet::All,
// 不覆盖任何默认配置(全部继承 AgentConfig::default() // 默认配置
mode_config: ModeConfig { mode_config: ModeConfig {
max_steps: None, max_steps: 8,
enable_thinking: None, enable_thinking: None,
tool_timeout_secs: None, tool_timeout_secs: 120,
permission_profile: None, permission_profile: None,
}, },
}; };

View File

@ -62,9 +62,9 @@ pub const LITERATURE_READER_MODE: AgentMode = AgentMode {
// 配置:较少步数、强制只读权限 // 配置:较少步数、强制只读权限
mode_config: ModeConfig { mode_config: ModeConfig {
max_steps: Some(6), max_steps: 25,
enable_thinking: Some(false), // 文献阅读不需要思考模式,节省 token enable_thinking: Some(false), // 文献阅读不需要思考模式,节省 token
tool_timeout_secs: Some(120), tool_timeout_secs: 120,
permission_profile: Some("readonly"), permission_profile: Some("readonly"),
}, },
}; };

View File

@ -38,12 +38,12 @@ pub enum ToolSet {
/// 所有字段为 `None` 时表示不覆盖,使用 AgentConfig 的默认值。 /// 所有字段为 `None` 时表示不覆盖,使用 AgentConfig 的默认值。
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct ModeConfig { pub struct ModeConfig {
/// 最大推理步数None = 使用 AgentConfig 默认值 8 /// 最大推理步数
pub max_steps: Option<usize>, pub max_steps: usize,
/// 是否强制启用思考模式None = 用户可覆盖) /// 是否强制启用思考模式None = 用户可覆盖)
pub enable_thinking: Option<bool>, pub enable_thinking: Option<bool>,
/// 工具执行超时秒数None = 使用 AgentConfig 默认值 120 /// 工具执行超时秒数
pub tool_timeout_secs: Option<u64>, pub tool_timeout_secs: u64,
/// 权限档案名称,对应 permission_profile::load_profile 的参数 /// 权限档案名称,对应 permission_profile::load_profile 的参数
/// None = 不额外加载权限档案) /// None = 不额外加载权限档案)
pub permission_profile: Option<&'static str>, pub permission_profile: Option<&'static str>,
@ -234,9 +234,9 @@ mod tests {
#[test] #[test]
fn test_mode_config_default_all_none() { fn test_mode_config_default_all_none() {
let config = ModeConfig::default(); 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.enable_thinking.is_none());
assert!(config.tool_timeout_secs.is_none());
assert!(config.permission_profile.is_none()); assert!(config.permission_profile.is_none());
} }
@ -253,7 +253,7 @@ mod tests {
#[test] #[test]
fn test_deep_research_mode_config() { fn test_deep_research_mode_config() {
let mode = &DEEP_RESEARCH_MODE; 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.enable_thinking, Some(true));
assert_eq!(mode.mode_config.permission_profile, Some("research")); assert_eq!(mode.mode_config.permission_profile, Some("research"));
} }

View File

@ -151,15 +151,8 @@ impl CheckpointManager {
CheckpointManager { CheckpointManager {
enabled: enabled && inner.repo.is_some(), enabled: enabled && inner.repo.is_some(),
repo_path: store_path, repo_path: store_path,
max_snapshots: std::env::var("AGENT_CHECKPOINT_MAX_SNAPSHOTS") max_snapshots: 10,
.ok() max_file_size: 10 * 1024 * 1024, // 10 MB
.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
inner: Mutex::new(inner), inner: Mutex::new(inner),
} }
} }

View File

@ -285,24 +285,9 @@ impl HardlineResult {
/// ///
/// 执行反规避标准化后再匹配,返回第一个命中的模式。 /// 执行反规避标准化后再匹配,返回第一个命中的模式。
/// ///
/// 注意:此函数在模块导入时冻结 `HERMES_YOLO_MODE`
/// 确保运行时无法通过设置环境变量绕过 hardline 检查。
pub fn check_command(raw_command: &str) -> HardlineResult { pub fn check_command(raw_command: &str) -> HardlineResult {
let normalized = normalize_command(raw_command); 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 let matches: Vec<usize> = HARDLINE_REGEX_SET
.matches(&normalized) .matches(&normalized)
.into_iter() .into_iter()

View File

@ -66,9 +66,7 @@ pub struct AgentConfig {
pub tool_timeout_secs: u64, pub tool_timeout_secs: u64,
/// 工具输出最大字符数 /// 工具输出最大字符数
pub max_tool_output_chars: usize, pub max_tool_output_chars: usize,
/// 上下文 Token 估算上限(触发自动摘要压缩) /// Token 预算软限制 — 各压缩层统一触发阈值
pub context_char_limit: usize,
/// Token 预算软限制(触发 nudging 提醒)
pub token_soft_limit: usize, pub token_soft_limit: usize,
/// Token 预算硬限制(触发强制动作) /// Token 预算硬限制(触发强制动作)
pub token_hard_limit: usize, pub token_hard_limit: usize,
@ -99,80 +97,32 @@ pub struct AgentConfig {
impl AgentConfig { impl AgentConfig {
/// 从环境变量加载配置,缺失时使用默认值。 /// 从环境变量加载配置,缺失时使用默认值。
pub fn from_env_optional() -> Self { pub fn from_env_optional() -> Self {
let mut config = AgentConfig { AgentConfig {
max_steps: std::env::var("AGENT_MAX_STEPS") max_steps: 8,
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(8),
duplicate_call_threshold: 3, duplicate_call_threshold: 3,
tool_timeout_secs: std::env::var("AGENT_TOOL_TIMEOUT_SECS") tool_timeout_secs: 120,
.ok() max_tool_output_chars: 4000,
.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),
token_soft_limit: std::env::var("AGENT_TOKEN_SOFT_LIMIT") token_soft_limit: std::env::var("AGENT_TOKEN_SOFT_LIMIT")
.ok() .ok()
.and_then(|v| v.parse().ok()) .and_then(|v| v.parse().ok())
.unwrap_or(32000), .unwrap_or(80000),
token_hard_limit: std::env::var("AGENT_TOKEN_HARD_LIMIT") token_hard_limit: std::env::var("AGENT_TOKEN_HARD_LIMIT")
.ok() .ok()
.and_then(|v| v.parse().ok()) .and_then(|v| v.parse().ok())
.unwrap_or(40000), .unwrap_or(100000),
max_messages: std::env::var("AGENT_MAX_MESSAGES") max_messages: 50,
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(50),
enable_thinking: false, enable_thinking: false,
permission_deny_rules: parse_comma_list("AGENT_PERMISSIONS_DENY"), permission_deny_rules: parse_comma_list("AGENT_PERMISSIONS_DENY"),
permission_allow_rules: parse_comma_list("AGENT_PERMISSIONS_ALLOW"), permission_allow_rules: parse_comma_list("AGENT_PERMISSIONS_ALLOW"),
permission_ask_rules: parse_comma_list("AGENT_PERMISSIONS_ASK"), permission_ask_rules: parse_comma_list("AGENT_PERMISSIONS_ASK"),
permission_mode: std::env::var("AGENT_PERMISSION_MODE") permission_mode: std::env::var("AGENT_PERMISSION_MODE")
.unwrap_or_else(|_| "default".to_string()), .unwrap_or_else(|_| "default".to_string()),
denial_max_consecutive: std::env::var("AGENT_DENIAL_MAX_CONSECUTIVE") denial_max_consecutive: 3,
.ok() denial_max_total: 20,
.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),
additional_allowed_dirs: parse_comma_list("AGENT_ADDITIONAL_DIRS"), additional_allowed_dirs: parse_comma_list("AGENT_ADDITIONAL_DIRS"),
subagent_allowed_tools: parse_comma_list("AGENT_SUBAGENT_ALLOWED_TOOLS"), subagent_allowed_tools: parse_comma_list("AGENT_SUBAGENT_ALLOWED_TOOLS"),
mode: std::env::var("AGENT_MODE").unwrap_or_else(|_| "default".to_string()), mode: "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()
);
}
} }
config
} }
} }
@ -380,10 +330,7 @@ impl AgentRuntime {
apply_mode_tool_filter(&mut tool_registry, mode); apply_mode_tool_filter(&mut tool_registry, mode);
// 初始化 checkpoint 管理器 // 初始化 checkpoint 管理器
let checkpoint_enabled = std::env::var("AGENT_CHECKPOINT_ENABLED") let checkpoint_enabled = true;
.unwrap_or_else(|_| "true".to_string())
.to_lowercase()
!= "false";
let checkpoint_store = app_state.config.library_dir.join("..").join(".checkpoints"); let checkpoint_store = app_state.config.library_dir.join("..").join(".checkpoints");
let checkpoint_manager = Arc::new(checkpoint::CheckpointManager::new( let checkpoint_manager = Arc::new(checkpoint::CheckpointManager::new(
std::fs::canonicalize(&checkpoint_store).unwrap_or(checkpoint_store), std::fs::canonicalize(&checkpoint_store).unwrap_or(checkpoint_store),
@ -455,10 +402,7 @@ impl AgentRuntime {
apply_mode_tool_filter(&mut tool_registry, mode); apply_mode_tool_filter(&mut tool_registry, mode);
// 初始化 checkpoint 管理器 // 初始化 checkpoint 管理器
let checkpoint_enabled = std::env::var("AGENT_CHECKPOINT_ENABLED") let checkpoint_enabled = true;
.unwrap_or_else(|_| "true".to_string())
.to_lowercase()
!= "false";
let checkpoint_store = app_state.config.library_dir.join("..").join(".checkpoints"); let checkpoint_store = app_state.config.library_dir.join("..").join(".checkpoints");
let checkpoint_manager = Arc::new(checkpoint::CheckpointManager::new( let checkpoint_manager = Arc::new(checkpoint::CheckpointManager::new(
std::fs::canonicalize(&checkpoint_store).unwrap_or(checkpoint_store), std::fs::canonicalize(&checkpoint_store).unwrap_or(checkpoint_store),
@ -561,7 +505,8 @@ impl AgentRuntime {
compact::compress_context_with_hooks_and_log( compact::compress_context_with_hooks_and_log(
messages, messages,
llm, llm,
self.config.context_char_limit, self.config.token_soft_limit,
self.config.max_messages,
session_id, session_id,
Some(hook_registry), Some(hook_registry),
Some(&self.collapse_log), Some(&self.collapse_log),
@ -799,7 +744,7 @@ impl AgentRuntime {
None => compact::rough_estimate_tokens(messages), None => compact::rough_estimate_tokens(messages),
}; };
// 使用 token 预算的软限制作为压缩触发点(而非粗糙的 context_char_limit * 1.5 // 使用 TokenBudget 软限制作为压缩触发点
let token_limit = token_budget.soft_limit; let token_limit = token_budget.soft_limit;
let mut did_compress = false; let mut did_compress = false;
@ -1418,14 +1363,15 @@ impl AgentRuntime {
error_recovery::RecoveryStep::AggressiveCompact => { error_recovery::RecoveryStep::AggressiveCompact => {
info!("[AgentRuntime] 错误恢复: 激进压缩 (snip + micro with keep_recent=2)"); info!("[AgentRuntime] 错误恢复: 激进压缩 (snip + micro with keep_recent=2)");
compact::snip_compact(messages, self.config.max_messages); compact::snip_compact(messages, self.config.max_messages);
compact::micro_compact(messages, 2); *messages = compact::micro_compact(messages, 2);
} }
error_recovery::RecoveryStep::ReactiveCompact => { error_recovery::RecoveryStep::ReactiveCompact => {
info!("[AgentRuntime] 错误恢复: LLM 摘要压缩"); info!("[AgentRuntime] 错误恢复: LLM 摘要压缩");
compact::compress_context( compact::compress_context(
messages, messages,
llm, llm,
self.config.context_char_limit, self.config.token_soft_limit,
self.config.max_messages,
session_id, session_id,
) )
.await; .await;
@ -1846,15 +1792,11 @@ impl AgentRuntime {
/// ///
/// 仅覆盖 mode_config 中 Some 的字段None 保持原值不变。 /// 仅覆盖 mode_config 中 Some 的字段None 保持原值不变。
fn apply_mode_config(config: &mut AgentConfig, mode: &AgentMode) { fn apply_mode_config(config: &mut AgentConfig, mode: &AgentMode) {
if let Some(max_steps) = mode.mode_config.max_steps { config.max_steps = mode.mode_config.max_steps;
config.max_steps = max_steps;
}
if let Some(enable_thinking) = mode.mode_config.enable_thinking { if let Some(enable_thinking) = mode.mode_config.enable_thinking {
config.enable_thinking = enable_thinking; config.enable_thinking = enable_thinking;
} }
if let Some(tool_timeout_secs) = mode.mode_config.tool_timeout_secs { config.tool_timeout_secs = mode.mode_config.tool_timeout_secs;
config.tool_timeout_secs = tool_timeout_secs;
}
// 加载权限档案(模式绑定的 permission_profile // 加载权限档案(模式绑定的 permission_profile
if let Some(profile_name) = mode.mode_config.permission_profile { if let Some(profile_name) = mode.mode_config.permission_profile {

View File

@ -296,7 +296,7 @@ impl SubAgentRunner {
.iter() .iter()
.map(|m| m.content.as_ref().map_or(0, |c| c.len()) + 4) .map(|m| m.content.as_ref().map_or(0, |c| c.len()) + 4)
.sum(); .sum();
if est_tokens > self.config.context_char_limit * 3 / 2 { if est_tokens > self.config.token_soft_limit * 3 / 2 {
info!( info!(
"[SubAgent] 上下文超限 (est. {} tokens),触发压缩", "[SubAgent] 上下文超限 (est. {} tokens),触发压缩",
est_tokens est_tokens
@ -304,7 +304,8 @@ impl SubAgentRunner {
compact::compress_context_with_hooks( compact::compress_context_with_hooks(
&mut messages, &mut messages,
llm, llm,
self.config.context_char_limit, self.config.token_soft_limit,
self.config.max_messages,
subagent_name, subagent_name,
self.hook_registry.as_ref().map(|a| a.as_ref()), self.hook_registry.as_ref().map(|a| a.as_ref()),
) )

View File

@ -179,8 +179,15 @@ async fn run_teammate_react_turn(
.iter() .iter()
.map(|m| m.content.as_ref().map_or(0, |c| c.len()) + 4) .map(|m| m.content.as_ref().map_or(0, |c| c.len()) + 4)
.sum(); .sum();
if est_tokens > config.context_char_limit * 3 / 2 { if est_tokens > config.token_soft_limit * 3 / 2 {
compact::compress_context(messages, llm, config.context_char_limit, "teammate").await; compact::compress_context(
messages,
llm,
config.token_soft_limit,
config.max_messages,
"teammate",
)
.await;
} }
// LLM 流式调用 // LLM 流式调用

View File

@ -276,16 +276,14 @@ fn extract_first_command_word(command: &str) -> String {
String::new() String::new()
} }
/// 网络访问命令 — 当 `AGENT_BLOCK_NETWORK=true`(默认)时被禁止。 /// 网络访问命令 — 始终被禁止。
const NETWORK_COMMANDS: &[&str] = &[ const NETWORK_COMMANDS: &[&str] = &[
"curl", "wget", "nc", "netcat", "socat", "ncat", "ftp", "tftp", "curl", "wget", "nc", "netcat", "socat", "ncat", "ftp", "tftp",
]; ];
/// 检查是否启用网络访问限制(默认启用)。 /// 检查是否启用网络访问限制(始终启用)。
fn is_network_blocked() -> bool { fn is_network_blocked() -> bool {
std::env::var("AGENT_BLOCK_NETWORK") true
.map(|v| v != "false" && v != "0")
.unwrap_or(true)
} }
/// 检查命令首词是否在安全白名单中。 /// 检查命令首词是否在安全白名单中。
@ -550,20 +548,10 @@ mod tests {
#[test] #[test]
fn test_network_commands_blocking() { 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("curl https://example.com").is_some());
assert!(validate_bash_command("wget https://example.com/file").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()); 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] #[test]

View File

@ -51,9 +51,7 @@ impl Config {
let llm_api_base = let llm_api_base =
env::var("LLM_API_BASE").unwrap_or_else(|_| "https://api.openai.com/v1".to_string()); 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_model = env::var("LLM_MODEL").unwrap_or_else(|_| "gpt-4o-mini".to_string());
let llm_fallback_model = env::var("LLM_FALLBACK_MODEL") let llm_fallback_model = env::var("LLM_FALLBACK_MODEL").unwrap_or_default();
.or_else(|_| env::var("FALLBACK_MODEL"))
.unwrap_or_default();
let llm_fallback_chain = env::var("LLM_FALLBACK_CHAIN") let llm_fallback_chain = env::var("LLM_FALLBACK_CHAIN")
.unwrap_or_default() .unwrap_or_default()
.split(',') .split(',')