feat: add time tool with offset support and update localization for weekday

This commit is contained in:
shiyu
2026-01-16 15:46:42 +08:00
parent d6eb6e1605
commit 373b6410c2
5 changed files with 376 additions and 204 deletions

View File

@@ -31,6 +31,7 @@ def _build_system_prompt(current_path: Optional[str]) -> str:
"你可以通过工具对文件/目录进行查询、读写、移动、复制、删除以及运行处理器processor",
"",
"可用工具:",
"- time获取服务器当前时间精确到秒英文星期支持 year/month/day/hour/minute/second 偏移。",
"- vfs_list_dir浏览目录列出 entries + pagination",
"- vfs_stat查看文件/目录信息。",
"- vfs_read_text读取文本文件内容不支持二进制",

View File

@@ -1,5 +1,7 @@
import calendar
import json
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any, Awaitable, Callable, Dict, List, Optional
from domain.processors import ProcessDirectoryRequest, ProcessRequest, ProcessorService
@@ -16,6 +18,68 @@ class ToolSpec:
handler: Callable[[Dict[str, Any]], Awaitable[Any]]
def _parse_offset(args: Dict[str, Any], key: str) -> int:
value = args.get(key)
if value is None:
return 0
try:
return int(value)
except (TypeError, ValueError):
return 0
def _add_months(dt: datetime, months: int) -> datetime:
if months == 0:
return dt
total = dt.year * 12 + (dt.month - 1) + months
year = total // 12
month = total % 12 + 1
last_day = calendar.monthrange(year, month)[1]
day = min(dt.day, last_day)
return dt.replace(year=year, month=month, day=day)
async def _time(args: Dict[str, Any]) -> Dict[str, Any]:
now = datetime.now()
year_offset = _parse_offset(args, "year")
month_offset = _parse_offset(args, "month")
day_offset = _parse_offset(args, "day")
hour_offset = _parse_offset(args, "hour")
minute_offset = _parse_offset(args, "minute")
second_offset = _parse_offset(args, "second")
dt = _add_months(now, year_offset * 12 + month_offset)
dt = dt + timedelta(days=day_offset, hours=hour_offset, minutes=minute_offset, seconds=second_offset)
weekday_names = [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
]
weekday = weekday_names[dt.weekday()]
dt_str = dt.strftime("%Y-%m-%d %H:%M:%S")
return {
"ok": True,
"summary": f"{dt_str} · {weekday}",
"data": {
"datetime": dt_str,
"weekday": weekday,
"offset": {
"year": year_offset,
"month": month_offset,
"day": day_offset,
"hour": hour_offset,
"minute": minute_offset,
"second": second_offset,
},
},
}
async def _processors_list(_: Dict[str, Any]) -> Dict[str, Any]:
return {"processors": ProcessorService.list_processors()}
@@ -188,6 +252,27 @@ async def _vfs_search(args: Dict[str, Any]) -> Dict[str, Any]:
TOOLS: Dict[str, ToolSpec] = {
"time": ToolSpec(
name="time",
description=(
"获取服务器当前时间(精确到秒,含英文星期)。"
" 支持 year/month/day/hour/minute/second 偏移(可为负数)。"
),
parameters={
"type": "object",
"properties": {
"year": {"type": "integer", "description": "年偏移(可为负数)"},
"month": {"type": "integer", "description": "月偏移(可为负数)"},
"day": {"type": "integer", "description": "日偏移(可为负数)"},
"hour": {"type": "integer", "description": "时偏移(可为负数)"},
"minute": {"type": "integer", "description": "分偏移(可为负数)"},
"second": {"type": "integer", "description": "秒偏移(可为负数)"},
},
"additionalProperties": False,
},
requires_confirmation=False,
handler=_time,
),
"processors_list": ToolSpec(
name="processors_list",
description="获取可用处理器列表type/name/config_schema 等)。",
@@ -401,12 +486,138 @@ def openai_tools() -> List[Dict[str, Any]]:
return out
def tool_result_to_content(result: Any) -> str:
if result is None:
def _stringify_value(value: Any) -> str:
if value is None:
return ""
if isinstance(result, str):
return result
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, str):
return value
try:
return json.dumps(result, ensure_ascii=False)
return json.dumps(value, ensure_ascii=False)
except TypeError:
return json.dumps({"result": str(result)}, ensure_ascii=False)
return str(value)
def _list_to_view_items(items: List[Any]) -> List[Any]:
normalized: List[Any] = []
for item in items:
if isinstance(item, dict):
normalized.append({str(k): _stringify_value(v) for k, v in item.items()})
else:
normalized.append(_stringify_value(item))
return normalized
def _dict_to_kv_items(data: Dict[str, Any]) -> List[Dict[str, str]]:
return [{"key": str(k), "value": _stringify_value(v)} for k, v in data.items()]
def _first_list_field(data: Dict[str, Any]) -> tuple[Optional[str], Optional[List[Any]]]:
for key, value in data.items():
if isinstance(value, list):
return str(key), value
return None, None
def _build_view(data: Any) -> Dict[str, Any]:
if data is None:
return {"type": "kv", "items": []}
if isinstance(data, str):
return {"type": "text", "text": data}
if isinstance(data, list):
return {"type": "list", "items": _list_to_view_items(data)}
if isinstance(data, dict):
content = data.get("content")
if isinstance(content, str):
meta = {k: _stringify_value(v) for k, v in data.items() if k != "content"}
view: Dict[str, Any] = {"type": "text", "text": content}
if meta:
view["meta"] = meta
return view
list_key, list_val = _first_list_field(data)
if list_key and isinstance(list_val, list):
meta = {k: _stringify_value(v) for k, v in data.items() if k != list_key}
view = {"type": "list", "title": list_key, "items": _list_to_view_items(list_val)}
if meta:
view["meta"] = meta
return view
return {"type": "kv", "items": _dict_to_kv_items(data)}
return {"type": "text", "text": _stringify_value(data)}
def _build_summary(view: Dict[str, Any]) -> str:
view_type = str(view.get("type") or "")
if view_type == "text":
text = view.get("text")
size = len(text) if isinstance(text, str) else 0
return f"chars: {size}" if size else "text"
if view_type == "list":
items = view.get("items")
count = len(items) if isinstance(items, list) else 0
title = str(view.get("title") or "items")
return f"{title}: {count}"
if view_type == "kv":
items = view.get("items")
count = len(items) if isinstance(items, list) else 0
return f"fields: {count}"
if view_type == "error":
return str(view.get("message") or "error")
return ""
def _build_error_payload(code: str, message: str, detail: Any = None) -> Dict[str, Any]:
summary = "Canceled" if code == "canceled" else message or "error"
view = {"type": "error", "message": summary}
payload: Dict[str, Any] = {
"ok": False,
"summary": summary,
"view": view,
"error": {
"code": code,
"message": message,
},
}
if detail is not None:
payload["error"]["detail"] = detail
return payload
def _normalize_tool_result(result: Any) -> Dict[str, Any]:
if isinstance(result, dict) and "ok" in result:
payload = dict(result)
if payload.get("ok") is False:
error = payload.get("error")
message = _stringify_value(error.get("message") if isinstance(error, dict) else error)
payload.setdefault("summary", message or "error")
payload.setdefault("view", {"type": "error", "message": payload["summary"]})
return payload
data = payload.get("data")
if payload.get("view") is None:
payload["view"] = _build_view(data)
if not payload.get("summary"):
payload["summary"] = _build_summary(payload["view"])
return payload
if isinstance(result, dict) and result.get("canceled"):
reason = _stringify_value(result.get("reason") or "canceled")
return _build_error_payload("canceled", reason, detail=result)
if isinstance(result, dict) and "error" in result:
error = result.get("error")
message = _stringify_value(error.get("message") if isinstance(error, dict) else error)
return _build_error_payload("error", message, detail=error)
view = _build_view(result)
summary = _build_summary(view)
return {"ok": True, "summary": summary, "view": view, "data": result}
def tool_result_to_content(result: Any) -> str:
payload = _normalize_tool_result(result)
try:
return json.dumps(payload, ensure_ascii=False, default=str)
except TypeError:
return json.dumps({"ok": False, "summary": "error", "view": {"type": "error", "message": "error"}}, ensure_ascii=False)

View File

@@ -54,6 +54,47 @@ function shortId(id: string, keep: number = 6): string {
return `${s.slice(0, keep)}${s.slice(-keep)}`;
}
function clampText(value: string, maxLen: number): string {
if (value.length <= maxLen) return value;
return `${value.slice(0, maxLen)}`;
}
function formatDisplayValue(value: any, maxLen: number = 120): string {
if (value == null) return '';
if (typeof value === 'string') return clampText(value, maxLen);
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
try {
return clampText(JSON.stringify(value), maxLen);
} catch {
return clampText(String(value), maxLen);
}
}
function isPlainObject(value: any): value is Record<string, any> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
type ToolPayload = {
ok?: boolean;
summary?: string;
view?: {
type?: string;
title?: string;
meta?: Record<string, any>;
items?: any[];
text?: string;
message?: string;
};
data?: any;
error?: any;
};
function parseToolPayload(raw: string): ToolPayload | null {
const parsed = tryParseJson<ToolPayload>(raw);
if (!parsed || typeof parsed !== 'object') return null;
return parsed;
}
interface AiAgentWidgetProps {
currentPath?: string | null;
open: boolean;
@@ -329,94 +370,37 @@ const AiAgentWidget = memo(function AiAgentWidget({ currentPath, open, onOpenCha
}
}, [t]);
const renderToolResultSummary = useCallback((toolName: string, rawContent: string, toolArgs?: Record<string, any> | null) => {
const data = tryParseJson<Record<string, any>>(rawContent);
if (!data) return '';
const renderToolResultSummary = useCallback((rawContent: string) => {
const payload = parseToolPayload(rawContent);
if (!payload) return '';
const summary = typeof payload.summary === 'string' ? payload.summary.trim() : '';
if (summary) return summary;
if (data.canceled) return t('Canceled');
if (data.error) return `${t('Error')}: ${String(data.error)}`;
if (payload.ok === false) {
const message = typeof payload.error?.message === 'string' ? payload.error.message : '';
return message ? `${t('Error')}: ${message}` : t('Error');
}
if (toolName === 'processors_list') {
const processors = Array.isArray(data.processors) ? data.processors : [];
return `${t('Processors')}: ${processors.length}`;
const view = payload.view || {};
const viewType = typeof view.type === 'string' ? view.type : '';
if (viewType === 'text') {
const text = typeof view.text === 'string' ? view.text : '';
return text ? `${text.length} ${t('chars')}` : '';
}
if (toolName === 'processors_run') {
const ctx = (() => {
const processorType = typeof toolArgs?.processor_type === 'string' ? toolArgs.processor_type.trim() : '';
const path = typeof toolArgs?.path === 'string' ? toolArgs.path.trim() : '';
const parts = [processorType, path].filter(Boolean);
return parts.length ? parts.join(' · ') : '';
})();
if (typeof data.task_id === 'string') {
return ctx ? `${t('Task submitted')}: ${ctx} · ${shortId(data.task_id)}` : `${t('Task submitted')}: ${shortId(data.task_id)}`;
}
const taskIds = Array.isArray(data.task_ids) ? data.task_ids : [];
const scheduled = typeof data.scheduled === 'number' ? data.scheduled : taskIds.length;
if (scheduled) return ctx ? `${t('Tasks submitted')}: ${ctx} · ${scheduled}` : `${t('Tasks submitted')}: ${scheduled}`;
return t('Task submitted');
if (viewType === 'list') {
const items = Array.isArray(view.items) ? view.items : [];
return `${items.length} ${t('items')}`;
}
if (toolName === 'vfs_list_dir') {
const path = typeof data.path === 'string' ? data.path : '';
const entries = Array.isArray(data.entries) ? data.entries : [];
const names = entries
.map((it: any) => String(it?.name || '').trim())
.filter(Boolean)
.slice(0, 3);
const head = `${t('Directory')}: ${path || '/'}`;
const tail = `${entries.length} ${t('items')}`;
const sample = names.length ? ` · ${names.join(', ')}` : '';
return `${head} · ${tail}${sample}`;
}
if (toolName === 'vfs_search') {
const query = typeof data.query === 'string' ? data.query : '';
const items = Array.isArray(data.items) ? data.items : [];
return `${t('Search')}: ${query || '-'} · ${items.length} ${t('results')}`;
}
if (toolName === 'vfs_stat') {
const isDir = Boolean(data.is_dir);
const path = typeof data.path === 'string' ? data.path : '';
return `${t('Info')}: ${path || '-'} · ${isDir ? t('Folder') : t('File')}`;
}
if (toolName === 'vfs_read_text') {
const path = typeof data.path === 'string' ? data.path : '';
const length = typeof data.length === 'number' ? data.length : undefined;
const truncated = Boolean(data.truncated);
const tail = length != null ? ` · ${length} ${t('chars')}${truncated ? `(${t('Truncated')})` : ''}` : '';
return `${t('Read')}: ${path || '-'}${tail}`;
}
if (toolName === 'vfs_write_text') {
const path = typeof data.path === 'string' ? data.path : '';
const bytes = typeof data.bytes === 'number' ? data.bytes : undefined;
return `${t('Write')}: ${path || '-'}${bytes != null ? ` · ${bytes} bytes` : ''}`;
}
if (toolName === 'vfs_mkdir') {
const path = typeof data.path === 'string' ? data.path : '';
return `${t('Created')}: ${path || '-'}`;
}
if (toolName === 'vfs_delete') {
const path = typeof data.path === 'string' ? data.path : '';
return `${t('Deleted')}: ${path || '-'}`;
}
if (toolName === 'vfs_move') {
const src = typeof data.src === 'string' ? data.src : '';
const dst = typeof data.dst === 'string' ? data.dst : '';
return `${t('Moved')}: ${src || '-'}${dst || '-'}`;
}
if (toolName === 'vfs_copy') {
const src = typeof data.src === 'string' ? data.src : '';
const dst = typeof data.dst === 'string' ? data.dst : '';
return `${t('Copied')}: ${src || '-'}${dst || '-'}`;
}
if (toolName === 'vfs_rename') {
const src = typeof data.src === 'string' ? data.src : '';
const dst = typeof data.dst === 'string' ? data.dst : '';
return `${t('Renamed')}: ${src || '-'}${dst || '-'}`;
if (viewType === 'kv') {
const items = Array.isArray(view.items) ? view.items : [];
return `${items.length} ${t('items')}`;
}
return '';
}, [t]);
const renderToolDetails = useCallback((toolKey: string, toolName: string, rawContent: string) => {
const data = tryParseJson<Record<string, any>>(rawContent);
const renderToolDetails = useCallback((toolKey: string, rawContent: string) => {
const payload = parseToolPayload(rawContent);
const view = payload?.view;
const showRaw = !!expandedRaw[toolKey];
const toggleRaw = () => setExpandedRaw((prev) => ({ ...prev, [toolKey]: !prev[toolKey] }));
@@ -454,26 +438,40 @@ const AiAgentWidget = memo(function AiAgentWidget({ currentPath, open, onOpenCha
</Space>
);
if (toolName === 'processors_list') {
const processors = Array.isArray(data?.processors) ? data!.processors : [];
const viewType = typeof view?.type === 'string' ? view.type : '';
const title = typeof view?.title === 'string' ? view.title : '';
const metaEntries = isPlainObject(view?.meta) ? Object.entries(view!.meta) : [];
const renderMeta = () => {
if (metaEntries.length === 0 && !title) return null;
return (
<>
<Space direction="vertical" size={6} style={{ width: '100%' }}>
{title ? (
<Text type="secondary" style={{ fontSize: 12 }}>{title}</Text>
) : null}
{metaEntries.slice(0, 6).map(([key, value]) => (
<Text key={key} type="secondary" style={{ fontSize: 12 }}>
{key}: {formatDisplayValue(value, 180) || '-'}
</Text>
))}
</Space>
<Divider style={{ margin: '10px 0' }} />
</>
);
};
if (viewType === 'error') {
const message = typeof view?.message === 'string'
? view.message
: (typeof payload?.error?.message === 'string' ? payload.error.message : t('Error'));
return (
<div className="fx-agent-tool-details">
{header}
<Divider style={{ margin: '10px 0' }} />
<List
size="small"
dataSource={processors}
locale={{ emptyText: t('No results') }}
renderItem={(item: any) => (
<List.Item>
<Space size={10} wrap>
<Text code style={{ fontVariantNumeric: 'tabular-nums' }}>{String(item?.type || '')}</Text>
<Text>{String(item?.name || '')}</Text>
</Space>
</List.Item>
)}
style={{ background: 'transparent' }}
/>
<Paragraph style={{ marginBottom: 0, whiteSpace: 'pre-wrap' }}>
{message || t('Error')}
</Paragraph>
{showRaw && (
<>
<Divider style={{ margin: '10px 0' }} />
@@ -484,40 +482,43 @@ const AiAgentWidget = memo(function AiAgentWidget({ currentPath, open, onOpenCha
);
}
if (toolName === 'vfs_list_dir') {
const path = typeof data?.path === 'string' ? data!.path : '/';
const entries = Array.isArray(data?.entries) ? data!.entries : [];
const pagination = data?.pagination && typeof data.pagination === 'object' ? data.pagination : null;
if (viewType === 'text') {
const text = typeof view?.text === 'string' ? view.text : '';
return (
<div className="fx-agent-tool-details">
{header}
<Divider style={{ margin: '10px 0' }} />
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Text type="secondary" style={{ fontSize: 12 }}>{t('Directory')}: {path}</Text>
{pagination?.total != null ? (
<Text type="secondary" style={{ fontSize: 12 }}>
{t('Total')}: {String(pagination.total)}
</Text>
) : null}
</Space>
{renderMeta()}
<pre className="fx-agent-pre" style={{ marginTop: metaEntries.length || title ? 0 : 10 }}>{text || ''}</pre>
{showRaw && (
<>
<Divider style={{ margin: '10px 0' }} />
<pre className="fx-agent-pre">{rawJson}</pre>
</>
)}
</div>
);
}
if (viewType === 'kv') {
const items = Array.isArray(view?.items) ? view!.items : [];
return (
<div className="fx-agent-tool-details">
{header}
<Divider style={{ margin: '10px 0' }} />
{renderMeta()}
<List
size="small"
dataSource={entries}
dataSource={items}
locale={{ emptyText: t('No results') }}
renderItem={(item: any) => {
const name = String(item?.name || '');
const type = String(item?.type || (item?.is_dir ? 'dir' : 'file'));
renderItem={(item: any, idx) => {
const key = typeof item?.key === 'string' ? item.key : (typeof item?.label === 'string' ? item.label : String(idx));
const value = typeof item?.value === 'string' ? item.value : formatDisplayValue(item?.value, 200);
return (
<List.Item>
<Space size={10} wrap style={{ width: '100%', justifyContent: 'space-between' }}>
<Space size={10} wrap>
<Text code style={{ fontVariantNumeric: 'tabular-nums' }}>{type}</Text>
<Text>{name}</Text>
</Space>
{!item?.is_dir && typeof item?.size === 'number' ? (
<Text type="secondary" style={{ fontSize: 12 }}>{item.size} bytes</Text>
) : null}
<Space size={10} wrap>
<Text code style={{ fontVariantNumeric: 'tabular-nums' }}>{key || '-'}</Text>
<Text>{value || '-'}</Text>
</Space>
</List.Item>
);
@@ -534,44 +535,40 @@ const AiAgentWidget = memo(function AiAgentWidget({ currentPath, open, onOpenCha
);
}
if (toolName === 'vfs_search') {
const query = typeof data?.query === 'string' ? data!.query : '';
const mode = typeof data?.mode === 'string' ? data!.mode : '';
const items = Array.isArray(data?.items) ? data!.items : [];
const pagination = data?.pagination && typeof data.pagination === 'object' ? data.pagination : null;
if (viewType === 'list') {
const items = Array.isArray(view?.items) ? view!.items : [];
return (
<div className="fx-agent-tool-details">
{header}
<Divider style={{ margin: '10px 0' }} />
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Text type="secondary" style={{ fontSize: 12 }}>{t('Search')}: {query || '-'}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>{t('Mode')}: {mode || '-'}</Text>
{pagination?.has_more != null ? (
<Text type="secondary" style={{ fontSize: 12 }}>
{t('Page')}: {String(pagination.page)} · {t('Has more')}: {String(Boolean(pagination.has_more))}
</Text>
) : null}
</Space>
<Divider style={{ margin: '10px 0' }} />
{renderMeta()}
<List
size="small"
dataSource={items}
locale={{ emptyText: t('No results') }}
renderItem={(item: any) => {
const type = String(item?.source_type || item?.mime || '');
const path = String(item?.path || '');
const score = item?.score != null ? Number(item.score) : null;
if (isPlainObject(item)) {
const entries = Object.entries(item);
const shown = entries.slice(0, 4);
const extra = entries.length - shown.length;
return (
<List.Item>
<Space size={10} wrap style={{ width: '100%', justifyContent: 'space-between' }}>
<Space size={10} wrap>
{shown.map(([key, value]) => (
<Text key={key}>
<Text type="secondary">{key}</Text>: {formatDisplayValue(value, 160) || '-'}
</Text>
))}
{extra > 0 ? <Text type="secondary">+{extra}</Text> : null}
</Space>
</Space>
</List.Item>
);
}
return (
<List.Item>
<Space size={10} wrap style={{ width: '100%', justifyContent: 'space-between' }}>
<Space size={10} wrap>
{type ? <Text code style={{ fontVariantNumeric: 'tabular-nums' }}>{type}</Text> : null}
<Text>{path}</Text>
</Space>
{score != null && !Number.isNaN(score) ? (
<Text type="secondary" style={{ fontSize: 12 }}>{score.toFixed(3)}</Text>
) : null}
</Space>
<Text>{formatDisplayValue(item, 200) || '-'}</Text>
</List.Item>
);
}}
@@ -587,25 +584,6 @@ const AiAgentWidget = memo(function AiAgentWidget({ currentPath, open, onOpenCha
);
}
if (toolName === 'vfs_read_text') {
const path = typeof data?.path === 'string' ? data!.path : '';
const content = typeof data?.content === 'string' ? data!.content : '';
return (
<div className="fx-agent-tool-details">
{header}
<Divider style={{ margin: '10px 0' }} />
<Text type="secondary" style={{ fontSize: 12 }}>{t('File')}: {path || '-'}</Text>
<pre className="fx-agent-pre" style={{ marginTop: 10 }}>{content || ''}</pre>
{showRaw && (
<>
<Divider style={{ margin: '10px 0' }} />
<pre className="fx-agent-pre">{rawJson}</pre>
</>
)}
</div>
);
}
return (
<div className="fx-agent-tool-details">
{header}
@@ -614,41 +592,21 @@ const AiAgentWidget = memo(function AiAgentWidget({ currentPath, open, onOpenCha
<pre className="fx-agent-pre">{rawJson}</pre>
) : (
<Paragraph style={{ marginBottom: 0, whiteSpace: 'pre-wrap' }}>
{extractTextContent(data ?? rawContent) || <Text type="secondary">{t('No content')}</Text>}
{extractTextContent(payload ?? rawContent) || <Text type="secondary">{t('No content')}</Text>}
</Paragraph>
)}
</div>
);
}, [copyToClipboard, expandedRaw, t]);
const renderToolArgsSummary = useCallback((toolName: string, args?: Record<string, any> | null) => {
const a = args || {};
if (toolName === 'processors_run') {
const path = typeof a.path === 'string' ? a.path : '';
return path ? `${t('Path')}: ${path}` : '';
}
if (toolName === 'vfs_read_text' || toolName === 'vfs_list_dir' || toolName === 'vfs_stat' || toolName === 'vfs_delete' || toolName === 'vfs_mkdir') {
const path = typeof a.path === 'string' ? a.path : '';
return path ? `${t('Path')}: ${path}` : '';
}
if (toolName === 'vfs_search') {
const query = typeof a.query === 'string' ? a.query : '';
return query ? `${t('Search')}: ${query}` : '';
}
if (toolName === 'vfs_write_text') {
const path = typeof a.path === 'string' ? a.path : '';
return path ? `${t('Path')}: ${path}` : '';
}
if (toolName === 'vfs_move' || toolName === 'vfs_copy' || toolName === 'vfs_rename') {
const src = typeof a.src === 'string' ? a.src : '';
const dst = typeof a.dst === 'string' ? a.dst : '';
if (src && dst) return `${src}${dst}`;
if (src) return src;
if (dst) return dst;
return '';
}
return '';
}, [t]);
const renderToolArgsSummary = useCallback((args?: Record<string, any> | null) => {
const entries = Object.entries(args || {})
.filter(([, value]) => value != null && String(value).trim() !== '');
if (entries.length === 0) return '';
return entries.slice(0, 2)
.map(([key, value]) => `${key}: ${formatDisplayValue(value, 60)}`)
.join(' · ');
}, []);
return (
<>
@@ -715,7 +673,7 @@ const AiAgentWidget = memo(function AiAgentWidget({ currentPath, open, onOpenCha
if (isTool) {
const rawContent = extractTextContent((m as any).content);
const expanded = !!expandedTools[msgKey];
const summary = toolName ? renderToolResultSummary(toolName, rawContent, toolInfo?.args || null) : '';
const summary = rawContent ? renderToolResultSummary(rawContent) : '';
return (
<div key={msgKey} className="fx-agent-msg fx-agent-msg-tool">
<div className="fx-agent-tool-block">
@@ -752,7 +710,7 @@ const AiAgentWidget = memo(function AiAgentWidget({ currentPath, open, onOpenCha
</pre>
</div>
)}
{renderToolDetails(msgKey, toolName || t('Tool'), rawContent)}
{renderToolDetails(msgKey, rawContent)}
</div>
)}
</div>
@@ -826,7 +784,7 @@ const AiAgentWidget = memo(function AiAgentWidget({ currentPath, open, onOpenCha
const key = `pending:${p.id}`;
const expanded = !!expandedTools[key];
const running = Object.prototype.hasOwnProperty.call(runningTools, p.id);
const summary = renderToolArgsSummary(p.name, args);
const summary = renderToolArgsSummary(args);
return (
<div key={p.id} className="fx-agent-tool-block fx-agent-pending-item">
<div className="fx-agent-tool-bar">

View File

@@ -536,6 +536,7 @@
"This will delete all logs irreversibly.": "This will delete all logs irreversibly.",
"Cleared {count} logs": "Cleared {count} logs",
"Time": "Time",
"Weekday": "Weekday",
"Level": "Level",
"Source": "Source",
"Message": "Message",

View File

@@ -527,6 +527,7 @@
"This will delete all logs irreversibly.": "将删除全部日志且不可恢复",
"Cleared {count} logs": "成功清理 {count} 条日志",
"Time": "时间",
"Weekday": "星期",
"Level": "级别",
"Source": "来源",
"Message": "消息",