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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "ฉัน"
|
||||
},
|
||||
|
||||
@@ -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": "我"
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user