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:
@ -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/<id>_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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 ID,Audience 会自动生成为 mcp:<id>。';
|
||||
authzHint = t('先填写 MCP ID,Audience 会自动生成为 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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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' }}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user