Compare commits
2 Commits
0b190b717c
...
50d0b92336
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50d0b92336 | ||
|
|
3c889caacf |
@ -54,6 +54,12 @@ impl CommandHandler for ListTodosCommandHandler {
|
|||||||
.list_todos(scope_key)
|
.list_todos(scope_key)
|
||||||
.map_err(|e| CommandError::new("LIST_TODOS_ERROR", e.to_string()))?;
|
.map_err(|e| CommandError::new("LIST_TODOS_ERROR", e.to_string()))?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
scope_key = %scope_key,
|
||||||
|
record_count = records.len(),
|
||||||
|
"list_todos handler: reading from store"
|
||||||
|
);
|
||||||
|
|
||||||
let summaries: Vec<TodoItemSummary> = records
|
let summaries: Vec<TodoItemSummary> = records
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|r| TodoItemSummary {
|
.map(|r| TodoItemSummary {
|
||||||
@ -69,6 +75,8 @@ impl CommandHandler for ListTodosCommandHandler {
|
|||||||
let todos_json = serde_json::to_string(&summaries)
|
let todos_json = serde_json::to_string(&summaries)
|
||||||
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
|
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
|
||||||
|
|
||||||
Ok(CommandResponse::success(ctx.request_id).with_metadata("todos", &todos_json))
|
Ok(CommandResponse::success(ctx.request_id)
|
||||||
|
.with_metadata("todos", &todos_json)
|
||||||
|
.with_metadata("todos_scope_key", scope_key))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -190,13 +190,11 @@ impl AgentExecutionService {
|
|||||||
// 只有当是最新回合时才触发历史压缩
|
// 只有当是最新回合时才触发历史压缩
|
||||||
let should_schedule_compaction = is_current_turn;
|
let should_schedule_compaction = is_current_turn;
|
||||||
|
|
||||||
// 拦截 todo_write 结果:持久化 + 前端推送
|
// 拦截 todo_write 结果:持久化 + 前端推送(不受 is_current_turn 限制)
|
||||||
if is_current_turn {
|
|
||||||
session.intercept_todo_write_results(
|
session.intercept_todo_write_results(
|
||||||
&request.result.emitted_messages,
|
&request.result.emitted_messages,
|
||||||
request.chat_id,
|
request.chat_id,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
Ok(FinalizedAgentResult {
|
Ok(FinalizedAgentResult {
|
||||||
outbound_messages,
|
outbound_messages,
|
||||||
|
|||||||
@ -437,6 +437,11 @@ impl Session {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// 持久化到 SQLite
|
// 持久化到 SQLite
|
||||||
|
tracing::info!(
|
||||||
|
scope_key = %scope_key,
|
||||||
|
todo_count = records.len(),
|
||||||
|
"intercept_todo_write_results: persisting todos"
|
||||||
|
);
|
||||||
if let Err(e) = self.store.replace_todos(&scope_key, &records) {
|
if let Err(e) = self.store.replace_todos(&scope_key, &records) {
|
||||||
tracing::warn!(error = %e, scope_key = %scope_key, "Failed to persist todo list");
|
tracing::warn!(error = %e, scope_key = %scope_key, "Failed to persist todo list");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -525,7 +525,9 @@ async fn handle_inbound(
|
|||||||
// 处理 Todo 列表
|
// 处理 Todo 列表
|
||||||
if let Some(todos_json) = response.metadata.get("todos") {
|
if let Some(todos_json) = response.metadata.get("todos") {
|
||||||
if let Ok(todos) = serde_json::from_str::<Vec<crate::protocol::TodoItemSummary>>(todos_json) {
|
if let Ok(todos) = serde_json::from_str::<Vec<crate::protocol::TodoItemSummary>>(todos_json) {
|
||||||
let _ = sender.send(WsOutbound::TodoList { todos, scope_key: String::new() }).await;
|
let scope_key = response.metadata.get("todos_scope_key").cloned().unwrap_or_default();
|
||||||
|
tracing::info!(todo_count = todos.len(), %scope_key, "list_todos command response");
|
||||||
|
let _ = sender.send(WsOutbound::TodoList { todos, scope_key }).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { User, Bot, Wrench, CheckCircle, AlertCircle, Terminal, File, Image, FileText, Music, Video, Download, ChevronDown, ChevronRight, Copy, Check, Loader2, XCircle, Clock, Loader, X, Brain } from 'lucide-react'
|
import { User, Bot, Wrench, CheckCircle, AlertCircle, Terminal, File, Image, FileText, Music, Video, Download, ChevronDown, ChevronRight, Copy, Check, Loader2, XCircle, Clock, Loader, X, Brain, Maximize2 } from 'lucide-react'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import type { ChatMessage, Attachment, TaskToolResult } from '../../types/protocol'
|
import type { ChatMessage, Attachment, TaskToolResult } from '../../types/protocol'
|
||||||
|
import { ToolDetailModal } from './ToolDetailModal'
|
||||||
|
|
||||||
// 状态图标组件
|
// 状态图标组件
|
||||||
function StatusIcon({ status, size = 14 }: { status: 'calling' | 'result' | 'pending' | 'success' | 'failed' | 'timeout', size?: number }) {
|
function StatusIcon({ status, size = 14 }: { status: 'calling' | 'result' | 'pending' | 'success' | 'failed' | 'timeout', size?: number }) {
|
||||||
@ -294,6 +295,7 @@ export function MessageBubble({ message, onNavigateToSubAgent, showThinking = tr
|
|||||||
const isTool = message.role === 'tool'
|
const isTool = message.role === 'tool'
|
||||||
const isMergedTool = message.type === 'merged_tool'
|
const isMergedTool = message.type === 'merged_tool'
|
||||||
const [toolExpanded, setToolExpanded] = useState(false)
|
const [toolExpanded, setToolExpanded] = useState(false)
|
||||||
|
const [showDetailModal, setShowDetailModal] = useState(false)
|
||||||
const [lightboxImage, setLightboxImage] = useState<{ base64: string; mimeType: string; fileName?: string } | null>(null)
|
const [lightboxImage, setLightboxImage] = useState<{ base64: string; mimeType: string; fileName?: string } | null>(null)
|
||||||
|
|
||||||
const lightboxElement = lightboxImage ? (
|
const lightboxElement = lightboxImage ? (
|
||||||
@ -429,6 +431,13 @@ export function MessageBubble({ message, onNavigateToSubAgent, showThinking = tr
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{hasResult && <CopyButton text={taskResult ? taskResult.output : displayContent} />}
|
{hasResult && <CopyButton text={taskResult ? taskResult.output : displayContent} />}
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setShowDetailModal(true) }}
|
||||||
|
className="opacity-0 group-hover:opacity-100 transition-opacity p-0.5 rounded hover:bg-[var(--overlay-subtle)] flex-shrink-0"
|
||||||
|
title="放大查看"
|
||||||
|
>
|
||||||
|
<Maximize2 className="h-3 w-3 text-[var(--text-muted)]" />
|
||||||
|
</button>
|
||||||
<span className="ml-auto flex-shrink-0">
|
<span className="ml-auto flex-shrink-0">
|
||||||
{toolExpanded ? (
|
{toolExpanded ? (
|
||||||
<ChevronDown className="h-3.5 w-3.5 text-[var(--text-muted)]" />
|
<ChevronDown className="h-3.5 w-3.5 text-[var(--text-muted)]" />
|
||||||
@ -585,6 +594,18 @@ export function MessageBubble({ message, onNavigateToSubAgent, showThinking = tr
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{lightboxElement}
|
{lightboxElement}
|
||||||
|
{showDetailModal && (
|
||||||
|
<ToolDetailModal
|
||||||
|
toolName={message.toolName || 'Tool'}
|
||||||
|
status={status}
|
||||||
|
statusLabel={status === 'calling' ? '执行中' : status === 'result' ? '已完成' : status === 'pending' ? '待确认' : status}
|
||||||
|
arguments={message.arguments}
|
||||||
|
resultContent={message.resultContent || ''}
|
||||||
|
callContent={message.callContent || ''}
|
||||||
|
durationMs={message.durationMs}
|
||||||
|
onClose={() => setShowDetailModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
146
web/src/components/Chat/ToolDetailModal.tsx
Normal file
146
web/src/components/Chat/ToolDetailModal.tsx
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { X, Terminal, Clock, Maximize2 } from 'lucide-react'
|
||||||
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
|
|
||||||
|
interface ToolDetailModalProps {
|
||||||
|
toolName: string
|
||||||
|
status: string
|
||||||
|
statusLabel: string
|
||||||
|
arguments?: unknown
|
||||||
|
resultContent: string
|
||||||
|
callContent: string
|
||||||
|
durationMs?: number
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
if (ms < 1000) return `${ms}ms`
|
||||||
|
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
|
||||||
|
const minutes = Math.floor(ms / 60000)
|
||||||
|
const seconds = Math.floor((ms % 60000) / 1000)
|
||||||
|
return `${minutes}m ${seconds}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatJSON(text: string): string {
|
||||||
|
if (!text) return ''
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text)
|
||||||
|
return JSON.stringify(parsed, null, 2)
|
||||||
|
} catch {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolDetailModal({
|
||||||
|
toolName,
|
||||||
|
status,
|
||||||
|
statusLabel,
|
||||||
|
arguments: args,
|
||||||
|
resultContent,
|
||||||
|
callContent,
|
||||||
|
durationMs,
|
||||||
|
onClose,
|
||||||
|
}: ToolDetailModalProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
const displayContent = resultContent || callContent
|
||||||
|
const formattedContent = formatJSON(displayContent)
|
||||||
|
|
||||||
|
const statusColor =
|
||||||
|
status === 'calling' ? 'var(--accent-amber)' :
|
||||||
|
status === 'result' ? 'var(--accent-green)' :
|
||||||
|
status === 'pending' ? '#f59e0b' :
|
||||||
|
'var(--text-muted)'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm animate-fade-in"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
{/* Modal container */}
|
||||||
|
<div
|
||||||
|
className="relative w-[90vw] max-w-4xl max-h-[90vh] rounded-2xl border border-[var(--border-color)] bg-[var(--bg-secondary)] shadow-2xl flex flex-col overflow-hidden animate-scale-in"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3 shrink-0 px-6 py-4 border-b border-[var(--border-color)] bg-[var(--bg-tertiary)]/50">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<Terminal className="h-5 w-5 text-[var(--accent-cyan)]" />
|
||||||
|
<span className="text-lg font-semibold text-[var(--text-primary)]">{toolName}</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="px-2.5 py-1 rounded-full text-sm font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${statusColor}15`,
|
||||||
|
color: statusColor,
|
||||||
|
border: `1px solid ${statusColor}30`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
{durationMs != null && (
|
||||||
|
<span className="flex items-center gap-1.5 text-sm text-[var(--text-muted)]">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
{formatDuration(durationMs)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="ml-auto p-2 rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors"
|
||||||
|
aria-label="关闭"
|
||||||
|
title="关闭 (Esc)"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body — scrollable */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 space-y-5">
|
||||||
|
{/* Arguments */}
|
||||||
|
{args !== undefined && args !== null && (
|
||||||
|
<div>
|
||||||
|
<div className="text-base font-semibold text-[var(--text-primary)] mb-2 flex items-center gap-2">
|
||||||
|
<span className="w-1 h-5 rounded-full bg-[var(--accent-cyan)] inline-block" />
|
||||||
|
参数
|
||||||
|
</div>
|
||||||
|
<pre className="text-base leading-relaxed text-[var(--text-secondary)] font-mono whitespace-pre-wrap bg-[var(--overlay-dim)] rounded-xl p-4 overflow-x-auto border border-[var(--border-color)]">
|
||||||
|
{JSON.stringify(args, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Result / Output */}
|
||||||
|
{displayContent && (
|
||||||
|
<div>
|
||||||
|
<div className="text-base font-semibold text-[var(--text-primary)] mb-2 flex items-center gap-2">
|
||||||
|
<span className="w-1 h-5 rounded-full bg-[var(--accent-green)] inline-block" />
|
||||||
|
{resultContent ? '结果' : '输出'}
|
||||||
|
</div>
|
||||||
|
<div className="text-base leading-relaxed text-[var(--text-secondary)] font-mono whitespace-pre-wrap bg-[var(--overlay-dim)] rounded-xl p-4 overflow-x-auto border border-[var(--border-color)] markdown-content">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
|
{formattedContent}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="shrink-0 px-6 py-3 border-t border-[var(--border-color)] bg-[var(--bg-tertiary)]/30 flex items-center justify-between text-sm text-[var(--text-muted)]">
|
||||||
|
<span>按 Esc 关闭</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Maximize2 className="h-3.5 w-3.5" />
|
||||||
|
放大视图
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { ChevronDown, ChevronRight, Play, Check, AlertTriangle, Terminal } from 'lucide-react'
|
import { ChevronDown, ChevronRight, Play, Check, AlertTriangle, Terminal, Maximize2 } from 'lucide-react'
|
||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import type { ChatMessage } from '../../types/protocol'
|
import type { ChatMessage } from '../../types/protocol'
|
||||||
|
import { ToolDetailModal } from '../Chat/ToolDetailModal'
|
||||||
|
|
||||||
interface ToolPanelProps {
|
interface ToolPanelProps {
|
||||||
messages: ChatMessage[]
|
messages: ChatMessage[]
|
||||||
@ -77,6 +78,7 @@ function mergeToolMessages(messages: ChatMessage[]): ToolCallItem[] {
|
|||||||
|
|
||||||
export function ToolPanel({ messages }: ToolPanelProps) {
|
export function ToolPanel({ messages }: ToolPanelProps) {
|
||||||
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set())
|
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set())
|
||||||
|
const [detailModalTool, setDetailModalTool] = useState<ToolCallItem | null>(null)
|
||||||
|
|
||||||
const toolCalls = useMemo(() => mergeToolMessages(messages), [messages])
|
const toolCalls = useMemo(() => mergeToolMessages(messages), [messages])
|
||||||
|
|
||||||
@ -143,6 +145,7 @@ export function ToolPanel({ messages }: ToolPanelProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<style>{animStyles}</style>
|
<style>{animStyles}</style>
|
||||||
<div className="border-b border-[var(--border-color)] p-4 font-semibold text-[var(--text-primary)] flex items-center gap-2">
|
<div className="border-b border-[var(--border-color)] p-4 font-semibold text-[var(--text-primary)] flex items-center gap-2">
|
||||||
@ -187,13 +190,20 @@ export function ToolPanel({ messages }: ToolPanelProps) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="flex-shrink-0 ml-2">
|
<div className="flex items-center gap-1 ml-2 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setDetailModalTool(tool) }}
|
||||||
|
className="p-0.5 rounded hover:bg-[var(--overlay-subtle)] transition-colors"
|
||||||
|
title="放大查看"
|
||||||
|
>
|
||||||
|
<Maximize2 className="h-3.5 w-3.5 text-[var(--text-muted)]" />
|
||||||
|
</button>
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<ChevronDown className="h-4 w-4 text-[var(--text-muted)]" />
|
<ChevronDown className="h-4 w-4 text-[var(--text-muted)]" />
|
||||||
) : (
|
) : (
|
||||||
<ChevronRight className="h-4 w-4 text-[var(--text-muted)]" />
|
<ChevronRight className="h-4 w-4 text-[var(--text-muted)]" />
|
||||||
)}
|
)}
|
||||||
</span>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 结果预览区 — 始终可见 */}
|
{/* 结果预览区 — 始终可见 */}
|
||||||
@ -234,6 +244,19 @@ export function ToolPanel({ messages }: ToolPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{detailModalTool && (
|
||||||
|
<ToolDetailModal
|
||||||
|
toolName={detailModalTool.toolName}
|
||||||
|
status={detailModalTool.status}
|
||||||
|
statusLabel={detailModalTool.status === 'calling' ? '执行中' : detailModalTool.status === 'result' ? '已完成' : detailModalTool.status === 'pending' ? '待确认' : detailModalTool.status}
|
||||||
|
arguments={detailModalTool.arguments}
|
||||||
|
resultContent={detailModalTool.resultContent}
|
||||||
|
callContent={detailModalTool.callContent}
|
||||||
|
durationMs={detailModalTool.durationMs}
|
||||||
|
onClose={() => setDetailModalTool(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user