feat: add tool status management and localization for skill installation

- Updated chat message types to include tool statuses.
- Enhanced localization files for English, Thai, and Chinese to support new tool status messages.
- Modified HomePage and SkillsPage components to handle tool statuses in chat messages.
- Implemented tool status merging and updating logic in the chat store.
- Added handling for tool status events in the gateway event processing.
- Created tests for chat message rendering with tool statuses and skill installation shortcuts.
- Improved gateway event dispatching for tool lifecycle events.
This commit is contained in:
duanshuwen
2026-04-23 20:27:54 +08:00
parent 979fb0a0f6
commit df600272d6
29 changed files with 2041 additions and 384 deletions

View File

@@ -1,7 +1,22 @@
import { memo, useEffect, useState, useRef } from 'react';
import { Check, ChevronRight, Copy, ImageIcon, Paperclip, Sparkles } from 'lucide-react';
import { memo, useEffect, useRef, useState } from 'react';
import {
AlertCircle,
BookOpen,
Check,
CheckCircle2,
ChevronRight,
Copy,
FolderOpen,
ImageIcon,
Link2,
Loader2,
Paperclip,
Wrench,
} from 'lucide-react';
import type { ChatMessageItem } from './types';
import type { ToolStatus } from '../../shared/chat-model';
import { useI18n } from '../../i18n';
import { apiOpenSkillPath, apiOpenSkillReadme } from '../../lib/skills-api';
import ChatEmptyState from './ChatEmptyState';
import aiAvatar from '../../assets/images/ai_avatar.png';
import meAvatar from '../../assets/images/me_avatar.png';
@@ -10,6 +25,7 @@ type ChatMessageListProps = {
messages: ChatMessageItem[];
loading?: boolean;
showWelcomeState?: boolean;
streamingTools?: ToolStatus[];
};
function cn(...classes: Array<string | false | null | undefined>) {
@@ -89,16 +105,379 @@ function AssistantMeta({
);
}
function ChatMessageList({ messages, loading, showWelcomeState }: ChatMessageListProps) {
function formatToolDuration(durationMs?: number): string | null {
if (!durationMs || !Number.isFinite(durationMs)) return null;
if (durationMs < 1000) return `${Math.round(durationMs)}ms`;
return `${(durationMs / 1000).toFixed(1)}s`;
}
type TranslateFn = ReturnType<typeof useI18n>['t'];
type ToolDetail = {
label: string;
value: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function getRecordString(value: unknown, ...keys: string[]): string | undefined {
if (!isRecord(value)) return undefined;
for (const key of keys) {
const field = value[key];
if (typeof field === 'string' && field.trim()) {
return field.trim();
}
}
return undefined;
}
function getToolDisplayName(name: string, t: TranslateFn): string {
switch (name) {
case 'skills.install':
return t('conversation.messageList.toolNames.skillsInstall');
case 'browser.open_url':
return t('conversation.messageList.toolNames.browserOpenUrl');
default:
return name || t('conversation.messageList.toolNames.unknown');
}
}
function getToolStatusLabel(status: ToolStatus['status'], t: TranslateFn): string {
switch (status) {
case 'running':
return t('conversation.messageList.toolStatus.running');
case 'completed':
return t('conversation.messageList.toolStatus.completed');
case 'error':
return t('conversation.messageList.toolStatus.error');
default:
return status;
}
}
function buildToolDetails(tool: ToolStatus, t: TranslateFn): ToolDetail[] {
if (tool.name === 'skills.install') {
const slug = getRecordString(tool.result, 'slug') || getRecordString(tool.input, 'slug');
const source = getRecordString(tool.result, 'source') || getRecordString(tool.input, 'kind');
const baseDir = getRecordString(tool.result, 'baseDir');
const requestUrl = getRecordString(tool.input, 'url');
const error = getRecordString(tool.result, 'error');
const details: ToolDetail[] = [];
if (slug) {
details.push({ label: t('conversation.messageList.toolFields.skill'), value: slug });
}
if (source) {
details.push({ label: t('conversation.messageList.toolFields.source'), value: source });
}
if (baseDir) {
details.push({ label: t('conversation.messageList.toolFields.path'), value: baseDir });
}
if (requestUrl) {
details.push({ label: t('conversation.messageList.toolFields.request'), value: requestUrl });
}
if (tool.status === 'error' && error) {
details.push({ label: t('conversation.messageList.toolFields.error'), value: error });
}
return details;
}
if (tool.name === 'browser.open_url') {
const link = getRecordString(tool.result, 'pageUrl', 'url') || getRecordString(tool.input, 'url');
const title = getRecordString(tool.result, 'title');
const error = getRecordString(tool.result, 'error');
const details: ToolDetail[] = [];
if (link) {
details.push({ label: t('conversation.messageList.toolFields.link'), value: link });
}
if (title) {
details.push({ label: t('conversation.messageList.toolFields.title'), value: title });
}
if (tool.status === 'error' && error) {
details.push({ label: t('conversation.messageList.toolFields.error'), value: error });
}
return details;
}
const error = getRecordString(tool.result, 'error');
return error ? [{ label: t('conversation.messageList.toolFields.error'), value: error }] : [];
}
function shouldHideAssistantToolSummary(message: ChatMessageItem): boolean {
if (message.role !== 'assistant' || !message.toolStatuses || message.toolStatuses.length !== 1) {
return false;
}
const summary = message.toolStatuses[0]?.summary?.trim();
const content = message.content.trim();
return Boolean(summary && content && summary === content);
}
function ToolActionButton({
label,
icon: Icon,
onClick,
disabled,
busy,
}: {
label: string;
icon: typeof FolderOpen;
onClick: () => void;
disabled?: boolean;
busy?: boolean;
}) {
return (
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-full border border-[#D8CCB9] bg-[#F8F3EA] px-3 py-1.5 text-[11px] font-medium text-[#5D5548] transition-colors hover:bg-[#EFE5D6] disabled:cursor-not-allowed disabled:opacity-55 dark:border-white/10 dark:bg-white/5 dark:text-gray-200 dark:hover:bg-white/10"
onClick={onClick}
disabled={disabled || busy}
>
{busy ? <Loader2 className="h-3 w-3 animate-spin" /> : <Icon className="h-3 w-3" />}
<span>{label}</span>
</button>
);
}
function ToolResultCard({
tool,
}: {
tool: ToolStatus;
}) {
const { t } = useI18n();
const [feedback, setFeedback] = useState<{ kind: 'success' | 'error'; text: string } | null>(null);
const [busyAction, setBusyAction] = useState<string | null>(null);
const [copiedAction, setCopiedAction] = useState<string | null>(null);
const duration = formatToolDuration(tool.durationMs);
const isRunning = tool.status === 'running';
const isError = tool.status === 'error';
const details = buildToolDetails(tool, t);
const skillKey = getRecordString(tool.result, 'skillKey', 'slug') || getRecordString(tool.input, 'slug');
const skillSlug = getRecordString(tool.result, 'slug') || getRecordString(tool.input, 'slug');
const skillBaseDir = getRecordString(tool.result, 'baseDir');
const browserUrl = getRecordString(tool.result, 'pageUrl', 'url') || getRecordString(tool.input, 'url');
async function handleAction(
actionKey: string,
task: () => Promise<void>,
successMessage?: string,
) {
setBusyAction(actionKey);
setFeedback(null);
try {
await task();
if (successMessage) {
setFeedback({ kind: 'success', text: successMessage });
}
} catch (error) {
setFeedback({
kind: 'error',
text: error instanceof Error ? error.message : String(error),
});
} finally {
setBusyAction(null);
}
}
async function handleCopy(actionKey: string, value: string, successMessage: string) {
if (!navigator?.clipboard?.writeText) {
setFeedback({
kind: 'error',
text: t('conversation.messageList.toolActions.copyUnavailable'),
});
return;
}
setFeedback(null);
try {
await navigator.clipboard.writeText(value);
setCopiedAction(actionKey);
setFeedback({ kind: 'success', text: successMessage });
window.setTimeout(() => {
setCopiedAction((current) => (current === actionKey ? null : current));
}, 1500);
} catch (error) {
setFeedback({
kind: 'error',
text: error instanceof Error ? error.message : String(error),
});
}
}
return (
<div
className={cn(
'flex w-full flex-col gap-3 rounded-[20px] border px-4 py-4 shadow-[0_8px_22px_rgba(90,76,50,0.05)]',
isRunning && 'border-[#C7D9FF] bg-[#F5F9FF] text-[#244A87] dark:border-[#2A4B7C] dark:bg-[#182536] dark:text-[#D7E6FF]',
!isRunning && !isError && 'border-[#D9E8D6] bg-[#F4FBF2] text-[#276749] dark:border-[#244A34] dark:bg-[#16241B] dark:text-[#CFF3DA]',
isError && 'border-[#F3C7CB] bg-[#FFF2F3] text-[#B42318] dark:border-[#4B2229] dark:bg-[#2B1C1F] dark:text-[#FFB4BF]',
)}
>
<div className="flex flex-wrap items-center gap-2">
<div className="inline-flex items-center gap-2 rounded-full bg-white/55 px-3 py-1 text-[11px] font-medium dark:bg-black/10">
{isRunning ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
) : isError ? (
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
) : (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
)}
<Wrench className="h-3.5 w-3.5 shrink-0 opacity-80" />
<span>{getToolDisplayName(tool.name, t)}</span>
</div>
<div className="inline-flex items-center rounded-full border border-current/15 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.08em] opacity-90">
{getToolStatusLabel(tool.status, t)}
</div>
{duration ? <span className="text-[11px] opacity-75">{duration}</span> : null}
</div>
{tool.summary ? (
<p className="whitespace-pre-wrap break-words text-[13px] leading-6 text-current/90">
{tool.summary}
</p>
) : null}
{details.length > 0 ? (
<div className="grid gap-2 rounded-2xl bg-white/45 p-3 dark:bg-black/10">
{details.map((detail) => (
<div key={`${tool.toolCallId || tool.id || tool.name}-${detail.label}`} className="grid gap-1">
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] opacity-70">
{detail.label}
</div>
<div className="break-all text-[12px] leading-5 text-current/90">
{detail.value}
</div>
</div>
))}
</div>
) : null}
{tool.name === 'skills.install' && !isRunning && (skillKey || skillBaseDir) ? (
<div className="flex flex-col gap-2">
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] opacity-70">
{t('conversation.messageList.toolNextActions')}
</div>
<div className="flex flex-wrap gap-2">
<ToolActionButton
label={t('conversation.messageList.toolActions.openFolder')}
icon={FolderOpen}
busy={busyAction === 'open-folder'}
onClick={() => {
void handleAction(
'open-folder',
() => apiOpenSkillPath(skillKey || skillSlug || '', skillSlug, skillBaseDir),
t('conversation.messageList.toolActions.openedFolder'),
);
}}
/>
<ToolActionButton
label={t('conversation.messageList.toolActions.openReadme')}
icon={BookOpen}
busy={busyAction === 'open-readme'}
onClick={() => {
void handleAction(
'open-readme',
() => apiOpenSkillReadme(skillKey || skillSlug || '', skillSlug, skillBaseDir),
t('conversation.messageList.toolActions.openedReadme'),
);
}}
/>
{skillBaseDir ? (
<ToolActionButton
label={copiedAction === 'copy-path'
? t('conversation.messageList.toolActions.copied')
: t('conversation.messageList.toolActions.copyPath')}
icon={copiedAction === 'copy-path' ? Check : Copy}
onClick={() => {
void handleCopy(
'copy-path',
skillBaseDir,
t('conversation.messageList.toolActions.copiedPath'),
);
}}
/>
) : null}
</div>
</div>
) : null}
{tool.name === 'browser.open_url' && !isRunning && browserUrl ? (
<div className="flex flex-col gap-2">
<div className="text-[10px] font-semibold uppercase tracking-[0.08em] opacity-70">
{t('conversation.messageList.toolNextActions')}
</div>
<div className="flex flex-wrap gap-2">
<ToolActionButton
label={copiedAction === 'copy-url'
? t('conversation.messageList.toolActions.copied')
: t('conversation.messageList.toolActions.copyUrl')}
icon={copiedAction === 'copy-url' ? Check : Link2}
onClick={() => {
void handleCopy(
'copy-url',
browserUrl,
t('conversation.messageList.toolActions.copiedUrl'),
);
}}
/>
</div>
</div>
) : null}
{feedback ? (
<div
className={cn(
'text-[11px] leading-5',
feedback.kind === 'success' ? 'text-current/80' : 'text-[#B42318] dark:text-[#FFB4BF]',
)}
>
{feedback.text}
</div>
) : null}
</div>
);
}
function ToolResultCards({
tools,
heading,
}: {
tools: ToolStatus[];
heading?: string;
}) {
return (
<div className="flex w-full flex-col gap-2.5">
{heading ? (
<div className="px-1 text-xs text-[#1D2129] dark:text-gray-500">
{heading}
</div>
) : null}
{tools.map((tool) => (
<ToolResultCard key={tool.toolCallId || tool.id || `${tool.name}-${tool.updatedAt}`} tool={tool} />
))}
</div>
);
}
function ChatMessageList({ messages, loading, showWelcomeState, streamingTools = [] }: ChatMessageListProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const { t } = useI18n();
const shouldShowWelcomeState = !loading && (showWelcomeState || messages.length === 0);
const shouldShowWelcomeState = !loading && streamingTools.length === 0 && (showWelcomeState || messages.length === 0);
const hasStreamingAssistantMessage = messages.some((message) => message.role === 'assistant' && message.isStreaming);
const shouldRenderStandaloneToolStatus = streamingTools.length > 0 && !hasStreamingAssistantMessage;
useEffect(() => {
const container = containerRef.current;
if (!container) return;
container.scrollTop = container.scrollHeight;
}, [loading, messages]);
}, [loading, messages, streamingTools]);
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden p-4 dark:bg-[#161618] sm:px-6">
@@ -130,19 +509,32 @@ function ChatMessageList({ messages, loading, showWelcomeState }: ChatMessageLis
>
{message.role === 'assistant' && message.isStreaming ? (
<>
<div className="px-1 text-xs text-[#1D2129] dark:text-gray-500">
{t('conversation.messageList.streaming')}
</div>
<button
type="button"
className="flex w-full items-center gap-2 rounded-lg border border-[#DDD3C3] bg-[#ECE3D3] px-4 py-2.5 text-left text-[13px] font-medium text-[#5F574A] shadow-[0_4px_14px_rgba(90,76,50,0.03)] dark:border-white/10 dark:bg-white/5 dark:text-gray-300"
>
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
<span>Thinking</span>
</button>
{streamingTools.length > 0 ? (
<ToolResultCards
tools={streamingTools}
heading={t('conversation.messageList.toolRunning')}
/>
) : (
<>
<div className="px-1 text-xs text-[#1D2129] dark:text-gray-500">
{t('conversation.messageList.streaming')}
</div>
<button
type="button"
className="flex w-full items-center gap-2 rounded-lg border border-[#DDD3C3] bg-[#ECE3D3] px-4 py-2.5 text-left text-[13px] font-medium text-[#5F574A] shadow-[0_4px_14px_rgba(90,76,50,0.03)] dark:border-white/10 dark:bg-white/5 dark:text-gray-300"
>
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
<span>Thinking</span>
</button>
</>
)}
</>
) : null}
{message.role === 'assistant' && message.toolStatuses && message.toolStatuses.length > 0 ? (
<ToolResultCards tools={message.toolStatuses} />
) : null}
{message.role === 'user' && message.attachments && message.attachments.length > 0 ? (
<div className="flex flex-wrap justify-end gap-2">
{message.attachments.map((attachment, index) => (
@@ -155,7 +547,7 @@ function ChatMessageList({ messages, loading, showWelcomeState }: ChatMessageLis
</div>
) : null}
{message.content ? (
{message.content && !shouldHideAssistantToolSummary(message) ? (
<div
className={cn(
'max-w-full rounded-lg px-5 py-3.5 text-sm',
@@ -205,6 +597,19 @@ function ChatMessageList({ messages, loading, showWelcomeState }: ChatMessageLis
) : null}
</article>
))}
{shouldRenderStandaloneToolStatus ? (
<article className="group flex w-full items-start gap-3 justify-start">
<div className="mt-1 flex h-9 w-9 shrink-0 items-center justify-center rounded-full">
<img className="h-full w-full object-cover" src={aiAvatar} alt="aiAvatar" />
</div>
<div className="min-w-0 flex w-full max-w-[78%] flex-col gap-2 items-start">
<ToolResultCards
tools={streamingTools}
heading={t('conversation.messageList.toolRunning')}
/>
</div>
</article>
) : null}
</div>
</div>
);

View File

@@ -1,4 +1,4 @@
import type { AttachedFileMeta } from '../../shared/chat-model';
import type { AttachedFileMeta, ToolStatus } from '../../shared/chat-model';
export type TaskTabValue = 'pending' | 'completed';
@@ -20,6 +20,7 @@ export type ChatMessageItem = {
time: string;
content: string;
attachments?: AttachedFileMeta[];
toolStatuses?: ToolStatus[];
isStreaming?: boolean;
isError?: boolean;
};

View File

@@ -20,6 +20,39 @@
"loading": "Loading conversation...",
"emptyHint": "Start a new conversation by typing your question. Existing messages and streaming responses will appear here directly.",
"streaming": "Generating reply...",
"toolRunning": "Running tools...",
"toolNextActions": "Next actions",
"toolStatus": {
"running": "Running",
"completed": "Completed",
"error": "Failed"
},
"toolNames": {
"skillsInstall": "Install Skill",
"browserOpenUrl": "Open Webpage",
"unknown": "Tool execution"
},
"toolFields": {
"skill": "Skill",
"source": "Source",
"path": "Install path",
"request": "Request",
"link": "Link",
"title": "Page title",
"error": "Error details"
},
"toolActions": {
"openFolder": "Open folder",
"openReadme": "Open README",
"copyPath": "Copy path",
"copyUrl": "Copy link",
"copied": "Copied",
"copiedPath": "Path copied",
"copiedUrl": "Link copied",
"openedFolder": "Folder opened",
"openedReadme": "README opened",
"copyUnavailable": "Copy is unavailable in this environment"
},
"assistantBadge": "AI",
"userBadge": "Me"
},

View File

@@ -20,6 +20,39 @@
"loading": "กำลังโหลดเนื้อหาการสนทนา...",
"emptyHint": "พิมพ์คำถามเพื่อเริ่มการสนทนาใหม่ ข้อความเดิมและคำตอบแบบสตรีมจะแสดงที่นี่โดยตรง",
"streaming": "กำลังสร้างคำตอบ...",
"toolRunning": "กำลังเรียกใช้เครื่องมือ...",
"toolNextActions": "การดำเนินการถัดไป",
"toolStatus": {
"running": "กำลังทำงาน",
"completed": "เสร็จสิ้น",
"error": "ล้มเหลว"
},
"toolNames": {
"skillsInstall": "ติดตั้ง Skill",
"browserOpenUrl": "เปิดหน้าเว็บ",
"unknown": "การเรียกใช้เครื่องมือ"
},
"toolFields": {
"skill": "Skill",
"source": "แหล่งที่มา",
"path": "ตำแหน่งติดตั้ง",
"request": "คำขอ",
"link": "ลิงก์",
"title": "ชื่อหน้า",
"error": "รายละเอียดข้อผิดพลาด"
},
"toolActions": {
"openFolder": "เปิดโฟลเดอร์",
"openReadme": "เปิด README",
"copyPath": "คัดลอกพาธ",
"copyUrl": "คัดลอกลิงก์",
"copied": "คัดลอกแล้ว",
"copiedPath": "คัดลอกพาธแล้ว",
"copiedUrl": "คัดลอกลิงก์แล้ว",
"openedFolder": "เปิดโฟลเดอร์แล้ว",
"openedReadme": "เปิด README แล้ว",
"copyUnavailable": "สภาพแวดล้อมนี้ไม่รองรับการคัดลอก"
},
"assistantBadge": "AI",
"userBadge": "ฉัน"
},

View File

@@ -20,6 +20,39 @@
"loading": "正在加载会话内容...",
"emptyHint": "输入你的问题开始一段新对话,现有消息和流式响应都会直接显示在这里。",
"streaming": "正在生成回复...",
"toolRunning": "正在执行工具...",
"toolNextActions": "后续动作",
"toolStatus": {
"running": "运行中",
"completed": "已完成",
"error": "失败"
},
"toolNames": {
"skillsInstall": "安装 Skill",
"browserOpenUrl": "打开网页",
"unknown": "工具执行"
},
"toolFields": {
"skill": "Skill",
"source": "来源",
"path": "安装目录",
"request": "请求",
"link": "链接",
"title": "页面标题",
"error": "错误细节"
},
"toolActions": {
"openFolder": "打开目录",
"openReadme": "打开 README",
"copyPath": "复制路径",
"copyUrl": "复制链接",
"copied": "已复制",
"copiedPath": "路径已复制",
"copiedUrl": "链接已复制",
"openedFolder": "已打开目录",
"openedReadme": "已打开 README",
"copyUnavailable": "当前环境不支持复制"
},
"assistantBadge": "AI",
"userBadge": "我"
},

View File

@@ -146,6 +146,7 @@ function mapMessages(
time: getMessageTime(locale, message.timestamp),
content: text,
attachments: message._attachedFiles,
toolStatuses: message._toolStatuses,
isError: Boolean(message.isError),
};
});
@@ -228,6 +229,7 @@ export default function HomePage() {
const chatMessages = useChatStore((state) => state.messages);
const chatLoading = useChatStore((state) => state.loading);
const chatStreamingMessage = useChatStore((state) => state.streamingMessage);
const chatStreamingTools = useChatStore((state) => state.streamingTools);
const chatSessions = useChatStore((state) => state.sessions);
const chatCurrentSessionKey = useChatStore((state) => state.currentSessionKey);
const chatCurrentAgentId = useChatStore((state) => state.currentAgentId);
@@ -509,6 +511,7 @@ export default function HomePage() {
loading={chatLoading}
messages={visibleMessages}
showWelcomeState={!hasConversationHistory}
streamingTools={chatStreamingTools}
/>
<HomeChatComposerSection
value={inputMessage}

View File

@@ -13,6 +13,7 @@ import {
apiUpdateSkillConfig,
} from '@src/lib/skills-api';
import { onGatewayEvent } from '@src/lib/gateway-client';
import { isRuntimeChangedGatewayEvent, runtimeEventHasTopic } from '@src/lib/runtime-events';
import type { MarketplaceSkill, Skill } from '@src/lib/skills-types';
import { useSkillsCopy } from './copy';
import { type EnvVarEntry } from './components/EnvVarManager';
@@ -73,6 +74,11 @@ export default function SkillsPage() {
const unsubscribeGateway = onGatewayEvent((event) => {
if (event.type === 'gateway:status') {
setIsGatewayRunning(event.status === 'connected');
return;
}
if (isRuntimeChangedGatewayEvent(event) && runtimeEventHasTopic(event, 'skills')) {
void loadSkills();
}
});

View File

@@ -253,11 +253,72 @@ function pruneChatEventDedupe(now: number): void {
}
}
function mergeToolStatus(
existing: ToolStatus['status'],
incoming: ToolStatus['status'],
): ToolStatus['status'] {
const order: Record<ToolStatus['status'], number> = {
running: 0,
completed: 1,
error: 2,
};
return order[incoming] >= order[existing] ? incoming : existing;
}
function upsertToolStatuses(current: ToolStatus[], updates: ToolStatus[]): ToolStatus[] {
const next = [...current];
for (const update of updates) {
const key = update.toolCallId || update.id || update.name;
const index = next.findIndex((tool) => (tool.toolCallId || tool.id || tool.name) === key);
if (index === -1) {
next.push(update);
continue;
}
const existing = next[index];
next[index] = {
...existing,
...update,
status: mergeToolStatus(existing.status, update.status),
updatedAt: Math.max(existing.updatedAt, update.updatedAt),
};
}
return next;
}
function attachToolStatuses(message: RawMessage, tools: ToolStatus[]): RawMessage {
if (!tools.length) {
return message;
}
const merged = upsertToolStatuses(message._toolStatuses || [], tools);
return {
...message,
_toolStatuses: merged,
};
}
function buildChatEventDedupeKey(event: GatewayEvent): string | null {
const runId = 'runId' in event && typeof event.runId === 'string' ? event.runId : '';
const sessionKey = 'sessionKey' in event && typeof event.sessionKey === 'string' ? event.sessionKey : '';
const type = event.type;
if (!runId && !sessionKey && !type) return null;
if (event.type === 'tool:status') {
return [
runId,
sessionKey,
type,
event.toolCallId || event.toolName,
event.status,
String(event.updatedAt),
].join('|');
}
return `${runId}|${sessionKey}|${type}`;
}
@@ -864,6 +925,28 @@ async function handleGatewayEvent(event: GatewayEvent): Promise<void> {
queueStreamingDelta(event.delta, typeof event.runId === 'string' ? event.runId : undefined);
break;
}
case 'tool:status': {
const toolUpdate: ToolStatus = {
id: event.toolCallId || event.toolName,
toolCallId: event.toolCallId,
name: event.toolName,
status: event.status,
durationMs: event.durationMs,
summary: event.summary,
updatedAt: event.updatedAt,
input: event.input,
result: event.result,
};
patchState({
sending: true,
error: null,
activeRunId: event.runId || state.activeRunId,
pendingFinal: true,
streamingTools: upsertToolStatuses(state.streamingTools, [toolUpdate]),
});
break;
}
case 'chat:final': {
flushPendingStreamingDelta();
@@ -873,17 +956,18 @@ async function handleGatewayEvent(event: GatewayEvent): Promise<void> {
content: `${extractText(state.streamingMessage)}${event.message.content}`,
}
: event.message;
const messageWithTools = attachToolStatuses(composedMessage, state.streamingTools);
const messageId = composedMessage.id || `run-${event.runId || Date.now()}`;
const hasOutput = Boolean(extractText(composedMessage).trim());
const toolOnly = isToolOnlyMessage(composedMessage);
const messageId = messageWithTools.id || `run-${event.runId || Date.now()}`;
const hasOutput = Boolean(extractText(messageWithTools).trim());
const toolOnly = isToolOnlyMessage(messageWithTools);
if (!state.messages.some((message) => message.id === messageId)) {
patchState({
messages: [...state.messages, { ...composedMessage, id: messageId }],
messages: [...state.messages, { ...messageWithTools, id: messageId }],
sessionLastActivity: {
...state.sessionLastActivity,
[state.currentSessionKey]: composedMessage.timestamp ? toMs(composedMessage.timestamp) : Date.now(),
[state.currentSessionKey]: messageWithTools.timestamp ? toMs(messageWithTools.timestamp) : Date.now(),
},
streamingMessage: null,
streamingTools: [],

View File

@@ -7,7 +7,10 @@ export type RuntimeRefreshTopic =
| 'models'
| 'agents'
| 'channels'
| 'channel-targets';
| 'channel-targets'
| 'skills';
export type GatewayToolStatus = 'running' | 'completed' | 'error';
export type ThemeMode = 'light' | 'dark' | 'system';
@@ -84,6 +87,19 @@ export type GatewayEvent =
sessionKey: string;
runId: string;
}
| {
type: 'tool:status';
sessionKey: string;
runId: string;
toolName: string;
status: GatewayToolStatus;
updatedAt: number;
toolCallId?: string;
summary?: string;
durationMs?: number;
input?: unknown;
result?: unknown;
}
| {
type: 'gateway:status';
status: 'connected' | 'disconnected' | 'reconnecting';