feat: 添加swarms团队编排功能并优化agent委派系统

- 引入AgentTeamOrchestrator支持多agent协同任务执行
- 增加第三方swarms库依赖并配置git协议替换以改善包管理
- 扩展DelegationManager支持团队任务调度和进度跟踪
- 实现中文bigram分词算法提升中文任务检索准确性
- 调整A2AClient和DelegationManager超时时间从30秒增至600秒
- 优化AgentRunResult状态判断逻辑增加有意义摘要检测
- 修改Dockerfile配置npm仓库镜像地址和git协议映射
- 更新CLI命令行接口支持网关端口配置传递
- 调整提供者超时配置机制增强请求稳定性
- 移除过时的support_group字段简化agent描述符结构
- 增强错误处理和进度事件报告机制改进用户体验
This commit is contained in:
2026-04-14 14:34:23 +08:00
parent fee9007da6
commit cdfc222c9f
85 changed files with 5443 additions and 1392 deletions

View File

@ -35,6 +35,9 @@ import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import type { AppLocale } from '@/lib/i18n/core';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
const EMPTY_AGENT_FORM = {
id: '',
@ -70,7 +73,7 @@ function formatJson(value: Record<string, unknown>): string {
return JSON.stringify(value, null, 2);
}
function parseJsonObject(raw: string, label: string): Record<string, unknown> {
function parseJsonObject(raw: string, label: string, locale: AppLocale): Record<string, unknown> {
const probe = raw.trim();
if (!probe) {
return {};
@ -79,25 +82,46 @@ function parseJsonObject(raw: string, label: string): Record<string, unknown> {
try {
parsed = JSON.parse(probe);
} catch {
throw new Error(`${label} 需要是合法 JSON`);
throw new Error(`${label} ${pickAppText(locale, '需要是合法 JSON', 'must be valid JSON')}`);
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(`${label} 需要是 JSON 对象`);
throw new Error(`${label} ${pickAppText(locale, '需要是 JSON 对象', 'must be a JSON object')}`);
}
return parsed as Record<string, unknown>;
}
function parseNestedJsonObject(raw: string, label: string): Record<string, Record<string, unknown>> {
const parsed = parseJsonObject(raw, label);
function parseNestedJsonObject(raw: string, label: string, locale: AppLocale): Record<string, Record<string, unknown>> {
const parsed = parseJsonObject(raw, label, locale);
for (const [key, value] of Object.entries(parsed)) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`${label} 中的 ${key} 必须是 JSON 对象`);
throw new Error(
pickAppText(
locale,
`${label} 中的 ${key} 必须是 JSON 对象`,
`${key} in ${label} must be a JSON object`
)
);
}
}
return parsed as Record<string, Record<string, unknown>>;
}
function agentSourceLabel(source: UiAgentDescriptor['source'], locale: AppLocale): string {
switch (source) {
case 'workspace':
return pickAppText(locale, '工作区', 'Workspace');
case 'plugin':
return pickAppText(locale, '插件', 'Plugin');
case 'skill':
return pickAppText(locale, '技能', 'Skill');
default:
return pickAppText(locale, '内置', 'Built-in');
}
}
export default function AgentsPage() {
const { locale } = useAppI18n();
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
const cachedAgents = useChatStore((s) => s.agentRegistry);
const setCachedAgents = useChatStore((s) => s.setAgentRegistry);
const [agents, setAgents] = useState<UiAgentDescriptor[]>(cachedAgents);
@ -133,7 +157,7 @@ export default function AgentsPage() {
setSubagents(nextSubagents);
setCachedAgents(nextAgents);
} catch (err: any) {
setError(err.message || '加载智能体失败');
setError(err.message || t('加载智能体失败', 'Failed to load agents'));
} finally {
if (background) {
setRefreshing(false);
@ -161,7 +185,7 @@ export default function AgentsPage() {
setSubagents(nextSubagents);
setCachedAgents(nextAgents);
} catch (err: any) {
setError(err.message || '刷新智能体失败');
setError(err.message || t('刷新智能体失败', 'Failed to refresh agents'));
} finally {
setRefreshing(false);
}
@ -188,7 +212,7 @@ export default function AgentsPage() {
e.preventDefault();
const hasAddress = [agentForm.base_url, agentForm.endpoint, agentForm.card_url].some((value) => value.trim());
if (!hasAddress) {
setError('请至少填写 A2A 部署地址、接口地址或卡片地址');
setError(t('请至少填写 A2A 部署地址、接口地址或卡片地址', 'Enter at least an A2A base URL, endpoint, or card URL'));
return;
}
setAgentSubmitting(true);
@ -214,7 +238,7 @@ export default function AgentsPage() {
handleAgentDialogOpenChange(false);
await load(true);
} catch (err: any) {
setError(err.message || '新增智能体失败');
setError(err.message || t('新增智能体失败', 'Failed to create the agent'));
} finally {
setAgentSubmitting(false);
}
@ -225,7 +249,7 @@ export default function AgentsPage() {
await deleteAgent(agentId);
await load(true);
} catch (err: any) {
setError(err.message || '删除智能体失败');
setError(err.message || t('删除智能体失败', 'Failed to delete the agent'));
}
};
@ -251,7 +275,7 @@ export default function AgentsPage() {
const handleSaveSubagent = async (e: React.FormEvent) => {
e.preventDefault();
if (!subagentForm.id.trim()) {
setError('Sub-agent ID 不能为空');
setError(t('Sub-agent ID 不能为空', 'Sub-agent ID cannot be empty'));
return;
}
setSubagentSubmitting(true);
@ -268,8 +292,8 @@ export default function AgentsPage() {
allow_mcp: subagentForm.allow_mcp,
tags: subagentForm.tags.split(',').map((item) => item.trim()).filter(Boolean),
aliases: subagentForm.aliases.split(',').map((item) => item.trim()).filter(Boolean),
metadata: parseJsonObject(subagentForm.metadata_json, 'Metadata'),
mcp_servers: parseNestedJsonObject(subagentForm.mcp_servers_json, 'MCP Servers'),
metadata: parseJsonObject(subagentForm.metadata_json, 'Metadata', locale),
mcp_servers: parseNestedJsonObject(subagentForm.mcp_servers_json, 'MCP Servers', locale),
};
if (editingSubagentId) {
await updateSubagent(editingSubagentId, payload);
@ -279,7 +303,7 @@ export default function AgentsPage() {
handleSubagentDialogOpenChange(false);
await load(true);
} catch (err: any) {
setError(err.message || '保存 Sub-Agent 失败');
setError(err.message || t('保存 Sub-Agent 失败', 'Failed to save the sub-agent'));
} finally {
setSubagentSubmitting(false);
}
@ -290,7 +314,7 @@ export default function AgentsPage() {
await deleteSubagent(subagentId);
await load(true);
} catch (err: any) {
setError(err.message || '删除 Sub-Agent 失败');
setError(err.message || t('删除 Sub-Agent 失败', 'Failed to delete the sub-agent'));
}
};
@ -308,47 +332,47 @@ export default function AgentsPage() {
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Bot className="w-6 h-6" />
{t('智能体', 'Agents')}
</h1>
<p className="text-sm text-muted-foreground mt-1">
A2A Sub-Agent
{t('管理外部 A2A 智能体,以及持久化的本地 Sub-Agent。', 'Manage external A2A agents and persistent local sub-agents.')}
</p>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Button variant="outline" size="sm" onClick={handleRefresh}>
<RefreshCw className={`w-4 h-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
{t('刷新', 'Refresh')}
</Button>
<Dialog open={agentDialogOpen} onOpenChange={handleAgentDialogOpenChange}>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<Plus className="w-4 h-4 mr-2" />
{t('新增智能体', 'Add agent')}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle>{t('新增工作区智能体', 'Add workspace agent')}</DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={handleCreateAgent}>
<div className="space-y-2">
<Label htmlFor="base_url">A2A </Label>
<Label htmlFor="base_url">{t('A2A 部署地址', 'A2A base URL')}</Label>
<Input
id="base_url"
value={agentForm.base_url}
onChange={(e) => setAgentForm((s) => ({ ...s, base_url: e.target.value }))}
placeholder="https://agent.example.com 或 agent.example.com:19090"
placeholder={t('https://agent.example.com 或 agent.example.com:19090', 'https://agent.example.com or agent.example.com:19090')}
/>
<p className="text-xs text-muted-foreground leading-relaxed">
{t('默认只需要填写部署地址。保存时会自动读取', 'Usually the base URL is enough. Save will auto-read')}
<code className="mx-1">/.well-known</code>
card
{t('路径并补齐 card 信息。', 'and complete the card metadata.')}
</p>
</div>
<Collapsible open={agentAdvancedOpen} onOpenChange={setAgentAdvancedOpen}>
<CollapsibleTrigger asChild>
<Button type="button" variant="outline" className="w-full justify-between">
{t('高级设置(可选)', 'Advanced settings (optional)')}
<ChevronDown className={`w-4 h-4 transition-transform ${agentAdvancedOpen ? 'rotate-180' : ''}`} />
</Button>
</CollapsibleTrigger>
@ -359,27 +383,27 @@ export default function AgentsPage() {
<Input id="id" value={agentForm.id} onChange={(e) => setAgentForm((s) => ({ ...s, id: e.target.value }))} />
</div>
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Label htmlFor="name">{t('名称', 'Name')}</Label>
<Input id="name" value={agentForm.name} onChange={(e) => setAgentForm((s) => ({ ...s, name: e.target.value }))} />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Label htmlFor="description">{t('描述', 'Description')}</Label>
<Textarea id="description" value={agentForm.description} onChange={(e) => setAgentForm((s) => ({ ...s, description: e.target.value }))} rows={3} />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="endpoint"></Label>
<Label htmlFor="endpoint">{t('接口地址', 'Endpoint URL')}</Label>
<Input id="endpoint" value={agentForm.endpoint} onChange={(e) => setAgentForm((s) => ({ ...s, endpoint: e.target.value }))} />
</div>
<div className="space-y-2">
<Label htmlFor="card_url"></Label>
<Label htmlFor="card_url">{t('卡片地址', 'Card URL')}</Label>
<Input id="card_url" value={agentForm.card_url} onChange={(e) => setAgentForm((s) => ({ ...s, card_url: e.target.value }))} />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="auth_mode"></Label>
<Label htmlFor="auth_mode">{t('鉴权模式', 'Auth mode')}</Label>
<select
id="auth_mode"
value={agentForm.auth_mode}
@ -400,31 +424,34 @@ export default function AgentsPage() {
</div>
</div>
<div className="space-y-2">
<Label htmlFor="auth_env"></Label>
<Label htmlFor="auth_env">{t('认证环境变量', 'Credential env var')}</Label>
<Input id="auth_env" value={agentForm.auth_env} onChange={(e) => setAgentForm((s) => ({ ...s, auth_env: e.target.value }))} />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="tags"></Label>
<Label htmlFor="tags">{t('标签', 'Tags')}</Label>
<Input id="tags" value={agentForm.tags} onChange={(e) => setAgentForm((s) => ({ ...s, tags: e.target.value }))} />
</div>
<div className="space-y-2">
<Label htmlFor="aliases"></Label>
<Label htmlFor="aliases">{t('别名', 'Aliases')}</Label>
<Input id="aliases" value={agentForm.aliases} onChange={(e) => setAgentForm((s) => ({ ...s, aliases: e.target.value }))} />
</div>
</div>
</CollapsibleContent>
</Collapsible>
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
Sub-Agent Sub-Agent registry
{t(
'如果这是持久化本地 Sub-Agent请改用下面的 Sub-Agent 面板,不要在这里单独删除 registry 记录。',
'If this is a persistent local sub-agent, manage it from the sub-agent panel below instead of deleting the registry entry here.'
)}
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => handleAgentDialogOpenChange(false)}>
{t('取消', 'Cancel')}
</Button>
<Button type="submit" disabled={agentSubmitting}>
{agentSubmitting ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Plus className="w-4 h-4 mr-2" />}
{t('保存', 'Save')}
</Button>
</div>
</form>
@ -434,12 +461,12 @@ export default function AgentsPage() {
<DialogTrigger asChild>
<Button size="sm">
<Plus className="w-4 h-4 mr-2" />
Sub-Agent
{t('新增 Sub-Agent', 'Add sub-agent')}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>{editingSubagentId ? '编辑 Sub-Agent' : '新增 Persistent Sub-Agent'}</DialogTitle>
<DialogTitle>{editingSubagentId ? t('编辑 Sub-Agent', 'Edit sub-agent') : t('新增 Persistent Sub-Agent', 'Create persistent sub-agent')}</DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSaveSubagent}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
@ -454,7 +481,7 @@ export default function AgentsPage() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="subagent_name"></Label>
<Label htmlFor="subagent_name">{t('名称', 'Name')}</Label>
<Input
id="subagent_name"
value={subagentForm.name}
@ -464,13 +491,13 @@ export default function AgentsPage() {
</div>
</div>
<div className="space-y-2">
<Label htmlFor="subagent_description"></Label>
<Label htmlFor="subagent_description">{t('描述', 'Description')}</Label>
<Textarea
id="subagent_description"
rows={3}
value={subagentForm.description}
onChange={(e) => setSubagentForm((s) => ({ ...s, description: e.target.value }))}
placeholder="用于研究和资料整理的本地持久化子智能体"
placeholder={t('用于研究和资料整理的本地持久化子智能体', 'A persistent local sub-agent for research and note taking')}
/>
</div>
<div className="space-y-2">
@ -485,16 +512,16 @@ export default function AgentsPage() {
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="subagent_model"></Label>
<Label htmlFor="subagent_model">{t('模型', 'Model')}</Label>
<Input
id="subagent_model"
value={subagentForm.model}
onChange={(e) => setSubagentForm((s) => ({ ...s, model: e.target.value }))}
placeholder="留空则继承主 Agent 默认模型"
placeholder={t('留空则继承主 Agent 默认模型', 'Leave blank to inherit the lead agent model')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="delegation_mode"></Label>
<Label htmlFor="delegation_mode">{t('委派模式', 'Delegation mode')}</Label>
<select
id="delegation_mode"
value={subagentForm.delegation_mode}
@ -509,8 +536,8 @@ export default function AgentsPage() {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="flex items-center justify-between rounded-md border border-border/70 px-3 py-2">
<div>
<Label htmlFor="subagent_enabled"></Label>
<p className="text-xs text-muted-foreground mt-1"> workspace </p>
<Label htmlFor="subagent_enabled">{t('启用', 'Enabled')}</Label>
<p className="text-xs text-muted-foreground mt-1">{t('关闭后仍保留 workspace 和配置', 'Turning this off keeps the workspace and config intact')}</p>
</div>
<Switch
id="subagent_enabled"
@ -520,8 +547,8 @@ export default function AgentsPage() {
</div>
<div className="flex items-center justify-between rounded-md border border-border/70 px-3 py-2">
<div>
<Label htmlFor="subagent_allow_mcp"> MCP</Label>
<p className="text-xs text-muted-foreground mt-1"> MCP </p>
<Label htmlFor="subagent_allow_mcp">{t('允许 MCP', 'Allow MCP')}</Label>
<p className="text-xs text-muted-foreground mt-1">{t('保留 MCP 配置并在运行时接入', 'Keep MCP config and attach it at runtime')}</p>
</div>
<Switch
id="subagent_allow_mcp"
@ -532,7 +559,7 @@ export default function AgentsPage() {
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="subagent_tags"></Label>
<Label htmlFor="subagent_tags">{t('标签', 'Tags')}</Label>
<Input
id="subagent_tags"
value={subagentForm.tags}
@ -541,7 +568,7 @@ export default function AgentsPage() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="subagent_aliases"></Label>
<Label htmlFor="subagent_aliases">{t('别名', 'Aliases')}</Label>
<Input
id="subagent_aliases"
value={subagentForm.aliases}
@ -553,7 +580,7 @@ export default function AgentsPage() {
<Collapsible open={subagentAdvancedOpen} onOpenChange={setSubagentAdvancedOpen}>
<CollapsibleTrigger asChild>
<Button type="button" variant="outline" className="w-full justify-between">
JSON Metadata / MCP
{t('原始 JSON 设置Metadata / MCP', 'Raw JSON settings (Metadata / MCP)')}
<ChevronDown className={`w-4 h-4 transition-transform ${subagentAdvancedOpen ? 'rotate-180' : ''}`} />
</Button>
</CollapsibleTrigger>
@ -579,19 +606,19 @@ export default function AgentsPage() {
</CollapsibleContent>
</Collapsible>
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
workspace
{t('创建后会自动生成独立 workspace、写入', 'Creating this will generate an isolated workspace and write')}
<code className="mx-1">AGENTS.json</code>
{t('和', 'and')}
<code className="mx-1">AGENTS.md</code>
{t(',并注册到工作区智能体列表。', 'and register it in the workspace agent registry.')}
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => handleSubagentDialogOpenChange(false)}>
{t('取消', 'Cancel')}
</Button>
<Button type="submit" disabled={subagentSubmitting}>
{subagentSubmitting ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : editingSubagentId ? <Pencil className="w-4 h-4 mr-2" /> : <Plus className="w-4 h-4 mr-2" />}
{editingSubagentId ? '更新' : '创建'}
{editingSubagentId ? t('更新', 'Update') : t('创建', 'Create')}
</Button>
</div>
</form>
@ -613,8 +640,8 @@ export default function AgentsPage() {
<Tabs defaultValue="agents" className="space-y-4">
<TabsList>
<TabsTrigger value="agents"></TabsTrigger>
<TabsTrigger value="subagents">Persistent Sub-Agents</TabsTrigger>
<TabsTrigger value="agents">{t('委派目标', 'Delegation targets')}</TabsTrigger>
<TabsTrigger value="subagents">{t('Persistent Sub-Agents', 'Persistent sub-agents')}</TabsTrigger>
</TabsList>
<TabsContent value="agents" className="space-y-4">
@ -630,25 +657,24 @@ export default function AgentsPage() {
<CardTitle className="text-base truncate">{agent.name}</CardTitle>
<p className="text-xs text-muted-foreground mt-1 font-mono">{agent.id}</p>
<p className="text-sm text-muted-foreground mt-2 leading-relaxed">
{agent.description || '—'}
{agent.description || t('暂无描述', 'No description')}
</p>
</div>
<div className="flex items-center gap-2 flex-wrap justify-end">
<Badge variant="outline">{agent.source === 'workspace' ? '工作区' : agent.source === 'plugin' ? '插件' : agent.source === 'skill' ? '技能' : '内置'}</Badge>
<Badge variant="secondary">{agent.protocol || '本地'}</Badge>
{isManagedSubagent && <Badge className="bg-amber-600"> Sub-Agent</Badge>}
{agent.support_streaming && <Badge className="bg-sky-600"></Badge>}
{agent.support_group && <Badge className="bg-emerald-600"></Badge>}
<Badge variant="outline">{agentSourceLabel(agent.source, locale)}</Badge>
<Badge variant="secondary">{agent.protocol || t('本地', 'Local')}</Badge>
{isManagedSubagent && <Badge className="bg-amber-600">{t('受管 Sub-Agent', 'Managed sub-agent')}</Badge>}
{agent.support_streaming && <Badge className="bg-sky-600">{t('流式', 'Streaming')}</Badge>}
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 pt-0">
<div className="grid grid-cols-1 gap-2 text-xs text-muted-foreground">
{agent.base_url && <div><span className="font-medium text-foreground"></span> {agent.base_url}</div>}
{agent.endpoint && <div><span className="font-medium text-foreground"></span> {agent.endpoint}</div>}
{agent.card_url && <div><span className="font-medium text-foreground"></span> {agent.card_url}</div>}
{agent.auth_env && <div><span className="font-medium text-foreground"></span> {agent.auth_env}</div>}
{agent.auth_mode && agent.auth_mode !== 'none' && <div><span className="font-medium text-foreground"></span> {agent.auth_mode}</div>}
{agent.base_url && <div><span className="font-medium text-foreground">{t('基础地址:', 'Base URL:')}</span> {agent.base_url}</div>}
{agent.endpoint && <div><span className="font-medium text-foreground">{t('接口地址:', 'Endpoint:')}</span> {agent.endpoint}</div>}
{agent.card_url && <div><span className="font-medium text-foreground">{t('卡片地址:', 'Card URL:')}</span> {agent.card_url}</div>}
{agent.auth_env && <div><span className="font-medium text-foreground">{t('认证变量:', 'Auth env:')}</span> {agent.auth_env}</div>}
{agent.auth_mode && agent.auth_mode !== 'none' && <div><span className="font-medium text-foreground">{t('鉴权模式:', 'Auth mode:')}</span> {agent.auth_mode}</div>}
{agent.auth_audience && <div><span className="font-medium text-foreground">Audience</span> {agent.auth_audience}</div>}
{(agent.auth_scopes || []).length > 0 && <div><span className="font-medium text-foreground">Scopes</span> {(agent.auth_scopes || []).join(', ')}</div>}
</div>
@ -664,7 +690,7 @@ export default function AgentsPage() {
)}
{agent.aliases.length > 0 && (
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
<span className="font-medium text-foreground"></span>
<span className="font-medium text-foreground">{t('别名:', 'Aliases:')}</span>
{agent.aliases.map((alias) => (
<code key={alias} className="px-2 py-0.5 rounded bg-muted">{alias}</code>
))}
@ -674,14 +700,14 @@ export default function AgentsPage() {
)}
<div className="flex justify-end">
{isManagedSubagent ? (
<span className="text-xs text-muted-foreground"> Sub-Agent </span>
<span className="text-xs text-muted-foreground">{t('请在 Sub-Agent 面板管理', 'Manage this in the sub-agent panel')}</span>
) : isWorkspace ? (
<Button variant="outline" size="sm" onClick={() => handleDeleteAgent(agent.id)}>
<Trash2 className="w-4 h-4 mr-2" />
{t('删除', 'Delete')}
</Button>
) : (
<span className="text-xs text-muted-foreground"></span>
<span className="text-xs text-muted-foreground">{t('只读来源', 'Read-only source')}</span>
)}
</div>
</CardContent>
@ -694,12 +720,11 @@ export default function AgentsPage() {
<TabsContent value="subagents" className="space-y-4">
<Card className="border-border/70 bg-muted/20">
<CardContent className="pt-6 text-sm text-muted-foreground leading-relaxed">
Sub-Agent
{t('持久化 Sub-Agent 会在', 'Persistent sub-agents keep their own workspace under')}
<code className="mx-1">~/.nanobot/workspace/agents/&lt;id&gt;_agent</code>
workspace`AGENTS.json``AGENTS.md`skills memory
{t('下拥有自己的 workspace、`AGENTS.json`、`AGENTS.md`、skills 和 memory。默认委派模式是', ', plus `AGENTS.json`, `AGENTS.md`, skills, and memory. The default delegation mode is')}
<code className="mx-1">remote_a2a_only</code>
A2A agent
{t(',即只能向外委派到远端 A2A agent。', ', which only allows delegation to remote A2A agents.')}
</CardContent>
</Card>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
@ -711,12 +736,12 @@ export default function AgentsPage() {
<CardTitle className="text-base truncate">{subagent.name}</CardTitle>
<p className="text-xs text-muted-foreground mt-1 font-mono">{subagent.id}</p>
<p className="text-sm text-muted-foreground mt-2 leading-relaxed">
{subagent.description || '—'}
{subagent.description || t('暂无描述', 'No description')}
</p>
</div>
<div className="flex items-center gap-2 flex-wrap justify-end">
<Badge variant={subagent.enabled ? 'default' : 'outline'}>
{subagent.enabled ? '启用' : '停用'}
{subagent.enabled ? t('启用', 'Enabled') : t('停用', 'Disabled')}
</Badge>
<Badge variant="secondary">{subagent.delegation_mode}</Badge>
{subagent.allow_mcp && <Badge className="bg-sky-600">MCP</Badge>}
@ -726,11 +751,11 @@ export default function AgentsPage() {
</CardHeader>
<CardContent className="space-y-3 pt-0">
<div className="grid grid-cols-1 gap-2 text-xs text-muted-foreground">
<div><span className="font-medium text-foreground">Workspace</span> {subagent.workspace}</div>
<div><span className="font-medium text-foreground">{t('Workspace', 'Workspace:')}</span> {subagent.workspace}</div>
<div><span className="font-medium text-foreground">Base URL</span> {subagent.base_url}</div>
<div><span className="font-medium text-foreground">RPC</span> {subagent.endpoint}</div>
<div><span className="font-medium text-foreground">Card</span> {subagent.card_url}</div>
<div><span className="font-medium text-foreground">MCP Servers</span> {Object.keys(subagent.mcp_servers || {}).length}</div>
<div><span className="font-medium text-foreground">{t('MCP Servers', 'MCP servers:')}</span> {Object.keys(subagent.mcp_servers || {}).length}</div>
</div>
{subagent.system_prompt && (
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2">
@ -752,7 +777,7 @@ export default function AgentsPage() {
)}
{subagent.aliases.length > 0 && (
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
<span className="font-medium text-foreground"></span>
<span className="font-medium text-foreground">{t('别名:', 'Aliases:')}</span>
{subagent.aliases.map((alias) => (
<code key={alias} className="px-2 py-0.5 rounded bg-muted">{alias}</code>
))}
@ -763,11 +788,11 @@ export default function AgentsPage() {
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => handleEditSubagent(subagent)}>
<Pencil className="w-4 h-4 mr-2" />
{t('编辑', 'Edit')}
</Button>
<Button variant="outline" size="sm" onClick={() => handleDeleteManagedSubagent(subagent.id)}>
<Trash2 className="w-4 h-4 mr-2" />
{t('删除', 'Delete')}
</Button>
</div>
</CardContent>
@ -777,7 +802,7 @@ export default function AgentsPage() {
{subagents.length === 0 && (
<Card>
<CardContent className="pt-6 text-sm text-muted-foreground">
Sub-Agent Sub-Agent
{t('还没有持久化 Sub-Agent。点击右上角“新增 Sub-Agent”开始创建。', 'There are no persistent sub-agents yet. Use "Add sub-agent" in the top-right corner to create one.')}
</CardContent>
</Card>
)}

View File

@ -40,10 +40,13 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { useChatStore } from '@/lib/store';
import type { CronJob } from '@/types';
export default function CronPage() {
const { locale } = useAppI18n();
const sessionId = useChatStore((s) => s.sessionId);
const [jobs, setJobs] = useState<CronJob[]>([]);
const [loading, setLoading] = useState(true);
@ -58,7 +61,7 @@ export default function CronPage() {
const data = await listCronJobs(true);
setJobs(data);
} catch (err: any) {
setError(err.message || '加载任务失败');
setError(err.message || pickAppText(locale, '加载任务失败', 'Failed to load jobs'));
} finally {
setLoading(false);
}
@ -138,16 +141,16 @@ export default function CronPage() {
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold flex items-center gap-2">
<Clock className="w-6 h-6" />
{pickAppText(locale, '定时任务', 'Scheduled tasks')}
</h1>
<div className="flex items-center gap-2">
<Button onClick={loadJobs} variant="outline" size="sm">
<RefreshCw className="w-4 h-4 mr-2" />
{pickAppText(locale, '刷新', 'Refresh')}
</Button>
<Button onClick={() => setShowAdd(true)} size="sm">
<Plus className="w-4 h-4 mr-2" />
{pickAppText(locale, '新建任务', 'New job')}
</Button>
</div>
</div>
@ -178,21 +181,21 @@ export default function CronPage() {
{jobs.length === 0 ? (
<div className="py-12 text-center text-muted-foreground">
<Clock className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="font-medium"></p>
<p className="text-sm mt-1"></p>
<p className="font-medium">{pickAppText(locale, '暂无定时任务', 'No scheduled tasks yet')}</p>
<p className="text-sm mt-1">{pickAppText(locale, '新建一个任务,让智能体按计划自动执行。', 'Create a job to let the agent run on a schedule.')}</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-24"></TableHead>
<TableHead className="w-16">{pickAppText(locale, '启用', 'Enabled')}</TableHead>
<TableHead>{pickAppText(locale, '名称', 'Name')}</TableHead>
<TableHead>{pickAppText(locale, '计划', 'Schedule')}</TableHead>
<TableHead>{pickAppText(locale, '消息', 'Message')}</TableHead>
<TableHead>{pickAppText(locale, '上次运行', 'Last run')}</TableHead>
<TableHead>{pickAppText(locale, '下次运行', 'Next run')}</TableHead>
<TableHead>{pickAppText(locale, '状态', 'Status')}</TableHead>
<TableHead className="w-24">{pickAppText(locale, '操作', 'Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@ -238,7 +241,7 @@ export default function CronPage() {
)}
{job.last_status === 'error' && (
<Badge variant="destructive" className="text-xs">
{pickAppText(locale, '错误', 'Error')}
</Badge>
)}
{!job.last_status && (
@ -254,7 +257,7 @@ export default function CronPage() {
size="icon"
className="h-7 w-7"
onClick={() => handleRun(job.id)}
title="立即执行"
title={pickAppText(locale, '立即执行', 'Run now')}
>
<Play className="w-3.5 h-3.5" />
</Button>
@ -263,7 +266,7 @@ export default function CronPage() {
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDelete(job.id)}
title="删除"
title={pickAppText(locale, '删除', 'Delete')}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
@ -294,6 +297,7 @@ function AddJobForm({
}) => void;
onCancel: () => void;
}) {
const { locale } = useAppI18n();
const [name, setName] = useState('');
const [message, setMessage] = useState('');
const [scheduleType, setScheduleType] = useState<'every' | 'cron'>('every');
@ -317,7 +321,7 @@ function AddJobForm({
<Card>
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<CardTitle className="text-base"></CardTitle>
<CardTitle className="text-base">{pickAppText(locale, '新建定时任务', 'New scheduled task')}</CardTitle>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onCancel}>
<X className="w-4 h-4" />
</Button>
@ -327,16 +331,16 @@ function AddJobForm({
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Label htmlFor="name">{pickAppText(locale, '任务名称', 'Job name')}</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="例如:日报汇总"
placeholder={pickAppText(locale, '例如:日报汇总', 'Example: daily summary')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="schedule-type"></Label>
<Label htmlFor="schedule-type">{pickAppText(locale, '调度类型', 'Schedule type')}</Label>
<Select
value={scheduleType}
onValueChange={(v) => setScheduleType(v as 'every' | 'cron')}
@ -345,8 +349,8 @@ function AddJobForm({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="every"> N </SelectItem>
<SelectItem value="cron">Cron </SelectItem>
<SelectItem value="every">{pickAppText(locale, '固定间隔(每 N 秒)', 'Fixed interval (every N seconds)')}</SelectItem>
<SelectItem value="cron">{pickAppText(locale, 'Cron 表达式', 'Cron expression')}</SelectItem>
</SelectContent>
</Select>
</div>
@ -354,7 +358,7 @@ function AddJobForm({
{scheduleType === 'every' ? (
<div className="space-y-2">
<Label htmlFor="every"></Label>
<Label htmlFor="every">{pickAppText(locale, '间隔(秒)', 'Interval (seconds)')}</Label>
<Input
id="every"
type="number"
@ -365,15 +369,15 @@ function AddJobForm({
/>
<p className="text-xs text-muted-foreground">
{parseInt(everySeconds, 10) >= 3600
? `${Math.floor(parseInt(everySeconds, 10) / 3600)} 小时 ${Math.floor((parseInt(everySeconds, 10) % 3600) / 60)}`
? pickAppText(locale, `${Math.floor(parseInt(everySeconds, 10) / 3600)} 小时 ${Math.floor((parseInt(everySeconds, 10) % 3600) / 60)}`, `About ${Math.floor(parseInt(everySeconds, 10) / 3600)}h ${Math.floor((parseInt(everySeconds, 10) % 3600) / 60)}m`)
: parseInt(everySeconds, 10) >= 60
? `${Math.floor(parseInt(everySeconds, 10) / 60)}${parseInt(everySeconds, 10) % 60}`
? pickAppText(locale, `${Math.floor(parseInt(everySeconds, 10) / 60)}${parseInt(everySeconds, 10) % 60}`, `About ${Math.floor(parseInt(everySeconds, 10) / 60)}m ${parseInt(everySeconds, 10) % 60}s`)
: ''}
</p>
</div>
) : (
<div className="space-y-2">
<Label htmlFor="cron">Cron </Label>
<Label htmlFor="cron">{pickAppText(locale, 'Cron 表达式', 'Cron expression')}</Label>
<Input
id="cron"
value={cronExpr}
@ -381,31 +385,31 @@ function AddJobForm({
placeholder="0 9 * * *"
/>
<p className="text-xs text-muted-foreground">
{pickAppText(locale, '格式:分钟 小时 日 月 周', 'Format: minute hour day month weekday')}
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="message"></Label>
<Label htmlFor="message">{pickAppText(locale, '发送给智能体的消息', 'Message for the agent')}</Label>
<Input
id="message"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="例如:检查我的邮件并生成摘要"
placeholder={pickAppText(locale, '例如:检查我的邮件并生成摘要', 'Example: check my email and generate a summary')}
/>
<p className="text-xs text-muted-foreground">
Web <code className="bg-muted px-1 py-0.5 rounded">{targetSessionKey}</code>
{pickAppText(locale, '任务结果会自动回写到当前 Web 会话:', 'Results are written back to the current web session:')} <code className="bg-muted px-1 py-0.5 rounded">{targetSessionKey}</code>
</p>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel}>
{pickAppText(locale, '取消', 'Cancel')}
</Button>
<Button type="submit" disabled={!name.trim() || !message.trim()}>
<Plus className="w-4 h-4 mr-2" />
{pickAppText(locale, '创建任务', 'Create job')}
</Button>
</div>
</form>

View File

@ -29,8 +29,11 @@ import {
import type { WorkspaceItem } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
export default function FilesPage() {
const { locale } = useAppI18n();
const [items, setItems] = useState<WorkspaceItem[]>([]);
const [currentPath, setCurrentPath] = useState('');
const [loading, setLoading] = useState(true);
@ -63,8 +66,14 @@ export default function FilesPage() {
};
const handleDelete = async (item: WorkspaceItem) => {
const label = item.type === 'directory' ? '文件夹' : '文件';
if (!confirm(`确定删除${label} "${item.name}"${item.type === 'directory' ? '(包含所有子文件' : ''}`)) {
const label = item.type === 'directory'
? pickAppText(locale, '文件', 'folder')
: pickAppText(locale, '文件', 'file');
if (!confirm(pickAppText(
locale,
`确定删除${label} "${item.name}"${item.type === 'directory' ? '(包含所有子文件)' : ''}`,
`Delete ${label} "${item.name}"?${item.type === 'directory' ? ' (including all nested files)' : ''}`
))) {
return;
}
try {
@ -144,7 +153,7 @@ export default function FilesPage() {
const formatDate = (iso: string) => {
try {
return new Date(iso).toLocaleString('zh-CN', {
return new Date(iso).toLocaleString(locale, {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
@ -159,7 +168,7 @@ export default function FilesPage() {
<div className="max-w-4xl mx-auto p-6">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold"></h1>
<h1 className="text-2xl font-bold">{pickAppText(locale, '文件管理', 'Files')}</h1>
<div className="flex items-center gap-2">
<Button
variant="outline"
@ -168,7 +177,7 @@ export default function FilesPage() {
disabled={loading}
>
<FolderPlus className="w-4 h-4 mr-1" />
{pickAppText(locale, '新建文件夹', 'New folder')}
</Button>
<Button
variant="outline"
@ -184,7 +193,7 @@ export default function FilesPage() {
) : (
<>
<Upload className="w-4 h-4 mr-1" />
{pickAppText(locale, '上传', 'Upload')}
</>
)}
</Button>
@ -212,7 +221,7 @@ export default function FilesPage() {
className="flex items-center gap-1 hover:text-foreground transition-colors px-1.5 py-0.5 rounded hover:bg-accent"
>
<Home className="w-3.5 h-3.5" />
{pickAppText(locale, '工作区', 'Workspace')}
</button>
{breadcrumbs.map((segment, idx) => {
const path = breadcrumbs.slice(0, idx + 1).join('/');
@ -251,12 +260,12 @@ export default function FilesPage() {
setNewDirName('');
}
}}
placeholder="文件夹名称"
placeholder={pickAppText(locale, '文件夹名称', 'Folder name')}
className="flex-1 px-3 py-1.5 text-sm border border-border rounded-md bg-background focus:outline-none focus:ring-1 focus:ring-ring"
autoFocus
/>
<Button size="sm" onClick={handleCreateDir}>
{pickAppText(locale, '创建', 'Create')}
</Button>
<Button
size="sm"
@ -266,7 +275,7 @@ export default function FilesPage() {
setNewDirName('');
}}
>
{pickAppText(locale, '取消', 'Cancel')}
</Button>
</div>
)}
@ -279,8 +288,8 @@ export default function FilesPage() {
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<FolderOpen className="w-12 h-12 mb-4 opacity-50" />
<p className="text-lg font-medium"></p>
<p className="text-sm">"上传""新建文件夹"使</p>
<p className="text-lg font-medium">{pickAppText(locale, '空文件夹', 'Empty folder')}</p>
<p className="text-sm">{pickAppText(locale, '点击上方"上传"或"新建文件夹"按钮开始使用', 'Use "Upload" or "New folder" above to get started')}</p>
</div>
) : (
<ScrollArea className="h-[calc(100vh-14rem)]">
@ -330,7 +339,7 @@ export default function FilesPage() {
size="icon"
className="h-7 w-7"
onClick={() => handleDownload(item)}
title="下载"
title={pickAppText(locale, '下载', 'Download')}
>
<Download className="w-4 h-4" />
</Button>
@ -340,7 +349,7 @@ export default function FilesPage() {
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDelete(item)}
title="删除"
title={pickAppText(locale, '删除', 'Delete')}
>
<Trash2 className="w-4 h-4" />
</Button>

View File

@ -1,168 +0,0 @@
'use client';
import React, { useState } from 'react';
import {
MessageSquare,
Terminal,
Layers,
Wifi,
WifiOff,
Plus,
Trash2,
Send,
ChevronDown,
ChevronRight,
} from 'lucide-react';
interface SectionProps {
icon: React.ReactNode;
title: string;
children: React.ReactNode;
defaultOpen?: boolean;
}
function Section({ icon, title, children, defaultOpen = false }: SectionProps) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="border border-border rounded-lg overflow-hidden">
<button
className="w-full flex items-center gap-3 px-4 py-3 bg-card hover:bg-accent/50 transition-colors text-left"
onClick={() => setOpen((v) => !v)}
>
<span className="text-primary">{icon}</span>
<span className="font-medium flex-1">{title}</span>
{open ? <ChevronDown className="w-4 h-4 text-muted-foreground" /> : <ChevronRight className="w-4 h-4 text-muted-foreground" />}
</button>
{open && (
<div className="px-4 py-4 space-y-3 text-sm text-muted-foreground border-t border-border bg-background">
{children}
</div>
)}
</div>
);
}
function Tag({ children, color = 'default' }: { children: React.ReactNode; color?: 'green' | 'yellow' | 'red' | 'default' }) {
const cls = {
green: 'bg-green-900/30 text-green-400 border-green-800',
yellow: 'bg-yellow-900/30 text-yellow-400 border-yellow-800',
red: 'bg-red-900/30 text-red-400 border-red-800',
default: 'bg-muted text-foreground border-border',
}[color];
return (
<span className={`inline-block px-2 py-0.5 rounded border text-xs font-mono ${cls}`}>
{children}
</span>
);
}
export default function HelpPage() {
return (
<div className="max-w-2xl mx-auto px-4 py-8 space-y-4">
<div className="mb-6">
<h1 className="text-2xl font-bold mb-1">使</h1>
<p className="text-muted-foreground text-sm">使 Boardware Agent Sandbox </p>
</div>
<Section icon={<MessageSquare className="w-5 h-5" />} title="如何开始对话" defaultOpen>
<p><strong className="text-foreground"></strong><strong className="text-foreground"></strong></p>
<ol className="list-decimal list-inside space-y-1.5 ml-1">
<li></li>
<li> <Tag>Enter</Tag> <Tag>Shift + Enter</Tag> </li>
<li> Boardware Agent Sandbox "思考中..."</li>
</ol>
<p className="mt-1">
<Tag></Tag>
</p>
</Section>
<Section icon={<Terminal className="w-5 h-5" />} title="斜杠命令(/命令)">
<p> <Tag>/</Tag> </p>
<ul className="space-y-1.5 ml-1">
<li> <Tag>/</Tag> </li>
<li> <Tag></Tag> <Tag></Tag> </li>
<li> <Tag>Enter</Tag> <Tag>Tab</Tag> </li>
<li> <Tag>Esc</Tag> </li>
</ul>
<p><strong className="text-foreground"></strong><strong className="text-foreground"></strong></p>
</Section>
<Section icon={<Layers className="w-5 h-5" />} title="对话管理">
<ul className="space-y-2 ml-1">
<li>
<span className="inline-flex items-center gap-1"><Plus className="w-3.5 h-3.5" /><strong className="text-foreground"></strong></span>
{' '}
</li>
<li>
<span className="inline-flex items-center gap-1"><MessageSquare className="w-3.5 h-3.5" /><strong className="text-foreground"></strong></span>
{' '}
</li>
<li>
<span className="inline-flex items-center gap-1"><Trash2 className="w-3.5 h-3.5" /><strong className="text-foreground"></strong></span>
{' '}
</li>
</ul>
<p className="text-xs mt-1 text-muted-foreground/70"></p>
</Section>
<Section icon={<Wifi className="w-5 h-5" />} title="连接状态说明">
<p></p>
<div className="space-y-2 ml-1 mt-2">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-500 flex-shrink-0" />
<Tag color="green"></Tag>
<span> Boardware Agent Sandbox </span>
</div>
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-yellow-500 flex-shrink-0" />
<Tag color="yellow"> / </Tag>
<span> </span>
</div>
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0" />
<Tag color="red">线</Tag>
<span> Boardware Agent Sandbox </span>
</div>
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0" />
<Tag color="red"></Tag>
<span> </span>
</div>
</div>
<p className="mt-3 text-xs">
<strong className="text-foreground"></strong>"服务离线"
</p>
</Section>
<Section icon={<Send className="w-5 h-5" />} title="输入技巧">
<ul className="space-y-2 ml-1">
<li><Tag>Enter</Tag> </li>
<li><Tag>Shift + Enter</Tag> </li>
<li>使 Enter Enter </li>
<li> <Tag>/</Tag> </li>
</ul>
</Section>
<Section icon={<WifiOff className="w-5 h-5" />} title="常见问题">
<div className="space-y-4">
<div>
<p className="font-medium text-foreground mb-1">Q</p>
<p>"已连接""服务离线""未连接"</p>
</div>
<div>
<p className="font-medium text-foreground mb-1">Q Boardware Agent Sandbox </p>
<p><strong className="text-foreground"></strong>AI </p>
</div>
<div>
<p className="font-medium text-foreground mb-1">Q使</p>
<p><strong className="text-foreground"></strong></p>
</div>
<div>
<p className="font-medium text-foreground mb-1">Q</p>
<p><strong className="text-foreground"></strong> Boardware Agent Sandbox <strong className="text-foreground"></strong> Agent </p>
</div>
</div>
</Section>
</div>
);
}

View File

@ -1,5 +1,6 @@
import Header from '@/components/Header';
import AuthGuard from '@/components/AuthGuard';
import { AppRuntimeBridge } from '@/components/AppRuntimeBridge';
export default function AppLayout({
children,
@ -10,7 +11,10 @@ export default function AppLayout({
<div className="min-h-screen bg-background text-foreground">
<Header />
<main className="pt-16">
<AuthGuard>{children}</AuthGuard>
<AuthGuard>
<AppRuntimeBridge />
{children}
</AuthGuard>
</main>
</div>
);

View File

@ -28,8 +28,11 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import type { Marketplace, MarketplacePlugin } from '@/types';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
export default function MarketplacePage() {
const { locale } = useAppI18n();
const [marketplaces, setMarketplaces] = useState<Marketplace[]>([]);
const [selectedMarketplace, setSelectedMarketplace] = useState<string | null>(null);
const [plugins, setPlugins] = useState<MarketplacePlugin[]>([]);
@ -60,7 +63,7 @@ export default function MarketplacePage() {
setPlugins([]);
}
} catch (err: any) {
setError(err.message || '加载市场失败');
setError(err.message || pickAppText(locale, '加载市场失败', 'Failed to load marketplaces'));
} finally {
setLoading(false);
}
@ -72,7 +75,7 @@ export default function MarketplacePage() {
const data = await listMarketplacePlugins(marketplaceName);
setPlugins(Array.isArray(data) ? data : []);
} catch (err: any) {
setError(err.message || '加载插件失败');
setError(err.message || pickAppText(locale, '加载插件失败', 'Failed to load plugins'));
} finally {
setPluginsLoading(false);
}
@ -99,7 +102,7 @@ export default function MarketplacePage() {
await loadMarketplaces();
setSelectedMarketplace(marketplace.name);
} catch (err: any) {
setError(err.message || '添加市场失败');
setError(err.message || pickAppText(locale, '添加市场失败', 'Failed to add the marketplace'));
} finally {
setAdding(false);
}
@ -115,7 +118,7 @@ export default function MarketplacePage() {
}
await loadMarketplaces();
} catch (err: any) {
setError(err.message || '移除市场失败');
setError(err.message || pickAppText(locale, '移除市场失败', 'Failed to remove the marketplace'));
}
};
@ -126,7 +129,7 @@ export default function MarketplacePage() {
await updateMarketplace(name);
await loadPlugins(name);
} catch (err: any) {
setError(err.message || '更新市场失败');
setError(err.message || pickAppText(locale, '更新市场失败', 'Failed to update the marketplace'));
} finally {
setUpdatingMarketplace(null);
}
@ -139,7 +142,7 @@ export default function MarketplacePage() {
await installMarketplacePlugin(marketplaceName, pluginName);
await loadPlugins(marketplaceName);
} catch (err: any) {
setError(err.message || '更新插件失败');
setError(err.message || pickAppText(locale, '更新插件失败', 'Failed to update the plugin'));
} finally {
setActionPlugin(null);
}
@ -152,7 +155,7 @@ export default function MarketplacePage() {
await installMarketplacePlugin(marketplaceName, pluginName);
await loadPlugins(marketplaceName);
} catch (err: any) {
setError(err.message || '安装插件失败');
setError(err.message || pickAppText(locale, '安装插件失败', 'Failed to install the plugin'));
} finally {
setActionPlugin(null);
}
@ -167,7 +170,7 @@ export default function MarketplacePage() {
await loadPlugins(selectedMarketplace);
}
} catch (err: any) {
setError(err.message || '卸载插件失败');
setError(err.message || pickAppText(locale, '卸载插件失败', 'Failed to uninstall the plugin'));
} finally {
setActionPlugin(null);
}
@ -195,10 +198,10 @@ export default function MarketplacePage() {
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Store className="w-6 h-6" />
{pickAppText(locale, '插件市场', 'Plugin marketplace')}
</h1>
<p className="text-sm text-muted-foreground mt-1">
{pickAppText(locale, '浏览并安装已注册市场中的插件', 'Browse and install plugins from registered marketplaces')}
</p>
</div>
<div className="flex items-center gap-2">
@ -208,11 +211,11 @@ export default function MarketplacePage() {
size="sm"
>
<Plus className="w-4 h-4 mr-2" />
{pickAppText(locale, '添加市场', 'Add marketplace')}
</Button>
<Button onClick={handleRefresh} variant="outline" size="sm">
<RefreshCw className="w-4 h-4 mr-2" />
{pickAppText(locale, '刷新', 'Refresh')}
</Button>
</div>
</div>
@ -245,7 +248,7 @@ export default function MarketplacePage() {
<CardContent className="pt-6">
<div className="flex items-center gap-2">
<Input
placeholder="本地路径或 Git 地址(例如 /path/to/marketplace 或 https://github.com/..."
placeholder={pickAppText(locale, '本地路径或 Git 地址(例如 /path/to/marketplace 或 https://github.com/...', 'Local path or Git URL (for example /path/to/marketplace or https://github.com/...)')}
value={addSource}
onChange={(e) => setAddSource(e.target.value)}
onKeyDown={(e) => {
@ -260,7 +263,7 @@ export default function MarketplacePage() {
) : (
<Plus className="w-4 h-4 mr-2" />
)}
{pickAppText(locale, '添加', 'Add')}
</Button>
<Button
onClick={() => {
@ -270,7 +273,7 @@ export default function MarketplacePage() {
variant="ghost"
size="sm"
>
{pickAppText(locale, '取消', 'Cancel')}
</Button>
</div>
</CardContent>
@ -301,7 +304,7 @@ export default function MarketplacePage() {
className="h-8 w-8 p-0 text-muted-foreground hover:text-primary"
disabled={updatingMarketplace === marketplace.name}
onClick={() => handleUpdateMarketplace(marketplace.name)}
title="更新市场"
title={pickAppText(locale, '更新市场', 'Update marketplace')}
>
{updatingMarketplace === marketplace.name ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
@ -327,9 +330,9 @@ export default function MarketplacePage() {
<Card>
<CardContent className="py-16 text-center text-muted-foreground">
<Store className="w-12 h-12 mx-auto mb-4 opacity-30" />
<p className="font-medium"></p>
<p className="font-medium">{pickAppText(locale, '还没有注册任何市场', 'No marketplaces are registered yet')}</p>
<p className="text-sm mt-2 max-w-sm mx-auto">
<strong></strong> Git 使
{pickAppText(locale, '点击上方的', 'Use the')}<strong>{pickAppText(locale, '添加市场', 'Add marketplace')}</strong>{pickAppText(locale, ',填入本地路径或 Git 地址即可开始使用。', ' action above and provide a local path or Git URL to get started.')}
</p>
</CardContent>
</Card>
@ -346,8 +349,8 @@ export default function MarketplacePage() {
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<Store className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="font-medium"></p>
<p className="text-sm mt-1"></p>
<p className="font-medium">{pickAppText(locale, '暂无可用插件', 'No plugins available')}</p>
<p className="text-sm mt-1">{pickAppText(locale, '这个市场里暂时还没有插件。', 'There are no plugins in this marketplace yet.')}</p>
</CardContent>
</Card>
) : (
@ -364,7 +367,7 @@ export default function MarketplacePage() {
{plugin.installed && (
<Badge variant="secondary" className="text-xs gap-1">
<Check className="w-3 h-3" />
{pickAppText(locale, '已安装', 'Installed')}
</Badge>
)}
</div>
@ -390,7 +393,7 @@ export default function MarketplacePage() {
) : (
<RefreshCw className="w-4 h-4 mr-2" />
)}
{pickAppText(locale, '更新', 'Update')}
</Button>
<Button
variant="outline"
@ -403,7 +406,7 @@ export default function MarketplacePage() {
) : (
<Trash2 className="w-4 h-4 mr-2" />
)}
{pickAppText(locale, '卸载', 'Uninstall')}
</Button>
</>
) : (
@ -420,7 +423,7 @@ export default function MarketplacePage() {
) : (
<Download className="w-4 h-4 mr-2" />
)}
{pickAppText(locale, '安装', 'Install')}
</Button>
)}
</div>

View File

@ -15,6 +15,9 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import type { AppLocale } from '@/lib/i18n/core';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
type McpFormMode = 'remote' | 'install';
@ -81,20 +84,22 @@ function resolveAuthzMcpScopes(authzStatus: AuthzStatus | null, serverId: string
};
}
function serverStatusLabel(status?: string | null) {
if (status === 'connected') return '已连接';
if (status === 'error') return '异常';
if (status === 'disconnected' || !status) return '未连接';
function serverStatusLabel(status: string | null | undefined, locale: AppLocale) {
if (status === 'connected') return pickAppText(locale, '已连接', 'Connected');
if (status === 'error') return pickAppText(locale, '异常', 'Error');
if (status === 'disconnected' || !status) return pickAppText(locale, '未连接', 'Disconnected');
return status;
}
function transportLabel(transport?: string) {
if (transport === 'stdio') return '标准输入输出';
function transportLabel(transport: string | undefined, locale: AppLocale) {
if (transport === 'stdio') return pickAppText(locale, '标准输入输出', 'Standard I/O');
if (transport === 'http') return 'HTTP';
return transport || '-';
}
export default function MCPPage() {
const { locale } = useAppI18n();
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
const cachedServers = useChatStore((s) => s.mcpRegistry);
const cachedTools = useChatStore((s) => s.mcpToolRegistry);
const setCachedServers = useChatStore((s) => s.setMcpRegistry);
@ -134,7 +139,7 @@ export default function MCPPage() {
setAuthzStatus(authzData);
setSelectedServerId((current) => (current && nextServers.some((server) => server.id === current) ? current : null));
} catch (err: any) {
setError(err.message || '加载 MCP 服务失败');
setError(err.message || t('加载 MCP 服务失败', 'Failed to load MCP servers'));
} finally {
if (background) {
setRefreshing(false);
@ -172,7 +177,7 @@ export default function MCPPage() {
if (!value.trim()) return {};
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(`${label} 必须是 JSON 对象`);
throw new Error(`${label} ${t('必须是 JSON 对象', 'must be a JSON object')}`);
}
return parsed as Record<string, string>;
};
@ -187,16 +192,16 @@ export default function MCPPage() {
const command = form.command.trim();
const toolTimeout = Number(form.tool_timeout || 30);
if (!id) {
throw new Error('ID 不能为空');
throw new Error(t('ID 不能为空', 'ID cannot be empty'));
}
if (!Number.isFinite(toolTimeout) || toolTimeout < 1) {
throw new Error('工具超时必须大于 0');
throw new Error(t('工具超时必须大于 0', 'Tool timeout must be greater than 0'));
}
if (form.mode === 'remote' && !url) {
throw new Error('请输入 MCP Server 地址');
throw new Error(t('请输入 MCP Server 地址', 'Enter an MCP server URL'));
}
if (form.mode === 'install' && !command) {
throw new Error('请输入安装或启动命令');
throw new Error(t('请输入安装或启动命令', 'Enter an install or launch command'));
}
const authMode = form.mode === 'remote' ? (form.auth_mode || 'none') : 'none';
@ -209,7 +214,7 @@ export default function MCPPage() {
: [],
env: {},
url: form.mode === 'remote' ? url : '',
headers: form.mode === 'remote' ? parseObjectField('请求头', form.headers) : {},
headers: form.mode === 'remote' ? parseObjectField(t('请求头', 'Headers'), form.headers) : {},
auth_mode: authMode,
auth_audience: authAudience,
auth_scopes: [],
@ -224,7 +229,7 @@ export default function MCPPage() {
resetForm();
await load();
} catch (err: any) {
setError(err.message || '保存 MCP 服务失败');
setError(err.message || t('保存 MCP 服务失败', 'Failed to save the MCP server'));
} finally {
setSubmitting(false);
}
@ -236,7 +241,7 @@ export default function MCPPage() {
setSelectedServerId((current) => (current === serverId ? null : current));
await load();
} catch (err: any) {
setError(err.message || '删除 MCP 服务失败');
setError(err.message || t('删除 MCP 服务失败', 'Failed to delete the MCP server'));
}
};
@ -246,7 +251,7 @@ export default function MCPPage() {
await testMcpServer(serverId);
await load(true);
} catch (err: any) {
setError(err.message || '测试 MCP 服务失败');
setError(err.message || t('测试 MCP 服务失败', 'Failed to test the MCP server'));
} finally {
setTestingId(null);
}
@ -257,20 +262,32 @@ export default function MCPPage() {
const showAuthzPreview = form.auth_mode === 'oauth_backend_token';
const selectedServer = selectedServerId ? servers.find((server) => server.id === selectedServerId) || null : null;
const selectedToolGroup = selectedServerId ? tools.find((group) => group.server_id === selectedServerId) || null : null;
let authzHint = '无需手动填写。Audience 会按 MCP ID 自动生成Scopes 按 AuthZ 当前权限动态决定。';
let authzHint = t(
'无需手动填写。Audience 会按 MCP ID 自动生成Scopes 按 AuthZ 当前权限动态决定。',
'No manual input is required. The audience is generated from the MCP ID and scopes follow current AuthZ permissions.'
);
if (showAuthzPreview) {
if (!form.id.trim()) {
authzHint = '先填写 MCP IDAudience 会自动生成为 mcp:<id>。';
authzHint = t('先填写 MCP IDAudience 会自动生成为 mcp:<id>。', 'Enter the MCP ID first. The audience will become mcp:<id>.');
} else if (!authzStatus?.enabled) {
authzHint = '当前 workspace 没启用 AuthZ选择 oauth_backend_token 后将无法申请访问 token。';
authzHint = t(
'当前 workspace 没启用 AuthZ选择 oauth_backend_token 后将无法申请访问 token。',
'AuthZ is not enabled for this workspace, so oauth_backend_token cannot request access tokens.'
);
} else if (!authzStatus.local_backend.registered) {
authzHint = '当前 backend 还没有在 AuthZ 注册,暂时无法读取权限或申请 token。';
authzHint = t(
'当前 backend 还没有在 AuthZ 注册,暂时无法读取权限或申请 token。',
'The backend is not registered in AuthZ yet, so permissions and access tokens are unavailable.'
);
} else if (authzStatus.error) {
authzHint = `读取 AuthZ 权限失败:${authzStatus.error}`;
authzHint = t(`读取 AuthZ 权限失败:${authzStatus.error}`, `Failed to read AuthZ permissions: ${authzStatus.error}`);
} else if (!authzMcpScopes.available || !authzMcpScopes.enabled) {
authzHint = `AuthZ 里还没有为 ${authAudience || '这个 MCP'} 开启权限,保存后调用会返回 403。`;
authzHint = t(
`AuthZ 里还没有为 ${authAudience || '这个 MCP'} 开启权限,保存后调用会返回 403。`,
`AuthZ does not have permissions enabled for ${authAudience || 'this MCP'} yet, so calls will return 403 after saving.`
);
} else {
authzHint = `已从 AuthZ 读取到 ${authAudience} 的当前权限。`;
authzHint = t(`已从 AuthZ 读取到 ${authAudience} 的当前权限。`, `Loaded current permissions for ${authAudience} from AuthZ.`);
}
}
@ -288,16 +305,16 @@ export default function MCPPage() {
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<ServerCog className="w-6 h-6" />
MCP
{t('MCP 服务', 'MCP servers')}
</h1>
<p className="text-sm text-muted-foreground mt-1">
MCP
{t('管理 MCP 服务配置、连通性和当前已发现的工具。', 'Manage MCP server configuration, connectivity, and discovered tools.')}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => void load(true)}>
<RefreshCw className={`w-4 h-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
{t('刷新', 'Refresh')}
</Button>
<Dialog open={dialogOpen} onOpenChange={(open) => {
setDialogOpen(open);
@ -306,12 +323,12 @@ export default function MCPPage() {
<DialogTrigger asChild>
<Button size="sm">
<Plus className="w-4 h-4 mr-2" />
MCP
{t('新增 MCP', 'Add MCP')}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{editingId ? '编辑 MCP 服务' : '新增 MCP 服务'}</DialogTitle>
<DialogTitle>{editingId ? t('编辑 MCP 服务', 'Edit MCP server') : t('新增 MCP 服务', 'Add MCP server')}</DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
@ -320,7 +337,7 @@ export default function MCPPage() {
<Input id="id" value={form.id} onChange={(e) => setForm((s) => ({ ...s, id: e.target.value }))} required disabled={!!editingId} />
</div>
<div className="space-y-2">
<Label htmlFor="tool_timeout"></Label>
<Label htmlFor="tool_timeout">{t('工具超时', 'Tool timeout')}</Label>
<Input id="tool_timeout" type="number" min="1" value={form.tool_timeout} onChange={(e) => setForm((s) => ({ ...s, tool_timeout: e.target.value }))} />
</div>
</div>
@ -330,24 +347,24 @@ export default function MCPPage() {
className="space-y-4"
>
<div className="space-y-2">
<Label></Label>
<Label>{t('接入方式', 'Connection mode')}</Label>
<TabsList className="grid h-auto w-full grid-cols-1 gap-2 bg-transparent p-0 sm:grid-cols-2">
<TabsTrigger
value="remote"
className="h-full flex-col items-start gap-1 rounded-lg border border-border/70 bg-background/80 px-4 py-3 text-left whitespace-normal data-[state=active]:border-primary"
>
<span className="text-sm font-medium"> MCP Server</span>
<span className="text-sm font-medium">{t('连接已有 MCP Server', 'Connect to an existing MCP server')}</span>
<span className="text-xs font-normal text-muted-foreground">
MCP URL
{t('适合已经部署好的远程 MCP 服务,填写 URL、请求头和鉴权即可。', 'Use this for a remote MCP server that is already deployed. Provide the URL, headers, and auth settings.')}
</span>
</TabsTrigger>
<TabsTrigger
value="install"
className="h-full flex-col items-start gap-1 rounded-lg border border-border/70 bg-background/80 px-4 py-3 text-left whitespace-normal data-[state=active]:border-primary"
>
<span className="text-sm font-medium"></span>
<span className="text-sm font-medium">{t('命令安装并启动', 'Install and launch with a command')}</span>
<span className="text-xs font-normal text-muted-foreground">
`npx``uvx` MCP
{t('适合本机通过 `npx`、`uvx` 或其他命令启动 MCP 进程。', 'Use this when the MCP process runs locally via `npx`, `uvx`, or another command.')}
</span>
</TabsTrigger>
</TabsList>
@ -355,10 +372,10 @@ export default function MCPPage() {
<TabsContent value="remote" className="mt-0 rounded-lg border border-border/70 p-4 space-y-4">
<div className="text-sm text-muted-foreground">
MCP Server访
{t('连接一个已经存在的 MCP Server前端只保存访问地址、请求头和鉴权配置。', 'Connect to an existing MCP server. The frontend only stores the address, headers, and auth settings.')}
</div>
<div className="space-y-2">
<Label htmlFor="url">MCP Server </Label>
<Label htmlFor="url">{t('MCP Server 地址', 'MCP server URL')}</Label>
<Input
id="url"
value={form.url}
@ -369,7 +386,7 @@ export default function MCPPage() {
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="auth_mode"></Label>
<Label htmlFor="auth_mode">{t('鉴权模式', 'Auth mode')}</Label>
<select
id="auth_mode"
value={form.auth_mode}
@ -381,20 +398,20 @@ export default function MCPPage() {
</select>
</div>
<div className="space-y-2 sm:col-span-2">
<Label>AuthZ </Label>
<Label>{t('AuthZ 权限', 'AuthZ permissions')}</Label>
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-3 text-sm space-y-2">
<div className="flex flex-col gap-1">
<span className="text-muted-foreground">Audience</span>
<span className="font-mono text-xs break-all">
{showAuthzPreview ? (authAudience || '填写 MCP ID 后自动生成') : '关闭鉴权时无需配置'}
{showAuthzPreview ? (authAudience || t('填写 MCP ID 后自动生成', 'Generated after you enter the MCP ID')) : t('关闭鉴权时无需配置', 'Not required when auth is disabled')}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-muted-foreground">Scopes</span>
<span className="text-xs break-words">
{showAuthzPreview
? (authzMcpScopes.scopes.length > 0 ? authzMcpScopes.scopes.join(', ') : '由 AuthZ 当前权限动态决定')
: '关闭鉴权时无需配置'}
? (authzMcpScopes.scopes.length > 0 ? authzMcpScopes.scopes.join(', ') : t('由 AuthZ 当前权限动态决定', 'Derived from current AuthZ permissions'))
: t('关闭鉴权时无需配置', 'Not required when auth is disabled')}
</span>
</div>
<div className="text-xs text-muted-foreground">
@ -404,7 +421,7 @@ export default function MCPPage() {
</div>
</div>
<div className="space-y-2">
<Label htmlFor="headers"> JSON</Label>
<Label htmlFor="headers">{t('请求头 JSON', 'Headers JSON')}</Label>
<Textarea
id="headers"
rows={8}
@ -416,11 +433,11 @@ export default function MCPPage() {
<TabsContent value="install" className="mt-0 rounded-lg border border-border/70 p-4 space-y-4">
<div className="text-sm text-muted-foreground">
MCP `npx``uvx`
{t('通过命令安装并启动本地 MCP 进程,适合 `npx`、`uvx`、脚本或容器方式。', 'Install and launch a local MCP process with a command, such as `npx`, `uvx`, a script, or a container.')}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="command"></Label>
<Label htmlFor="command">{t('命令', 'Command')}</Label>
<Input
id="command"
value={form.command}
@ -430,7 +447,7 @@ export default function MCPPage() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="args"></Label>
<Label htmlFor="args">{t('参数', 'Arguments')}</Label>
<Input
id="args"
value={form.args}
@ -443,11 +460,11 @@ export default function MCPPage() {
</Tabs>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
{t('取消', 'Cancel')}
</Button>
<Button type="submit" disabled={submitting}>
{submitting ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Plus className="w-4 h-4 mr-2" />}
{t('保存', 'Save')}
</Button>
</div>
</form>
@ -493,27 +510,27 @@ export default function MCPPage() {
<p className="text-xs text-muted-foreground mt-1 font-mono">{server.id}</p>
</div>
<div className="flex items-center gap-2 flex-wrap justify-end">
<Badge variant="outline">{transportLabel(server.transport)}</Badge>
<Badge variant="outline">{transportLabel(server.transport, locale)}</Badge>
<Badge variant={server.status === 'connected' ? 'default' : server.status === 'error' ? 'destructive' : 'secondary'}>
{serverStatusLabel(server.status)}
{serverStatusLabel(server.status, locale)}
</Badge>
</div>
</div>
</CardHeader>
<CardContent className="pt-0 space-y-3 text-sm">
{server.url && <div><span className="font-medium">URL:</span> <span className="text-muted-foreground break-all">{server.url}</span></div>}
{server.command && <div><span className="font-medium"></span> <span className="text-muted-foreground">{server.command} {(server.args || []).join(' ')}</span></div>}
{server.auth_mode && server.auth_mode !== 'none' && <div><span className="font-medium"></span> <span className="text-muted-foreground">{server.auth_mode}</span></div>}
{server.command && <div><span className="font-medium">{t('命令:', 'Command:')}</span> <span className="text-muted-foreground">{server.command} {(server.args || []).join(' ')}</span></div>}
{server.auth_mode && server.auth_mode !== 'none' && <div><span className="font-medium">{t('鉴权:', 'Auth:')}</span> <span className="text-muted-foreground">{server.auth_mode}</span></div>}
{(server.auth_audience || server.auth_mode === 'oauth_backend_token') && (
<div><span className="font-medium">Audience</span> <span className="text-muted-foreground">{server.auth_audience || resolveAuthAudience(server.id)}</span></div>
)}
{(server.auth_scopes || []).length > 0 && <div><span className="font-medium">Scopes</span> <span className="text-muted-foreground break-all">{(server.auth_scopes || []).join(', ')}</span></div>}
{server.auth_mode === 'oauth_backend_token' && (!server.auth_scopes || server.auth_scopes.length === 0) && (
<div><span className="font-medium">Scopes</span> <span className="text-muted-foreground"> AuthZ </span></div>
<div><span className="font-medium">Scopes</span> <span className="text-muted-foreground">{t('由 AuthZ 动态决定', 'Derived from AuthZ')}</span></div>
)}
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
<span>{server.tool_count || 0} </span>
<span>{selectedServerId === server.id ? '已选中' : '点击查看工具'}</span>
<span>{t(`${server.tool_count || 0} 个工具`, `${server.tool_count || 0} tools`)}</span>
<span>{selectedServerId === server.id ? t('已选中', 'Selected') : t('点击查看工具', 'Click to view tools')}</span>
{server.last_error && <span className="text-rose-300">{server.last_error}</span>}
</div>
<div className="flex items-center gap-2 justify-end">
@ -521,21 +538,21 @@ export default function MCPPage() {
event.stopPropagation();
openEdit(server);
}}>
{t('编辑', 'Edit')}
</Button>
<Button variant="outline" size="sm" onClick={(event) => {
event.stopPropagation();
void handleTest(server.id);
}} disabled={testingId === server.id}>
{testingId === server.id ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <TestTube2 className="w-4 h-4 mr-2" />}
{t('测试', 'Test')}
</Button>
<Button variant="outline" size="sm" onClick={(event) => {
event.stopPropagation();
void handleDelete(server.id);
}}>
<Trash2 className="w-4 h-4 mr-2" />
{t('删除', 'Delete')}
</Button>
</div>
</CardContent>
@ -544,7 +561,7 @@ export default function MCPPage() {
{servers.length === 0 && (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
MCP
{t('暂无 MCP 服务。', 'There are no MCP servers yet.')}
</CardContent>
</Card>
)}
@ -554,17 +571,17 @@ export default function MCPPage() {
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Wrench className="w-4 h-4" />
{selectedServer ? `${selectedServer.name} 的工具` : 'MCP 工具'}
{selectedServer ? t(`${selectedServer.name} 的工具`, `${selectedServer.name} tools`) : t('MCP 工具', 'MCP tools')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{!selectedServer && (
<div className="py-10 text-sm text-muted-foreground text-center">
MCP
{t('点击左侧 MCP 服务后,这里才会显示对应的已发现工具。', 'Select an MCP server on the left to show its discovered tools here.')}
</div>
)}
{selectedServer && !selectedToolGroup && (
<div className="text-sm text-muted-foreground"> MCP </div>
<div className="text-sm text-muted-foreground">{t('这个 MCP 暂时还没有发现任何工具。', 'No tools have been discovered for this MCP yet.')}</div>
)}
{selectedToolGroup && (
<div className="space-y-2">

View File

@ -34,8 +34,84 @@ import {
} from '@/components/ui/sheet';
import { ScrollArea } from '@/components/ui/scroll-area';
import { buildOfficeView, isOfficeTaskTerminal } from '@/lib/office';
import { appEventKindLabel } from '@/lib/i18n/common';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { useChatStore } from '@/lib/store';
function traceMetadataLabels(locale: 'zh-CN' | 'en-US'): Record<string, string> {
return {
stage_label: pickAppText(locale, '阶段', 'Stage'),
source: pickAppText(locale, '来源', 'Source'),
phase: 'Phase',
step: 'Step',
selection_mode: pickAppText(locale, '选人方式', 'Selection mode'),
selected_mode: pickAppText(locale, '选中模式', 'Selected mode'),
execution_mode: pickAppText(locale, '执行模式', 'Execution mode'),
selected_targets: pickAppText(locale, '成员', 'Members'),
selected_count: pickAppText(locale, '成员数', 'Member count'),
requested_targets: pickAppText(locale, '请求成员', 'Requested targets'),
planned_targets: pickAppText(locale, '计划成员', 'Planned targets'),
matched_procedure_id: pickAppText(locale, '命中 Procedure', 'Matched procedure'),
candidate_procedure_id: pickAppText(locale, '候选 Procedure', 'Candidate procedure'),
announcement_path: pickAppText(locale, '回流路径', 'Announcement path'),
announcement_sender_id: pickAppText(locale, '回流 Sender', 'Announcement sender'),
announcement_category: pickAppText(locale, '回流类别', 'Announcement category'),
external_fallback_reason: pickAppText(locale, '外部回退原因', 'External fallback reason'),
failure_type: pickAppText(locale, '失败分类', 'Failure type'),
failure_reason: pickAppText(locale, '失败原因', 'Failure reason'),
error: pickAppText(locale, '错误', 'Error'),
origin_channel: pickAppText(locale, '来源 Channel', 'Origin channel'),
origin_chat_id: pickAppText(locale, '来源 Chat', 'Origin chat'),
};
}
function formatTraceValue(value: unknown): string | null {
if (value === null || value === undefined) return null;
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed || null;
}
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
if (Array.isArray(value)) {
const parts = value
.map((item) => formatTraceValue(item))
.filter((item): item is string => Boolean(item));
return parts.length > 0 ? parts.join(', ') : null;
}
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function traceMetadataEntries(
metadata: Record<string, unknown> | null | undefined,
labels: Record<string, string>
): Array<{ key: string; label: string; value: string }> {
if (!metadata) return [];
const entries: Array<{ key: string; label: string; value: string }> = [];
const used = new Set<string>();
for (const [key, label] of Object.entries(labels)) {
const value = formatTraceValue(metadata[key]);
if (!value) continue;
used.add(key);
entries.push({ key, label, value });
}
for (const [key, rawValue] of Object.entries(metadata)) {
if (used.has(key)) continue;
const value = formatTraceValue(rawValue);
if (!value) continue;
entries.push({ key, label: key, value });
}
return entries;
}
function PixelPanel({
title,
subtitle,
@ -87,6 +163,7 @@ function BoardPanel({
}
export default function OfficeDetailPage() {
const { locale } = useAppI18n();
const params = useParams<{ taskId: string }>();
const taskId = decodeURIComponent(Array.isArray(params?.taskId) ? params.taskId[0] : params?.taskId ?? '');
@ -96,9 +173,10 @@ export default function OfficeDetailPage() {
const processArtifacts = useChatStore((state) => state.processArtifacts);
const office = React.useMemo(
() => buildOfficeView(taskId, { sessions, processRuns, processEvents, processArtifacts }),
[processArtifacts, processEvents, processRuns, sessions, taskId]
() => buildOfficeView(taskId, { sessions, processRuns, processEvents, processArtifacts }, locale),
[locale, processArtifacts, processEvents, processRuns, sessions, taskId]
);
const metadataLabels = React.useMemo(() => traceMetadataLabels(locale), [locale]);
const [selectedRunId, setSelectedRunId] = React.useState<string | null>(null);
const [detailOpen, setDetailOpen] = React.useState(false);
@ -112,12 +190,20 @@ export default function OfficeDetailPage() {
() => office?.tasks.find((task) => task.runId === selectedRunId) ?? office?.tasks[0] ?? null,
[office?.tasks, selectedRunId]
);
const selectedRun = React.useMemo(
() => processRuns.find((run) => run.run_id === selectedTask?.runId) ?? null,
[processRuns, selectedTask?.runId]
);
const selectedRunMetadata = React.useMemo(
() => traceMetadataEntries(selectedRun?.metadata, metadataLabels),
[metadataLabels, selectedRun?.metadata]
);
const selectedEvents = React.useMemo(
() => processEvents
.filter((event) => event.run_id === selectedTask?.runId)
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.slice(0, 8),
.slice(0, 16),
[processEvents, selectedTask?.runId]
);
@ -139,14 +225,18 @@ export default function OfficeDetailPage() {
<Button asChild variant="outline" className="w-fit">
<Link href="/office">
<ArrowLeft className="mr-2 h-4 w-4" />
Office
{pickAppText(locale, '返回 Office 列表', 'Back to office list')}
</Link>
</Button>
<Card className="border-dashed">
<CardContent className="py-16 text-center">
<h1 className="text-2xl font-semibold"></h1>
<h1 className="text-2xl font-semibold">{pickAppText(locale, '任务不存在', 'Task not found')}</h1>
<p className="mt-2 text-sm text-muted-foreground">
store task Office
{pickAppText(
locale,
'当前 store 中没有这个 task 的运行数据。先从对话页发起任务,或者回到 Office 列表查看当前可用任务。',
'The current store does not contain runtime data for this task yet. Start it from chat first, or return to the office list to inspect available tasks.'
)}
</p>
</CardContent>
</Card>
@ -164,13 +254,13 @@ export default function OfficeDetailPage() {
<Button asChild variant="outline" size="sm">
<Link href="/office">
<ArrowLeft className="mr-2 h-4 w-4" />
Office
{pickAppText(locale, '返回 Office', 'Back to office')}
</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/">
<MessageSquare className="mr-2 h-4 w-4" />
{pickAppText(locale, '回到对话', 'Back to chat')}
</Link>
</Button>
</div>
@ -186,18 +276,18 @@ export default function OfficeDetailPage() {
<OfficeStatusBadge status={office.status} className="bg-black/20" />
</div>
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 font-mono text-xs uppercase tracking-[0.14em] text-slate-400">
<span>Lead: {office.rootActorName}</span>
<span>Session: {office.sourceSessionLabel}</span>
<span>Started: {formatOfficeTime(office.createdAt)}</span>
<span>Duration: {formatOfficeDuration(office.durationMs)}</span>
<span>{pickAppText(locale, '负责人', 'Lead')}: {office.rootActorName}</span>
<span>{pickAppText(locale, '会话', 'Session')}: {office.sourceSessionLabel}</span>
<span>{pickAppText(locale, '开始', 'Started')}: {formatOfficeTime(office.createdAt, locale)}</span>
<span>{pickAppText(locale, '耗时', 'Duration')}: {formatOfficeDuration(office.durationMs, locale)}</span>
</div>
</div>
<div className="grid min-w-[320px] gap-3 sm:grid-cols-2 lg:w-[430px]">
<MetricTile label="运行实例" value={String(office.stats.totalRuns)} />
<MetricTile label="参与成员" value={String(office.stats.memberCount)} />
<MetricTile label="产物数量" value={String(office.stats.artifactCount)} />
<MetricTile label="告警数量" value={String(office.alerts.length)} />
<MetricTile label={pickAppText(locale, '运行实例', 'Runs')} value={String(office.stats.totalRuns)} />
<MetricTile label={pickAppText(locale, '参与成员', 'Members')} value={String(office.stats.memberCount)} />
<MetricTile label={pickAppText(locale, '产物数量', 'Artifacts')} value={String(office.stats.artifactCount)} />
<MetricTile label={pickAppText(locale, '告警数量', 'Alerts')} value={String(office.alerts.length)} />
</div>
</div>
</div>
@ -213,12 +303,12 @@ export default function OfficeDetailPage() {
<div className="mx-auto grid max-w-[1280px] gap-5 xl:grid-cols-[390px_minmax(0,1fr)_390px]">
<PixelPanel
title="昨日小记"
subtitle="用任务摘要、告警和最近更新来替代原版 memo 区。"
title={pickAppText(locale, '昨日小记', 'Yesterday notes')}
subtitle={pickAppText(locale, '用任务摘要、告警和最近更新来替代原版 memo 区。', 'Use task summaries, alerts, and recent updates instead of the original memo area.')}
>
<div className="space-y-3 text-sm leading-6 text-slate-300">
<div className="rounded-none border-2 border-[#2d3348] bg-[#0f1420] px-3 py-3">
{selectedTask?.summary || '当前选中任务没有摘要,先从右侧任务看板切一个具体 run 看现场。'}
{selectedTask?.summary || pickAppText(locale, '当前选中任务没有摘要,先从右侧任务看板切一个具体 run 看现场。', 'The selected task has no summary yet. Pick a specific run from the board on the right to inspect the floor.')}
</div>
{office.alerts.slice(0, 2).map((alert) => (
<button
@ -236,13 +326,13 @@ export default function OfficeDetailPage() {
</PixelPanel>
<PixelPanel
title="任务控制台"
subtitle="保留原版中间控制栏的位置,但改成适配 task runtime 的真实数据。"
title={pickAppText(locale, '任务控制台', 'Task console')}
subtitle={pickAppText(locale, '保留原版中间控制栏的位置,但改成适配 task runtime 的真实数据。', 'Keep the original center console position, but back it with real task runtime data.')}
>
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<MiniMetric label="当前阶段" value={office.progress.stageLabel ?? office.currentStageLabel ?? '-'} />
<MiniMetric label="活跃实例" value={String(office.stats.activeRuns)} />
<MiniMetric label={pickAppText(locale, '当前阶段', 'Current stage')} value={office.progress.stageLabel ?? office.currentStageLabel ?? '-'} />
<MiniMetric label={pickAppText(locale, '活跃实例', 'Active runs')} value={String(office.stats.activeRuns)} />
</div>
<div className="space-y-2">
@ -260,10 +350,10 @@ export default function OfficeDetailPage() {
{selectedTask ? (
<div className="rounded-none border-2 border-[#2d3348] bg-[#0f1420] px-3 py-3">
<div className="font-mono text-[11px] uppercase tracking-[0.14em] text-slate-400"></div>
<div className="font-mono text-[11px] uppercase tracking-[0.14em] text-slate-400">{pickAppText(locale, '当前聚焦', 'Current focus')}</div>
<div className="mt-2 text-sm font-semibold text-slate-100">{selectedTask.title}</div>
<div className="mt-1 text-xs text-slate-400">
{selectedTask.actorName} · {selectedTask.stageLabel ?? '无阶段标签'}
{selectedTask.actorName} · {selectedTask.stageLabel ?? pickAppText(locale, '无阶段标签', 'No stage label')}
</div>
</div>
) : null}
@ -273,7 +363,7 @@ export default function OfficeDetailPage() {
onClick={() => setDetailOpen(true)}
className="w-full rounded-none border-2 border-[#2f3b16] bg-[#78a340] text-[#f3ffe6] hover:bg-[#8fbe4a]"
>
{pickAppText(locale, '打开详情', 'Open details')}
<PanelRightOpen className="ml-2 h-4 w-4" />
</Button>
<Button
@ -282,7 +372,7 @@ export default function OfficeDetailPage() {
className="w-full rounded-none border-2 border-[#30364d] bg-[#171b29] text-slate-100 hover:bg-[#21283a]"
>
<Link href="/">
{pickAppText(locale, '回到对话', 'Back to chat')}
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
@ -290,15 +380,15 @@ export default function OfficeDetailPage() {
{isOfficeTaskTerminal(office.status) ? (
<div className="rounded-none border-2 border-[#365443] bg-[#12221d] px-3 py-3 text-sm text-emerald-200">
{pickAppText(locale, '任务已结束,办公室已解散,但现场记录仍可回看。', 'The task has ended and the office has dissolved, but the floor record is still available for review.')}
</div>
) : null}
</div>
</PixelPanel>
<PixelPanel
title="办公人员名单"
subtitle="原版 visitor 区的替代,这里展示当前参与 task 的 agent 成员。"
title={pickAppText(locale, '办公人员名单', 'Roster')}
subtitle={pickAppText(locale, '原版 visitor 区的替代,这里展示当前参与 task 的 agent 成员。', 'Replacement for the original visitor area, showing the agents currently participating in this task.')}
>
<div className="space-y-2">
{office.members.map((member) => (
@ -322,8 +412,8 @@ export default function OfficeDetailPage() {
<div className="mx-auto grid max-w-[1280px] gap-5 xl:grid-cols-[1.08fr_0.92fr]">
<BoardPanel
icon={ListTree}
title="任务看板"
description="当前 task 下所有 run 的结构化列表。"
title={pickAppText(locale, '任务看板', 'Task board')}
description={pickAppText(locale, '当前 task 下所有 run 的结构化列表。', 'Structured list of all runs under this task.')}
>
<div className="space-y-3">
{office.tasks.map((task) => (
@ -349,8 +439,8 @@ export default function OfficeDetailPage() {
</div>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-slate-400">
<span>{task.actorName}</span>
<span>{formatOfficeTime(task.updatedAt)}</span>
<span>{task.artifactCount} </span>
<span>{formatOfficeTime(task.updatedAt, locale)}</span>
<span>{pickAppText(locale, `${task.artifactCount} 个产物`, `${task.artifactCount} artifacts`)}</span>
</div>
</div>
<OfficeStatusBadge status={task.status} />
@ -363,13 +453,13 @@ export default function OfficeDetailPage() {
<div className="space-y-5">
<BoardPanel
icon={Boxes}
title="分工关系"
description="主 Agent 到子 Agent 的委派关系。"
title={pickAppText(locale, '分工关系', 'Assignments')}
description={pickAppText(locale, '主 Agent 到子 Agent 的委派关系。', 'Delegation links from the lead agent to sub-agents.')}
>
<div className="space-y-2">
{office.assignments.length === 0 ? (
<div className="rounded-none border-2 border-dashed border-[#30364d] bg-[#0f1420] px-3 py-4 text-sm text-slate-400">
{pickAppText(locale, '当前没有可见的子任务分工。', 'No visible subtask assignments yet.')}
</div>
) : (
office.assignments.map((assignment) => (
@ -389,13 +479,13 @@ export default function OfficeDetailPage() {
<BoardPanel
icon={Siren}
title="现场告警"
description="优先展示失败、阻塞和较高风险的任务信号。"
title={pickAppText(locale, '现场告警', 'Live alerts')}
description={pickAppText(locale, '优先展示失败、阻塞和较高风险的任务信号。', 'Prioritize failed, blocked, and higher-risk task signals.')}
>
<div className="space-y-2">
{office.alerts.length === 0 ? (
<div className="rounded-none border-2 border-dashed border-[#30364d] bg-[#0f1420] px-3 py-4 text-sm text-slate-400">
{pickAppText(locale, '当前没有高优先级告警。', 'There are no high-priority alerts right now.')}
</div>
) : (
office.alerts.map((alert) => (
@ -420,17 +510,17 @@ export default function OfficeDetailPage() {
<Sheet open={detailOpen} onOpenChange={setDetailOpen}>
<SheetContent side="right" className="w-full border-l border-border sm:max-w-3xl">
<SheetHeader className="pr-8">
<SheetTitle>{selectedTask?.title ?? '任务详情'}</SheetTitle>
<SheetTitle>{selectedTask?.title ?? pickAppText(locale, '任务详情', 'Task details')}</SheetTitle>
<SheetDescription>
{selectedTask
? `${selectedTask.actorName} · ${selectedTask.stageLabel ?? '无阶段标签'}`
: '当前没有选中的任务实例。'}
? `${selectedTask.actorName} · ${selectedTask.stageLabel ?? pickAppText(locale, '无阶段标签', 'No stage label')}`
: pickAppText(locale, '当前没有选中的任务实例。', 'No task run is currently selected.')}
</SheetDescription>
</SheetHeader>
{!selectedTask ? (
<div className="mt-6 rounded-xl border border-dashed border-border/60 px-4 py-6 text-sm text-muted-foreground">
{pickAppText(locale, '当前没有可展示的任务详情。', 'There are no task details to display right now.')}
</div>
) : (
<ScrollArea className="mt-6 h-[calc(100vh-8.5rem)] pr-3">
@ -445,18 +535,31 @@ export default function OfficeDetailPage() {
</div>
<div className="mt-3 grid gap-2 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground"></span>
<span>{formatOfficeTime(selectedTask.startedAt)}</span>
<span className="text-muted-foreground">{pickAppText(locale, '开始时间', 'Started')}</span>
<span>{formatOfficeTime(selectedTask.startedAt, locale)}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground"></span>
<span>{formatOfficeTime(selectedTask.updatedAt)}</span>
<span className="text-muted-foreground">{pickAppText(locale, '最近更新', 'Last update')}</span>
<span>{formatOfficeTime(selectedTask.updatedAt, locale)}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">{pickAppText(locale, '阶段', 'Stage')}</span>
<span>{selectedTask.stageLabel ?? '-'}</span>
</div>
</div>
{selectedRunMetadata.length > 0 ? (
<div className="mt-3 rounded-lg border border-border/60 bg-muted/20 px-3 py-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{pickAppText(locale, '链路上下文', 'Trace context')}</div>
<div className="mt-2 space-y-1.5">
{selectedRunMetadata.map((item) => (
<div key={item.key} className="grid gap-1 text-xs sm:grid-cols-[110px_minmax(0,1fr)]">
<span className="text-muted-foreground">{item.label}</span>
<span className="break-words font-mono text-foreground/90">{item.value}</span>
</div>
))}
</div>
</div>
) : null}
{selectedTask.summary ? (
<div className="mt-3 rounded-lg bg-muted/40 px-3 py-3 text-sm text-muted-foreground">
{selectedTask.summary}
@ -466,16 +569,16 @@ export default function OfficeDetailPage() {
<div className="grid gap-4 lg:grid-cols-[0.95fr_1.05fr]">
<div className="rounded-xl border border-border/60">
<div className="border-b border-border/60 px-4 py-3 text-sm font-medium"></div>
<div className="border-b border-border/60 px-4 py-3 text-sm font-medium">{pickAppText(locale, '产物', 'Artifacts')}</div>
<div className="space-y-2 p-4">
{selectedArtifacts.length === 0 ? (
<div className="text-sm text-muted-foreground"></div>
<div className="text-sm text-muted-foreground">{pickAppText(locale, '当前没有产物。', 'There are no artifacts for this task.')}</div>
) : (
selectedArtifacts.map((artifact) => (
<div key={artifact.artifact_id} className="rounded-lg border border-border/60 px-3 py-3">
<div className="font-medium">{artifact.title}</div>
<div className="mt-1 text-xs text-muted-foreground">
{artifact.artifact_type} · {formatOfficeTime(artifact.created_at)}
{artifact.artifact_type} · {formatOfficeTime(artifact.created_at, locale)}
</div>
</div>
))
@ -484,22 +587,40 @@ export default function OfficeDetailPage() {
</div>
<div className="rounded-xl border border-border/60">
<div className="border-b border-border/60 px-4 py-3 text-sm font-medium"></div>
<div className="border-b border-border/60 px-4 py-3 text-sm font-medium">{pickAppText(locale, '最近事件', 'Recent events')}</div>
<div className="space-y-2 p-4">
{selectedEvents.length === 0 ? (
<div className="text-sm text-muted-foreground"></div>
<div className="text-sm text-muted-foreground">{pickAppText(locale, '当前没有事件。', 'There are no events for this task.')}</div>
) : (
selectedEvents.map((event) => (
selectedEvents.map((event) => {
const metadataEntries = traceMetadataEntries(event.metadata, metadataLabels);
return (
<div key={event.event_id} className="rounded-lg border border-border/60 px-3 py-3">
<div className="flex items-center justify-between gap-3">
<div className="text-xs uppercase tracking-wide text-muted-foreground">{event.kind}</div>
<div className="text-xs text-muted-foreground">{formatOfficeTime(event.created_at)}</div>
<div className="text-xs uppercase tracking-wide text-muted-foreground">{appEventKindLabel(event.kind, locale)}</div>
<div className="text-xs text-muted-foreground">{formatOfficeTime(event.created_at, locale)}</div>
</div>
{event.status ? (
<div className="mt-2 text-xs text-muted-foreground">{pickAppText(locale, '状态', 'Status')}: {event.status}</div>
) : null}
<div className="mt-2 text-sm text-foreground/90">
{event.text || '结构化更新'}
{event.text || pickAppText(locale, '结构化更新', 'Structured update')}
</div>
{metadataEntries.length > 0 ? (
<div className="mt-3 rounded-md bg-muted/20 px-3 py-2">
<div className="mb-2 text-[11px] uppercase tracking-wide text-muted-foreground">{pickAppText(locale, '事件上下文', 'Event context')}</div>
<div className="space-y-1.5">
{metadataEntries.map((item) => (
<div key={`${event.event_id}:${item.key}`} className="grid gap-1 text-xs sm:grid-cols-[110px_minmax(0,1fr)]">
<span className="text-muted-foreground">{item.label}</span>
<span className="break-words font-mono text-foreground/90">{item.value}</span>
</div>
))}
</div>
</div>
) : null}
</div>
))
)})
)}
</div>
</div>

View File

@ -17,6 +17,9 @@ import { TaskManagementTabs } from '@/components/task-management/TaskManagementT
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { buildOfficeTaskList, isOfficeTaskTerminal } from '@/lib/office';
import { appConnectionStatusLabel } from '@/lib/i18n/common';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { useChatStore } from '@/lib/store';
function TaskCard({
@ -33,6 +36,7 @@ function TaskCard({
currentStageLabel,
progressLabel,
progressValue,
locale,
}: {
taskId: string;
title: string;
@ -47,6 +51,7 @@ function TaskCard({
currentStageLabel: string | null;
progressLabel: string;
progressValue: number;
locale: 'zh-CN' | 'en-US';
}) {
return (
<Card className="border-border/80 transition-colors hover:border-primary/30">
@ -55,9 +60,9 @@ function TaskCard({
<div className="min-w-0 flex-1">
<CardTitle className="truncate text-lg">{title}</CardTitle>
<CardDescription className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span>: {sessionLabel}</span>
<span> Agent: {rootActorName}</span>
<span> {formatOfficeTime(updatedAt)}</span>
<span>{pickAppText(locale, '会话', 'Session')}: {sessionLabel}</span>
<span>{pickAppText(locale, '主 Agent', 'Lead agent')}: {rootActorName}</span>
<span>{pickAppText(locale, '更新于', 'Updated')} {formatOfficeTime(updatedAt, locale)}</span>
</CardDescription>
</div>
<OfficeStatusBadge status={status} />
@ -65,10 +70,10 @@ function TaskCard({
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-4">
<Metric icon={Users} label="成员" value={String(memberCount)} />
<Metric icon={Activity} label="活跃" value={String(activeRuns)} />
<Metric icon={FolderKanban} label="产物" value={String(artifactCount)} />
<Metric icon={Sparkles} label="异常" value={String(errorCount)} />
<Metric icon={Users} label={pickAppText(locale, '成员', 'Members')} value={String(memberCount)} />
<Metric icon={Activity} label={pickAppText(locale, '活跃', 'Active')} value={String(activeRuns)} />
<Metric icon={FolderKanban} label={pickAppText(locale, '产物', 'Artifacts')} value={String(artifactCount)} />
<Metric icon={Sparkles} label={pickAppText(locale, '异常', 'Alerts')} value={String(errorCount)} />
</div>
<div className="space-y-2">
@ -87,7 +92,7 @@ function TaskCard({
<div className="flex justify-end">
<Button asChild size="sm">
<Link href={`/office/${encodeURIComponent(taskId)}`}>
{pickAppText(locale, '进入办公室', 'Open office')}
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
@ -118,6 +123,7 @@ function Metric({
}
export default function OfficeListPage() {
const { locale } = useAppI18n();
const sessionId = useChatStore((state) => state.sessionId);
const sessions = useChatStore((state) => state.sessions);
const processRuns = useChatStore((state) => state.processRuns);
@ -132,8 +138,8 @@ export default function OfficeListPage() {
processRuns,
processEvents,
processArtifacts,
}),
[processArtifacts, processEvents, processRuns, sessionId, sessions]
}, locale),
[locale, processArtifacts, processEvents, processRuns, sessionId, sessions]
);
const activeTasks = tasks.filter((task) => !isOfficeTaskTerminal(task.status));
@ -147,18 +153,22 @@ export default function OfficeListPage() {
<div>
<h1 className="text-3xl font-semibold tracking-tight">Office</h1>
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">
Agent Agent
{pickAppText(
locale,
'基于当前会话的真实运行数据,展示主 Agent 与子 Agent 的任务现场。任务结束后会从活跃现场移除,但保留回看入口。',
'Show the live task floor for the lead agent and its sub-agents using real runtime data from the current session. Finished tasks leave the active floor but remain available for review.'
)}
</p>
</div>
<Card className="min-w-[280px] border-border/70">
<CardContent className="flex items-center justify-between gap-4 p-4">
<div>
<div className="text-xs text-muted-foreground"></div>
<div className="text-xs text-muted-foreground">{pickAppText(locale, '当前会话', 'Current session')}</div>
<div className="mt-1 font-medium">{sessionId}</div>
</div>
<div className="text-right">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 font-medium">{wsStatus === 'connected' ? '已连接' : wsStatus}</div>
<div className="text-xs text-muted-foreground">{pickAppText(locale, '连接状态', 'Connection')}</div>
<div className="mt-1 font-medium">{appConnectionStatusLabel(wsStatus, wsStatus === 'connected' ? true : null, locale)}</div>
</div>
</CardContent>
</Card>
@ -167,7 +177,7 @@ export default function OfficeListPage() {
{wsStatus === 'connecting' && tasks.length === 0 ? (
<div className="flex items-center gap-3 rounded-xl border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
...
{pickAppText(locale, '正在等待运行时数据...', 'Waiting for runtime data...')}
</div>
) : null}
@ -175,12 +185,16 @@ export default function OfficeListPage() {
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<Clock3 className="h-10 w-10 text-muted-foreground/50" />
<h2 className="mt-4 text-xl font-semibold"></h2>
<h2 className="mt-4 text-xl font-semibold">{pickAppText(locale, '当前没有可展示的任务现场', 'No task floor is available yet')}</h2>
<p className="mt-2 max-w-xl text-sm text-muted-foreground">
Agent office
{pickAppText(
locale,
'先回到对话页发起一次主 Agent 任务。开始执行后,这里会出现活跃的 office 卡片。',
'Start a lead-agent task from the chat page first. Once it begins running, active office cards will appear here.'
)}
</p>
<Button asChild className="mt-6">
<Link href="/"></Link>
<Link href="/">{pickAppText(locale, '回到对话', 'Back to chat')}</Link>
</Button>
</CardContent>
</Card>
@ -189,15 +203,15 @@ export default function OfficeListPage() {
<section className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold"> Office</h2>
<p className="text-sm text-muted-foreground"></p>
<h2 className="text-xl font-semibold">{pickAppText(locale, '活跃 Office', 'Active office')}</h2>
<p className="text-sm text-muted-foreground">{pickAppText(locale, '正在运行中的任务现场会优先显示。', 'Running task floors are shown first.')}</p>
</div>
<div className="text-sm text-muted-foreground">{activeTasks.length} </div>
<div className="text-sm text-muted-foreground">{pickAppText(locale, `${activeTasks.length} 个任务`, `${activeTasks.length} tasks`)}</div>
</div>
{activeTasks.length === 0 ? (
<Card className="border-dashed">
<CardContent className="py-10 text-center text-sm text-muted-foreground">
{pickAppText(locale, '当前没有活跃任务,下面可以查看最近结束的任务。', 'There are no active tasks right now. Recent finished tasks are listed below.')}
</CardContent>
</Card>
) : (
@ -218,6 +232,7 @@ export default function OfficeListPage() {
currentStageLabel={task.currentStageLabel}
progressLabel={task.progress.label}
progressValue={progressPercent(task.progress.value, task.progress.max)}
locale={locale}
/>
))}
</div>
@ -227,15 +242,15 @@ export default function OfficeListPage() {
<section className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold"></h2>
<p className="text-sm text-muted-foreground"></p>
<h2 className="text-xl font-semibold">{pickAppText(locale, '最近结束', 'Recently finished')}</h2>
<p className="text-sm text-muted-foreground">{pickAppText(locale, '已完成、失败或取消的任务仍保留回看入口。', 'Completed, failed, or cancelled tasks remain available for review.')}</p>
</div>
<div className="text-sm text-muted-foreground">{recentTasks.length} </div>
<div className="text-sm text-muted-foreground">{pickAppText(locale, `${recentTasks.length} 个任务`, `${recentTasks.length} tasks`)}</div>
</div>
{recentTasks.length === 0 ? (
<Card className="border-dashed">
<CardContent className="py-10 text-center text-sm text-muted-foreground">
{pickAppText(locale, '还没有历史任务。', 'There is no task history yet.')}
</CardContent>
</Card>
) : (
@ -256,6 +271,7 @@ export default function OfficeListPage() {
currentStageLabel={task.currentStageLabel}
progressLabel={task.progress.label}
progressValue={progressPercent(task.progress.value, task.progress.max)}
locale={locale}
/>
))}
</div>

View File

@ -54,6 +54,9 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import type { AppLocale } from '@/lib/i18n/core';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
type OutlookFormState = OutlookConnectionPayload;
type OutlookView = 'inbox' | 'sent' | 'calendar' | 'settings';
@ -88,11 +91,11 @@ function toFormState(status: OutlookStatus | null): OutlookFormState {
};
}
function formatDateTime(value?: string | null): string {
function formatDateTime(value?: string | null, locale: AppLocale = 'zh-CN'): string {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('zh-CN', {
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
@ -115,19 +118,19 @@ function formatDateKey(value?: string | null): string | null {
return toLocalDateKey(date);
}
function formatDayLabel(value: Date): string {
return new Intl.DateTimeFormat('zh-CN', {
function formatDayLabel(value: Date, locale: AppLocale = 'zh-CN'): string {
return new Intl.DateTimeFormat(locale, {
month: '2-digit',
day: '2-digit',
weekday: 'short',
}).format(value);
}
function formatTime(value?: string | null): string {
function formatTime(value?: string | null, locale: AppLocale = 'zh-CN'): string {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('zh-CN', {
return new Intl.DateTimeFormat(locale, {
hour: '2-digit',
minute: '2-digit',
}).format(date);
@ -241,9 +244,9 @@ function sanitizeEmailHtml(html: string): string {
return documentRef.body.innerHTML;
}
function buildEmailPreviewDocument(html: string): string {
function buildEmailPreviewDocument(html: string, locale: AppLocale = 'zh-CN'): string {
return `<!DOCTYPE html>
<html lang="zh-CN">
<html lang="${locale}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
@ -334,6 +337,8 @@ function renderPlainText(content: string): React.ReactNode[] {
}
export default function OutlookPage() {
const { locale } = useAppI18n();
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
const [status, setStatus] = useState<OutlookStatus | null>(null);
const [form, setForm] = useState<OutlookFormState>(EMPTY_FORM);
const [formDirty, setFormDirty] = useState(false);
@ -382,11 +387,11 @@ export default function OutlookPage() {
if (!preserveExisting) {
setOverview(null);
}
setError(err.message || '加载 Outlook 概览失败');
setError(err.message || t('加载 Outlook 概览失败', 'Failed to load the Outlook overview'));
} finally {
setOverviewLoading(false);
}
}, []);
}, [t]);
const loadMailboxPage = useCallback(async (view: OutlookMailboxView, skip = 0) => {
setMailboxLoading((current) => ({ ...current, [view]: true }));
@ -402,11 +407,17 @@ export default function OutlookPage() {
}
setError(null);
} catch (err: any) {
setError(err.message || `加载${view === 'inbox' ? '收件箱' : '发件箱'}失败`);
setError(
err.message
|| t(
`加载${view === 'inbox' ? '收件箱' : '发件箱'}失败`,
`Failed to load the ${view === 'inbox' ? 'inbox' : 'sent mailbox'}`
)
);
} finally {
setMailboxLoading((current) => ({ ...current, [view]: false }));
}
}, []);
}, [t]);
const loadCalendarPage = useCallback(async (anchorKey: string) => {
setCalendarLoading(true);
@ -420,11 +431,11 @@ export default function OutlookPage() {
setCalendarPage(nextPage);
setError(null);
} catch (err: any) {
setError(err.message || '加载日程失败');
setError(err.message || t('加载日程失败', 'Failed to load calendar events'));
} finally {
setCalendarLoading(false);
}
}, []);
}, [t]);
const loadStatus = useCallback(async (
background = false,
@ -452,7 +463,7 @@ export default function OutlookPage() {
setOverviewLoading(false);
}
} catch (err: any) {
setError(err.message || '加载 Outlook 集成状态失败');
setError(err.message || t('加载 Outlook 集成状态失败', 'Failed to load Outlook integration status'));
setOverviewLoading(false);
} finally {
if (background) {
@ -461,7 +472,7 @@ export default function OutlookPage() {
setStatusLoading(false);
}
}
}, [applyStatus, loadOverview]);
}, [applyStatus, loadOverview, t]);
useEffect(() => {
void loadStatus();
@ -483,7 +494,7 @@ export default function OutlookPage() {
})
.catch((err: any) => {
if (!cancelled) {
setError(err.message || '加载邮件详情失败');
setError(err.message || t('加载邮件详情失败', 'Failed to load message details'));
}
})
.finally(() => {
@ -495,7 +506,7 @@ export default function OutlookPage() {
return () => {
cancelled = true;
};
}, [selectedMessageRef]);
}, [selectedMessageRef, t]);
const canTest = useMemo(
() => Boolean(
@ -519,8 +530,8 @@ export default function OutlookPage() {
return [
{
id: 'settings' as const,
label: '设置',
hint: '配置 Outlook 连接',
label: t('设置', 'Settings'),
hint: t('配置 Outlook 连接', 'Configure the Outlook connection'),
icon: Settings2,
count: null,
},
@ -530,34 +541,34 @@ export default function OutlookPage() {
return [
{
id: 'inbox' as const,
label: '收件箱',
hint: '最近接收邮件',
label: t('收件箱', 'Inbox'),
hint: t('最近接收邮件', 'Recently received mail'),
icon: Inbox,
count: null,
},
{
id: 'sent' as const,
label: '发件箱',
hint: '最近发送记录',
label: t('发件箱', 'Sent'),
hint: t('最近发送记录', 'Recently sent messages'),
icon: Send,
count: null,
},
{
id: 'calendar' as const,
label: '日程',
hint: '未来 7 天',
label: t('日程', 'Calendar'),
hint: t('未来 7 天', 'Next 7 days'),
icon: CalendarDays,
count: overviewPending ? null : eventCount,
},
{
id: 'settings' as const,
label: '设置',
hint: '连接与状态',
label: t('设置', 'Settings'),
hint: t('连接与状态', 'Connection and status'),
icon: Settings2,
count: null,
},
];
}, [eventCount, inboxCount, isConfigured, overviewPending, sentCount]);
}, [eventCount, inboxCount, isConfigured, overviewPending, sentCount, t]);
useEffect(() => {
if (!availableViews.some((view) => view.id === activeView)) {
@ -604,7 +615,7 @@ export default function OutlookPage() {
const result = await testOutlookConnection(form);
setTestResult(result);
} catch (err: any) {
setError(err.message || '测试连接失败');
setError(err.message || t('测试连接失败', 'Failed to test the connection'));
setTestResult(null);
} finally {
setTesting(false);
@ -626,7 +637,7 @@ export default function OutlookPage() {
await loadStatus(true, { forceFormSync: true });
setActiveView('inbox');
} catch (err: any) {
setError(err.message || '保存 Outlook 配置失败');
setError(err.message || t('保存 Outlook 配置失败', 'Failed to save Outlook settings'));
} finally {
setSaving(false);
}
@ -649,7 +660,7 @@ export default function OutlookPage() {
setFormDirty(false);
await loadStatus(true, { forceFormSync: true });
} catch (err: any) {
setError(err.message || '断开 Outlook 连接失败');
setError(err.message || t('断开 Outlook 连接失败', 'Failed to disconnect Outlook'));
} finally {
setDisconnecting(false);
}
@ -688,16 +699,16 @@ export default function OutlookPage() {
) : (
<>
<Badge variant={statusVariant(isConnected)}>
{isConnected ? '已连通' : isConfigured ? '已配置' : '未配置'}
{isConnected ? t('已连通', 'Connected') : isConfigured ? t('已配置', 'Configured') : t('未配置', 'Not configured')}
</Badge>
<Badge variant={status?.mcp_registered ? 'default' : 'secondary'}>
{status?.mcp_registered ? 'MCP 已注册' : 'MCP 未注册'}
{status?.mcp_registered ? t('MCP 已注册', 'MCP registered') : t('MCP 未注册', 'MCP not registered')}
</Badge>
<Badge variant="secondary">{status?.provider || 'ews'}</Badge>
<span className="text-muted-foreground"> {overview?.mailbox || status?.saved?.email || '-'}</span>
<span className="text-muted-foreground"> {status?.saved?.default_timezone || overview?.timezone || form.default_timezone}</span>
<span className="text-muted-foreground">{t('邮箱', 'Mailbox')} {overview?.mailbox || status?.saved?.email || '-'}</span>
<span className="text-muted-foreground">{t('时区', 'Timezone')} {status?.saved?.default_timezone || overview?.timezone || form.default_timezone}</span>
<span className="text-muted-foreground">
{formatDateTime((overview?.meta?.last_overview_refresh_at || status?.meta?.last_overview_refresh_at) as string | undefined)}
{t('最近刷新', 'Last refreshed')} {formatDateTime((overview?.meta?.last_overview_refresh_at || status?.meta?.last_overview_refresh_at) as string | undefined, locale)}
</span>
</>
)}
@ -706,14 +717,14 @@ export default function OutlookPage() {
<div className="flex flex-wrap items-center gap-2">
{isConfigured ? (
<>
<TopStat label="收件箱" value={String(inboxCount)} loading={overviewPending} />
<TopStat label="发件箱" value={String(sentCount)} loading={overviewPending} />
<TopStat label="日程" value={String(eventCount)} loading={overviewPending} />
<TopStat label={t('收件箱', 'Inbox')} value={String(inboxCount)} loading={overviewPending} />
<TopStat label={t('发件箱', 'Sent')} value={String(sentCount)} loading={overviewPending} />
<TopStat label={t('日程', 'Calendar')} value={String(eventCount)} loading={overviewPending} />
</>
) : null}
<Button variant="outline" size="sm" onClick={() => void refreshOverview()}>
<RefreshCw className={`mr-2 h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
{t('刷新', 'Refresh')}
</Button>
</div>
</div>
@ -765,7 +776,7 @@ export default function OutlookPage() {
<div className="text-left">
<p className="text-sm font-semibold">{view.label}</p>
{typeof view.count === 'number' ? (
<p className="text-xs text-muted-foreground">{view.count} </p>
<p className="text-xs text-muted-foreground">{t(`${view.count}`, `${view.count} items`)}</p>
) : null}
</div>
</div>
@ -777,12 +788,13 @@ export default function OutlookPage() {
<TabsContent value="inbox" className="mt-0">
<MessageCard
title="收件箱"
title={t('收件箱', 'Inbox')}
icon={<MailOpen className="h-4 w-4" />}
items={inboxPage?.value || []}
page={inboxPage?.page || null}
loading={mailboxLoading.inbox || (activeView === 'inbox' && !inboxPage)}
emptyLabel="还没有读取到收件箱邮件"
emptyLabel={t('还没有读取到收件箱邮件', 'No inbox messages have been loaded yet')}
locale={locale}
onOpen={(item) => setSelectedMessageRef(item.id ? { id: item.id, changekey: item.changekey } : null)}
onRefresh={() => void loadMailboxPage('inbox', inboxPage?.page.skip ?? 0)}
refreshing={mailboxLoading.inbox}
@ -798,12 +810,13 @@ export default function OutlookPage() {
<TabsContent value="sent" className="mt-0">
<MessageCard
title="发件箱"
title={t('发件箱', 'Sent')}
icon={<Send className="h-4 w-4" />}
items={sentPage?.value || []}
page={sentPage?.page || null}
loading={mailboxLoading.sent || (activeView === 'sent' && !sentPage)}
emptyLabel="还没有读取到已发送邮件"
emptyLabel={t('还没有读取到已发送邮件', 'No sent messages have been loaded yet')}
locale={locale}
onOpen={(item) => setSelectedMessageRef(item.id ? { id: item.id, changekey: item.changekey } : null)}
onRefresh={() => void loadMailboxPage('sent', sentPage?.page.skip ?? 0)}
refreshing={mailboxLoading.sent}
@ -822,6 +835,7 @@ export default function OutlookPage() {
items={calendarPage?.value || []}
startDate={calendarAnchorKey}
loading={calendarLoading || (activeView === 'calendar' && !calendarPage)}
locale={locale}
onOpen={(item) => setSelectedEvent(item)}
onRefresh={() => void loadCalendarPage(calendarAnchorKey)}
refreshing={calendarLoading}
@ -849,33 +863,33 @@ export default function OutlookPage() {
<div className="grid gap-6 xl:grid-cols-[1.08fr,0.92fr]">
<Card className="rounded-[28px] shadow-sm">
<CardHeader className="border-b pb-5">
<CardTitle className="text-xl text-foreground"></CardTitle>
<CardTitle className="text-xl text-foreground">{t('连接设置', 'Connection settings')}</CardTitle>
</CardHeader>
<CardContent className="space-y-5 pt-6">
<div className="grid gap-4 md:grid-cols-2">
<Field label="邮箱地址" required>
<Field label={t('邮箱地址', 'Email address')} required>
<Input
value={form.email}
onChange={(event) => updateField('email', event.target.value)}
placeholder="you@boardware.com"
/>
</Field>
<Field label="用户名">
<Field label={t('用户名', 'Username')}>
<Input
value={form.username}
onChange={(event) => updateField('username', event.target.value)}
placeholder="留空时默认取邮箱前缀"
placeholder={t('留空时默认取邮箱前缀', 'Leave blank to default to the email prefix')}
/>
</Field>
<Field label="密码" required>
<Field label={t('密码', 'Password')} required>
<Input
type="password"
value={form.password}
onChange={(event) => updateField('password', event.target.value)}
placeholder="请输入邮箱密码"
placeholder={t('请输入邮箱密码', 'Enter the mailbox password')}
/>
</Field>
<Field label="域">
<Field label={t('域', 'Domain')}>
<Input
value={form.domain}
onChange={(event) => updateField('domain', event.target.value)}
@ -898,7 +912,7 @@ export default function OutlookPage() {
disabled={form.autodiscover}
/>
</Field>
<Field label="时区">
<Field label={t('时区', 'Timezone')}>
<Input
value={form.default_timezone}
onChange={(event) => updateField('default_timezone', event.target.value)}
@ -912,7 +926,7 @@ export default function OutlookPage() {
Autodiscover
</Label>
<p className="mt-1 text-xs text-muted-foreground">
使 Exchange EWS URL
{t('开启后优先使用 Exchange 自动发现,不再强依赖手填 EWS URL。', 'When enabled, Exchange autodiscover is preferred so the EWS URL does not need to be entered manually.')}
</p>
</div>
<Switch
@ -927,11 +941,11 @@ export default function OutlookPage() {
<div className="flex flex-wrap justify-end gap-2">
<Button variant="outline" onClick={handleTest} disabled={!canTest || testing}>
{testing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <CheckCircle2 className="mr-2 h-4 w-4" />}
{t('测试连接', 'Test connection')}
</Button>
<Button onClick={handleConnect} disabled={!canTest || saving}>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
{t('保存并启用', 'Save and enable')}
</Button>
<Button
variant="outline"
@ -939,21 +953,21 @@ export default function OutlookPage() {
disabled={!status?.configured || disconnecting}
>
{disconnecting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Unplug className="mr-2 h-4 w-4" />}
{t('断开连接', 'Disconnect')}
</Button>
</div>
{testResult && (
<div className="rounded-3xl border bg-muted/30 p-4 text-sm">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="default"></Badge>
<Badge variant="default">{t('测试成功', 'Test succeeded')}</Badge>
<span className="text-muted-foreground">{testResult.mailbox}</span>
<span className="text-muted-foreground">: {testResult.resolved_username}</span>
<span className="text-muted-foreground">{t('用户名', 'Username')}: {testResult.resolved_username}</span>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-3">
<MiniStat label="检测到文件夹" value={String(testResult.sample.folders.length)} />
<MiniStat label="收件箱样本" value={String(testResult.sample.inbox.length)} />
<MiniStat label="日程样本" value={String(testResult.sample.events.length)} />
<MiniStat label={t('检测到文件夹', 'Detected folders')} value={String(testResult.sample.folders.length)} />
<MiniStat label={t('收件箱样本', 'Inbox samples')} value={String(testResult.sample.inbox.length)} />
<MiniStat label={t('日程样本', 'Calendar samples')} value={String(testResult.sample.events.length)} />
</div>
{testWarnings.length > 0 && (
<div className="mt-4 space-y-2 rounded-2xl border border-amber-300 bg-amber-50/80 p-3 text-amber-900">
@ -972,7 +986,7 @@ export default function OutlookPage() {
<Card className="rounded-[28px] shadow-sm">
<CardHeader className="border-b pb-5">
<CardTitle className="text-xl text-foreground"></CardTitle>
<CardTitle className="text-xl text-foreground">{t('连接状态', 'Connection status')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4 pt-6">
<div className="flex flex-wrap gap-2">
@ -985,27 +999,27 @@ export default function OutlookPage() {
) : (
<>
<Badge variant={statusVariant(isConnected)}>
{isConnected ? '已连通' : isConfigured ? '已配置' : '未配置'}
{isConnected ? t('已连通', 'Connected') : isConfigured ? t('已配置', 'Configured') : t('未配置', 'Not configured')}
</Badge>
<Badge variant={status?.mcp_registered ? 'default' : 'secondary'}>
{status?.mcp_registered ? 'MCP 已注册' : 'MCP 未注册'}
{status?.mcp_registered ? t('MCP 已注册', 'MCP registered') : t('MCP 未注册', 'MCP not registered')}
</Badge>
<Badge variant="secondary">{status?.provider || 'ews'}</Badge>
</>
)}
</div>
<InfoRow label="邮箱" value={status?.saved?.email || '-'} loading={statusPending} />
<InfoRow label="用户名" value={status?.saved?.username || '-'} loading={statusPending} />
<InfoRow label="域" value={status?.saved?.domain || '-'} loading={statusPending} />
<InfoRow label={t('邮箱', 'Email')} value={status?.saved?.email || '-'} loading={statusPending} />
<InfoRow label={t('用户名', 'Username')} value={status?.saved?.username || '-'} loading={statusPending} />
<InfoRow label={t('域', 'Domain')} value={status?.saved?.domain || '-'} loading={statusPending} />
<InfoRow label="EWS URL" value={status?.saved?.service_endpoint || '-'} loading={statusPending} />
<InfoRow label="Server Host" value={status?.saved?.server || '-'} loading={statusPending} />
<InfoRow label="时区" value={status?.saved?.default_timezone || status?.defaults.fields.default_timezone || '-'} loading={statusPending} />
<InfoRow label="最近验证" value={formatDateTime(status?.meta?.last_verified_at as string | undefined)} loading={statusPending} />
<InfoRow label="最近接入" value={formatDateTime(status?.meta?.last_connected_at as string | undefined)} loading={statusPending} />
<InfoRow label={t('时区', 'Timezone')} value={status?.saved?.default_timezone || status?.defaults.fields.default_timezone || '-'} loading={statusPending} />
<InfoRow label={t('最近验证', 'Last verified')} value={formatDateTime(status?.meta?.last_verified_at as string | undefined, locale)} loading={statusPending} />
<InfoRow label={t('最近接入', 'Last connected')} value={formatDateTime(status?.meta?.last_connected_at as string | undefined, locale)} loading={statusPending} />
<InfoRow
label="最近刷新"
value={formatDateTime((overview?.meta?.last_overview_refresh_at || status?.meta?.last_overview_refresh_at) as string | undefined)}
label={t('最近刷新', 'Last refreshed')}
value={formatDateTime((overview?.meta?.last_overview_refresh_at || status?.meta?.last_overview_refresh_at) as string | undefined, locale)}
loading={statusPending || overviewPending}
/>
@ -1017,12 +1031,15 @@ export default function OutlookPage() {
<div className="rounded-3xl border bg-muted/30 p-4">
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
{t('当前存储位置', 'Current storage mode')}
</p>
<p className="mt-2 text-sm font-medium text-foreground">
{status?.storage_mode === 'authz'
? '当前为 AuthZ 模式。Outlook 凭据保存在 AuthZ Service由外置 Outlook MCP 按 backend 身份读取。'
: <> workspace Outlook workspace <code>state/bw_outlook_mcp</code></>}
? t(
'当前为 AuthZ 模式。Outlook 凭据保存在 AuthZ Service由外置 Outlook MCP 按 backend 身份读取。',
'AuthZ mode is active. Outlook credentials are stored in the AuthZ service and read by the external Outlook MCP using the backend identity.'
)
: <>{t('当前为 workspace 模式。Outlook 状态文件会写入当前 workspace 的', 'Workspace mode is active. Outlook state files are written to')} <code>state/bw_outlook_mcp</code>.</>}
</p>
</div>
</CardContent>
@ -1034,9 +1051,9 @@ export default function OutlookPage() {
<Dialog open={Boolean(selectedMessageRef)} onOpenChange={(open) => !open && setSelectedMessageRef(null)}>
<DialogContent className="sm:max-w-5xl">
<DialogHeader>
<DialogTitle>{selectedMessage?.subject || '邮件详情'}</DialogTitle>
<DialogTitle>{selectedMessage?.subject || t('邮件详情', 'Message details')}</DialogTitle>
<DialogDescription>
{selectedMessage?.receivedDateTime ? formatDateTime(selectedMessage.receivedDateTime) : '正在加载'}
{selectedMessage?.receivedDateTime ? formatDateTime(selectedMessage.receivedDateTime, locale) : t('正在加载', 'Loading')}
</DialogDescription>
</DialogHeader>
{messageLoading ? (
@ -1046,32 +1063,33 @@ export default function OutlookPage() {
) : selectedMessage ? (
<div className="grid gap-4 lg:grid-cols-[280px,1fr]">
<div className="space-y-4 rounded-2xl border bg-muted/20 p-4 text-sm">
<InfoRow label="发件人" value={mailboxLabel(selectedMessage.from)} />
<InfoRow label={t('发件人', 'From')} value={mailboxLabel(selectedMessage.from)} />
<InfoRow
label="收件人"
value={(selectedMessage.toRecipients || []).map(mailboxLabel).filter(Boolean).join('') || '-'}
label={t('收件人', 'To')}
value={(selectedMessage.toRecipients || []).map(mailboxLabel).filter(Boolean).join(locale === 'en-US' ? '; ' : '') || '-'}
/>
<InfoRow
label="抄送"
value={(selectedMessage.ccRecipients || []).map(mailboxLabel).filter(Boolean).join('') || '-'}
label={t('抄送', 'Cc')}
value={(selectedMessage.ccRecipients || []).map(mailboxLabel).filter(Boolean).join(locale === 'en-US' ? '; ' : '') || '-'}
/>
<InfoRow label="接收时间" value={formatDateTime(selectedMessage.receivedDateTime)} />
<InfoRow label={t('接收时间', 'Received at')} value={formatDateTime(selectedMessage.receivedDateTime, locale)} />
<div className="flex flex-wrap gap-2">
<Badge variant={selectedMessage.isRead ? 'secondary' : 'default'}>
{selectedMessage.isRead ? '已读' : '未读'}
{selectedMessage.isRead ? t('已读', 'Read') : t('未读', 'Unread')}
</Badge>
</div>
</div>
<div className="overflow-hidden rounded-2xl border bg-background">
<div className="border-b px-4 py-3 text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
{t('正文', 'Body')}
</div>
{selectedMessage.body?.contentType?.toLowerCase() === 'html' ? (
<iframe
title="邮件正文"
title={t('邮件正文', 'Message body')}
srcDoc={buildEmailPreviewDocument(
selectedMessage.body?.content || selectedMessage.bodyPreview || ''
selectedMessage.body?.content || selectedMessage.bodyPreview || '',
locale
)}
className="h-[60vh] w-full bg-white"
sandbox="allow-popups allow-popups-to-escape-sandbox"
@ -1086,7 +1104,7 @@ export default function OutlookPage() {
</div>
</div>
) : (
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm text-muted-foreground">{t('未加载到邮件详情。', 'Message details were not loaded.')}</p>
)}
</DialogContent>
</Dialog>
@ -1094,26 +1112,26 @@ export default function OutlookPage() {
<Dialog open={Boolean(selectedEvent)} onOpenChange={(open) => !open && setSelectedEvent(null)}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{selectedEvent?.subject || '日程详情'}</DialogTitle>
<DialogTitle>{selectedEvent?.subject || t('日程详情', 'Event details')}</DialogTitle>
<DialogDescription>
{selectedEvent
? `${formatDateTime(selectedEvent.start?.dateTime)} - ${formatDateTime(selectedEvent.end?.dateTime)}`
: '日程详情'}
? `${formatDateTime(selectedEvent.start?.dateTime, locale)} - ${formatDateTime(selectedEvent.end?.dateTime, locale)}`
: t('日程详情', 'Event details')}
</DialogDescription>
</DialogHeader>
{selectedEvent && (
<div className="space-y-4 text-sm">
<InfoRow label="组织者" value={mailboxLabel(selectedEvent.organizer)} />
<InfoRow label="地点" value={selectedEvent.location?.displayName || '-'} />
<InfoRow label={t('组织者', 'Organizer')} value={mailboxLabel(selectedEvent.organizer)} />
<InfoRow label={t('地点', 'Location')} value={selectedEvent.location?.displayName || '-'} />
<InfoRow
label="参会人"
value={(selectedEvent.attendees || []).map(mailboxLabel).filter(Boolean).join('') || '-'}
label={t('参会人', 'Attendees')}
value={(selectedEvent.attendees || []).map(mailboxLabel).filter(Boolean).join(locale === 'en-US' ? '; ' : '') || '-'}
/>
<Separator />
<div className="space-y-2">
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground"></p>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">{t('说明', 'Notes')}</p>
<div className="rounded-lg border bg-muted/40 p-3 whitespace-pre-wrap">
{selectedEvent.bodyPreview || '没有更多说明。'}
{selectedEvent.bodyPreview || t('没有更多说明。', 'No additional notes.')}
</div>
</div>
</div>
@ -1185,6 +1203,7 @@ function MessageCard({
icon,
items,
page,
locale,
loading = false,
emptyLabel,
onOpen,
@ -1197,6 +1216,7 @@ function MessageCard({
icon: React.ReactNode;
items: OutlookMessageSummary[];
page: OutlookPageInfo | null;
locale: AppLocale;
loading?: boolean;
emptyLabel: string;
onOpen: (item: OutlookMessageSummary) => void;
@ -1205,8 +1225,9 @@ function MessageCard({
onPreviousPage: () => void;
onNextPage: () => void;
}) {
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
const currentPage = page ? Math.floor(page.skip / Math.max(page.top, 1)) + 1 : 1;
const pageLabel = page ? `${currentPage} 页 · 本页 ${page.returned}` : '正在读取邮件…';
const pageLabel = page ? t(`${currentPage} 页 · 本页 ${page.returned}`, `Page ${currentPage} · ${page.returned} messages`) : t('正在读取邮件…', 'Loading messages...');
return (
<Card className="rounded-[28px] shadow-sm">
@ -1216,14 +1237,14 @@ function MessageCard({
{icon}
{title}
</CardTitle>
<p className="text-sm text-muted-foreground">{loading ? '正在读取邮件…' : pageLabel}</p>
<p className="text-sm text-muted-foreground">{loading ? t('正在读取邮件…', 'Loading messages...') : pageLabel}</p>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={onRefresh} disabled={refreshing}>
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
</Button>
<Button variant="outline" size="sm" onClick={onPreviousPage} disabled={!page || page.skip === 0 || refreshing}>
{t('上一页', 'Previous')}
</Button>
<Button
variant="outline"
@ -1231,7 +1252,7 @@ function MessageCard({
onClick={onNextPage}
disabled={!page || !page.has_more || refreshing}
>
{t('下一页', 'Next')}
</Button>
</div>
</CardHeader>
@ -1262,17 +1283,17 @@ function MessageCard({
>
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground">{item.subject || '(无主题)'}</p>
<p className="truncate font-medium text-foreground">{item.subject || t('(无主题)', '(No subject)')}</p>
<p className="mt-1 truncate text-xs text-muted-foreground">{mailboxLabel(item.from)}</p>
<p className="mt-3 line-clamp-2 text-sm leading-6 text-muted-foreground">
{item.bodyPreview || '没有预览内容。'}
{item.bodyPreview || t('没有预览内容。', 'No preview available.')}
</p>
</div>
<div className="flex shrink-0 items-center gap-2 lg:flex-col lg:items-end">
<Badge variant={item.isRead ? 'secondary' : 'default'}>
{item.isRead ? '已读' : '未读'}
{item.isRead ? t('已读', 'Read') : t('未读', 'Unread')}
</Badge>
<span className="text-xs text-muted-foreground">{formatDateTime(item.receivedDateTime)}</span>
<span className="text-xs text-muted-foreground">{formatDateTime(item.receivedDateTime, locale)}</span>
</div>
</div>
</button>
@ -1287,6 +1308,7 @@ function MessageCard({
function EventCard({
items,
startDate,
locale,
loading = false,
onOpen,
onRefresh,
@ -1297,6 +1319,7 @@ function EventCard({
}: {
items: OutlookEventSummary[];
startDate?: string | null;
locale: AppLocale;
loading?: boolean;
onOpen: (item: OutlookEventSummary) => void;
onRefresh: () => void;
@ -1305,6 +1328,7 @@ function EventCard({
onNextWeek: () => void;
onCurrentWeek: () => void;
}) {
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
const initialAnchor = startDate ? new Date(startDate) : new Date();
const anchor = Number.isNaN(initialAnchor.getTime()) ? new Date() : initialAnchor;
const weekDays = Array.from({ length: 7 }, (_, index) => {
@ -1316,7 +1340,7 @@ function EventCard({
const key = toLocalDateKey(day);
return {
key,
label: formatDayLabel(day),
label: formatDayLabel(day, locale),
items: items
.filter((item) => formatDateKey(item.start?.dateTime) === key)
.sort((left, right) => {
@ -1333,21 +1357,21 @@ function EventCard({
<div className="space-y-1">
<CardTitle className="flex items-center gap-2 text-base">
<CalendarDays className="h-4 w-4" />
{t('日程安排', 'Schedule')}
</CardTitle>
<p className="text-sm text-muted-foreground">
{formatDayLabel(weekDays[0])} - {formatDayLabel(weekDays[weekDays.length - 1])}
{formatDayLabel(weekDays[0], locale)} - {formatDayLabel(weekDays[weekDays.length - 1], locale)}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={onPreviousWeek} disabled={refreshing}>
{t('上一周', 'Previous week')}
</Button>
<Button variant="outline" size="sm" onClick={onCurrentWeek} disabled={refreshing}>
{t('本周', 'This week')}
</Button>
<Button variant="outline" size="sm" onClick={onNextWeek} disabled={refreshing}>
{t('下一周', 'Next week')}
</Button>
<Button variant="ghost" size="sm" onClick={onRefresh} disabled={refreshing}>
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
@ -1372,11 +1396,11 @@ function EventCard({
<div className="flex items-center justify-between gap-3">
<div>
<p className="font-medium text-foreground">{day.label}</p>
<p className="text-xs text-muted-foreground">{day.items.length} </p>
<p className="text-xs text-muted-foreground">{t(`${day.items.length} 条安排`, `${day.items.length} events`)}</p>
</div>
</div>
{day.items.length === 0 ? (
<p className="mt-4 text-sm text-muted-foreground"></p>
<p className="mt-4 text-sm text-muted-foreground">{t('暂无安排', 'No events')}</p>
) : (
<div className="mt-4 space-y-3">
{day.items.map((item) => (
@ -1386,12 +1410,12 @@ function EventCard({
onClick={() => onOpen(item)}
className="w-full rounded-xl border bg-background p-3 text-left transition-colors hover:bg-muted/40"
>
<p className="font-medium text-foreground">{item.subject || '(无主题)'}</p>
<p className="font-medium text-foreground">{item.subject || t('(无主题)', '(No subject)')}</p>
<p className="mt-1 text-xs text-muted-foreground">
{formatTime(item.start?.dateTime)} - {formatTime(item.end?.dateTime)}
{formatTime(item.start?.dateTime, locale)} - {formatTime(item.end?.dateTime, locale)}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{item.location?.displayName || '未设置地点'}
{item.location?.displayName || t('未设置地点', 'No location set')}
</p>
</button>
))}

View File

@ -14,7 +14,6 @@ import {
createSession,
deleteSession,
getSession,
getStatus,
listCommands,
listSessions,
sendMessage,
@ -22,29 +21,10 @@ import {
wsManager,
} from '@/lib/api';
import { buildOfficeTaskList, isOfficeTaskTerminal } from '@/lib/office';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { useChatStore } from '@/lib/store';
import type { ChatMessage, FileAttachment, ProcessWsEvent, SessionUpdatedEvent, SlashCommand, WsEvent } from '@/types';
function scheduleWhenIdle(task: () => void, timeout = 1200): () => void {
if (typeof window === 'undefined') {
task();
return () => {};
}
const idleWindow = window as Window &
typeof globalThis & {
requestIdleCallback?: (callback: IdleRequestCallback, options?: IdleRequestOptions) => number;
cancelIdleCallback?: (handle: number) => void;
};
if (typeof idleWindow.requestIdleCallback === 'function') {
const id = idleWindow.requestIdleCallback(() => task(), { timeout });
return () => idleWindow.cancelIdleCallback?.(id);
}
const id = globalThis.setTimeout(task, 250);
return () => globalThis.clearTimeout(id);
}
import type { ChatMessage, FileAttachment, SessionUpdatedEvent, SlashCommand, WsEvent } from '@/types';
function messageFingerprint(msg: ChatMessage): string {
const attachmentKey = (msg.attachments ?? [])
@ -76,16 +56,12 @@ function mergeServerWithPendingUsers(serverMessages: ChatMessage[], localMessage
return [...serverMessages, ...pendingUsers];
}
function isProcessEvent(data: WsEvent | Record<string, unknown>): data is ProcessWsEvent {
const type = typeof data.type === 'string' ? data.type : '';
return type.startsWith('process_') || type === 'process_cancel_ack';
}
function isSessionUpdatedEvent(data: WsEvent | Record<string, unknown>): data is SessionUpdatedEvent {
return data.type === 'session_updated' && typeof data.session_id === 'string';
}
export default function ChatPage() {
const { locale } = useAppI18n();
const {
sessionId,
messages,
@ -100,13 +76,8 @@ export default function ChatPage() {
setMessages,
addMessage,
setIsLoading,
setSessions,
clearMessages,
setWsStatus,
setIsThinking,
setNanobotReady,
resetProcessState,
ingestProcessEvent,
setSelectedRunId,
} = useChatStore();
@ -124,9 +95,8 @@ export default function ChatPage() {
const commandsLoadedRef = useRef(false);
const refreshSessionOnReconnectRef = useRef(false);
const hasConnectedRef = useRef(false);
const statusCheckCleanupRef = useRef<(() => void) | null>(null);
const statusCheckInFlightRef = useRef(false);
const shouldSnapToLatestRef = useRef(true);
const wsStatus = useChatStore((state) => state.wsStatus);
const filteredCommands = useMemo(() => {
if (!input.startsWith('/') || input.includes(' ')) return [];
@ -136,6 +106,28 @@ export default function ChatPage() {
);
}, [commands, input]);
const sessionProcessRuns = useMemo(
() => processRuns.filter((run) => run.session_id === sessionId),
[processRuns, sessionId]
);
const sessionRunIds = useMemo(
() => new Set(sessionProcessRuns.map((run) => run.run_id)),
[sessionProcessRuns]
);
const sessionProcessEvents = useMemo(
() => processEvents.filter((event) => sessionRunIds.has(event.run_id)),
[processEvents, sessionRunIds]
);
const sessionProcessArtifacts = useMemo(
() => processArtifacts.filter((artifact) => sessionRunIds.has(artifact.run_id)),
[processArtifacts, sessionRunIds]
);
const selectedSessionRunId = selectedRunId && sessionRunIds.has(selectedRunId) ? selectedRunId : null;
const officeTasks = useMemo(
() => buildOfficeTaskList({
sessionId,
@ -143,8 +135,8 @@ export default function ChatPage() {
processRuns,
processEvents,
processArtifacts,
}),
[processArtifacts, processEvents, processRuns, sessionId, sessions]
}, locale),
[locale, processArtifacts, processEvents, processRuns, sessionId, sessions]
);
const currentOfficeTask = officeTasks.find((task) => !isOfficeTaskTerminal(task.status)) ?? officeTasks[0] ?? null;
@ -152,11 +144,11 @@ export default function ChatPage() {
const loadSessions = useCallback(async () => {
try {
const list = await listSessions();
setSessions(list);
useChatStore.getState().setSessions(list);
} catch {
// backend may be offline during first render
}
}, [setSessions]);
}, []);
const loadSessionMessages = useCallback(async (key: string) => {
const reqSeq = ++loadSessionReqSeq.current;
@ -170,6 +162,7 @@ export default function ChatPage() {
? mergeServerWithPendingUsers(detail.messages, localSnapshot)
: detail.messages;
setMessages(nextMessages);
shouldSnapToLatestRef.current = true;
const last = nextMessages[nextMessages.length - 1];
if (last?.role === 'assistant') {
setIsThinking(false);
@ -192,23 +185,6 @@ export default function ChatPage() {
}
}, []);
const scheduleStatusCheck = useCallback(() => {
if (statusCheckInFlightRef.current) return;
statusCheckCleanupRef.current?.();
statusCheckCleanupRef.current = scheduleWhenIdle(async () => {
statusCheckInFlightRef.current = true;
try {
await getStatus();
setNanobotReady(true);
} catch {
setNanobotReady(false);
} finally {
statusCheckInFlightRef.current = false;
}
});
}, [setNanobotReady]);
useEffect(() => {
if (input.startsWith('/') && !input.includes(' ')) {
void loadCommands();
@ -220,40 +196,29 @@ export default function ChatPage() {
setPickerIndex(0);
}, [filteredCommands]);
useEffect(() => {
loadSessions();
}, [loadSessions]);
useEffect(() => {
clearMessages();
setIsLoading(false);
setIsThinking(false);
resetProcessState();
const wsSessionId = sessionId.startsWith('web:') ? sessionId.slice(4) : sessionId;
wsManager.connect(wsSessionId);
loadSessionMessages(sessionId);
}, [clearMessages, loadSessionMessages, resetProcessState, sessionId, setIsLoading, setIsThinking]);
void loadSessionMessages(sessionId);
}, [clearMessages, loadSessionMessages, sessionId, setIsLoading, setIsThinking]);
useEffect(() => {
const unsubStatus = wsManager.onStatusChange(async (status) => {
setWsStatus(status);
if (status === 'connected') {
if (hasConnectedRef.current && refreshSessionOnReconnectRef.current) {
refreshSessionOnReconnectRef.current = false;
void loadSessionMessages(useChatStore.getState().sessionId);
}
hasConnectedRef.current = true;
scheduleStatusCheck();
} else {
if (status === 'disconnected' && hasConnectedRef.current) {
refreshSessionOnReconnectRef.current = true;
}
statusCheckCleanupRef.current?.();
statusCheckCleanupRef.current = null;
setNanobotReady(null);
if (wsStatus === 'connected') {
if (hasConnectedRef.current && refreshSessionOnReconnectRef.current) {
refreshSessionOnReconnectRef.current = false;
void loadSessionMessages(useChatStore.getState().sessionId);
}
});
hasConnectedRef.current = true;
return;
}
if (wsStatus === 'disconnected' && hasConnectedRef.current) {
refreshSessionOnReconnectRef.current = true;
}
}, [loadSessionMessages, wsStatus]);
useEffect(() => {
const unsubMessage = wsManager.onMessage((data) => {
if (isSessionUpdatedEvent(data)) {
void loadSessions();
@ -263,11 +228,6 @@ export default function ChatPage() {
return;
}
if (isProcessEvent(data)) {
ingestProcessEvent(data);
return;
}
if (data.type === 'status' && data.status === 'thinking') {
setIsThinking(true);
} else if (data.type === 'message' && data.role === 'assistant') {
@ -284,12 +244,9 @@ export default function ChatPage() {
});
return () => {
statusCheckCleanupRef.current?.();
statusCheckCleanupRef.current = null;
unsubStatus();
unsubMessage();
};
}, [addMessage, ingestProcessEvent, loadSessionMessages, loadSessions, scheduleStatusCheck, setIsLoading, setIsThinking, setNanobotReady, setWsStatus]);
}, [addMessage, loadSessionMessages, loadSessions, setIsLoading, setIsThinking]);
useEffect(() => {
if (!isLoading && !isThinking) {
@ -304,21 +261,34 @@ export default function ChatPage() {
const scrollMessagesToLatest = useCallback((behavior: ScrollBehavior) => {
const viewport = messageViewportRef.current;
if (!viewport) return;
messagesEndRef.current?.scrollIntoView({ block: 'end', behavior });
viewport.scrollTo({ top: viewport.scrollHeight, behavior });
}, []);
const scheduleScrollToLatest = useCallback((behavior: ScrollBehavior) => {
if (typeof window === 'undefined') {
scrollMessagesToLatest(behavior);
return;
}
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
scrollMessagesToLatest(behavior);
});
});
}, [scrollMessagesToLatest]);
useEffect(() => {
shouldSnapToLatestRef.current = true;
}, [sessionId]);
useLayoutEffect(() => {
if (messages.length === 0 && !isThinking && processEvents.length === 0) {
if (messages.length === 0 && !isThinking && sessionProcessEvents.length === 0) {
return;
}
scrollMessagesToLatest(shouldSnapToLatestRef.current ? 'auto' : 'smooth');
scheduleScrollToLatest(shouldSnapToLatestRef.current ? 'auto' : 'smooth');
shouldSnapToLatestRef.current = false;
}, [isThinking, messages, processEvents, scrollMessagesToLatest]);
}, [isThinking, messages.length, scheduleScrollToLatest, sessionProcessEvents.length]);
useEffect(() => {
if (!showCommandPicker || !pickerRef.current) return;
@ -348,7 +318,7 @@ export default function ChatPage() {
setPendingFiles([]);
setShowCommandPicker(false);
const msgContent = text || '(仅附件)';
const msgContent = text || pickAppText(locale, '(仅附件)', '(Attachments only)');
addMessage({
role: 'user',
content: msgContent,
@ -392,12 +362,12 @@ export default function ChatPage() {
}
addMessage({
role: 'assistant',
content: '发送失败,请检查后端服务是否正在运行。',
content: pickAppText(locale, '发送失败,请检查后端服务是否正在运行。', 'Send failed. Please check whether the backend service is running.'),
timestamp: new Date().toISOString(),
});
}
}
}, [addMessage, input, isLoading, loadSessionMessages, loadSessions, pendingFiles, sessionId, setIsLoading, setIsThinking]);
}, [addMessage, input, isLoading, loadSessionMessages, loadSessions, locale, pendingFiles, sessionId, setIsLoading, setIsThinking]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (showCommandPicker && filteredCommands.length > 0) {
@ -436,7 +406,7 @@ export default function ChatPage() {
for (const file of files) {
if (file.size > 50 * 1024 * 1024) {
setPendingFiles((prev) => [...prev, { file, progress: 0, error: '文件过大(最大 50MB' }]);
setPendingFiles((prev) => [...prev, { file, progress: 0, error: pickAppText(locale, '文件过大(最大 50MB', 'File is too large (max 50MB)') }]);
continue;
}
@ -447,16 +417,17 @@ export default function ChatPage() {
});
setPendingFiles((prev) => prev.map((item) => (item.file === file ? { ...item, id: result.file_id, progress: 100 } : item)));
} catch (err: any) {
setPendingFiles((prev) => prev.map((item) => (item.file === file ? { ...item, error: err.message || '上传失败' } : item)));
setPendingFiles((prev) => prev.map((item) => (item.file === file ? { ...item, error: err.message || pickAppText(locale, '上传失败', 'Upload failed') } : item)));
}
}
}, [sessionId]);
}, [locale, sessionId]);
const handleNewSession = async () => {
const id = `web:${Date.now()}`;
setSessionId(id);
setSelectedRunId(null);
clearMessages();
resetProcessState();
useChatStore.getState().resetProcessState();
try {
await createSession(id);
} catch {
@ -472,7 +443,7 @@ export default function ChatPage() {
if (key === sessionId) {
setSessionId('web:default');
clearMessages();
resetProcessState();
useChatStore.getState().resetProcessState();
}
loadSessions();
} catch {
@ -481,20 +452,21 @@ export default function ChatPage() {
};
const handleSelectSession = (key: string) => {
setSelectedRunId(null);
setSessionId(key);
};
const handleCancelRun = async (runId: string) => {
const handleCancelRun = useCallback(async (runId: string) => {
try {
await cancelDelegation(runId);
} catch (err: any) {
addMessage({
role: 'assistant',
content: `取消任务 ${runId} 失败:${err.message || '未知错误'}`,
content: pickAppText(locale, `取消任务 ${runId} 失败:${err.message || '未知错误'}`, `Failed to cancel task ${runId}: ${err.message || 'Unknown error'}`),
timestamp: new Date().toISOString(),
});
}
};
}, [addMessage, locale]);
const removePendingFile = useCallback((file: File) => {
setPendingFiles((prev) => prev.filter((item) => item.file !== file));
@ -503,10 +475,10 @@ export default function ChatPage() {
const formatSessionName = (key: string) => {
if (key.startsWith('web:')) {
const id = key.slice(4);
if (id === 'default') return '默认';
if (id === 'default') return pickAppText(locale, '默认', 'Default');
const numeric = Number(id);
if (!Number.isNaN(numeric)) {
return new Date(numeric).toLocaleDateString('zh-CN', {
return new Date(numeric).toLocaleDateString(locale, {
month: 'short',
day: 'numeric',
hour: '2-digit',
@ -524,14 +496,14 @@ export default function ChatPage() {
<div className="p-3">
<Button onClick={handleNewSession} variant="outline" className="w-full justify-start gap-2" size="sm">
<Plus className="w-4 h-4" />
{pickAppText(locale, '新对话', 'New chat')}
</Button>
</div>
<Separator />
<ScrollArea className="flex-1">
<div className="p-2 space-y-1">
{sessions.length === 0 && (
<p className="text-xs text-muted-foreground px-2 py-4 text-center"></p>
<p className="text-xs text-muted-foreground px-2 py-4 text-center">{pickAppText(locale, '暂无对话记录', 'No chat history yet')}</p>
)}
{sessions.map((session) => (
<div
@ -567,22 +539,22 @@ export default function ChatPage() {
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Building2 className="h-4 w-4" />
{pickAppText(locale, '当前任务现场', 'Current task floor')}
</div>
<OfficeStatusBadge status={currentOfficeTask.status} />
</div>
<div className="mt-1 truncate text-sm text-muted-foreground">
{currentOfficeTask.title}
<span className="ml-2"> Agent: {currentOfficeTask.rootActorName}</span>
<span className="ml-2">{pickAppText(locale, '主 Agent', 'Lead agent')}: {currentOfficeTask.rootActorName}</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button asChild variant="outline" size="sm">
<Link href="/office"> Office</Link>
<Link href="/office">{pickAppText(locale, '查看全部 Office', 'View all office tasks')}</Link>
</Button>
<Button asChild size="sm">
<Link href={`/office/${encodeURIComponent(currentOfficeTask.taskId)}`}>
{pickAppText(locale, '查看任务现场', 'Open task floor')}
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
@ -597,11 +569,11 @@ export default function ChatPage() {
isThinking={isThinking || (isLoading && messages[messages.length - 1]?.role === 'user')}
messagesEndRef={messagesEndRef}
messageViewportRef={messageViewportRef}
processRuns={processRuns}
processEvents={processEvents}
processArtifacts={processArtifacts}
selectedRunId={selectedRunId}
onSelectRun={(runId) => setSelectedRunId(selectedRunId === runId ? null : runId)}
processRuns={sessionProcessRuns}
processEvents={sessionProcessEvents}
processArtifacts={sessionProcessArtifacts}
selectedRunId={selectedSessionRunId}
onSelectRun={(runId) => setSelectedRunId(selectedSessionRunId === runId ? null : runId)}
onCancelRun={handleCancelRun}
/>
</div>
@ -623,7 +595,7 @@ export default function ChatPage() {
<div className="h-full bg-primary rounded-full transition-all" style={{ width: `${item.progress}%` }} />
</div>
) : (
<span className="text-green-500 text-xs"></span>
<span className="text-green-500 text-xs">{pickAppText(locale, '就绪', 'Ready')}</span>
)}
<button onClick={() => removePendingFile(item.file)} className="text-muted-foreground hover:text-foreground">
<X className="w-3.5 h-3.5" />
@ -660,7 +632,7 @@ export default function ChatPage() {
<span className="text-muted-foreground text-xs truncate ml-auto">{command.description}</span>
{command.plugin_name !== 'builtin' && (
<span className={`text-xs px-1 rounded shrink-0 ${command.plugin_name === 'skill' ? 'bg-blue-500/10 text-blue-500' : 'bg-muted'}`}>
{command.plugin_name === 'skill' ? '技能' : command.plugin_name}
{command.plugin_name === 'skill' ? pickAppText(locale, '技能', 'Skill') : command.plugin_name}
</span>
)}
</button>
@ -675,7 +647,7 @@ export default function ChatPage() {
variant="ghost"
size="icon"
className="h-10 w-10 flex-shrink-0"
title="添加附件"
title={pickAppText(locale, '添加附件', 'Add attachment')}
>
<Paperclip className="w-4 h-4" />
</Button>
@ -685,7 +657,7 @@ export default function ChatPage() {
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入消息或 / 呼出命令…回车发送Shift+回车换行)"
placeholder={pickAppText(locale, '输入消息或 / 呼出命令…回车发送Shift+回车换行)', 'Type a message or use / for commands... (Enter to send, Shift+Enter for a new line)')}
rows={1}
className="flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
style={{ minHeight: '40px', maxHeight: '200px' }}

View File

@ -19,8 +19,11 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import type { PluginInfo } from '@/types';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
export default function PluginsPage() {
const { locale } = useAppI18n();
const [plugins, setPlugins] = useState<PluginInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -32,7 +35,7 @@ export default function PluginsPage() {
const data = await listPlugins();
setPlugins(Array.isArray(data) ? data : []);
} catch (err: any) {
setError(err.message || '加载插件失败');
setError(err.message || pickAppText(locale, '加载插件失败', 'Failed to load plugins'));
} finally {
setLoading(false);
}
@ -57,15 +60,16 @@ export default function PluginsPage() {
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Blocks className="w-6 h-6" />
{pickAppText(locale, '插件', 'Plugins')}
</h1>
<p className="text-sm text-muted-foreground mt-1">
workspace <code className="text-xs bg-muted px-1 py-0.5 rounded">plugins/</code>
{pickAppText(locale, '已安装位置:全局插件目录或当前 workspace 的 ', 'Installed from the global plugin directory or this workspace\'s ')}
<code className="text-xs bg-muted px-1 py-0.5 rounded">plugins/</code>
</p>
</div>
<Button onClick={load} variant="outline" size="sm">
<RefreshCw className="w-4 h-4 mr-2" />
{pickAppText(locale, '刷新', 'Refresh')}
</Button>
</div>
@ -86,10 +90,11 @@ export default function PluginsPage() {
<Card>
<CardContent className="py-16 text-center text-muted-foreground">
<Blocks className="w-12 h-12 mx-auto mb-4 opacity-30" />
<p className="font-medium"></p>
<p className="font-medium">{pickAppText(locale, '还没有安装任何插件', 'No plugins are installed yet')}</p>
<p className="text-sm mt-2 max-w-sm mx-auto">
workspace <code className="text-xs bg-muted px-1 py-0.5 rounded">plugins/</code>
Boardware Agent Sandbox
{pickAppText(locale, '把插件目录放到全局插件目录或当前 workspace 的 ', 'Put a plugin directory in the global plugin directory or this workspace\'s ')}
<code className="text-xs bg-muted px-1 py-0.5 rounded">plugins/</code>
{pickAppText(locale, ',然后重启 Boardware Agent Sandbox。', ', then restart Boardware Agent Sandbox.')}
</p>
</CardContent>
</Card>
@ -106,6 +111,7 @@ export default function PluginsPage() {
}
function PluginCard({ plugin }: { plugin: PluginInfo }) {
const { locale } = useAppI18n();
const [agentsOpen, setAgentsOpen] = useState(true);
const [commandsOpen, setCommandsOpen] = useState(true);
const [skillsOpen, setSkillsOpen] = useState(false);
@ -132,19 +138,19 @@ function PluginCard({ plugin }: { plugin: PluginInfo }) {
{plugin.agents.length > 0 && (
<span className="flex items-center gap-1 text-xs bg-muted px-2 py-0.5 rounded-full">
<Bot className="w-3 h-3" />
{plugin.agents.length}
{pickAppText(locale, `${plugin.agents.length} 个智能体`, `${plugin.agents.length} agents`)}
</span>
)}
{plugin.commands.length > 0 && (
<span className="flex items-center gap-1 text-xs bg-muted px-2 py-0.5 rounded-full">
<Terminal className="w-3 h-3" />
{plugin.commands.length}
{pickAppText(locale, `${plugin.commands.length} 条命令`, `${plugin.commands.length} commands`)}
</span>
)}
{plugin.skills.length > 0 && (
<span className="flex items-center gap-1 text-xs bg-muted px-2 py-0.5 rounded-full">
<Wrench className="w-3 h-3" />
{plugin.skills.length}
{pickAppText(locale, `${plugin.skills.length} 个技能`, `${plugin.skills.length} skills`)}
</span>
)}
</div>
@ -157,7 +163,7 @@ function PluginCard({ plugin }: { plugin: PluginInfo }) {
{plugin.agents.length > 0 && (
<Section
icon={<Bot className="w-3.5 h-3.5" />}
label="智能体"
label={pickAppText(locale, '智能体', 'Agents')}
count={plugin.agents.length}
open={agentsOpen}
onToggle={() => setAgentsOpen((v) => !v)}
@ -186,7 +192,7 @@ function PluginCard({ plugin }: { plugin: PluginInfo }) {
{plugin.commands.length > 0 && (
<Section
icon={<Terminal className="w-3.5 h-3.5" />}
label="命令"
label={pickAppText(locale, '命令', 'Commands')}
count={plugin.commands.length}
open={commandsOpen}
onToggle={() => setCommandsOpen((v) => !v)}
@ -213,7 +219,7 @@ function PluginCard({ plugin }: { plugin: PluginInfo }) {
{plugin.skills.length > 0 && (
<Section
icon={<Wrench className="w-3.5 h-3.5" />}
label="技能"
label={pickAppText(locale, '技能', 'Skills')}
count={plugin.skills.length}
open={skillsOpen}
onToggle={() => setSkillsOpen((v) => !v)}
@ -234,18 +240,19 @@ function PluginCard({ plugin }: { plugin: PluginInfo }) {
}
function SourceBadge({ source }: { source: 'global' | 'workspace' }) {
const { locale } = useAppI18n();
if (source === 'workspace') {
return (
<Badge variant="default" className="text-xs gap-1">
<FolderOpen className="w-3 h-3" />
{pickAppText(locale, '工作区', 'Workspace')}
</Badge>
);
}
return (
<Badge variant="secondary" className="text-xs gap-1">
<Globe className="w-3 h-3" />
{pickAppText(locale, '全局', 'Global')}
</Badge>
);
}

View File

@ -24,8 +24,11 @@ import {
TableRow,
} from '@/components/ui/table';
import type { Skill } from '@/types';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
export default function SkillsPage() {
const { locale } = useAppI18n();
const [skills, setSkills] = useState<Skill[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -39,7 +42,7 @@ export default function SkillsPage() {
const data = await listSkills();
setSkills(Array.isArray(data) ? data : []);
} catch (err: any) {
setError(err.message || '加载技能失败');
setError(err.message || pickAppText(locale, '加载技能失败', 'Failed to load skills'));
} finally {
setLoading(false);
}
@ -59,7 +62,7 @@ export default function SkillsPage() {
setDeleting(null);
loadSkills();
} catch (err: any) {
setError(err.message || '删除技能失败');
setError(err.message || pickAppText(locale, '删除技能失败', 'Failed to delete the skill'));
setDeleting(null);
}
};
@ -82,16 +85,16 @@ export default function SkillsPage() {
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold flex items-center gap-2">
<Puzzle className="w-6 h-6" />
{pickAppText(locale, '技能', 'Skills')}
</h1>
<div className="flex items-center gap-2">
<Button onClick={loadSkills} variant="outline" size="sm">
<RefreshCw className="w-4 h-4 mr-2" />
{pickAppText(locale, '刷新', 'Refresh')}
</Button>
<Button onClick={() => setShowUpload(true)} size="sm">
<Upload className="w-4 h-4 mr-2" />
{pickAppText(locale, '上传技能', 'Upload skill')}
</Button>
</div>
</div>
@ -122,7 +125,7 @@ export default function SkillsPage() {
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<p className="text-sm">
<strong>{deleting}</strong>
{pickAppText(locale, '确定删除技能', 'Delete skill')} <strong>{deleting}</strong> {pickAppText(locale, '吗?此操作不可撤销。', '? This action cannot be undone.')}
</p>
<div className="flex items-center gap-2">
<Button
@ -130,14 +133,14 @@ export default function SkillsPage() {
size="sm"
onClick={() => setDeleting(null)}
>
{pickAppText(locale, '取消', 'Cancel')}
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => confirmDelete(deleting)}
>
{pickAppText(locale, '删除', 'Delete')}
</Button>
</div>
</div>
@ -151,18 +154,18 @@ export default function SkillsPage() {
{skills.length === 0 ? (
<div className="py-12 text-center text-muted-foreground">
<Puzzle className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="font-medium"></p>
<p className="text-sm mt-1"> zip 使</p>
<p className="font-medium">{pickAppText(locale, '暂无技能', 'No skills yet')}</p>
<p className="text-sm mt-1">{pickAppText(locale, '上传一个技能 zip 包即可开始使用。', 'Upload a skill zip package to get started.')}</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-24"></TableHead>
<TableHead>{pickAppText(locale, '名称', 'Name')}</TableHead>
<TableHead>{pickAppText(locale, '描述', 'Description')}</TableHead>
<TableHead>{pickAppText(locale, '来源', 'Source')}</TableHead>
<TableHead>{pickAppText(locale, '状态', 'Status')}</TableHead>
<TableHead className="w-24">{pickAppText(locale, '操作', 'Actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@ -177,22 +180,22 @@ export default function SkillsPage() {
<TableCell>
{skill.source === 'builtin' ? (
<Badge variant="secondary" className="text-xs">
{pickAppText(locale, '内置', 'Built in')}
</Badge>
) : (
<Badge variant="default" className="text-xs">
{pickAppText(locale, '工作区', 'Workspace')}
</Badge>
)}
</TableCell>
<TableCell>
{skill.available ? (
<Badge variant="default" className="text-xs bg-green-600">
{pickAppText(locale, '可用', 'Available')}
</Badge>
) : (
<Badge variant="outline" className="text-xs text-muted-foreground">
{pickAppText(locale, '不可用', 'Unavailable')}
</Badge>
)}
</TableCell>
@ -202,7 +205,7 @@ export default function SkillsPage() {
variant="ghost"
size="icon"
className="h-7 w-7"
title="下载"
title={pickAppText(locale, '下载', 'Download')}
onClick={() => downloadSkill(skill.name).catch((e) => setError(e.message))}
>
<Download className="w-3.5 h-3.5" />
@ -213,7 +216,7 @@ export default function SkillsPage() {
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDelete(skill.name)}
title="删除"
title={pickAppText(locale, '删除', 'Delete')}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
@ -240,6 +243,7 @@ function UploadSkillForm({
onCancel: () => void;
onError: (msg: string) => void;
}) {
const { locale } = useAppI18n();
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
@ -253,7 +257,7 @@ function UploadSkillForm({
await uploadSkill(file);
onDone();
} catch (err: any) {
onError(err.message || '上传失败');
onError(err.message || pickAppText(locale, '上传失败', 'Upload failed'));
} finally {
setUploading(false);
}
@ -263,7 +267,7 @@ function UploadSkillForm({
<Card>
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<CardTitle className="text-base"></CardTitle>
<CardTitle className="text-base">{pickAppText(locale, '上传技能', 'Upload skill')}</CardTitle>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onCancel}>
<X className="w-4 h-4" />
</Button>
@ -273,7 +277,7 @@ function UploadSkillForm({
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="skill-zip">
{pickAppText(locale, '技能压缩包', 'Skill archive')}
</label>
<input
id="skill-zip"
@ -283,23 +287,23 @@ function UploadSkillForm({
className="block w-full text-sm text-muted-foreground file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary file:text-primary-foreground hover:file:bg-primary/90 cursor-pointer"
/>
<p className="text-xs text-muted-foreground">
`SKILL.md`
{pickAppText(locale, '压缩包中必须包含 `SKILL.md` 文件', 'The archive must contain a `SKILL.md` file')}
</p>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel}>
{pickAppText(locale, '取消', 'Cancel')}
</Button>
<Button type="submit" disabled={uploading}>
{uploading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
{pickAppText(locale, '上传中...', 'Uploading...')}
</>
) : (
<>
<Upload className="w-4 h-4 mr-2" />
{pickAppText(locale, '上传', 'Upload')}
</>
)}
</Button>

View File

@ -27,8 +27,11 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import type { SystemStatus } from '@/types';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
export default function StatusPage() {
const { locale } = useAppI18n();
const [status, setStatus] = useState<SystemStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
@ -43,7 +46,7 @@ export default function StatusPage() {
const data = await getStatus();
setStatus(data);
} catch (err: any) {
setError(err.message || '连接后端失败');
setError(err.message || pickAppText(locale, '连接后端失败', 'Failed to connect to the backend'));
} finally {
setLoading(false);
}
@ -79,7 +82,7 @@ export default function StatusPage() {
setRestartDialogOpen(false);
setRestarting(true);
} catch (err: any) {
setRestartError(err.message || '重启失败');
setRestartError(err.message || pickAppText(locale, '重启失败', 'Restart failed'));
}
};
@ -99,16 +102,14 @@ export default function StatusPage() {
<div className="flex items-center gap-3 text-destructive">
<AlertCircle className="w-5 h-5" />
<div>
<p className="font-medium"> Boardware Agent Sandbox </p>
<p className="font-medium">{pickAppText(locale, '无法连接到 Boardware Agent Sandbox 后端', 'Unable to connect to the Boardware Agent Sandbox backend')}</p>
<p className="text-sm text-muted-foreground mt-1">{error}</p>
<p className="text-sm text-muted-foreground mt-1">
访
</p>
<p className="text-sm text-muted-foreground mt-1">{pickAppText(locale, '请确认后端服务已启动,并且当前页面可以访问它。', 'Please confirm the backend service is running and reachable from this page.')}</p>
</div>
</div>
<Button onClick={loadStatus} variant="outline" size="sm" className="mt-4">
<RefreshCw className="w-4 h-4 mr-2" />
{pickAppText(locale, '重试', 'Retry')}
</Button>
</CardContent>
</Card>
@ -121,10 +122,10 @@ export default function StatusPage() {
return (
<div className="max-w-4xl mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold"></h1>
<h1 className="text-2xl font-bold">{pickAppText(locale, '系统状态', 'System status')}</h1>
<Button onClick={loadStatus} variant="outline" size="sm" disabled={restarting}>
<RefreshCw className="w-4 h-4 mr-2" />
{pickAppText(locale, '刷新', 'Refresh')}
</Button>
</div>
@ -133,17 +134,17 @@ export default function StatusPage() {
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Server className="w-4 h-4" />
{pickAppText(locale, '系统信息', 'System information')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium"></p>
<p className="text-sm font-medium">{pickAppText(locale, '重启当前实例', 'Restart current instance')}</p>
<p className="text-sm text-muted-foreground">
{restarting
? '正在重启当前 docker服务恢复后页面会自动刷新。'
: '会重启当前 docker 容器。重启完成后需要重新登录。'}
? pickAppText(locale, '正在重启当前 docker服务恢复后页面会自动刷新。', 'Restarting the current Docker container. The page will refresh automatically once the service is back.')
: pickAppText(locale, '会重启当前 docker 容器。重启完成后需要重新登录。', 'This restarts the current Docker container. You will need to sign in again afterwards.')}
</p>
{restartError ? (
<p className="text-sm text-destructive">{restartError}</p>
@ -164,15 +165,15 @@ export default function StatusPage() {
</Button>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogTitle>{pickAppText(locale, '确认重启当前实例?', 'Restart the current instance?')}</AlertDialogTitle>
<AlertDialogDescription>
docker
{pickAppText(locale, '这会重启当前 docker 容器,页面会短暂不可用。由于当前登录态保存在内存里,重启完成后需要重新登录。', 'This restarts the current Docker container and the page will be temporarily unavailable. Because the current sign-in state is stored in memory, you will need to sign in again after the restart.')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={restarting}></AlertDialogCancel>
<AlertDialogCancel disabled={restarting}>{pickAppText(locale, '取消', 'Cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={handleRestart} disabled={restarting}>
{restarting ? '重启中...' : '确认 Restart'}
{restarting ? pickAppText(locale, '重启中...', 'Restarting...') : pickAppText(locale, '确认重启', 'Confirm restart')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@ -186,15 +187,15 @@ export default function StatusPage() {
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Cpu className="w-4 h-4" />
{pickAppText(locale, '智能体配置', 'Agent configuration')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<InfoRow label="模型" value={status.model} />
<InfoRow label="最大令牌数" value={String(status.max_tokens)} />
<InfoRow label="温度" value={String(status.temperature)} />
<InfoRow label={pickAppText(locale, '模型', 'Model')} value={status.model} />
<InfoRow label={pickAppText(locale, '最大令牌数', 'Max tokens')} value={String(status.max_tokens)} />
<InfoRow label={pickAppText(locale, '温度', 'Temperature')} value={String(status.temperature)} />
<InfoRow
label="最大工具迭代次数"
label={pickAppText(locale, '最大工具迭代次数', 'Max tool iterations')}
value={String(status.max_tool_iterations)}
/>
</CardContent>
@ -205,7 +206,7 @@ export default function StatusPage() {
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Key className="w-4 h-4" />
{pickAppText(locale, '提供商', 'Providers')}
</CardTitle>
</CardHeader>
<CardContent>
@ -239,7 +240,7 @@ export default function StatusPage() {
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Radio className="w-4 h-4" />
{pickAppText(locale, '通道', 'Channels')}
</CardTitle>
</CardHeader>
<CardContent>
@ -250,7 +251,7 @@ export default function StatusPage() {
variant={ch.enabled ? 'default' : 'secondary'}
className="text-xs"
>
{ch.enabled ? '开启' : '关闭'}
{ch.enabled ? pickAppText(locale, '开启', 'On') : pickAppText(locale, '关闭', 'Off')}
</Badge>
<span className="capitalize">{ch.name}</span>
</div>
@ -264,16 +265,16 @@ export default function StatusPage() {
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<AlertCircle className="w-4 h-4" />
{pickAppText(locale, '调度器', 'Scheduler')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<InfoRow
label="状态"
value={status.cron.enabled ? '运行中' : '已停止'}
label={pickAppText(locale, '状态', 'Status')}
value={status.cron.enabled ? pickAppText(locale, '运行中', 'Running') : pickAppText(locale, '已停止', 'Stopped')}
ok={status.cron.enabled}
/>
<InfoRow label="任务数" value={String(status.cron.jobs)} />
<InfoRow label={pickAppText(locale, '任务数', 'Jobs')} value={String(status.cron.jobs)} />
</CardContent>
</Card>
</div>