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:
fmq
2026-06-25 00:49:45 +08:00
parent 85b6429c30
commit b11b8ad015
37 changed files with 1981 additions and 1475 deletions
@@ -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>
);
}
@@ -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>
);
}
@@ -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;
}
}
@@ -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>
);
}
@@ -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>
);
}
@@ -0,0 +1,36 @@
// dashboard/src/components/agent/constants.ts
// Agent 渲染共享常量:safeSchema、颜色方案等
import { defaultSchema } from 'rehype-sanitize';
export const safeSchema = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
'*': (defaultSchema.attributes?.['*'] || []).concat([
'className', 'style', 'mathvariant', 'display',
]),
},
tagNames: (defaultSchema.tagNames || []).concat([
'math', 'mrow', 'mi', 'mo', 'mn', 'msup', 'msub', 'msubsup',
'mfrac', 'mover', 'munder', 'munderover', 'mspace', 'mtext', 'annotation',
]),
};
// 时间线颜色方案
export interface ColorScheme {
thought: { active: string; idle: string };
tool_call: { active: string; idle: string };
answer: string;
}
export const PARENT_COLORS: ColorScheme = {
thought: { active: 'bg-purple-400 animate-pulse', idle: 'bg-purple-300' },
tool_call: { active: 'bg-sky-500 animate-pulse', idle: 'bg-sky-400' },
answer: 'bg-emerald-400',
};
export const SUBAGENT_COLORS: ColorScheme = {
thought: { active: 'bg-violet-400 animate-pulse', idle: 'bg-violet-300' },
tool_call: { active: 'bg-violet-500 animate-pulse', idle: 'bg-violet-400' },
answer: 'bg-violet-400',
};
+23
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';
@@ -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;
}
@@ -0,0 +1,50 @@
// dashboard/src/components/agent/toolDisplayNames.ts
// 工具名称 → 人类可读中文名称(所有面板共用)
export function getToolDisplayName(name: string): string {
switch (name) {
// 文件系统工具
case 'read_file': return '读取文件内容';
case 'grep_files': return '正则搜索文件';
case 'glob_files': return '通配符匹配文件';
case 'run_bash': return '执行 Shell 命令';
case 'file_write': return '写入文件';
case 'file_edit': return '精确编辑文件';
// 天文科研工具
case 'search_papers': return '检索 ADS/arXiv 文献';
case 'get_paper_metadata': return '获取文献详细元数据';
case 'download_paper': return '下载文献全文资源';
case 'parse_paper': return '结构化解析文献内容';
case 'get_paper_content': return '获取文献全文内容';
case 'rag_search': return '检索馆藏知识库';
case 'query_target': return '查询天体物理参数 (CDS)';
case 'save_note': return '保存文献手札';
// Agent 控制工具
case 'todo_write': return '管理任务列表';
case 'compress_context': return '压缩上下文窗口';
case 'load_skill': return '加载专家技能';
case 'subagent': return '派发子代理';
case 'delegate_research': return '派发子代理';
case 'ask_user': return '向用户提问';
// 记忆系统
case 'save_memory': return '保存项目记忆';
case 'load_memory': return '读取项目记忆';
// 图片分析
case 'analyze_image': return '分析图像内容';
// 后台任务
case 'bg_task_run': return '启动后台任务';
case 'bg_task_check': return '检查后台任务状态';
// 团队协作
case 'spawn_teammate': return '创建团队成员';
case 'send_teammate_message': return '发送团队成员消息';
case 'team_broadcast': return '团队广播消息';
case 'check_team_inbox': return '检查团队收件箱';
default: {
// 子代理工具名带 [sub] 前缀
if (name.startsWith('[sub] ')) {
return `子代理: ${getToolDisplayName(name.slice(6))}`;
}
return name;
}
}
}
+54
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;
}
@@ -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 };
}
@@ -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,
};
}
+4 -4
View File
@@ -94,10 +94,10 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations,
{[
{ id: 'search', label: '统一检索', icon: Search },
{ id: 'library', label: '馆藏管理', icon: Library },
{ id: 'sync', label: '批量任务', icon: RefreshCw },
{ id: 'reader', label: '双语阅读', icon: BookOpen },
{ id: 'agent', label: '智能科研', icon: Sparkles },
{ id: 'citation', label: '引用星系', icon: GitFork },
{ id: 'sync', label: '批量任务', icon: RefreshCw },
{ id: 'agent', label: '智能科研', icon: Sparkles },
].map((tab: { id: string; label: string; icon: any; disabled?: boolean }) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
@@ -213,12 +213,12 @@ export function Sidebar({ activeTab, setActiveTab, selectedPaper, loadCitations,
<button
type="button"
onClick={onLogout}
className={`w-full flex items-center rounded-lg text-xs font-semibold tracking-wider transition-all duration-300 border border-transparent text-red-600 hover:bg-red-50 hover:text-red-700 cursor-pointer ${
className={`w-full flex items-center rounded-lg text-xs font-semibold tracking-wider transition-all duration-300 border border-transparent text-slate-500 hover:bg-rose-50/50 hover:text-rose-600 cursor-pointer group ${
isCollapsed ? 'px-2 py-2.5 justify-center' : 'px-3 py-2.5'
}`}
title={isCollapsed ? "退出登录" : undefined}
>
<LogOut className="w-4 h-4 shrink-0 text-red-500" />
<LogOut className="w-4 h-4 shrink-0 text-slate-400 group-hover:text-rose-500 transition-colors" />
<span
className={`truncate transition-all duration-300 origin-left ${
isCollapsed
@@ -1,14 +1,15 @@
// dashboard/src/features/agent/AskUserQuestionCard.tsx
import { useState, useEffect } from 'react';
import axios from 'axios';
import { MessageCircle, Send, X, Loader, CheckSquare, Square } from 'lucide-react';
import { MessageCircle, Send, Loader, CheckSquare, Square } from 'lucide-react';
import type { PendingQuestion } from '../../types';
interface AskUserQuestionCardProps {
onAnswered?: () => void;
onQuestionCountChange?: (count: number) => void;
}
export function AskUserQuestionCard({ onAnswered }: AskUserQuestionCardProps) {
export function AskUserQuestionCard({ onAnswered, onQuestionCountChange }: AskUserQuestionCardProps) {
const [pendingQuestions, setPendingQuestions] = useState<PendingQuestion[]>([]);
const [answers, setAnswers] = useState<Record<string, string[]>>({});
const [freeText, setFreeText] = useState<Record<string, string>>({});
@@ -24,7 +25,6 @@ export function AskUserQuestionCard({ onAnswered }: AskUserQuestionCardProps) {
try {
const res = await axios.get<PendingQuestion[]>('/api/chat/questions');
if (!cancelled) {
// 安全解析:确保返回的是数组
const data = Array.isArray(res.data) ? res.data : [];
setPendingQuestions(data);
// 自动展开新问题
@@ -51,7 +51,11 @@ export function AskUserQuestionCard({ onAnswered }: AskUserQuestionCardProps) {
};
}, []);
// 切换选项:使用 option.label 而非整个对象
// 当待处理问题数量改变时,报告给父容器
useEffect(() => {
onQuestionCountChange?.(pendingQuestions.length);
}, [pendingQuestions.length, onQuestionCountChange]);
const toggleOption = (questionId: string, optionLabel: string, multiSelect: boolean) => {
setAnswers(prev => {
const current = prev[questionId] || [];
@@ -117,9 +121,8 @@ export function AskUserQuestionCard({ onAnswered }: AskUserQuestionCardProps) {
if (pendingQuestions.length === 0) return null;
return (
<div className="space-y-3">
<div className="flex flex-col gap-2">
{pendingQuestions.map(q => {
// 防御:确保必填字段存在
if (!q || !q.question_id) return null;
const isExpanded = expanded[q.question_id] !== false;
@@ -131,62 +134,43 @@ export function AskUserQuestionCard({ onAnswered }: AskUserQuestionCardProps) {
return (
<div
key={q.question_id}
className="bg-amber-50 border-2 border-amber-300 rounded-xl shadow-lg overflow-hidden transition-all"
className="console-panel rounded-xl p-3 shadow-lg border-amber-200/80 bg-amber-50/45 transition-all pointer-events-auto text-xs"
>
{/* 头部 — 显示 header 标签和问题摘要 */}
<button
onClick={() => setExpanded(prev => ({ ...prev, [q.question_id]: !prev[q.question_id] }))}
className="w-full flex items-center justify-between px-4 py-3 bg-amber-100/50 hover:bg-amber-100 transition-colors cursor-pointer"
>
<div className="flex items-center gap-2 text-left min-w-0">
<MessageCircle className="w-4 h-4 text-amber-600 shrink-0" />
<span className="px-1.5 py-0.5 rounded bg-amber-200 border border-amber-300 text-[10px] font-extrabold text-amber-800 shrink-0">
{/* 标题栏 - 头部与问题放在同一行,可省略独立的问题文本框 */}
<div className="mb-2 flex items-center justify-between border-b border-amber-100/40 pb-1.5 min-w-0">
<div className="flex items-center gap-1.5 min-w-0 flex-1 mr-3">
<MessageCircle className="h-3.5 w-3.5 text-amber-550 shrink-0" />
<span className="px-1.5 py-0.2 rounded bg-amber-100 border border-amber-200 text-[9px] font-extrabold text-amber-800 shrink-0">
{q.header || '提问'}
</span>
{!isExpanded && (
<span className="text-[10px] text-amber-700 font-medium truncate">
{q.question || ''}
</span>
<span className="font-extrabold text-slate-800 text-[11px] truncate flex-1" title={q.question || ''}>
{q.question || ''}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
{options.length > 0 && (
<button
type="button"
onClick={() => setExpanded(prev => ({ ...prev, [q.question_id]: !prev[q.question_id] }))}
className="text-[10px] font-bold text-sky-600 hover:text-sky-850 cursor-pointer select-none"
>
{isExpanded ? '收起选项' : '展开选项'}
</button>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
{!isExpanded && (
<span className="text-[9px] text-amber-500 font-bold px-1.5 py-0.5 bg-amber-100 rounded">
</span>
)}
<button
onClick={(e) => { e.stopPropagation(); dismissQuestion(q.question_id); }}
className="p-1 rounded-md text-amber-400 hover:text-amber-600 hover:bg-amber-200 transition-colors cursor-pointer"
title="忽略此问题"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</button>
</div>
{/* 内容 */}
{/* 内容 */}
{isExpanded && (
<div className="px-4 py-3 space-y-3">
{/* 问题文本 */}
<div className="space-y-1.5">
<span className="text-[10px] font-bold text-amber-700 uppercase tracking-wider">
</span>
<p className="text-xs text-slate-800 font-semibold leading-relaxed bg-white rounded-lg p-3 border border-amber-200">
{q.question || ''}
</p>
</div>
{/* 选项列表 — 每个选项是 {label, description} 对象 */}
<div className="space-y-2.5">
{/* 选项列表 */}
{options.length > 0 && (
<div className="space-y-1.5">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-wider block">
{multiSelect ? '可多选' : '请选择一项'}
</span>
<div className="space-y-1.5">
{options.map((option, idx) => {
// 防御:确保 option 是有效对象
const label = typeof option?.label === 'string' ? option.label : String(option);
const desc = typeof option?.description === 'string' ? option.description : '';
const selected = (answers[q.question_id] || []).includes(label);
@@ -196,35 +180,36 @@ export function AskUserQuestionCard({ onAnswered }: AskUserQuestionCardProps) {
key={idx}
onClick={() => toggleOption(q.question_id, label, multiSelect)}
disabled={isSubmitting}
className={`w-full text-left px-3 py-2.5 rounded-lg border text-xs font-medium transition-all cursor-pointer flex items-start gap-2.5 ${
className={`w-full text-left px-2.5 py-1.5 rounded-lg border text-[11px] font-semibold transition-all cursor-pointer flex items-center gap-2 ${
selected
? 'bg-sky-50 border-sky-300 text-sky-800'
: 'bg-white border-slate-200 text-slate-700 hover:border-sky-200 hover:bg-sky-50/50'
? 'bg-sky-50/70 border-sky-300 text-sky-850 shadow-3xs'
: 'bg-white border-slate-200/60 text-slate-700 hover:border-sky-200 hover:bg-sky-50/50'
} disabled:opacity-50`}
>
{/* 选择指示器 */}
{multiSelect ? (
selected
? <CheckSquare className="w-3.5 h-3.5 text-sky-600 shrink-0 mt-0.5" />
: <Square className="w-3.5 h-3.5 text-slate-400 shrink-0 mt-0.5" />
? <CheckSquare className="w-3.5 h-3.5 text-sky-600 shrink-0" />
: <Square className="w-3.5 h-3.5 text-slate-450 shrink-0" />
) : (
<div className={`w-3.5 h-3.5 rounded-full border-2 shrink-0 mt-0.5 ${
selected ? 'border-sky-600 bg-sky-600' : 'border-slate-300'
<div className={`w-3.5 h-3.5 rounded-full border-2 shrink-0 ${
selected ? 'border-sky-600 bg-sky-600' : 'border-slate-350'
}`}>
{selected && (
<div className="w-full h-full flex items-center justify-center">
<div className="w-1.5 h-1.5 rounded-full bg-white" />
<div className="w-1 h-1 rounded-full bg-white" />
</div>
)}
</div>
)}
{/* label + description */}
<div className="min-w-0">
<div className="font-semibold text-slate-800">{label}</div>
{/* 将 label 与 desc 合并在同一行呈现以压缩高度 */}
<div className="min-w-0 flex-1 flex items-baseline gap-1.5 truncate">
<span className="font-extrabold text-slate-800 text-[11px] shrink-0 leading-normal">{label}</span>
{desc && (
<div className="text-[10px] text-slate-500 mt-0.5 leading-relaxed">
{desc}
</div>
<span className="text-[10px] text-slate-500 font-medium truncate leading-normal" title={desc}>
{desc}
</span>
)}
</div>
</button>
@@ -235,42 +220,39 @@ export function AskUserQuestionCard({ onAnswered }: AskUserQuestionCardProps) {
)}
{/* 自由文本 */}
<div className="space-y-1.5">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">
</span>
<div className="w-full">
<textarea
value={freeText[q.question_id] || ''}
onChange={(e) => setFreeText(prev => ({ ...prev, [q.question_id]: e.target.value }))}
disabled={isSubmitting}
placeholder="输入您的补充说明..."
rows={2}
className="w-full bg-white border border-slate-200 rounded-lg px-3 py-2 text-xs text-slate-800 placeholder-slate-400 focus:outline-none focus:border-sky-300 focus:ring-1 focus:ring-sky-500/20 resize-none disabled:opacity-50"
placeholder="补充说明(可选)..."
rows={1}
className="w-full bg-white border border-slate-200/60 rounded-lg px-2.5 py-1.5 text-[11px] text-slate-800 placeholder-slate-400 focus:outline-none focus:border-sky-300 focus:ring-1 focus:ring-sky-500/10 resize-none disabled:opacity-50 min-h-[32px] max-h-24 leading-normal"
/>
</div>
{/* 错误提示(按问题 ID 隔离) */}
{/* 错误提示 */}
{qError && (
<div className="text-[10px] text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2 font-medium">
<div className="text-[10px] text-red-700 bg-red-50 border border-red-200 rounded-lg px-2.5 py-1.5 font-bold">
{qError}
</div>
)}
{/* 提交按钮 */}
<div className="flex gap-2 pt-1">
<div className="flex gap-1.5">
<button
onClick={() => handleSubmit(q.question_id)}
disabled={isSubmitting}
className="flex-1 flex items-center justify-center gap-1.5 bg-amber-600 hover:bg-amber-700 text-white rounded-lg py-2.5 text-xs font-bold transition-colors cursor-pointer disabled:opacity-50"
className="flex-1 flex items-center justify-center gap-1.5 bg-emerald-600 hover:bg-emerald-700 border border-emerald-600 hover:border-emerald-750 text-white rounded-lg py-1.5 text-[11px] font-extrabold transition-all cursor-pointer disabled:opacity-50 hover:scale-102 active:scale-98"
>
{isSubmitting ? (
<>
<Loader className="w-3.5 h-3.5 animate-spin" />
<Loader className="w-3 h-3 animate-spin" />
<span>...</span>
</>
) : (
<>
<Send className="w-3.5 h-3.5" />
<Send className="w-3 h-3" />
<span></span>
</>
)}
@@ -278,7 +260,7 @@ export function AskUserQuestionCard({ onAnswered }: AskUserQuestionCardProps) {
<button
onClick={() => dismissQuestion(q.question_id)}
disabled={isSubmitting}
className="px-4 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg text-xs font-bold transition-colors cursor-pointer disabled:opacity-50"
className="px-3 bg-red-50 hover:bg-red-100 text-red-700 border border-red-200 hover:border-red-300 rounded-lg text-[11px] font-extrabold transition-all cursor-pointer disabled:opacity-50 hover:scale-102 active:scale-98"
>
</button>
@@ -291,3 +273,4 @@ export function AskUserQuestionCard({ onAnswered }: AskUserQuestionCardProps) {
</div>
);
}
@@ -6,13 +6,15 @@ import type { PendingPermissionRequest } from '../../types';
interface PermissionRequestCardProps {
sessionId: string;
onRequestCountChange?: (count: number) => void;
}
/** 显示待处理的工具执行权限请求卡片 */
export function PermissionRequestCard({ sessionId }: PermissionRequestCardProps) {
export function PermissionRequestCard({ sessionId, onRequestCountChange }: PermissionRequestCardProps) {
const [pending, setPending] = useState<PendingPermissionRequest[]>([]);
const [submitting, setSubmitting] = useState<Record<string, boolean>>({});
const [responses, setResponses] = useState<Record<string, 'allow' | 'deny' | null>>({});
const [expandedParams, setExpandedParams] = useState<Record<string, boolean>>({});
// 轮询待处理权限请求
useEffect(() => {
@@ -67,63 +69,93 @@ export function PermissionRequestCard({ sessionId }: PermissionRequestCardProps)
// 不显示已处理的请求
const activeRequests = pending.filter(p => !responses[p.tool_call_id]);
useEffect(() => {
onRequestCountChange?.(activeRequests.length);
}, [activeRequests.length, onRequestCountChange]);
const toggleParams = (id: string) => {
setExpandedParams(prev => ({ ...prev, [id]: !prev[id] }));
};
if (activeRequests.length === 0) return null;
return (
<div className="flex flex-col gap-3">
{activeRequests.map(req => (
<div
key={req.permission_id}
className="rounded-lg border border-amber-500/30 bg-amber-950/20 p-4"
>
<div className="mb-2 flex items-center gap-2">
<Shield className="h-5 w-5 text-amber-400" />
<span className="font-semibold text-amber-300"></span>
<code className="rounded bg-amber-900/40 px-1.5 py-0.5 text-xs text-amber-200">
{req.tool_name}
</code>
</div>
<div className="flex flex-col gap-2">
{activeRequests.map(req => {
const hasArgs = Object.keys(req.arguments || {}).length > 0;
const showArgs = expandedParams[req.permission_id] || false;
<p className="mb-3 text-sm text-amber-200/80">{req.message}</p>
{/* 显示工具参数的简化预览 */}
{Object.keys(req.arguments).length > 0 && (
<pre className="mb-3 max-h-24 overflow-auto rounded bg-black/30 p-2 text-xs text-amber-300/60">
{JSON.stringify(req.arguments, null, 2)}
</pre>
)}
<div className="flex gap-2">
<button
onClick={() => respond(req.tool_call_id, true, false)}
disabled={submitting[req.tool_call_id]}
className="flex items-center gap-1 rounded bg-emerald-600 px-3 py-1.5 text-sm text-white transition hover:bg-emerald-500 disabled:opacity-50"
>
{submitting[req.tool_call_id] ? (
<Loader className="h-4 w-4 animate-spin" />
) : (
<Check className="h-4 w-4" />
return (
<div
key={req.permission_id}
className="console-panel rounded-xl p-3 shadow-lg border-amber-200/80 bg-amber-50/45 transition-all pointer-events-auto text-xs"
>
{/* 标题与控制栏 */}
<div className="mb-2 flex items-center justify-between border-b border-amber-100/40 pb-1.5">
<div className="flex items-center gap-1.5 min-w-0">
<Shield className="h-3.5 w-3.5 text-amber-550 shrink-0" />
<span className="font-extrabold text-slate-800 tracking-wide text-[11px] shrink-0"></span>
<code className="rounded bg-amber-100/70 border border-amber-200/80 px-1.5 py-0.2 text-[9px] font-mono font-bold text-amber-800 truncate" title={req.tool_name}>
{req.tool_name}
</code>
</div>
{hasArgs && (
<button
type="button"
onClick={() => toggleParams(req.permission_id)}
className="text-[10px] font-bold text-sky-600 hover:text-sky-850 cursor-pointer select-none"
>
{showArgs ? '隐藏参数' : '参数详情'}
</button>
)}
Allow
</button>
<button
onClick={() => respond(req.tool_call_id, true, true)}
disabled={submitting[req.tool_call_id]}
className="flex items-center gap-1 rounded bg-emerald-700 px-3 py-1.5 text-sm text-emerald-200 transition hover:bg-emerald-600 disabled:opacity-50"
>
Always Allow
</button>
<button
onClick={() => respond(req.tool_call_id, false, false)}
disabled={submitting[req.tool_call_id]}
className="flex items-center gap-1 rounded bg-red-600 px-3 py-1.5 text-sm text-white transition hover:bg-red-500 disabled:opacity-50"
>
<X className="h-4 w-4" />
Deny
</button>
</div>
{/* 警告消息 */}
<p className="mb-2 text-[11px] font-semibold text-slate-650 leading-relaxed">{req.message}</p>
{/* 显示工具参数的简化预览 - 按需展开 */}
{hasArgs && showArgs && (
<pre className="mb-2.5 max-h-24 overflow-auto rounded-lg bg-slate-50 border border-slate-200/50 p-2 text-[9px] font-mono text-slate-600 scrollbar-thin">
{JSON.stringify(req.arguments, null, 2)}
</pre>
)}
{/* 操作按钮栏 - 更加紧凑 */}
<div className="flex flex-wrap gap-1.5">
<button
onClick={() => respond(req.tool_call_id, true, false)}
disabled={submitting[req.tool_call_id]}
className="flex items-center gap-1 px-3 py-1.5 rounded-md text-[10px] font-extrabold transition-all shadow-3xs cursor-pointer bg-emerald-600 hover:bg-emerald-700 text-white disabled:opacity-50 hover:scale-102 active:scale-98"
>
{submitting[req.tool_call_id] ? (
<Loader className="h-3 w-3 animate-spin" />
) : (
<Check className="h-3 w-3" />
)}
Allow
</button>
<button
onClick={() => respond(req.tool_call_id, true, true)}
disabled={submitting[req.tool_call_id]}
className="flex items-center gap-1 px-3 py-1.5 rounded-md text-[10px] font-extrabold transition-all shadow-3xs cursor-pointer bg-sky-600 hover:bg-sky-700 text-white disabled:opacity-50 hover:scale-102 active:scale-98"
>
Always Allow
</button>
<button
onClick={() => respond(req.tool_call_id, false, false)}
disabled={submitting[req.tool_call_id]}
className="flex items-center gap-1 px-3 py-1.5 rounded-md text-[10px] font-extrabold transition-all cursor-pointer bg-red-50 hover:bg-red-100 text-red-700 border border-red-200 hover:border-red-300 disabled:opacity-50 hover:scale-102 active:scale-98"
>
<X className="h-3 w-3" />
Deny
</button>
</div>
</div>
</div>
))}
);
})}
</div>
);
}
File diff suppressed because it is too large Load Diff
+344 -443
View File
@@ -1,17 +1,18 @@
// dashboard/src/features/reader/AIAssistantPanel.tsx
// 文献 AI 问答助手 — 使用共享 AgentMarkdown / useAgentSSE / 子代理路由 / 渲染组件
import { useState, useRef, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkMath from 'remark-math';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeKatex from 'rehype-katex';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
import 'katex/dist/katex.min.css';
import {
Send, Loader, Sparkles, X, BookOpen, AlertCircle, Compass,
Brain, Settings, ChevronDown, ChevronUp, AlertTriangle, Square
import {
Send, Loader, X, BookOpen, AlertCircle, Compass,
Brain, Square, Paperclip,
} from 'lucide-react';
import axios from 'axios';
import {
AgentMarkdown, useAgentSSE, useAutoScroll,
ThoughtCard, ToolCallCard, SubAgentContainer,
PARENT_COLORS,
routeThought, routeToolCall, routeToolResult, routeTextDelta,
} from '../../components/agent';
import type { SSEEventHandlers, TimelineItem } from '../../components/agent';
interface RetrievalSource {
bibcode: string;
@@ -20,402 +21,278 @@ interface RetrievalSource {
distance: number;
}
interface AgentStep {
step: number;
type: 'thought' | 'tool_call' | 'tool_result' | 'error';
label: string;
detail?: string;
isError?: boolean;
isFinished?: boolean;
}
interface Message {
sender: 'user' | 'ai';
text: string;
sources?: RetrievalSource[];
imageUrl?: string;
steps?: AgentStep[];
timeline: TimelineItem[];
}
interface AIAssistantPanelProps {
bibcode: string;
onClose: () => void;
onJumpToSource: (bibcode: string, paragraphIndex: number) => void;
pendingFigure?: { path: string; url: string } | null;
onClearPendingFigure?: () => void;
}
const safeSchema = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
'*': (defaultSchema.attributes?.['*'] || []).concat(['className', 'style', 'mathvariant', 'display']),
},
tagNames: (defaultSchema.tagNames || []).concat([
'math', 'mrow', 'mi', 'mo', 'mn', 'msup', 'msub', 'msubsup', 'mfrac', 'mover', 'munder', 'munderover', 'mspace', 'mtext', 'annotation'
]),
};
const SUGGESTED_QUESTIONS = [
"对比我馆藏的文献中,针对热亚矮星双星在共同包层抛射过程中恒星风流失速率的各种主流观点差异。",
"有哪些文献提及了脉动白矮星的非径向振动模?",
"简述目前文献中关于 Gaia DR3 视差零点改正的处理方法。",
"文献库中关于双星合并前奏(Precursor)观测特征 of 论述有哪些?"
'对比我馆藏的文献中,针对热亚矮星双星在共同包层抛射过程中恒星风流失速率的各种主流观点差异。',
'有哪些文献提及了脉动白矮星的非径向振动模?',
'简述目前文献中关于 Gaia DR3 视差零点改正的处理方法。',
'文献库中关于双星合并前奏(Precursor)观测特征 of 论述有哪些?',
];
function getToolDisplayName(name: string): string {
switch (name) {
case 'search_papers': return '检索 ADS/arXiv';
case 'get_paper_metadata': return '获取文献详细元数据';
case 'download_paper': return '下载文献全文资源';
case 'parse_paper': return '结构化解析文献内容';
case 'get_paper_content': return '获取文献全文内容';
case 'rag_search': return '语义库检索 (RAG)';
case 'query_target': return '查询天体物理参数';
default: return name;
}
}
export function AIAssistantPanel({
bibcode,
onClose,
onJumpToSource,
pendingFigure,
onClearPendingFigure
}: AIAssistantPanelProps) {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [sessionId, setSessionId] = useState<string | null>(null);
// 步骤展开状态
const [stepsExpanded, setStepsExpanded] = useState<Record<number, boolean>>({});
const chatEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [shouldAutoScroll, setShouldAutoScroll] = useState(true);
// 折叠状态(共享 ThoughtCard / ToolCallCard / SubAgentContainer 的展开控制)
const [expandedThoughts, setExpandedThoughts] = useState<Record<string, boolean>>({});
const [expandedArgs, setExpandedArgs] = useState<Record<string, boolean>>({});
const [expandedResults, setExpandedResults] = useState<Record<string, boolean>>({});
const [collapsedSubAgents, setCollapsedSubAgents] = useState<Record<string, boolean>>({});
const scrollToBottom = () => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
const [pendingImage, setPendingImage] = useState<{
data?: string; path?: string; mime_type: string; name: string;
} | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const { streaming, send, stop: sseStop } = useAgentSSE();
const { chatEndRef, scrollContainerRef, setShouldAutoScroll, handleScroll } =
useAutoScroll([messages, streaming]);
// ── 折叠控制 ──
const toggleThought = (key: string) =>
setExpandedThoughts(prev => ({ ...prev, [key]: !prev[key] }));
const toggleArgs = (tcId: string) =>
setExpandedArgs(prev => ({ ...prev, [tcId]: !prev[tcId] }));
const toggleResult = (tcId: string) =>
setExpandedResults(prev => ({ ...prev, [tcId]: !prev[tcId] }));
const toggleSubAgent = (id: string) =>
setCollapsedSubAgents(prev => ({ ...prev, [id]: !prev[id] }));
// ── 图片处理 ──
const attachImage = (file: File) => {
if (!file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = (e) => {
const result = e.target?.result as string;
const commaIdx = result.indexOf(',');
if (commaIdx === -1) return;
setPendingImage({
data: result.substring(commaIdx + 1),
mime_type: file.type,
name: file.name,
});
};
reader.readAsDataURL(file);
};
useEffect(() => {
if (shouldAutoScroll) {
scrollToBottom();
const handlePaste = (e: React.ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
e.preventDefault();
const file = items[i].getAsFile();
if (file) attachImage(file);
return;
}
}
}, [messages, loading, shouldAutoScroll]);
const handleScroll = () => {
if (!scrollContainerRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current;
// 距离底部 50 像素以内视为贴紧底部
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
setShouldAutoScroll(isAtBottom);
};
// 重置会话(当 bibcode 切换时)
// ── bibcode 切换重置 ──
useEffect(() => {
setMessages([]);
setSessionId(null);
setError(null);
setStepsExpanded({});
setExpandedThoughts({});
setExpandedArgs({});
setExpandedResults({});
setCollapsedSubAgents({});
setPendingImage(null);
}, [bibcode]);
// 手动停止智能体执行
const handleStop = async () => {
if (!sessionId) return;
try {
await axios.post(`/api/chat/sessions/${sessionId}/stop`);
} catch (e) {
console.error('停止智能体执行失败:', e);
sseStop();
if (sessionId) {
try { await axios.post(`/api/chat/sessions/${sessionId}/stop`); } catch { /* noop */ }
}
};
const handleSend = async (questionText: string) => {
if (!questionText.trim() || loading) return;
if ((!questionText.trim() && !pendingImage) || streaming) return;
const figure = pendingFigure;
const figure = pendingImage;
setError(null);
setPendingImage(null);
const userMsg: Message = {
sender: 'user',
text: questionText,
imageUrl: figure?.url || undefined
timeline: [],
imageUrl: figure
? (figure.path
? `/api/files/${figure.path}`
: `data:${figure.mime_type};base64,${figure.data}`)
: undefined,
};
setMessages(prev => [...prev, userMsg]);
setInput('');
setLoading(true);
setShouldAutoScroll(true);
if (onClearPendingFigure) {
onClearPendingFigure();
}
// AI 消息占位符(用 TimelineItem[] 替代旧 AgentStep[]
const aiMsg: Message = { sender: 'ai', text: '', timeline: [], sources: [] };
setMessages(prev => [...prev, aiMsg]);
// AI 初始消息占位符
const aiMsgPlaceholder: Message = {
sender: 'ai',
text: '',
steps: [],
sources: []
// ── SSE 事件处理器(使用共享子代理路由)──
const handlers: SSEEventHandlers = {
onSession: (sid) => {
if (!sessionId) setSessionId(sid);
},
onThought: (step, content) => {
setMessages(prev => updateAiTimeline(prev, (timeline) =>
routeThought(timeline, step, content),
));
},
onToolCall: (step, _id, name, args) => {
setMessages(prev => updateAiTimeline(prev, (timeline) =>
routeToolCall(timeline, step, _id, name, args),
));
},
onToolResult: (_step, tcId, name, _output, isError, metadata) => {
setExpandedResults(prev => ({ ...prev, [tcId]: true }));
setMessages(prev => {
if (prev.length === 0) return prev;
const last = prev[prev.length - 1];
if (last.sender !== 'ai') return prev;
const newTimeline = routeToolResult(last.timeline, tcId, name, _output, isError);
// RAG 来源收集
let sources = [...(last.sources || [])];
if (name === 'rag_search' && metadata?.sources) {
for (const s of metadata.sources) {
if (!sources.some(
c => c.bibcode === s.bibcode && c.paragraph_index === s.paragraph_index,
)) {
sources.push({
bibcode: s.bibcode, paragraph_index: s.paragraph_index,
content: s.preview || '', distance: s.distance,
});
}
}
}
return [...prev.slice(0, -1), { ...last, timeline: newTimeline, sources }];
});
},
onTextDelta: (content, toolCallId) => {
if (toolCallId) {
setExpandedResults(prev => ({ ...prev, [toolCallId]: true }));
}
setMessages(prev => {
const last = prev[prev.length - 1];
if (last?.sender !== 'ai') return prev;
const { timeline, answerDelta } = routeTextDelta(last.timeline, content, toolCallId);
return [
...prev.slice(0, -1),
{ ...last, timeline, text: last.text + answerDelta },
];
});
},
onError: (msg) => {
setError(msg);
},
};
setMessages(prev => [...prev, aiMsgPlaceholder]);
try {
// 如果有待处理的图片,先获取并转为 base64
let imagePayload: { data: string; mime_type: string } | undefined = undefined;
if (figure) {
try {
const imgResponse = await fetch(figure.url);
if (!imgResponse.ok) {
throw new Error(`Failed to fetch image: ${imgResponse.statusText}`);
}
const blob = await imgResponse.blob();
const base64Res = await new Promise<{ data: string; mimeType: string }>((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onloadend = () => {
const result = reader.result as string;
const commaIdx = result.indexOf(',');
if (commaIdx === -1) {
reject(new Error('Invalid data URL'));
return;
}
const data = result.substring(commaIdx + 1);
const match = result.match(/^data:([^;]+);base64,/);
const mimeType = match ? match[1] : blob.type || 'image/png';
resolve({ data, mimeType });
};
reader.onerror = reject;
});
imagePayload = {
data: base64Res.data,
mime_type: base64Res.mimeType
};
} catch (imgErr) {
console.error('Failed to process image attachment:', imgErr);
setError('获取或处理选中的图表失败,请重试');
setMessages(prev => {
const next = [...prev];
// 移除用户和 AI 占位消息
if (next.length >= 2) {
return next.slice(0, -2);
}
return next;
});
setLoading(false);
return;
}
}
const contextPrefix = `[当前正在阅读文献: ${bibcode}] `;
const requestQuery = questionText.includes(bibcode)
? questionText : `${contextPrefix}${questionText}`;
// 调用智能体流式对话接口(带文献上下文提示,确保 Agent 优先分析当前阅读的文献)
const contextPrefix = `[当前正在阅读文献: ${bibcode}] `;
const requestQuery = questionText.includes(bibcode) ? questionText : `${contextPrefix}${questionText}`;
try {
const response = await fetch('/api/chat/agent', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
question: requestQuery,
session_id: sessionId,
mode: 'literature-reader',
image: imagePayload,
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('ReadableStream not supported');
}
const decoder = new TextDecoder('utf-8');
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.startsWith('data:')) {
const dataStr = trimmed.slice(5).trim();
if (!dataStr) continue;
try {
const event = JSON.parse(dataStr);
switch (event.type) {
case 'session':
if (!sessionId) {
setSessionId(event.session_id);
}
break;
case 'thought':
setMessages(prev => {
const next = [...prev];
const last = next[next.length - 1];
if (last && last.sender === 'ai') {
if (!last.steps) last.steps = [];
let stepObj = last.steps.find(s => s.step === event.step && s.type === 'thought');
if (!stepObj) {
stepObj = { step: event.step, type: 'thought', label: '思考推理中' };
last.steps.push(stepObj);
}
stepObj.detail = event.content;
}
return next;
});
break;
case 'tool_call':
setMessages(prev => {
const next = [...prev];
const last = next[next.length - 1];
if (last && last.sender === 'ai') {
if (!last.steps) last.steps = [];
let stepObj = last.steps.find(s => s.step === event.step && s.type === 'tool_call');
if (!stepObj) {
stepObj = { step: event.step, type: 'tool_call', label: `调用: ${getToolDisplayName(event.name)}`, isFinished: false };
last.steps.push(stepObj);
}
stepObj.detail = typeof event.arguments === 'string' ? event.arguments : JSON.stringify(event.arguments);
}
return next;
});
break;
case 'tool_result':
setMessages(prev => {
const next = [...prev];
const last = next[next.length - 1];
if (last && last.sender === 'ai') {
if (!last.steps) last.steps = [];
const stepObj = last.steps.find(s => s.step === event.step && s.type === 'tool_call');
if (stepObj) {
stepObj.label = `调用: ${getToolDisplayName(event.name)} (已获取观测数据)`;
stepObj.isError = event.is_error;
stepObj.isFinished = true;
}
// 提取 RAG 检索到的参考来源文献,并在 sources 中进行去重和追加
if (event.name === 'rag_search' && event.metadata && event.metadata.sources) {
const newSources: RetrievalSource[] = event.metadata.sources.map((s: any) => ({
bibcode: s.bibcode,
paragraph_index: s.paragraph_index,
content: s.preview || '',
distance: s.distance,
}));
const existing = last.sources || [];
const combined = [...existing];
for (const src of newSources) {
if (!combined.some(c => c.bibcode === src.bibcode && c.paragraph_index === src.paragraph_index)) {
combined.push(src);
}
}
last.sources = combined;
}
}
return next;
});
break;
case 'text_delta':
setMessages(prev => {
const next = [...prev];
const last = next[next.length - 1];
if (last && last.sender === 'ai') {
last.text += event.content;
}
return next;
});
break;
case 'error':
setMessages(prev => {
const next = [...prev];
const last = next[next.length - 1];
if (last && last.sender === 'ai') {
if (!last.steps) last.steps = [];
last.steps.push({
step: 99,
type: 'error',
label: `错误返回`,
detail: event.message
});
}
return next;
});
break;
}
} catch (e) {
console.error('解析流式 SSE 失败:', e);
}
}
}
}
setLoading(false);
} catch (streamErr) {
console.error('智能体流式对话失败:', streamErr);
setError('智能体流式对话失败,请稍后重试');
setMessages(prev => {
const next = [...prev];
if (next.length > 0 && next[next.length - 1].sender === 'ai' && !next[next.length - 1].text) {
next.pop();
}
return next;
});
setLoading(false);
}
} catch (err: any) {
console.error('问答请求失败:', err);
setError(err.response?.data?.error || err.message || '网络请求错误,请稍后重试');
// 如果报错,清空多余的消息占位符
setMessages(prev => {
const next = [...prev];
if (next.length > 0 && next[next.length - 1].sender === 'ai' && !next[next.length - 1].text) {
next.pop();
}
return next;
});
setLoading(false);
let imagePayload: { data: string; mime_type: string } | undefined;
if (figure?.data) {
imagePayload = { data: figure.data, mime_type: figure.mime_type };
}
await send(
{
question: requestQuery,
sessionId,
mode: 'literature-reader',
image: imagePayload
? { data: imagePayload.data, mime_type: imagePayload.mime_type }
: undefined,
},
handlers,
);
};
const toggleSteps = (msgIdx: number) => {
setStepsExpanded(prev => ({
...prev,
[msgIdx]: !prev[msgIdx]
}));
// ── 时间线条目渲染(委托到共享组件)──
const renderTimelineItem = (item: TimelineItem, idx: number, isStreaming: boolean) => {
switch (item.type) {
case 'subagent_container':
return (
<SubAgentContainer
key={`subagent-${item.id}-${idx}`}
status={item.status}
children={item.children}
summary={item.summary}
isStreaming={isStreaming}
isCollapsed={collapsedSubAgents[item.id] !== false}
onToggle={() => toggleSubAgent(item.id)}
expandedThoughts={expandedThoughts}
expandedArgs={expandedArgs}
expandedResults={expandedResults}
onToggleThought={toggleThought}
onToggleArgs={toggleArgs}
onToggleResult={toggleResult}
/>
);
case 'thought': {
const thoughtKey = `ai-thought-${item.step}-${idx}`;
return (
<ThoughtCard
key={thoughtKey}
step={item.step}
content={item.content}
isStreaming={isStreaming}
colorScheme={PARENT_COLORS}
isExpanded={expandedThoughts[thoughtKey] === true}
onToggle={() => toggleThought(thoughtKey)}
/>
);
}
case 'tool_call': {
const tcId = item.id;
return (
<ToolCallCard
key={`tc-${tcId}-${idx}`}
step={item.step}
name={item.name}
arguments={item.arguments}
result={item.result}
isStreaming={isStreaming}
colorScheme={PARENT_COLORS}
isArgsExpanded={expandedArgs[tcId] === true}
isResultExpanded={expandedResults[tcId] === true}
onToggleArgs={() => toggleArgs(tcId)}
onToggleResult={() => toggleResult(tcId)}
/>
);
}
case 'answer':
return null; // finalAnswer 单独渲染在气泡内
default:
return null;
}
};
return (
<div className="console-panel rounded-xl border border-slate-200 bg-slate-50 flex flex-col overflow-hidden relative shadow-sm h-full">
{/* 头部面板 */}
<div className="px-4 py-3.5 border-b border-slate-200 flex items-center justify-between bg-white shrink-0">
<div className="flex items-center gap-1.5 select-none">
<Sparkles className="w-4 h-4 text-sky-600 animate-pulse" />
<span className="text-xs font-bold text-slate-800"></span>
</div>
<button onClick={onClose} className="text-slate-400 hover:text-slate-600 transition-colors cursor-pointer">
<X className="w-4 h-4" />
</button>
</div>
{/* 对话消息区 */}
<div
<div className="flex flex-col overflow-hidden h-full">
{/* 消息区 */}
<div
ref={scrollContainerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto p-4 pb-28 space-y-4 min-h-0"
@@ -429,10 +306,10 @@ export function AIAssistantPanel({
AI
</p>
</div>
{/* 推荐提示词 */}
<div className="space-y-2">
<span className="text-[10px] font-bold text-slate-400 tracking-wider uppercase">:</span>
<span className="text-[10px] font-bold text-slate-400 tracking-wider uppercase">
:
</span>
<div className="space-y-2">
{SUGGESTED_QUESTIONS.map((q, idx) => (
<button
@@ -448,9 +325,12 @@ export function AIAssistantPanel({
</div>
) : (
messages.map((msg, index) => (
<div key={index} className={`flex flex-col ${msg.sender === 'user' ? 'items-end' : 'items-start'} space-y-1.5`}>
<div
key={index}
className={`flex flex-col ${msg.sender === 'user' ? 'items-end' : 'items-start'} space-y-1.5`}
>
<span className="text-[10px] font-bold text-slate-400 px-1">
{msg.sender === 'user' ? '我' : '智能科研助手'}
{msg.sender === 'user' ? '我' : '天文科研助手'}
</span>
<div
className={`max-w-[95%] rounded-xl px-4 py-3 text-xs leading-relaxed font-medium shadow-sm border ${
@@ -462,73 +342,51 @@ export function AIAssistantPanel({
{msg.sender === 'user' ? (
<div className="space-y-2">
{msg.imageUrl && (
<img src={msg.imageUrl} alt="Attached Figure" className="max-w-40 max-h-40 object-contain rounded-md border border-sky-500/30" />
<img
src={msg.imageUrl}
alt="Attached Figure"
className="max-w-40 max-h-40 object-contain rounded-md border border-sky-500/30"
/>
)}
<p className="whitespace-pre-wrap">{msg.text}</p>
</div>
) : (
<div className="space-y-2.5">
{/* Collapsible Steps list */}
{msg.steps && msg.steps.length > 0 && (
{/* 时间线条目(思考/工具调用/子代理容器)*/}
{msg.timeline.length > 0 && (
<div className="border border-slate-100 bg-slate-50/50 rounded-lg p-2 select-none">
<button
onClick={() => toggleSteps(index)}
className="flex items-center justify-between w-full text-left text-[10px] font-bold text-slate-500 hover:text-slate-700 transition-colors cursor-pointer"
>
<span className="flex items-center gap-1">
<Brain className="w-3.5 h-3.5 text-purple-500" />
<span> ({msg.steps.length} )</span>
</span>
{stepsExpanded[index] ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
</button>
{stepsExpanded[index] && (
<div className="mt-2 pl-2.5 border-l border-slate-200 space-y-1.5 transition-all">
{msg.steps.map((step, sIdx) => (
<div key={sIdx} className="text-[9px] text-slate-655 flex flex-col gap-0.5">
<span className="font-bold flex items-center gap-1">
{step.type === 'thought' ? (
<Brain className="w-3 h-3 text-purple-500 shrink-0" />
) : step.type === 'error' ? (
<AlertTriangle className="w-3 h-3 text-rose-500 shrink-0" />
) : (
<Settings className={`w-3 h-3 text-sky-600 shrink-0 ${step.isFinished ? '' : 'animate-spin'}`} />
)}
<span>{step.label}</span>
</span>
{step.detail && (
<span className="font-mono text-[9px] opacity-75 whitespace-pre-wrap block bg-white px-1.5 py-0.5 rounded border border-slate-100 max-h-24 overflow-y-auto">
{step.detail}
</span>
)}
</div>
))}
</div>
)}
<div className="text-[10px] font-bold text-purple-500 flex items-center gap-1 mb-2 px-1">
<Brain className="w-3.5 h-3.5" />
<span> ({msg.timeline.length} )</span>
</div>
<div className="pl-4 border-l border-slate-200 space-y-2">
{msg.timeline.map((item, tIdx) =>
renderTimelineItem(item, tIdx, !!streaming),
)}
</div>
</div>
)}
{/* Final output text */}
{/* 最终回答 */}
{msg.text ? (
<div className="prose prose-sm max-w-none text-slate-800 leading-relaxed prose-headings:text-slate-900 prose-headings:font-bold prose-strong:text-slate-900 prose-code:text-sky-700 prose-img:rounded-lg">
<ReactMarkdown
remarkPlugins={[remarkMath, remarkGfm]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, safeSchema], rehypeKatex]}
>
{msg.text}
</ReactMarkdown>
<AgentMarkdown>{msg.text}</AgentMarkdown>
</div>
) : (
!loading && <span className="text-slate-400 italic">...</span>
!streaming && (
<span className="text-slate-400 italic">...</span>
)
)}
</div>
)}
</div>
{/* 关联来源展示 */}
{/* 关联来源 */}
{msg.sender === 'ai' && msg.sources && msg.sources.length > 0 && (
<div className="w-full mt-2 pl-2 space-y-1.5 border-l-2 border-slate-200">
<span className="text-[9px] font-bold text-slate-400 select-none">:</span>
<span className="text-[9px] font-bold text-slate-400 select-none">
:
</span>
<div className="grid grid-cols-1 gap-1.5 w-[95%]">
{msg.sources.map((src, sIdx) => (
<button
@@ -540,7 +398,9 @@ export function AIAssistantPanel({
<div className="flex items-center gap-1.5 truncate mr-2">
<BookOpen className="w-3 h-3 text-sky-600 shrink-0" />
<span className="font-bold text-slate-700 truncate">{src.bibcode}</span>
<span className="text-slate-400 text-[9px] font-bold">§{src.paragraph_index + 1}</span>
<span className="text-slate-400 text-[9px] font-bold">
§{src.paragraph_index + 1}
</span>
</div>
<span className="text-slate-400 text-[9px] shrink-0 font-medium">
: {src.distance.toFixed(3)}
@@ -554,7 +414,7 @@ export function AIAssistantPanel({
))
)}
{loading && messages.length > 0 && !messages[messages.length - 1].text && (
{streaming && messages.length > 0 && !messages[messages.length - 1].text && (
<div className="flex items-center space-x-2 text-slate-500 pl-2">
<Loader className="w-4 h-4 animate-spin text-sky-600" />
<span className="text-[10px] font-bold">...</span>
@@ -574,31 +434,7 @@ export function AIAssistantPanel({
<div ref={chatEndRef} />
</div>
{/* 待提问图片预览 */}
{pendingFigure && (
<div className="px-3 py-2 bg-slate-100 border-t border-slate-200 flex items-center justify-between shrink-0 select-none">
<div className="flex items-center gap-2">
<img
src={pendingFigure.url}
alt="Preview"
className="w-10 h-10 object-cover rounded-md border border-slate-300"
/>
<div className="text-[10px] text-slate-550">
<span className="font-bold text-slate-700 block"></span>
<span className="font-mono text-slate-400 truncate max-w-48 block">{pendingFigure.path.split('/').pop()}</span>
</div>
</div>
<button
type="button"
onClick={onClearPendingFigure}
className="text-slate-400 hover:text-slate-655 p-1 cursor-pointer"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
)}
{/* 底栏输入区 */}
{/* 底栏输入 */}
<form
onSubmit={(e) => {
e.preventDefault();
@@ -606,7 +442,7 @@ export function AIAssistantPanel({
}}
className="absolute bottom-0 left-0 right-0 p-3 bg-transparent pointer-events-none shrink-0"
>
<div className="bg-slate-50 border border-slate-200/60 rounded-2xl p-2.5 focus-within:bg-white focus-within:border-sky-500 focus-within:ring-1 focus-within:ring-sky-500/10 transition-all relative pointer-events-auto shadow-md">
<div className="bg-slate-50 border border-slate-200/60 rounded-2xl p-2.5 focus-within:bg-white focus-within:border-sky-500 focus-within:ring-1 focus-within:ring-sky-500/10 transition-all relative pointer-events-auto shadow-md flex flex-col gap-2">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
@@ -616,33 +452,98 @@ export function AIAssistantPanel({
handleSend(input);
}
}}
disabled={loading}
placeholder={pendingFigure ? "针对选中图表提问..." : "向 AI 馆藏助手提问..."}
onPaste={handlePaste}
disabled={streaming}
placeholder={pendingImage ? '针对选中图表提问...' : '向 AI 馆藏助手提问...'}
rows={2}
className="w-full bg-transparent resize-none border-none outline-none focus:outline-none focus:ring-0 text-xs text-slate-900 placeholder-slate-400 pr-8 leading-relaxed font-semibold min-h-[36px] max-h-32"
className="w-full bg-transparent resize-none border-none outline-none focus:outline-none focus:ring-0 text-xs text-slate-900 placeholder-slate-400 leading-relaxed font-semibold min-h-[36px] max-h-32 pb-1"
/>
<div className="absolute right-2 bottom-2">
{loading ? (
<div className="flex justify-between items-center mt-1.5 pt-1.5 border-t border-slate-200/40">
<div className="flex gap-2 items-center min-w-0">
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) attachImage(file);
e.target.value = '';
}}
/>
<button
type="button"
onClick={handleStop}
className="p-1.5 rounded-lg bg-rose-600 hover:bg-rose-700 text-white transition-colors cursor-pointer flex items-center justify-center shadow-xs"
title="手动停止执行"
onClick={() => fileInputRef.current?.click()}
disabled={streaming}
title="上传或粘贴图片(也可直接 Ctrl+V 粘贴)"
className={`p-1 rounded-md border text-[10px] font-bold transition-all cursor-pointer flex items-center justify-center shrink-0 ${
pendingImage
? 'bg-sky-50 border-sky-300 text-sky-700'
: 'bg-white border-slate-200 text-slate-400 hover:text-sky-600 hover:border-sky-200'
} disabled:opacity-60`}
>
<Square className="w-3.5 h-3.5 fill-white" />
<Paperclip className="w-3.5 h-3.5" />
</button>
) : (
<button
type="submit"
disabled={!input.trim()}
className="p-1.5 rounded-lg bg-sky-600 hover:bg-sky-700 text-white disabled:bg-slate-100 disabled:text-slate-350 transition-colors cursor-pointer flex items-center justify-center shadow-xs"
>
<Send className="w-3.5 h-3.5" />
</button>
)}
{pendingImage && (
<div className="relative inline-block ml-1 shrink-0 group select-none">
<img
src={
pendingImage.path
? `/api/files/${pendingImage.path}`
: `data:${pendingImage.mime_type};base64,${pendingImage.data}`
}
alt="Preview"
className="w-5 h-5 object-cover rounded border border-slate-300 shadow-2xs"
/>
<button
type="button"
onClick={() => setPendingImage(null)}
className="absolute -top-1 -right-1 bg-slate-500/80 hover:bg-slate-700 text-white rounded-full w-3 h-3 shadow-xs transition-colors cursor-pointer flex items-center justify-center"
title="移除图片"
>
<X className="w-2 h-2" />
</button>
</div>
)}
</div>
<div>
{streaming ? (
<button
type="button"
onClick={handleStop}
className="p-1 rounded-lg bg-slate-800 hover:bg-slate-950 text-white transition-colors cursor-pointer flex items-center justify-center shadow-xs"
title="手动停止执行"
>
<Square className="w-3.5 h-3.5" />
</button>
) : (
<button
type="submit"
disabled={!input.trim() && !pendingImage}
className="p-1 px-2.5 rounded-lg bg-sky-600 hover:bg-sky-700 text-white disabled:bg-slate-100 disabled:text-slate-350 transition-colors cursor-pointer flex items-center justify-center gap-1 shadow-xs text-[10px] font-bold"
>
<Send className="w-3.5 h-3.5" />
<span></span>
</button>
)}
</div>
</div>
</div>
</form>
</div>
);
}
// ── helpers ──
// 更新最后一条 AI 消息的 timeline
function updateAiTimeline(
prev: Message[],
fn: (timeline: TimelineItem[]) => TimelineItem[],
): Message[] {
if (prev.length === 0) return prev;
const last = prev[prev.length - 1];
if (last.sender !== 'ai') return prev;
return [...prev.slice(0, -1), { ...last, timeline: fn(last.timeline) }];
}
@@ -916,7 +916,6 @@ export function ReaderPanel({
<div className="flex-1 overflow-hidden h-full">
<AIAssistantPanel
bibcode={selectedPaper.bibcode}
onClose={() => setShowNotesPanel(false)}
onJumpToSource={onJumpToSource}
/>
</div>
+14
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, () => '$');
};