feat: 添加MinIO文件系统支持并优化外部连接器功能

- 添加MinIO用户文件系统配置选项(BEAVER_MINIO_ROOT_USER等)
- 更新外部连接器配置结构,包括BASE_URL和认证令牌设置
- 改进connector provider支持更多类型(official, feishu_bot等)
- 实现Mistral模型推理模式支持reasoning_effort参数
- 增强外部连接器策略配置和运行时配置管理
- 添加connector bridge事件验证和安全保护机制
- 优化任务路由逻辑,区分simple_chat和new_task场景
- 更新初始技能工具提示配置,分离authoring admin功能
This commit is contained in:
2026-06-05 11:46:40 +08:00
parent 236ac19789
commit 2c5205b06e
120 changed files with 8321 additions and 1865 deletions

View File

@ -34,6 +34,7 @@ import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { type AppLocale, pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { containedLongTextClass, containedPreservedLongTextClass } from '@/lib/text-wrapping';
const LOAD_RETRY_DELAYS_MS = [0, 600, 1200];
@ -126,8 +127,8 @@ export default function FilesPage() {
if (selectedFile?.path === item.path) {
setSelectedFile(null);
}
} catch {
// ignore
} catch (err: any) {
setLoadError(err.message || pickAppText(locale, '删除失败', 'Delete failed'));
}
};
@ -147,8 +148,8 @@ export default function FilesPage() {
a.click();
a.remove();
URL.revokeObjectURL(a.href);
} catch {
// ignore
} catch (err: any) {
setPreviewError(err.message || pickAppText(locale, '下载失败', 'Download failed'));
}
};
@ -160,13 +161,13 @@ export default function FilesPage() {
setUploadProgress(0);
try {
for (let i = 0; i < files.length; i++) {
await uploadUserFile(files[i], currentPath || 'uploads', (pct) => {
await uploadUserFile(files[i], currentPath, (pct) => {
setUploadProgress(Math.round((i / files.length) * 100 + pct / files.length));
});
}
await load();
} catch {
// ignore
} catch (err: any) {
setLoadError(err.message || pickAppText(locale, '上传失败', 'Upload failed'));
} finally {
setUploading(false);
setUploadProgress(0);
@ -183,8 +184,8 @@ export default function FilesPage() {
setShowMkdir(false);
setNewDirName('');
await load();
} catch {
// ignore
} catch (err: any) {
setLoadError(err.message || pickAppText(locale, '创建文件夹失败', 'Failed to create folder'));
}
};
@ -213,16 +214,17 @@ export default function FilesPage() {
};
return (
<div className="mx-auto max-w-7xl p-6">
<div className="mx-auto w-full max-w-7xl overflow-x-hidden px-4 py-6 sm:px-6">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-bold">{pickAppText(locale, '文件管理', 'Files')}</h1>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-11"
onClick={() => setShowMkdir(true)}
disabled={loading || !currentPath}
disabled={loading}
>
<FolderPlus className="w-4 h-4 mr-1" />
{pickAppText(locale, '新建文件夹', 'New folder')}
@ -230,8 +232,9 @@ export default function FilesPage() {
<Button
variant="outline"
size="sm"
className="h-11"
onClick={() => fileInputRef.current?.click()}
disabled={uploading || !currentPath}
disabled={uploading}
>
{uploading ? (
<>
@ -252,7 +255,15 @@ export default function FilesPage() {
className="hidden"
onChange={handleUpload}
/>
<Button variant="outline" size="sm" onClick={() => load()} disabled={loading}>
<Button
variant="outline"
size="icon"
className="h-11 w-11"
onClick={() => load()}
disabled={loading}
aria-label={pickAppText(locale, '刷新', 'Refresh')}
title={pickAppText(locale, '刷新', 'Refresh')}
>
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
@ -266,7 +277,7 @@ export default function FilesPage() {
<div className="flex items-center gap-1 mb-4 text-sm text-muted-foreground flex-wrap">
<button
onClick={() => navigateTo('')}
className="flex items-center gap-1 hover:text-foreground transition-colors px-1.5 py-0.5 rounded hover:bg-accent"
className="inline-flex h-11 items-center gap-1 rounded px-2 transition-colors hover:bg-accent hover:text-foreground"
>
<Home className="w-3.5 h-3.5" />
{pickAppText(locale, '文件', 'Files')}
@ -279,7 +290,7 @@ export default function FilesPage() {
<ChevronRight className="w-3 h-3 flex-shrink-0" />
<button
onClick={() => navigateTo(path)}
className={`px-1.5 py-0.5 rounded transition-colors ${
className={`inline-flex h-11 items-center rounded px-2 text-left transition-colors ${containedLongTextClass} ${
isLast
? 'text-foreground font-medium'
: 'hover:text-foreground hover:bg-accent'
@ -294,9 +305,13 @@ export default function FilesPage() {
{/* New directory input */}
{showMkdir && (
<div className="flex items-center gap-2 mb-4">
<div className="mb-4 flex flex-wrap items-center gap-2">
<Folder className="w-4 h-4 text-muted-foreground" />
<label htmlFor="new-folder-name" className="sr-only">
{pickAppText(locale, '文件夹名称', 'Folder name')}
</label>
<input
id="new-folder-name"
ref={mkdirInputRef}
type="text"
value={newDirName}
@ -309,15 +324,16 @@ export default function FilesPage() {
}
}}
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"
className="h-11 min-w-0 flex-1 rounded-md border border-border bg-background px-3 text-sm focus:outline-none focus:ring-1 focus:ring-ring"
autoFocus
/>
<Button size="sm" onClick={handleCreateDir}>
<Button size="sm" className="h-11" onClick={handleCreateDir}>
{pickAppText(locale, '创建', 'Create')}
</Button>
<Button
size="sm"
variant="ghost"
className="h-11"
onClick={() => {
setShowMkdir(false);
setNewDirName('');
@ -328,9 +344,9 @@ export default function FilesPage() {
</div>
)}
<div className="grid gap-4 lg:grid-cols-[minmax(360px,440px)_minmax(0,1fr)]">
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)] gap-4 lg:grid-cols-[minmax(360px,440px)_minmax(0,1fr)]">
{/* File list */}
<div className="min-h-[520px] rounded-lg border border-border bg-card">
<div className="min-w-0 rounded-lg border border-border bg-card lg:min-h-[520px]">
{loading && items.length === 0 ? (
<div className="flex items-center justify-center py-20 text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin" />
@ -340,7 +356,7 @@ export default function FilesPage() {
<FolderOpen className="w-12 h-12 mb-4 opacity-50" />
<p className="text-lg font-medium">{pickAppText(locale, '加载失败', 'Failed to load')}</p>
<p className="max-w-sm text-center text-sm">{loadError}</p>
<Button className="mt-4" variant="outline" size="sm" onClick={() => load()}>
<Button className="mt-4 h-11" variant="outline" size="sm" onClick={() => load()}>
<RefreshCw className="mr-1 h-4 w-4" />
{pickAppText(locale, '重试', 'Retry')}
</Button>
@ -349,90 +365,80 @@ export default function FilesPage() {
<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">{pickAppText(locale, '空文件夹', 'Empty folder')}</p>
<p className="text-sm">{pickAppText(locale, '点击上方"上传"或"新建文件夹"按钮开始使用', 'Use "Upload" or "New folder" above to get started')}</p>
<p className="px-4 text-center text-sm">{pickAppText(locale, '点击上方"上传"或"新建文件夹"按钮开始使用', 'Use "Upload" or "New folder" above to get started')}</p>
</div>
) : (
<ScrollArea className="h-[calc(100vh-15rem)] min-h-[520px]">
<ScrollArea className="max-h-[calc(100vh-15rem)] min-h-[360px] lg:min-h-[520px]">
<div className="space-y-1 p-2">
{items.map((item) => (
<button
<div
key={item.path}
type="button"
className={`group flex w-full items-center gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors hover:bg-accent/30 ${
className={`group flex min-w-0 flex-col gap-2 rounded-lg border p-2 text-left transition-colors hover:bg-accent/30 sm:flex-row sm:items-center ${
selectedFile?.path === item.path ? 'border-primary bg-accent/40' : 'border-border bg-card'
}`}
onClick={() => {
if (item.type === 'directory') {
navigateTo(item.path);
} else {
void openFile(item);
}
}}
>
<div className="flex-shrink-0">
{item.type === 'directory' ? (
<Folder className="w-5 h-5 text-blue-500" />
) : (
<FileIcon name={item.name} contentType={item.content_type || undefined} />
)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{item.name}</div>
<p className="text-xs text-muted-foreground">
{item.type === 'file' && formatSize(item.size)}
{item.modified && (
<>
{item.type === 'file' && ' · '}
{formatDate(item.modified)}
</>
<button
type="button"
className="flex min-h-[3.5rem] min-w-0 flex-1 items-center gap-3 rounded-md px-1 py-2 text-left"
onClick={() => {
if (item.type === 'directory') {
navigateTo(item.path);
} else {
void openFile(item);
}
}}
>
<div className="flex-shrink-0">
{item.type === 'directory' ? (
<Folder className="w-5 h-5 text-blue-500" />
) : (
<FileIcon name={item.name} contentType={item.content_type || undefined} />
)}
</p>
</div>
</div>
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<div className="min-w-0 flex-1">
<div className={`text-sm font-medium ${containedLongTextClass}`}>{item.name}</div>
<p className={`text-xs text-muted-foreground ${containedLongTextClass}`}>
{item.type === 'file' && formatSize(item.size)}
{item.modified && (
<>
{item.type === 'file' && ' · '}
{formatDate(item.modified)}
</>
)}
</p>
</div>
</button>
<div className="flex shrink-0 items-center justify-end gap-1 opacity-100 md:opacity-0 md:transition-opacity md:group-hover:opacity-100">
{item.type === 'file' && (
<span
role="button"
tabIndex={0}
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-accent"
<button
type="button"
className="inline-flex h-11 w-11 items-center justify-center rounded-md hover:bg-accent"
onClick={(event) => {
event.stopPropagation();
void handleDownload(item);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
void handleDownload(item);
}
}}
aria-label={`${pickAppText(locale, '下载', 'Download')} ${item.name}`}
title={pickAppText(locale, '下载', 'Download')}
>
<Download className="w-4 h-4" />
</span>
</button>
)}
<span
role="button"
tabIndex={0}
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-destructive hover:bg-accent hover:text-destructive"
<button
type="button"
className="inline-flex h-11 w-11 items-center justify-center rounded-md text-destructive hover:bg-accent hover:text-destructive"
onClick={(event) => {
event.stopPropagation();
void handleDelete(item);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
void handleDelete(item);
}
}}
aria-label={`${pickAppText(locale, '删除', 'Delete')} ${item.name}`}
title={pickAppText(locale, '删除', 'Delete')}
>
<Trash2 className="w-4 h-4" />
</span>
</button>
</div>
</button>
</div>
))}
</div>
</ScrollArea>
@ -471,7 +477,7 @@ function FilePreviewPanel({
locale: AppLocale;
}) {
return (
<div className="min-h-[520px] rounded-lg border border-border bg-card p-4">
<div className="min-w-0 rounded-lg border border-border bg-card p-4 lg:min-h-[520px]">
{loading ? (
<div className="flex h-[420px] items-center justify-center text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin" />
@ -485,16 +491,16 @@ function FilePreviewPanel({
</div>
) : (
<div className="space-y-3">
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-border pb-3">
<div className="flex min-w-0 flex-wrap items-start justify-between gap-3 border-b border-border pb-3">
<div className="min-w-0">
<h2 className="break-all text-base font-semibold">{file.name}</h2>
<p className="mt-1 text-xs text-muted-foreground">
<h2 className={`text-base font-semibold ${containedLongTextClass}`}>{file.name}</h2>
<p className={`mt-1 text-xs text-muted-foreground ${containedLongTextClass}`}>
{formatSize(file.size)} · {formatDate(file.modified)} · {file.content_type}
{file.is_truncated ? ` · ${pickAppText(locale, '仅预览前 1MB', 'Showing first 1MB')}` : ''}
</p>
</div>
{downloadUrl && (
<Button variant="outline" size="sm" asChild>
<Button variant="outline" size="sm" className="h-11" asChild>
<a href={downloadUrl}>
<Download className="mr-2 h-4 w-4" />
{pickAppText(locale, '下载', 'Download')}
@ -514,11 +520,11 @@ function FilePreviewPanel({
<p className="text-sm font-medium">{pickAppText(locale, '该文件不能直接预览', 'This file cannot be previewed')}</p>
</div>
) : isMarkdown(file) ? (
<div className="prose prose-sm max-h-[620px] max-w-none overflow-auto text-black prose-a:text-black prose-code:text-black prose-headings:text-black prose-li:text-black prose-p:text-black prose-pre:bg-muted prose-pre:text-black prose-strong:text-black [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<div className={`prose prose-sm max-h-[620px] max-w-none overflow-auto text-black prose-a:text-black prose-code:text-black prose-headings:text-black prose-li:text-black prose-p:text-black prose-pre:bg-muted prose-pre:text-black prose-strong:text-black [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_*]:min-w-0 ${containedLongTextClass}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{file.content || ''}</ReactMarkdown>
</div>
) : (
<pre className="max-h-[620px] overflow-auto whitespace-pre-wrap rounded-md border border-border bg-background p-4 text-xs leading-5 text-black">
<pre className={`max-h-[620px] overflow-auto rounded-md border border-border bg-background p-4 text-xs leading-5 text-black ${containedPreservedLongTextClass}`}>
{file.content || ''}
</pre>
)}

View File

@ -168,10 +168,18 @@ export default function MarketplacePage() {
const totalPages = useMemo(() => Math.max(1, Math.ceil(total / 12)), [total]);
return (
<div className="mx-auto max-w-7xl p-6">
<div className="mx-auto mb-10 max-w-4xl">
<div className="mx-auto max-w-7xl space-y-6 p-4 sm:p-6">
<div className="mx-auto max-w-4xl space-y-5">
<div className="text-center">
<h1 className="text-2xl font-bold tracking-normal sm:text-3xl">
{t('市场', 'Marketplace')}
</h1>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
{t('搜索、查看并安装 SkillHub 技能。', 'Search, inspect, and install SkillHub skills.')}
</p>
</div>
<form
className="flex gap-3"
className="flex flex-col gap-3 sm:flex-row"
onSubmit={(event) => {
event.preventDefault();
setPage(0);
@ -187,7 +195,7 @@ export default function MarketplacePage() {
className="h-14 rounded-2xl pl-12 text-base"
/>
</div>
<Button type="submit" className="h-14 rounded-2xl px-10 text-base">
<Button type="submit" className="h-14 rounded-2xl px-10 text-base sm:w-auto">
{t('搜索', 'Search')}
</Button>
</form>
@ -195,9 +203,9 @@ export default function MarketplacePage() {
{error && (
<Card className="mb-6 border-destructive">
<CardContent className="flex items-center gap-2 pt-6 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
{error}
<CardContent className="flex items-start gap-2 pt-6 text-sm text-destructive">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span className="min-w-0 break-words">{error}</span>
</CardContent>
</Card>
)}
@ -206,6 +214,7 @@ export default function MarketplacePage() {
<div className="space-y-5">
<Button
variant="ghost"
className="h-11"
onClick={() => {
setSelected(null);
setVersionDetail(null);
@ -239,7 +248,7 @@ export default function MarketplacePage() {
onOpenFile={(filePath) => void openFile(filePath)}
badges={
<>
<Badge variant="outline">@{selected.namespace}</Badge>
<Badge variant="outline" className="max-w-full break-all">@{selected.namespace}</Badge>
<Badge variant="outline">{t('下载', 'Downloads')}: {selected.downloadCount || 0}</Badge>
<Badge variant="outline">{t('收藏', 'Stars')}: {selected.starCount || 0}</Badge>
{selected.installed && (
@ -251,7 +260,7 @@ export default function MarketplacePage() {
</>
}
actions={
<Button onClick={installSelected} disabled={installing || detailLoading || versionLoading}>
<Button className="h-11" onClick={installSelected} disabled={installing || detailLoading || versionLoading}>
{installing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Download className="mr-2 h-4 w-4" />}
{selected.installed ? t('重新安装/更新', 'Reinstall/update') : t('安装', 'Install')}
</Button>
@ -283,7 +292,7 @@ export default function MarketplacePage() {
{label}
</Button>
))}
<span className="ml-4 text-sm font-medium text-muted-foreground">{t('筛选:', 'Filter:')}</span>
<span className="text-sm font-medium text-muted-foreground sm:ml-4">{t('筛选:', 'Filter:')}</span>
<Button size="sm" variant={starredOnly ? 'default' : 'outline'} onClick={() => setStarredOnly((value) => !value)}>
<Star className="mr-2 h-4 w-4" />
{t('只看已收藏', 'Starred only')}
@ -297,28 +306,34 @@ export default function MarketplacePage() {
) : (
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{items.map((item) => (
<Card key={`${item.namespace}/${item.slug}`} className="cursor-pointer transition hover:border-primary" onClick={() => void openDetail(item)}>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<CardTitle className="text-xl">{item.displayName || item.slug}</CardTitle>
<Badge variant="outline">@{item.namespace}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-5">
<p className="line-clamp-3 min-h-[4.5rem] text-sm leading-6 text-muted-foreground">{item.summary}</p>
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<Badge variant="secondary">v{publishedVersion(item) || '-'}</Badge>
<span>{item.downloadCount || 0}</span>
<span>{item.starCount || 0}</span>
{item.installed && <Badge variant="outline">{t('已安装', 'Installed')}</Badge>}
</div>
</CardContent>
<Card key={`${item.namespace}/${item.slug}`} className="overflow-hidden transition hover:border-primary">
<button
type="button"
className="block h-full w-full text-left"
onClick={() => void openDetail(item)}
>
<CardHeader>
<div className="flex flex-col items-start gap-3 sm:flex-row sm:justify-between">
<CardTitle className="min-w-0 break-words text-xl">{item.displayName || item.slug}</CardTitle>
<Badge variant="outline" className="max-w-full break-all">@{item.namespace}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-5">
<p className="line-clamp-3 min-h-[4.5rem] break-words text-sm leading-6 text-muted-foreground">{item.summary}</p>
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
<Badge variant="secondary" className="max-w-full break-all">v{publishedVersion(item) || '-'}</Badge>
<span>{item.downloadCount || 0}</span>
<span>{item.starCount || 0}</span>
{item.installed && <Badge variant="outline">{t('已安装', 'Installed')}</Badge>}
</div>
</CardContent>
</button>
</Card>
))}
</div>
)}
<div className="flex items-center justify-center gap-3">
<div className="flex flex-wrap items-center justify-center gap-3">
<Button variant="outline" disabled={page <= 0} onClick={() => setPage((value) => Math.max(0, value - 1))}>
{t('上一页', 'Previous')}
</Button>

View File

@ -247,6 +247,9 @@ export default function MCPPage() {
};
const handleDelete = async (serverId: string) => {
if (!window.confirm(t('确定删除这个 MCP 服务吗?此操作不可撤销。', 'Delete this MCP server? This action cannot be undone.'))) {
return;
}
try {
await deleteMcpServer(serverId);
setSelectedServerId((current) => (current === serverId ? null : current));
@ -312,9 +315,9 @@ export default function MCPPage() {
}
return (
<div className="max-w-6xl mx-auto p-6 space-y-6">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<div className="mx-auto max-w-6xl space-y-6 p-4 sm:p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h1 className="text-2xl font-bold flex items-center gap-2">
<ServerCog className="w-6 h-6" />
{t('工具', 'Tools')}
@ -323,8 +326,8 @@ export default function MCPPage() {
{t('本地工具和在线工具都通过 MCP Server 暴露;本地工具按类别由真实 stdio MCP 子进程承载。', 'Local and online tools are both exposed through MCP servers. Local tool categories run as real stdio MCP subprocesses.')}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => void load(true)}>
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto sm:justify-end">
<Button variant="outline" size="sm" className="h-11" onClick={() => void load(true)}>
<RefreshCw className={`w-4 h-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
{t('刷新', 'Refresh')}
</Button>
@ -333,12 +336,12 @@ export default function MCPPage() {
if (!open) resetForm();
}}>
<DialogTrigger asChild>
<Button size="sm">
<Button size="sm" className="h-11">
<Plus className="w-4 h-4 mr-2" />
{t('新增工具服务', 'Add tool server')}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogContent className="max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{editingId ? t('编辑 MCP 服务', 'Edit MCP server') : t('新增 MCP 服务', 'Add MCP server')}</DialogTitle>
</DialogHeader>
@ -346,11 +349,11 @@ export default function MCPPage() {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="id">ID</Label>
<Input id="id" value={form.id} onChange={(e) => setForm((s) => ({ ...s, id: e.target.value }))} required disabled={!!editingId} />
<Input id="id" className="h-11" 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">{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 }))} />
<Input id="tool_timeout" className="h-11" type="number" min="1" value={form.tool_timeout} onChange={(e) => setForm((s) => ({ ...s, tool_timeout: e.target.value }))} />
</div>
</div>
<Tabs
@ -390,6 +393,7 @@ export default function MCPPage() {
<Label htmlFor="url">{t('MCP Server 地址', 'MCP server URL')}</Label>
<Input
id="url"
className="h-11"
value={form.url}
onChange={(e) => setForm((s) => ({ ...s, url: e.target.value }))}
placeholder="http://localhost:3001/mcp"
@ -403,7 +407,7 @@ export default function MCPPage() {
id="auth_mode"
value={form.auth_mode}
onChange={(e) => setForm((s) => ({ ...s, auth_mode: e.target.value }))}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
className="flex h-11 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="none">none</option>
<option value="oauth_backend_token">oauth_backend_token</option>
@ -452,6 +456,7 @@ export default function MCPPage() {
<Label htmlFor="command">{t('命令', 'Command')}</Label>
<Input
id="command"
className="h-11"
value={form.command}
onChange={(e) => setForm((s) => ({ ...s, command: e.target.value }))}
placeholder="npx"
@ -462,6 +467,7 @@ export default function MCPPage() {
<Label htmlFor="args">{t('参数', 'Arguments')}</Label>
<Input
id="args"
className="h-11"
value={form.args}
onChange={(e) => setForm((s) => ({ ...s, args: e.target.value }))}
placeholder="-y @modelcontextprotocol/server-github"
@ -470,11 +476,11 @@ export default function MCPPage() {
</div>
</TabsContent>
</Tabs>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
<div className="sticky bottom-0 -mx-6 -mb-6 flex justify-end gap-2 border-t bg-background px-6 py-4">
<Button type="button" variant="outline" className="h-11" onClick={() => setDialogOpen(false)}>
{t('取消', 'Cancel')}
</Button>
<Button type="submit" disabled={submitting}>
<Button type="submit" className="h-11" disabled={submitting}>
{submitting ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Plus className="w-4 h-4 mr-2" />}
{t('保存', 'Save')}
</Button>
@ -488,7 +494,7 @@ export default function MCPPage() {
{error && (
<Card className="border-destructive">
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-destructive text-sm">
<div className="flex items-center gap-2 break-words text-sm text-destructive">
<AlertCircle className="w-4 h-4" />
{error}
</div>
@ -500,9 +506,9 @@ export default function MCPPage() {
setToolTab(value as 'local' | 'online');
setSelectedServerId(null);
}} className="space-y-4">
<TabsList>
<TabsTrigger value="local">{t('本地工具', 'Local tools')}</TabsTrigger>
<TabsTrigger value="online">{t('在线工具', 'Online tools')}</TabsTrigger>
<TabsList className="h-auto min-h-11">
<TabsTrigger value="local" className="h-11 px-4">{t('本地工具', 'Local tools')}</TabsTrigger>
<TabsTrigger value="online" className="h-11 px-4">{t('在线工具', 'Online tools')}</TabsTrigger>
</TabsList>
</Tabs>
@ -511,78 +517,73 @@ export default function MCPPage() {
{visibleServers.map((server) => (
<Card
key={server.id}
role="button"
tabIndex={0}
onClick={() => setSelectedServerId(server.id)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setSelectedServerId(server.id);
}
}}
className={cn(
'cursor-pointer transition-colors',
'min-w-0 transition-colors',
selectedServerId === server.id && 'border-primary bg-primary/5 shadow-sm'
)}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle className="text-base">{server.name}</CardTitle>
<p className="text-xs text-muted-foreground mt-1 font-mono">{server.id}</p>
<div
role="button"
tabIndex={0}
onClick={() => setSelectedServerId(server.id)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setSelectedServerId(server.id);
}
}}
className="min-h-11 cursor-pointer rounded-t-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<CardHeader className="pb-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<CardTitle className="break-words text-base">{server.name}</CardTitle>
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">{server.id}</p>
</div>
<div className="flex min-w-0 flex-wrap items-center gap-2 sm:justify-end">
<Badge variant="outline">{transportLabel(server.transport, locale)}</Badge>
<Badge variant="secondary">{server.category || (server.kind === 'local' ? 'local' : 'online')}</Badge>
{server.managed && <Badge variant="outline">{t('内置', 'Built-in')}</Badge>}
<Badge variant={server.status === 'connected' ? 'default' : server.status === 'error' ? 'destructive' : 'secondary'}>
{serverStatusLabel(server.status, locale)}
</Badge>
</div>
</div>
<div className="flex items-center gap-2 flex-wrap justify-end">
<Badge variant="outline">{transportLabel(server.transport, locale)}</Badge>
<Badge variant="secondary">{server.category || (server.kind === 'local' ? 'local' : 'online')}</Badge>
{server.managed && <Badge variant="outline">{t('内置', 'Built-in')}</Badge>}
<Badge variant={server.status === 'connected' ? 'default' : server.status === 'error' ? 'destructive' : 'secondary'}>
{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">{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">{t('由 AuthZ 动态决定', 'Derived from AuthZ')}</span></div>
)}
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
<span>{t(`${discoveredToolCount(server.id, tools, server.tool_count)} 个工具`, `${discoveredToolCount(server.id, tools, server.tool_count)} 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">
{!server.managed && (
<Button variant="outline" size="sm" onClick={(event) => {
event.stopPropagation();
openEdit(server);
}}>
{t('编辑', 'Edit')}
</Button>
</CardHeader>
<CardContent className="space-y-3 pt-0 text-sm">
{server.url && <div className="break-words"><span className="font-medium">URL:</span> <span className="break-all text-muted-foreground">{server.url}</span></div>}
{server.command && <div className="break-words"><span className="font-medium">{t('命令:', 'Command:')}</span> <span className="break-all text-muted-foreground">{server.command} {(server.args || []).join(' ')}</span></div>}
{server.auth_mode && server.auth_mode !== 'none' && <div className="break-words"><span className="font-medium">{t('鉴权:', 'Auth:')}</span> <span className="break-all text-muted-foreground">{server.auth_mode}</span></div>}
{(server.auth_audience || server.auth_mode === 'oauth_backend_token') && (
<div className="break-words"><span className="font-medium">Audience</span> <span className="break-all text-muted-foreground">{server.auth_audience || resolveAuthAudience(server.id)}</span></div>
)}
<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')}
{(server.auth_scopes || []).length > 0 && <div className="break-words"><span className="font-medium">Scopes</span> <span className="break-all text-muted-foreground">{(server.auth_scopes || []).join(', ')}</span></div>}
{server.auth_mode === 'oauth_backend_token' && (!server.auth_scopes || server.auth_scopes.length === 0) && (
<div className="break-words"><span className="font-medium">Scopes</span> <span className="text-muted-foreground">{t('由 AuthZ 动态决定', 'Derived from AuthZ')}</span></div>
)}
<div className="flex min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>{t(`${discoveredToolCount(server.id, tools, server.tool_count)} 个工具`, `${discoveredToolCount(server.id, tools, server.tool_count)} tools`)}</span>
<span>{selectedServerId === server.id ? t('已选中', 'Selected') : t('点击查看工具', 'Click to view tools')}</span>
{server.last_error && <span className="break-all text-destructive">{server.last_error}</span>}
</div>
</CardContent>
</div>
<CardContent className="flex flex-wrap items-center justify-end gap-2 pt-0">
{!server.managed && (
<Button variant="outline" size="sm" className="h-11" onClick={() => openEdit(server)}>
{t('编辑', 'Edit')}
</Button>
{!server.managed && (
<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>
)}
<Button variant="outline" size="sm" className="h-11" onClick={() => 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>
{!server.managed && (
<Button variant="outline" size="sm" className="h-11" onClick={() => void handleDelete(server.id)}>
<Trash2 className="w-4 h-4 mr-2" />
{t('删除', 'Delete')}
</Button>
)}
</CardContent>
</Card>
))}
@ -595,9 +596,9 @@ export default function MCPPage() {
)}
</div>
<Card>
<Card className="min-w-0">
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<CardTitle className="flex items-center gap-2 break-words text-base">
<Wrench className="w-4 h-4" />
{selectedServer ? t(`${selectedServer.name} 的工具`, `${selectedServer.name} tools`) : t('工具详情', 'Tool details')}
</CardTitle>
@ -613,12 +614,12 @@ export default function MCPPage() {
)}
{selectedToolGroup && (
<div className="space-y-2">
<div className="text-sm font-medium">{selectedToolGroup.server_id}</div>
<div className="break-all text-sm font-medium">{selectedToolGroup.server_id}</div>
<div className="space-y-2">
{selectedToolGroup.tools.map((tool) => (
<div key={String(tool.name)} className="rounded-md border border-border/70 px-3 py-2 bg-background/60">
<div className="text-sm font-medium">{String(tool.tool_name || tool.name)}</div>
<div className="text-xs text-muted-foreground mt-1 whitespace-pre-wrap break-words">
<div key={String(tool.name)} className="min-w-0 rounded-md border border-border/70 bg-background/60 px-3 py-2">
<div className="break-all text-sm font-medium">{String(tool.tool_name || tool.name)}</div>
<div className="mt-1 whitespace-pre-wrap break-words text-xs text-muted-foreground">
{String(tool.description || '—')}
</div>
</div>

View File

@ -9,6 +9,7 @@ import { getNotification, sendMessage } from '@/lib/api';
import type { ChatMessage, NotificationDetail } from '@/types';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { containedLongTextClass } from '@/lib/text-wrapping';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ChatWorkbench } from '@/components/chat-workbench/ChatWorkbench';
@ -27,6 +28,7 @@ export default function NotificationDetailPage() {
const [submitting, setSubmitting] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement>(null);
const replyTextareaId = `notification-reply-${scheduledRunId}`;
const load = React.useCallback(async () => {
setLoading(true);
@ -94,8 +96,8 @@ export default function NotificationDetailPage() {
if (!detail) {
return (
<main className="mx-auto max-w-4xl px-6 py-8">
<Link href="/notifications" className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
<main className="mx-auto max-w-4xl px-4 py-6 sm:px-6 sm:py-8">
<Link href="/notifications" className="inline-flex h-11 items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="h-4 w-4" />
{pickAppText(locale, '返回通知', 'Back to notifications')}
</Link>
@ -106,29 +108,29 @@ export default function NotificationDetailPage() {
return (
<main className="flex h-[calc(100vh-4rem)] flex-col bg-background">
<div className="border-b border-[#E6E1DE] bg-[#F7F6F5] px-6 py-4">
<div className="border-b border-[#E6E1DE] bg-[#F7F6F5] px-4 py-4 sm:px-6">
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<Link href="/notifications" className="mb-2 inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
<Link href="/notifications" className="mb-2 inline-flex h-11 items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="h-4 w-4" />
{pickAppText(locale, '通知列表', 'Notifications')}
</Link>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h1 className="truncate text-xl font-semibold">{detail.title || detail.job_name}</h1>
<Badge variant={detail.status === 'error' ? 'destructive' : 'secondary'}>{detail.status}</Badge>
{detail.engaged && <Badge>{pickAppText(locale, '已接入 Task', 'Task linked')}</Badge>}
<h1 className={`min-w-0 max-w-full text-lg font-semibold sm:text-xl ${containedLongTextClass}`}>{detail.title || detail.job_name}</h1>
<Badge variant={detail.status === 'error' ? 'destructive' : 'secondary'} className="shrink-0">{detail.status}</Badge>
{detail.engaged && <Badge className="shrink-0">{pickAppText(locale, '已接入 Task', 'Task linked')}</Badge>}
</div>
<p className="mt-1 text-sm text-muted-foreground">
{pickAppText(locale, '生成时间', 'Generated')}: {formatTime(detail.started_at)}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => void load()}>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" className="h-11" onClick={() => void load()}>
<RefreshCw className="mr-2 h-4 w-4" />
{pickAppText(locale, '刷新', 'Refresh')}
</Button>
{detail.task_id && (
<Button asChild size="sm">
<Button asChild size="sm" className="h-11">
<Link href={`/tasks/${encodeURIComponent(detail.task_id)}`}>{pickAppText(locale, '查看任务', 'Open task')}</Link>
</Button>
)}
@ -136,7 +138,7 @@ export default function NotificationDetailPage() {
</div>
</div>
{error && <div className="mx-auto w-full max-w-6xl px-6 pt-3 text-sm text-destructive">{error}</div>}
{error && <div className={`mx-auto w-full max-w-6xl px-4 pt-3 text-sm text-destructive sm:px-6 ${containedLongTextClass}`}>{error}</div>}
<div className="min-h-0 flex-1">
<ChatWorkbench
@ -154,13 +156,15 @@ export default function NotificationDetailPage() {
/>
</div>
<div className="border-t border-[#E6E1DE] bg-background px-6 py-4">
<div className="border-t border-[#E6E1DE] bg-background px-4 py-4 sm:px-6">
<div className="mx-auto max-w-5xl">
<div className="mb-2 flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant={intent === 'revise_once' ? 'default' : 'outline'}
className="h-11"
aria-pressed={intent === 'revise_once'}
onClick={() => setIntent('revise_once')}
>
<RefreshCw className="mr-2 h-4 w-4" />
@ -170,13 +174,15 @@ export default function NotificationDetailPage() {
type="button"
size="sm"
variant={intent === 'update_future' ? 'default' : 'outline'}
className="h-11"
aria-pressed={intent === 'update_future'}
onClick={() => setIntent('update_future')}
>
<Settings2 className="mr-2 h-4 w-4" />
{pickAppText(locale, '以后按这样', 'Apply going forward')}
</Button>
{detail.engaged && (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<span className="inline-flex min-h-11 items-center gap-1 text-xs text-muted-foreground">
<Check className="h-3.5 w-3.5" />
{pickAppText(locale, '这条通知已经接入 Task', 'This notification is linked to a Task')}
</span>
@ -184,7 +190,13 @@ export default function NotificationDetailPage() {
</div>
{intent && (
<div className="rounded-[20px] border border-[#E6E1DE] bg-white p-3 shadow-[0_6px_18px_rgba(0,0,0,0.06)]">
<label htmlFor={replyTextareaId} className="mb-2 block px-2 text-xs font-medium text-muted-foreground">
{intent === 'update_future'
? pickAppText(locale, '以后这类通知的调整说明', 'Future notification adjustment')
: pickAppText(locale, '本次通知的修改说明', 'Revision note for this notification')}
</label>
<textarea
id={replyTextareaId}
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder={
@ -192,10 +204,10 @@ export default function NotificationDetailPage() {
? pickAppText(locale, '告诉我以后这类通知要怎么调整...', 'Describe how future notifications should change...')
: pickAppText(locale, '告诉我这次内容要怎么改...', 'Describe how this result should change...')
}
className="block min-h-20 w-full resize-none border-0 bg-transparent px-2 py-1 text-sm leading-6 outline-none"
className={`block min-h-20 w-full resize-none border-0 bg-transparent px-2 py-1 text-sm leading-6 outline-none ${containedLongTextClass}`}
/>
<div className="flex justify-end">
<Button size="sm" onClick={() => void submitReply()} disabled={!input.trim() || submitting}>
<Button size="sm" className="h-11" onClick={() => void submitReply()} disabled={!input.trim() || submitting}>
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
{pickAppText(locale, '发送', 'Send')}
</Button>

View File

@ -8,6 +8,7 @@ import { listNotifications } from '@/lib/api';
import type { NotificationRun } from '@/types';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { containedLongTextClass } from '@/lib/text-wrapping';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
@ -40,18 +41,18 @@ export default function NotificationsPage() {
};
return (
<main className="mx-auto flex h-[calc(100vh-4rem)] max-w-6xl flex-col px-6 py-8">
<main className="mx-auto flex h-[calc(100vh-4rem)] max-w-6xl flex-col px-4 py-6 sm:px-6 sm:py-8">
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<div>
<div className="min-w-0">
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<Bell className="h-5 w-5" />
{pickAppText(locale, '通知', 'Notifications')}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
{pickAppText(locale, '定时任务生成的日报、提醒和总结会固定出现在这里。', 'Scheduled reports, reminders, and summaries appear here.')}
</p>
</div>
<Button onClick={() => void load()} variant="outline" size="sm">
<Button onClick={() => void load()} variant="outline" size="sm" className="h-11">
<RefreshCw className="mr-2 h-4 w-4" />
{pickAppText(locale, '刷新', 'Refresh')}
</Button>
@ -83,21 +84,21 @@ export default function NotificationsPage() {
<Link
key={item.scheduled_run_id}
href={`/notifications/${encodeURIComponent(item.scheduled_run_id)}`}
className="grid gap-3 px-5 py-4 transition-colors hover:bg-[#F7F6F5] md:grid-cols-[minmax(0,1fr)_180px_110px_24px]"
className="grid min-w-0 gap-3 px-4 py-4 transition-colors hover:bg-[#F7F6F5] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset sm:px-5 md:grid-cols-[minmax(0,1fr)_180px_110px_24px]"
>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium">{item.title || item.job_name}</span>
{item.engaged && <Badge variant="secondary">{pickAppText(locale, '已接入 Task', 'Task linked')}</Badge>}
{item.status === 'error' && <Badge variant="destructive">{pickAppText(locale, '错误', 'Error')}</Badge>}
<span className={`min-w-0 flex-1 font-medium ${containedLongTextClass}`}>{item.title || item.job_name}</span>
{item.engaged && <Badge variant="secondary" className="shrink-0">{pickAppText(locale, '已接入 Task', 'Task linked')}</Badge>}
{item.status === 'error' && <Badge variant="destructive" className="shrink-0">{pickAppText(locale, '错误', 'Error')}</Badge>}
</div>
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">{item.output || item.message}</p>
<p className={`mt-1 line-clamp-2 text-sm text-muted-foreground ${containedLongTextClass}`}>{item.output || item.message}</p>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="flex min-w-0 items-center gap-2 text-sm text-muted-foreground">
<Clock3 className="h-4 w-4" />
{formatTime(item.started_at)}
</div>
<div className="text-sm text-muted-foreground">{item.job_name}</div>
<div className={`text-sm text-muted-foreground ${containedLongTextClass}`}>{item.job_name}</div>
<ArrowRight className="hidden h-4 w-4 self-center text-muted-foreground md:block" />
</Link>
))}

View File

@ -318,7 +318,7 @@ function renderPlainText(content: string): React.ReactNode[] {
href={part}
target="_blank"
rel="noreferrer noopener"
className="text-primary underline underline-offset-2 break-all"
className="inline-block min-h-11 max-w-full break-all py-2 text-primary underline underline-offset-2"
>
{part}
</a>
@ -461,7 +461,9 @@ export default function OutlookPage() {
if (!background) {
setStatusLoading(false);
}
if (!nextStatus.configured) {
if (nextStatus.configured) {
await loadOverview(options?.preserveOverview ?? background);
} else {
setOverview(null);
setOverviewLoading(false);
}
@ -647,6 +649,9 @@ export default function OutlookPage() {
};
const handleDisconnect = async () => {
if (!window.confirm(t('确定断开 Outlook 连接吗?已保存的连接凭据会被移除。', 'Disconnect Outlook? Saved connection credentials will be removed.'))) {
return;
}
setDisconnecting(true);
setError(null);
try {
@ -671,9 +676,7 @@ export default function OutlookPage() {
const refreshOverview = async () => {
await loadStatus(true, { preserveOverview: true });
if (activeView === 'settings' && isConfigured) {
await loadOverview(true);
} else if (activeView === 'inbox') {
if (activeView === 'inbox') {
await loadMailboxPage('inbox', inboxPage?.page.skip ?? 0);
} else if (activeView === 'sent') {
await loadMailboxPage('sent', sentPage?.page.skip ?? 0);
@ -684,14 +687,14 @@ export default function OutlookPage() {
return (
<div className="min-h-full">
<div className="mx-auto max-w-7xl space-y-6 p-6">
<div className="mx-auto max-w-7xl space-y-6 p-4 sm:p-6">
<section className="rounded-2xl border bg-card px-4 py-4 shadow-sm">
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<div className="flex flex-wrap items-center gap-2 text-sm">
<div className="mr-2 flex items-center gap-2 text-lg font-semibold text-foreground">
<div className="flex min-w-0 flex-wrap items-center gap-2 text-sm">
<h1 className="mr-2 flex min-w-0 items-center gap-2 text-lg font-semibold text-foreground">
<Mail className="h-5 w-5" />
Outlook
</div>
</h1>
{statusPending ? (
<>
<Skeleton className="h-6 w-20 rounded-full" />
@ -710,9 +713,9 @@ export default function OutlookPage() {
{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">{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">
<span className="min-w-0 break-all text-muted-foreground">{t('邮箱', 'Mailbox')} {overview?.mailbox || status?.saved?.email || '-'}</span>
<span className="min-w-0 break-all text-muted-foreground">{t('时区', 'Timezone')} {status?.saved?.default_timezone || overview?.timezone || form.default_timezone}</span>
<span className="min-w-0 break-words text-muted-foreground">
{t('最近刷新', 'Last refreshed')} {formatDateTime((overview?.meta?.last_overview_refresh_at || status?.meta?.last_overview_refresh_at) as string | undefined, locale)}
</span>
</>
@ -727,7 +730,7 @@ export default function OutlookPage() {
<TopStat label={t('日程', 'Calendar')} value={String(eventCount)} loading={overviewPending} />
</>
) : null}
<Button variant="outline" size="sm" onClick={() => void refreshOverview()}>
<Button variant="outline" size="sm" className="h-11" onClick={() => void refreshOverview()}>
<RefreshCw className={`mr-2 h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
{t('刷新', 'Refresh')}
</Button>
@ -740,7 +743,7 @@ export default function OutlookPage() {
<CardContent className="pt-6">
<div className="flex items-start gap-3 text-sm text-destructive">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{error}</span>
<span className="min-w-0 flex-1 break-all">{error}</span>
</div>
</CardContent>
</Card>
@ -752,7 +755,7 @@ export default function OutlookPage() {
{overviewWarnings.map((warning, index) => (
<div key={`${warning}-${index}`} className="flex items-start gap-3">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{warning}</span>
<span className="min-w-0 flex-1 break-all">{warning}</span>
</div>
))}
</CardContent>
@ -771,7 +774,7 @@ export default function OutlookPage() {
<TabsTrigger
key={view.id}
value={view.id}
className="h-auto rounded-xl border border-transparent px-4 py-3 data-[state=active]:border-border data-[state=active]:shadow-sm"
className="min-h-11 rounded-xl border border-transparent px-4 py-3 data-[state=active]:border-border data-[state=active]:shadow-sm"
>
<div className="flex w-full items-center justify-between gap-3">
<div className="flex items-center gap-3">
@ -872,53 +875,67 @@ export default function OutlookPage() {
</CardHeader>
<CardContent className="space-y-5 pt-6">
<div className="grid gap-4 md:grid-cols-2">
<Field label={t('邮箱地址', 'Email address')} required>
<Field id="outlook-email" label={t('邮箱地址', 'Email address')} required>
<Input
id="outlook-email"
className="h-11"
value={form.email}
onChange={(event) => updateField('email', event.target.value)}
placeholder="you@boardware.com"
/>
</Field>
<Field label={t('用户名', 'Username')}>
<Field id="outlook-username" label={t('用户名', 'Username')}>
<Input
id="outlook-username"
className="h-11"
value={form.username}
onChange={(event) => updateField('username', event.target.value)}
placeholder={t('留空时默认取邮箱前缀', 'Leave blank to default to the email prefix')}
/>
</Field>
<Field label={t('密码', 'Password')} required>
<Field id="outlook-password" label={t('密码', 'Password')} required>
<Input
id="outlook-password"
className="h-11"
type="password"
value={form.password}
onChange={(event) => updateField('password', event.target.value)}
placeholder={t('请输入邮箱密码', 'Enter the mailbox password')}
/>
</Field>
<Field label={t('域', 'Domain')}>
<Field id="outlook-domain" label={t('域', 'Domain')}>
<Input
id="outlook-domain"
className="h-11"
value={form.domain}
onChange={(event) => updateField('domain', event.target.value)}
placeholder="boardware.com.mo"
/>
</Field>
<Field label="EWS URL">
<Field id="outlook-service-endpoint" label="EWS URL">
<Input
id="outlook-service-endpoint"
className="h-11"
value={form.service_endpoint}
onChange={(event) => updateField('service_endpoint', event.target.value)}
placeholder="https://mail.boardware.com.mo/EWS/Exchange.asmx"
disabled={form.autodiscover}
/>
</Field>
<Field label="Server Host">
<Field id="outlook-server" label="Server Host">
<Input
id="outlook-server"
className="h-11"
value={form.server}
onChange={(event) => updateField('server', event.target.value)}
placeholder="mail.boardware.com.mo"
disabled={form.autodiscover}
/>
</Field>
<Field label={t('时区', 'Timezone')}>
<Field id="outlook-timezone" label={t('时区', 'Timezone')}>
<Input
id="outlook-timezone"
className="h-11"
value={form.default_timezone}
onChange={(event) => updateField('default_timezone', event.target.value)}
placeholder="Asia/Shanghai"
@ -944,16 +961,17 @@ export default function OutlookPage() {
</div>
<div className="flex flex-wrap justify-end gap-2">
<Button variant="outline" onClick={handleTest} disabled={!canTest || testing}>
<Button variant="outline" className="h-11" 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}>
<Button className="h-11" 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"
className="h-11"
onClick={handleDisconnect}
disabled={!status?.configured || disconnecting}
>
@ -966,8 +984,8 @@ export default function OutlookPage() {
<div className="rounded-3xl border bg-muted/30 p-4 text-sm">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="default">{t('测试成功', 'Test succeeded')}</Badge>
<span className="text-muted-foreground">{testResult.mailbox}</span>
<span className="text-muted-foreground">{t('用户名', 'Username')}: {testResult.resolved_username}</span>
<span className="break-all text-muted-foreground">{testResult.mailbox}</span>
<span className="break-all text-muted-foreground">{t('用户名', 'Username')}: {testResult.resolved_username}</span>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-3">
<MiniStat label={t('检测到文件夹', 'Detected folders')} value={String(testResult.sample.folders.length)} />
@ -979,7 +997,7 @@ export default function OutlookPage() {
{testWarnings.map((warning, index) => (
<div key={`${warning}-${index}`} className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{warning}</span>
<span className="min-w-0 flex-1 break-all">{warning}</span>
</div>
))}
</div>
@ -1054,10 +1072,10 @@ export default function OutlookPage() {
</Tabs>
<Dialog open={Boolean(selectedMessageRef)} onOpenChange={(open) => !open && setSelectedMessageRef(null)}>
<DialogContent className="sm:max-w-5xl">
<DialogContent className="bottom-4 left-4 right-4 top-4 max-h-none w-auto max-w-none translate-x-0 translate-y-0 overflow-y-auto data-[state=open]:slide-in-from-left-0 data-[state=open]:slide-in-from-top-0 data-[state=closed]:slide-out-to-left-0 data-[state=closed]:slide-out-to-top-0 sm:bottom-auto sm:left-[50%] sm:right-auto sm:top-[50%] sm:max-h-[calc(100dvh-2rem)] sm:w-[calc(100vw-2rem)] sm:max-w-5xl sm:translate-x-[-50%] sm:translate-y-[-50%] sm:data-[state=open]:slide-in-from-left-1/2 sm:data-[state=open]:slide-in-from-top-[48%] sm:data-[state=closed]:slide-out-to-left-1/2 sm:data-[state=closed]:slide-out-to-top-[48%]">
<DialogHeader>
<DialogTitle>{selectedMessage?.subject || t('邮件详情', 'Message details')}</DialogTitle>
<DialogDescription>
<DialogTitle className="break-words pr-8 leading-6">{selectedMessage?.subject || t('邮件详情', 'Message details')}</DialogTitle>
<DialogDescription className="break-words">
{selectedMessage?.receivedDateTime ? formatDateTime(selectedMessage.receivedDateTime, locale) : t('正在加载', 'Loading')}
</DialogDescription>
</DialogHeader>
@ -1066,7 +1084,7 @@ export default function OutlookPage() {
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : selectedMessage ? (
<div className="grid gap-4 lg:grid-cols-[280px,1fr]">
<div className="grid min-w-0 gap-4 lg:grid-cols-[280px,1fr]">
<div className="space-y-4 rounded-2xl border bg-muted/20 p-4 text-sm">
<InfoRow label={t('发件人', 'From')} value={mailboxLabel(selectedMessage.from)} />
<InfoRow
@ -1085,7 +1103,7 @@ export default function OutlookPage() {
</div>
</div>
<div className="overflow-hidden rounded-2xl border bg-background">
<div className="min-w-0 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>
@ -1115,10 +1133,10 @@ export default function OutlookPage() {
</Dialog>
<Dialog open={Boolean(selectedEvent)} onOpenChange={(open) => !open && setSelectedEvent(null)}>
<DialogContent className="sm:max-w-2xl">
<DialogContent className="bottom-4 left-4 right-4 top-4 max-h-none w-auto max-w-none translate-x-0 translate-y-0 overflow-y-auto data-[state=open]:slide-in-from-left-0 data-[state=open]:slide-in-from-top-0 data-[state=closed]:slide-out-to-left-0 data-[state=closed]:slide-out-to-top-0 sm:bottom-auto sm:left-[50%] sm:right-auto sm:top-[50%] sm:max-h-[calc(100dvh-2rem)] sm:w-[calc(100vw-2rem)] sm:max-w-2xl sm:translate-x-[-50%] sm:translate-y-[-50%] sm:data-[state=open]:slide-in-from-left-1/2 sm:data-[state=open]:slide-in-from-top-[48%] sm:data-[state=closed]:slide-out-to-left-1/2 sm:data-[state=closed]:slide-out-to-top-[48%]">
<DialogHeader>
<DialogTitle>{selectedEvent?.subject || t('日程详情', 'Event details')}</DialogTitle>
<DialogDescription>
<DialogTitle className="break-words pr-8 leading-6">{selectedEvent?.subject || t('日程详情', 'Event details')}</DialogTitle>
<DialogDescription className="break-words">
{selectedEvent
? `${formatDateTime(selectedEvent.start?.dateTime, locale)} - ${formatDateTime(selectedEvent.end?.dateTime, locale)}`
: t('日程详情', 'Event details')}
@ -1135,7 +1153,7 @@ export default function OutlookPage() {
<Separator />
<div className="space-y-2">
<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">
<div className="rounded-lg border bg-muted/40 p-3 whitespace-pre-wrap break-words">
{selectedEvent.bodyPreview || t('没有更多说明。', 'No additional notes.')}
</div>
</div>
@ -1149,17 +1167,19 @@ export default function OutlookPage() {
}
function Field({
id,
label,
required = false,
children,
}: {
id: string;
label: string;
required?: boolean;
children: React.ReactNode;
}) {
return (
<div className="space-y-2">
<Label className="text-sm font-medium">
<Label htmlFor={id} className="text-sm font-medium">
{label}
{required ? <span className="ml-1 text-destructive">*</span> : null}
</Label>
@ -1235,25 +1255,26 @@ function MessageCard({
const pageLabel = page ? t(`${currentPage} 页 · 本页 ${page.returned}`, `Page ${currentPage} · ${page.returned} messages`) : t('正在读取邮件…', 'Loading messages...');
return (
<Card className="rounded-[28px] shadow-sm">
<CardHeader className="flex flex-row items-center justify-between gap-4 border-b pb-5">
<div className="space-y-1">
<CardTitle className="flex items-center gap-2 text-base">
<Card className="min-w-0 rounded-[28px] shadow-sm">
<CardHeader className="flex flex-col gap-4 border-b pb-5 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 space-y-1">
<CardTitle className="flex items-center gap-2 break-words text-base">
{icon}
{title}
</CardTitle>
<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}>
<div className="flex flex-wrap items-center gap-2">
<Button variant="ghost" size="sm" className="h-11 w-11 p-0" aria-label={t('刷新邮件', 'Refresh mail')} title={t('刷新邮件', 'Refresh mail')} 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}>
<Button variant="outline" size="sm" className="h-11" onClick={onPreviousPage} disabled={!page || page.skip === 0 || refreshing}>
{t('上一页', 'Previous')}
</Button>
<Button
variant="outline"
size="sm"
className="h-11"
onClick={onNextPage}
disabled={!page || !page.has_more || refreshing}
>
@ -1284,17 +1305,17 @@ function MessageCard({
key={item.id || `${item.subject}-${item.receivedDateTime}`}
type="button"
onClick={() => item.id && onOpen(item)}
className="w-full rounded-2xl border bg-card p-4 text-left transition-colors hover:bg-muted/40"
className="min-h-11 w-full rounded-2xl border bg-card p-4 text-left transition-colors hover:bg-muted/40"
>
<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 || 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">
<p className="break-words font-medium text-foreground">{item.subject || t('(无主题)', '(No subject)')}</p>
<p className="mt-1 break-all text-xs text-muted-foreground">{mailboxLabel(item.from)}</p>
<p className="mt-3 line-clamp-2 break-words text-sm leading-6 text-muted-foreground">
{item.bodyPreview || t('没有预览内容。', 'No preview available.')}
</p>
</div>
<div className="flex shrink-0 items-center gap-2 lg:flex-col lg:items-end">
<div className="flex shrink-0 flex-wrap items-center gap-2 lg:flex-col lg:items-end">
<Badge variant={item.isRead ? 'secondary' : 'default'}>
{item.isRead ? t('已读', 'Read') : t('未读', 'Unread')}
</Badge>
@ -1357,10 +1378,10 @@ function EventCard({
});
return (
<Card className="rounded-[28px] shadow-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 border-b pb-5">
<div className="space-y-1">
<CardTitle className="flex items-center gap-2 text-base">
<Card className="min-w-0 rounded-[28px] shadow-sm">
<CardHeader className="flex flex-col gap-4 space-y-0 border-b pb-5 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 space-y-1">
<CardTitle className="flex items-center gap-2 break-words text-base">
<CalendarDays className="h-4 w-4" />
{t('日程安排', 'Schedule')}
</CardTitle>
@ -1368,17 +1389,17 @@ function EventCard({
{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}>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" className="h-11" onClick={onPreviousWeek} disabled={refreshing}>
{t('上一周', 'Previous week')}
</Button>
<Button variant="outline" size="sm" onClick={onCurrentWeek} disabled={refreshing}>
<Button variant="outline" size="sm" className="h-11" onClick={onCurrentWeek} disabled={refreshing}>
{t('本周', 'This week')}
</Button>
<Button variant="outline" size="sm" onClick={onNextWeek} disabled={refreshing}>
<Button variant="outline" size="sm" className="h-11" onClick={onNextWeek} disabled={refreshing}>
{t('下一周', 'Next week')}
</Button>
<Button variant="ghost" size="sm" onClick={onRefresh} disabled={refreshing}>
<Button variant="ghost" size="sm" className="h-11 w-11 p-0" aria-label={t('刷新日程', 'Refresh calendar')} title={t('刷新日程', 'Refresh calendar')} onClick={onRefresh} disabled={refreshing}>
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
</Button>
</div>
@ -1397,7 +1418,7 @@ function EventCard({
) : (
<div className="grid gap-3 lg:grid-cols-2 2xl:grid-cols-3">
{eventsByDay.map((day) => (
<div key={day.key} className="rounded-2xl border bg-card p-4">
<div key={day.key} className="min-w-0 rounded-2xl border bg-card p-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="font-medium text-foreground">{day.label}</p>
@ -1413,13 +1434,13 @@ function EventCard({
key={item.id || `${item.subject}-${item.start?.dateTime}`}
type="button"
onClick={() => onOpen(item)}
className="w-full rounded-xl border bg-background p-3 text-left transition-colors hover:bg-muted/40"
className="min-h-11 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 || t('(无主题)', '(No subject)')}</p>
<p className="break-words font-medium text-foreground">{item.subject || t('(无主题)', '(No subject)')}</p>
<p className="mt-1 text-xs text-muted-foreground">
{formatTime(item.start?.dateTime, locale)} - {formatTime(item.end?.dateTime, locale)}
</p>
<p className="mt-2 text-sm text-muted-foreground">
<p className="mt-2 break-words text-sm text-muted-foreground">
{item.location?.displayName || t('未设置地点', 'No location set')}
</p>
</button>

View File

@ -2,15 +2,24 @@
import Link from 'next/link';
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Brain, Plus, Send, Trash2, X } from 'lucide-react';
import { Brain, Menu, Plus, Send, Trash2, X } from 'lucide-react';
import { ChatWorkbench } from '@/components/chat-workbench/ChatWorkbench';
import { CurrentSessionProgressSidebar } from '@/components/chat-workbench/CurrentSessionProgressSidebar';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
archiveSession,
createSession,
getActiveTask,
getBackendTask,
getSession,
getSessionProcess,
listSessions,
@ -27,9 +36,9 @@ import {
} from '@/lib/chat-messages';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { buildSessionProgressView } from '@/lib/session-progress';
import { useChatStore } from '@/lib/store';
import type { ActiveTask, ChatMessage, FileAttachment, SessionUpdatedEvent, WsEvent } from '@/types';
import { buildTaskTimelineView } from '@/lib/task-timeline-view';
import type { ActiveTask, BackendTask, ChatMessage, FileAttachment, SessionUpdatedEvent, WsEvent } from '@/types';
function isSessionUpdatedEvent(data: WsEvent | Record<string, unknown>): data is SessionUpdatedEvent {
return data.type === 'session_updated' && typeof data.session_id === 'string';
@ -86,13 +95,17 @@ export default function ChatPage() {
const [thinkingModeEnabled, setThinkingModeEnabled] = useState(loadThinkingModePreference);
const [pendingFiles, setPendingFiles] = useState<Array<{ file: File; id?: string; progress: number; error?: string }>>([]);
const [activeTask, setActiveTask] = useState<ActiveTask | null>(null);
const [activeTaskDetail, setActiveTaskDetail] = useState<BackendTask | null>(null);
const [revisionTargetRunId, setRevisionTargetRunId] = useState<string | null>(null);
const [documentHidden, setDocumentHidden] = useState(isDocumentHidden);
const [sessionDrawerOpen, setSessionDrawerOpen] = useState(false);
const [archiveTargetSessionId, setArchiveTargetSessionId] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const messageViewportRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const loadSessionReqSeq = useRef(0);
const loadActiveTaskReqSeq = useRef(0);
const loadedSessionIdRef = useRef<string | null>(null);
const refreshSessionOnReconnectRef = useRef(false);
const hasConnectedRef = useRef(false);
@ -120,16 +133,15 @@ export default function ChatPage() {
);
const selectedSessionRunId = selectedRunId && sessionRunIds.has(selectedRunId) ? selectedRunId : null;
const sessionProgressView = useMemo(
const activeTaskTimelineView = useMemo(
() =>
buildSessionProgressView({
sessionId,
processRuns,
processEvents,
processArtifacts,
locale,
buildTaskTimelineView({
task: activeTaskDetail,
liveRuns: processRuns,
liveEvents: processEvents,
liveArtifacts: processArtifacts,
}),
[locale, processArtifacts, processEvents, processRuns, sessionId]
[activeTaskDetail, processArtifacts, processEvents, processRuns]
);
const loadSessions = useCallback(async () => {
@ -142,12 +154,34 @@ export default function ChatPage() {
}, []);
const loadActiveTask = useCallback(async (key: string) => {
const reqSeq = ++loadActiveTaskReqSeq.current;
try {
if (useChatStore.getState().sessionId !== key) return;
setActiveTask(await getActiveTask(key));
const nextActiveTask = await getActiveTask(key);
if (reqSeq !== loadActiveTaskReqSeq.current || useChatStore.getState().sessionId !== key) return;
setActiveTask(nextActiveTask);
if (!nextActiveTask) {
setActiveTaskDetail(null);
return;
}
setActiveTaskDetail((current) => (current?.task_id === nextActiveTask.task_id ? current : null));
try {
const detail = await getBackendTask(nextActiveTask.task_id);
if (reqSeq !== loadActiveTaskReqSeq.current || useChatStore.getState().sessionId !== key) return;
if (detail.is_open === false) {
setActiveTask(null);
setActiveTaskDetail(null);
return;
}
setActiveTaskDetail(detail);
} catch {
if (reqSeq === loadActiveTaskReqSeq.current && useChatStore.getState().sessionId === key) {
setActiveTaskDetail(null);
}
}
} catch {
if (useChatStore.getState().sessionId === key) {
if (reqSeq === loadActiveTaskReqSeq.current && useChatStore.getState().sessionId === key) {
setActiveTask(null);
setActiveTaskDetail(null);
}
}
}, []);
@ -194,6 +228,7 @@ export default function ChatPage() {
setIsThinking(false);
}
setActiveTask(null);
setActiveTaskDetail(null);
setRevisionTargetRunId(null);
setInput(useChatStore.getState().getInputDraft(sessionId));
void loadSessionMessages(sessionId);
@ -299,6 +334,7 @@ export default function ChatPage() {
useEffect(() => {
shouldSnapToLatestRef.current = true;
setSessionDrawerOpen(false);
}, [sessionId]);
useLayoutEffect(() => {
@ -474,6 +510,7 @@ export default function ChatPage() {
setSessionId(id);
setSelectedRunId(null);
setActiveTask(null);
setActiveTaskDetail(null);
setRevisionTargetRunId(null);
clearInputDraft(id);
setInput('');
@ -487,14 +524,15 @@ export default function ChatPage() {
void loadSessions();
};
const handleArchiveSession = async (key: string, e: React.MouseEvent) => {
e.stopPropagation();
const handleArchiveSession = async (key: string) => {
try {
await archiveSession(key);
setArchiveTargetSessionId(null);
useChatStore.getState().setSessions(useChatStore.getState().sessions.filter((session) => session.key !== key));
if (key === sessionId) {
setSessionId('web:default');
setActiveTask(null);
setActiveTaskDetail(null);
setRevisionTargetRunId(null);
clearInputDraft(key);
setInput(useChatStore.getState().getInputDraft('web:default'));
@ -514,9 +552,11 @@ export default function ChatPage() {
const handleSelectSession = (key: string) => {
setSelectedRunId(null);
setActiveTask(null);
setActiveTaskDetail(null);
setRevisionTargetRunId(null);
setInput(useChatStore.getState().getInputDraft(key));
setSessionId(key);
setSessionDrawerOpen(false);
};
const removePendingFile = useCallback((file: File) => {
@ -551,53 +591,101 @@ export default function ChatPage() {
return key;
};
return (
<div className="flex h-[calc(100vh-4rem)] bg-background">
<aside className="flex w-[280px] shrink-0 flex-col border-r border-[#E6E1DE] bg-[#F7F6F5]">
<div className="px-5 pb-5 pt-6">
<button
type="button"
onClick={handleNewSession}
className="flex h-11 w-full items-center justify-center gap-2 rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-[#342E2B]"
>
<Plus className="h-4 w-4" />
{pickAppText(locale, '新对话', 'New chat')}
</button>
</div>
<ScrollArea className="flex-1">
<div className="space-y-3 px-3 pb-6">
<div className="px-3 pb-2 text-[14px] text-muted-foreground">{pickAppText(locale, '最近对话', 'Recent chats')}</div>
{sessions.length === 0 && (
<p className="px-3 py-4 text-sm text-muted-foreground">{pickAppText(locale, '暂无对话记录', 'No chat history yet')}</p>
)}
{sessions.map((session) => (
const archiveTargetSessionName = archiveTargetSessionId ? formatSessionName(archiveTargetSessionId) : '';
const renderSessionSidebar = (variant: 'desktop' | 'drawer') => (
<>
<div className="px-5 pb-5 pt-6">
<button
type="button"
onClick={() => {
setSessionDrawerOpen(false);
void handleNewSession();
}}
className="flex h-11 w-full items-center justify-center gap-2 rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-[#342E2B]"
>
<Plus className="h-4 w-4" />
{pickAppText(locale, '新对话', 'New chat')}
</button>
</div>
<ScrollArea className="flex-1">
<div className="space-y-3 px-3 pb-6">
<div className="px-3 pb-2 text-[14px] text-muted-foreground">{pickAppText(locale, '最近对话', 'Recent chats')}</div>
{sessions.length === 0 && (
<p className="px-3 py-4 text-sm text-muted-foreground">{pickAppText(locale, '暂无对话记录', 'No chat history yet')}</p>
)}
{sessions.map((session) => {
const sessionName = formatSessionName(session.key);
const isCurrent = session.key === sessionId;
return (
<div
key={session.key}
onClick={() => handleSelectSession(session.key)}
className={`group flex cursor-pointer items-center justify-between rounded-xl px-4 py-3 text-[15px] transition-colors ${
session.key === sessionId
key={`${variant}:${session.key}`}
className={`group flex items-center gap-1 rounded-xl px-2 py-1 text-[15px] transition-colors ${
isCurrent
? 'bg-[#EFEEED] text-foreground'
: 'text-foreground hover:bg-[#EFEEED]/70'
: 'text-foreground hover:bg-[#EFEEED]/70 focus-within:bg-[#EFEEED]/70'
}`}
>
<div className="truncate">
<span className="truncate">{formatSessionName(session.key)}</span>
</div>
<button
onClick={(event) => handleArchiveSession(session.key, event)}
className="opacity-0 group-hover:opacity-100 p-0.5 hover:text-destructive transition-opacity"
title={pickAppText(locale, '归档会话', 'Archive session')}
aria-label={pickAppText(locale, '归档会话', 'Archive session')}
<button
type="button"
onClick={() => handleSelectSession(session.key)}
className="flex h-11 min-w-0 flex-1 items-center rounded-lg px-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-current={isCurrent ? 'true' : undefined}
>
<Trash2 className="w-3.5 h-3.5" />
<span className="truncate">{sessionName}</span>
</button>
<button
type="button"
onClick={() => setArchiveTargetSessionId(session.key)}
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg text-muted-foreground opacity-100 transition-colors hover:bg-white hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100"
title={pickAppText(locale, '归档会话', 'Archive session')}
aria-label={pickAppText(locale, `归档会话 ${sessionName}`, `Archive session ${sessionName}`)}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
</ScrollArea>
);
})}
</div>
</ScrollArea>
</>
);
return (
<div className="relative flex h-[calc(100dvh-4rem)] overflow-hidden bg-background">
<aside className="hidden w-[280px] shrink-0 flex-col border-r border-[#E6E1DE] bg-[#F7F6F5] md:flex">
{renderSessionSidebar('desktop')}
</aside>
{sessionDrawerOpen && (
<div className="fixed inset-x-0 bottom-0 top-16 z-40 md:hidden">
<button
type="button"
className="absolute inset-0 bg-black/30"
aria-label={pickAppText(locale, '关闭最近对话', 'Close recent chats')}
onClick={() => setSessionDrawerOpen(false)}
/>
<aside className="absolute bottom-0 left-0 top-0 flex w-[min(86vw,320px)] flex-col border-r border-[#E6E1DE] bg-[#F7F6F5] shadow-2xl">
{renderSessionSidebar('drawer')}
</aside>
</div>
)}
<div className="flex-1 flex flex-col min-w-0">
<div className="flex min-h-14 items-center gap-2 border-b border-[#E6E1DE] bg-[#F7F6F5] px-3 md:hidden">
<button
type="button"
onClick={() => setSessionDrawerOpen(true)}
className="flex h-11 w-11 items-center justify-center rounded-full border border-[#E6E1DE] bg-white text-[#1D1715]"
aria-label={pickAppText(locale, '打开最近对话', 'Open recent chats')}
>
<Menu className="h-5 w-5" />
</button>
<div className="min-w-0 text-sm font-medium text-foreground">
<span className="block truncate">{formatSessionName(sessionId)}</span>
</div>
</div>
<div className="flex-1 min-h-0">
<ChatWorkbench
messages={messages}
@ -614,14 +702,14 @@ export default function ChatPage() {
/>
</div>
<div className="bg-background px-8 pb-8 pt-4">
<div className="bg-background px-3 pb-4 pt-3 sm:px-5 sm:pb-6 md:px-8 md:pb-8 md:pt-4">
<div className="mx-auto max-w-5xl">
{(activeTask || revisionTargetRunId) && (
<div className="mb-2 flex">
{activeTask ? (
<Link
href={`/tasks/${encodeURIComponent(activeTask.task_id)}`}
className="inline-flex max-w-full items-center gap-2 rounded-full border border-[#D8D2CE] bg-[#F7F6F5] px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-[#EFEEED]"
className="inline-flex h-11 max-w-full items-center gap-2 rounded-full border border-[#D8D2CE] bg-[#F7F6F5] px-3 text-xs text-foreground transition-colors hover:bg-[#EFEEED]"
title={activeTask.description}
>
<span className="shrink-0 text-muted-foreground">
@ -638,7 +726,7 @@ export default function ChatPage() {
{pendingFiles.length > 0 && (
<div className="mb-2 space-y-1">
{pendingFiles.map((item, index) => (
<div key={`${item.file.name}:${index}`} className="flex items-center gap-2 px-3 py-1.5 bg-muted rounded-md text-sm">
<div key={`${item.file.name}:${index}`} className="flex min-h-11 items-center gap-2 rounded-md bg-muted px-3 py-1.5 text-sm">
<span className="truncate flex-1">
{item.file.name}{' '}
<span className="text-muted-foreground">({(item.file.size / 1024).toFixed(0)}KB)</span>
@ -652,8 +740,13 @@ export default function ChatPage() {
) : (
<span className="text-[#657162] 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" />
<button
type="button"
onClick={() => removePendingFile(item.file)}
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-background hover:text-foreground"
aria-label={pickAppText(locale, `移除附件 ${item.file.name}`, `Remove attachment ${item.file.name}`)}
>
<X className="h-4 w-4" />
</button>
</div>
))}
@ -662,8 +755,14 @@ export default function ChatPage() {
<div className="relative rounded-[28px] border border-[#E6E1DE] bg-white p-4 shadow-[0_8px_24px_rgba(0,0,0,0.08)]">
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileSelect} />
<label htmlFor="chat-composer" className="sr-only">
{revisionTargetRunId
? pickAppText(locale, '修改要求', 'Revision request')
: pickAppText(locale, '消息内容', 'Message content')}
</label>
<textarea
id="chat-composer"
ref={textareaRef}
value={input}
onChange={(e) => {
@ -677,7 +776,7 @@ export default function ChatPage() {
: pickAppText(locale, '今天想聊什么?', 'What would you like to talk about today?')
}
rows={1}
className="block w-full resize-none border-0 bg-transparent px-2 pb-8 pt-1 text-[17px] leading-7 placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
className="block w-full resize-none border-0 bg-transparent px-1 pb-8 pt-1 text-[16px] leading-7 placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 sm:px-2 sm:text-[17px]"
style={{ minHeight: '72px', maxHeight: '200px' }}
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
@ -687,18 +786,20 @@ export default function ChatPage() {
/>
<div className="flex items-center justify-between">
<div className="flex items-center gap-5 text-[15px] text-muted-foreground">
<div className="flex items-center gap-2 text-[15px] text-muted-foreground sm:gap-5">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center gap-2 text-foreground transition-colors hover:text-muted-foreground"
className="inline-flex h-11 w-11 items-center justify-center rounded-full text-foreground transition-colors hover:bg-[#F7F5F4] hover:text-muted-foreground"
title={pickAppText(locale, '添加附件', 'Add attachment')}
aria-label={pickAppText(locale, '添加附件', 'Add attachment')}
>
<Plus className="h-5 w-5" />
</button>
<button
type="button"
onClick={toggleThinkingMode}
className={`inline-flex h-8 items-center gap-2 rounded-full border px-3 text-sm transition-colors ${
className={`inline-flex h-11 items-center gap-2 rounded-full border px-3 text-sm transition-colors ${
thinkingModeEnabled
? 'border-primary/40 bg-[#F1EFEE] text-foreground'
: 'border-[#E6E1DE] bg-white text-muted-foreground hover:text-foreground'
@ -729,7 +830,43 @@ export default function ChatPage() {
</div>
</div>
{sessionProgressView && <CurrentSessionProgressSidebar view={sessionProgressView} />}
<Dialog open={Boolean(archiveTargetSessionId)} onOpenChange={(open) => !open && setArchiveTargetSessionId(null)}>
<DialogContent className="max-w-[calc(100vw-2rem)] sm:max-w-md">
<DialogHeader>
<DialogTitle>{pickAppText(locale, '归档此会话?', 'Archive this chat?')}</DialogTitle>
<DialogDescription>
{pickAppText(
locale,
archiveTargetSessionName ? `会话「${archiveTargetSessionName}」会从最近对话中移除。` : '此会话会从最近对话中移除。',
archiveTargetSessionName ? `Chat "${archiveTargetSessionName}" will be removed from recent chats.` : 'This chat will be removed from recent chats.'
)}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-2">
<button
type="button"
onClick={() => setArchiveTargetSessionId(null)}
className="h-11 rounded-md border border-border px-4 text-sm text-muted-foreground hover:bg-accent"
>
{pickAppText(locale, '取消', 'Cancel')}
</button>
<button
type="button"
onClick={() => archiveTargetSessionId && void handleArchiveSession(archiveTargetSessionId)}
className="h-11 rounded-md bg-destructive px-4 text-sm font-medium text-destructive-foreground hover:bg-destructive/90"
>
{pickAppText(locale, '确认归档', 'Confirm archive')}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
{activeTaskDetail ? (
<CurrentSessionProgressSidebar
cards={activeTaskTimelineView?.cards ?? []}
isLive={Boolean(activeTaskDetail.is_open && wsStatus === 'connected')}
/>
) : null}
</div>
);
}

View File

@ -1,6 +1,7 @@
'use client';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import {
AlertCircle,
BarChart3,
@ -77,13 +78,25 @@ import { containedJsonTextClass, containedLongTextClass } from '@/lib/text-wrapp
const TERMINAL_DRAFT_STATUSES = new Set(['rejected', 'published', 'disabled', 'archived']);
const REJECTABLE_DRAFT_STATUSES = new Set(['draft', 'in_review', 'approved']);
type SkillsTab = 'published' | 'candidates' | 'drafts';
function normalizeSkillsTab(value: string | null | undefined): SkillsTab {
if (value === 'candidates' || value === 'drafts') {
return value;
}
return 'published';
}
export default function SkillsPage() {
const { locale } = useAppI18n();
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
const [skills, setSkills] = useState<Skill[]>([]);
const [candidates, setCandidates] = useState<SkillLearningCandidate[]>([]);
const [drafts, setDrafts] = useState<SkillDraft[]>([]);
const [activeTab, setActiveTab] = useState<SkillsTab>(() => normalizeSkillsTab(searchParams?.get('tab')));
const [loading, setLoading] = useState(true);
const [actionId, setActionId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
@ -119,6 +132,23 @@ export default function SkillsPage() {
void load();
}, [load]);
useEffect(() => {
setActiveTab(normalizeSkillsTab(searchParams?.get('tab')));
}, [searchParams]);
const changeTab = (value: string) => {
const nextTab = normalizeSkillsTab(value);
setActiveTab(nextTab);
const nextParams = new URLSearchParams(searchParams?.toString());
if (nextTab === 'published') {
nextParams.delete('tab');
} else {
nextParams.set('tab', nextTab);
}
const query = nextParams.toString();
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
};
const runAction = async (id: string, action: () => Promise<unknown>) => {
setActionId(id);
setError(null);
@ -193,18 +223,18 @@ export default function SkillsPage() {
}
return (
<div className="mx-auto max-w-6xl space-y-6 bg-white p-6 text-black [--background:0_0%_100%] [--card:0_0%_100%] [--card-foreground:0_0%_0%] [--foreground:0_0%_0%] [--muted-foreground:0_0%_0%] [--popover:0_0%_100%] [--popover-foreground:0_0%_0%] [--secondary-foreground:0_0%_0%]">
<div className="mx-auto w-full max-w-6xl space-y-6 overflow-x-hidden bg-white px-4 py-6 text-black [--background:0_0%_100%] [--card:0_0%_100%] [--card-foreground:0_0%_0%] [--foreground:0_0%_0%] [--muted-foreground:0_0%_0%] [--popover:0_0%_100%] [--popover-foreground:0_0%_0%] [--secondary-foreground:0_0%_0%] sm:px-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="flex items-center gap-2 text-2xl font-bold">
<Puzzle className="w-6 h-6" />
{t('技能', 'Skills')}
</h1>
<div className="flex items-center gap-2">
<Button onClick={() => void load()} variant="outline" size="sm">
<div className="flex flex-wrap items-center gap-2">
<Button onClick={() => void load()} variant="outline" size="sm" className="h-11">
<RefreshCw className="mr-2 h-4 w-4" />
{t('刷新', 'Refresh')}
</Button>
<Button onClick={() => setShowUpload(true)} size="sm">
<Button onClick={() => setShowUpload(true)} size="sm" className="h-11">
<Upload className="mr-2 h-4 w-4" />
{t('上传技能', 'Upload skill')}
</Button>
@ -238,6 +268,7 @@ export default function SkillsPage() {
<Button
variant="ghost"
size="sm"
className="h-11"
onClick={() => {
setSelectedSkillName(null);
setSkillDetail(null);
@ -277,19 +308,20 @@ export default function SkillsPage() {
}
actions={
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => downloadSkill(skillDetail.skill.name).catch((err) => setError(err.message))}>
<Button variant="outline" size="sm" className="h-11" onClick={() => downloadSkill(skillDetail.skill.name).catch((err) => setError(err.message))}>
<Download className="mr-2 h-4 w-4" />
{t('下载', 'Download')}
</Button>
{skillDetail.skill.source === 'workspace' && (
<>
<Button variant="outline" size="sm" onClick={() => onRollbackSkill(skillDetail.skill.name)}>
<Button variant="outline" size="sm" className="h-11" onClick={() => onRollbackSkill(skillDetail.skill.name)}>
<RefreshCw className="mr-2 h-4 w-4" />
{t('回滚', 'Rollback')}
</Button>
<Button
variant="outline"
size="sm"
className="h-11"
disabled={Boolean(actionId)}
onClick={() => void runAction(`disable:${skillDetail.skill.name}`, () => disablePublishedSkill(skillDetail.skill.name, t('人工禁用', 'Manual disable')))}
>
@ -299,7 +331,7 @@ export default function SkillsPage() {
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
className="h-11 text-destructive hover:text-destructive"
disabled={Boolean(actionId)}
onClick={() => void runAction(`delete:${skillDetail.skill.name}`, () => deleteSkill(skillDetail.skill.name)).then(() => {
setSelectedSkillName(null);
@ -330,14 +362,14 @@ export default function SkillsPage() {
)}
{!selectedSkillName && (
<Tabs defaultValue="published" className="space-y-4">
<TabsList>
<TabsTrigger value="published">{t('已发布', 'Published')}</TabsTrigger>
<TabsTrigger value="candidates">{t('候选', 'Candidates')}</TabsTrigger>
<TabsTrigger value="drafts">{t('草稿评审', 'Draft review')}</TabsTrigger>
<Tabs value={activeTab} onValueChange={changeTab} className="min-w-0 space-y-4">
<TabsList className="h-auto min-h-11 w-full max-w-full justify-start overflow-x-auto sm:w-auto">
<TabsTrigger value="published" className="h-10">{t('已发布', 'Published')}</TabsTrigger>
<TabsTrigger value="candidates" className="h-10">{t('候选', 'Candidates')}</TabsTrigger>
<TabsTrigger value="drafts" className="h-10">{t('草稿评审', 'Draft review')}</TabsTrigger>
</TabsList>
<TabsContent value="published">
<TabsContent value="published" className="min-w-0">
<PublishedSkillsTable
skills={skills}
onOpen={(name) => void openSkillDetail(name)}
@ -350,7 +382,7 @@ export default function SkillsPage() {
/>
</TabsContent>
<TabsContent value="candidates">
<TabsContent value="candidates" className="min-w-0">
<Card>
<CardHeader>
<CardTitle className="text-base">{t('学习候选', 'Learning candidates')}</CardTitle>
@ -384,7 +416,7 @@ export default function SkillsPage() {
</Card>
</TabsContent>
<TabsContent value="drafts">
<TabsContent value="drafts" className="min-w-0">
<Card>
<CardHeader>
<CardTitle className="text-base">{t('草稿、评审与发布', 'Drafts, review, and publish')}</CardTitle>
@ -473,6 +505,54 @@ function PublishedSkillsTable({
{skills.length === 0 ? (
<EmptyState icon={<Puzzle className="h-8 w-8" />} text={t('暂无技能', 'No skills yet')} />
) : (
<>
<div className="space-y-3 p-3 md:hidden">
{skills.map((skill) => (
<div key={`${skill.source}:${skill.name}:card`} className="min-w-0 rounded-lg border border-border bg-white p-4">
<button
type="button"
className="block min-h-11 w-full text-left"
onClick={() => onOpen(skill.name)}
>
<div className={`text-sm font-semibold ${containedLongTextClass}`}>{skill.name}</div>
<div className={`mt-1 text-sm leading-5 text-muted-foreground ${containedLongTextClass}`}>
{skill.description || '-'}
</div>
</button>
<div className="mt-3 flex flex-wrap gap-2">
<Badge variant={skill.source === 'builtin' ? 'secondary' : 'default'} className="text-xs">
{skill.source === 'builtin' ? t('内置', 'Built in') : t('工作区', 'Workspace')}
</Badge>
<Badge variant={skill.available ? 'default' : 'outline'} className="text-xs">
{skill.available ? t('可用', 'Available') : t('不可用', 'Unavailable')}
</Badge>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button variant="outline" size="sm" className="h-11" onClick={() => onDownload(skill.name)}>
<Download className="mr-2 h-4 w-4" />
{t('下载', 'Download')}
</Button>
{skill.source === 'workspace' && (
<>
<Button variant="outline" size="sm" className="h-11" onClick={() => onRollback(skill.name)}>
<RefreshCw className="mr-2 h-4 w-4" />
{t('回滚', 'Rollback')}
</Button>
<Button variant="outline" size="sm" className="h-11" onClick={() => onDisable(skill.name)}>
<ShieldCheck className="mr-2 h-4 w-4" />
{t('禁用', 'Disable')}
</Button>
<Button variant="outline" size="sm" className="h-11 text-destructive hover:text-destructive" onClick={() => onDelete(skill.name)}>
<Trash2 className="mr-2 h-4 w-4" />
{t('删除', 'Delete')}
</Button>
</>
)}
</div>
</div>
))}
</div>
<div className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
@ -490,7 +570,7 @@ function PublishedSkillsTable({
className="cursor-pointer"
onClick={() => onOpen(skill.name)}
>
<TableCell className="font-medium">{skill.name}</TableCell>
<TableCell className={`font-medium ${containedLongTextClass}`}>{skill.name}</TableCell>
<TableCell>
<span className="block max-w-[360px] truncate text-sm text-muted-foreground">
{skill.description}
@ -508,7 +588,14 @@ function PublishedSkillsTable({
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={(event) => { event.stopPropagation(); onDownload(skill.name); }}>
<Button
variant="ghost"
size="icon"
className="h-11 w-11"
aria-label={t('下载', 'Download')}
title={t('下载', 'Download')}
onClick={(event) => { event.stopPropagation(); onDownload(skill.name); }}
>
<Download className="h-3.5 w-3.5" />
</Button>
{skill.source === 'workspace' && (
@ -516,7 +603,9 @@ function PublishedSkillsTable({
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
className="h-11 w-11"
aria-label={t('回滚', 'Rollback')}
title={t('回滚', 'Rollback')}
onClick={(event) => {
event.stopPropagation();
onRollback(skill.name);
@ -527,7 +616,9 @@ function PublishedSkillsTable({
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
className="h-11 w-11"
aria-label={t('禁用', 'Disable')}
title={t('禁用', 'Disable')}
onClick={(event) => {
event.stopPropagation();
onDisable(skill.name);
@ -538,7 +629,9 @@ function PublishedSkillsTable({
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
className="h-11 w-11 text-destructive hover:text-destructive"
aria-label={t('删除', 'Delete')}
title={t('删除', 'Delete')}
onClick={(event) => {
event.stopPropagation();
onDelete(skill.name);
@ -554,6 +647,8 @@ function PublishedSkillsTable({
))}
</TableBody>
</Table>
</div>
</>
)}
</CardContent>
</Card>
@ -590,7 +685,7 @@ function CandidateCard({
: null;
return (
<div className="rounded-lg border border-border bg-white p-4">
<div className="min-w-0 max-w-full rounded-lg border border-border bg-white p-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1 space-y-3">
<div className="flex flex-wrap items-center gap-2">
@ -607,7 +702,7 @@ function CandidateCard({
<div>
<h3 className="break-words text-base font-semibold tracking-normal">{title}</h3>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
<p className={`mt-1 text-sm leading-6 text-muted-foreground ${containedLongTextClass}`}>
{candidate.reason || t('没有提供候选理由。', 'No candidate reason was provided.')}
</p>
</div>
@ -656,7 +751,7 @@ function CandidateCard({
)}
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span className="font-mono">{candidate.candidate_id}</span>
<span className={`font-mono ${containedLongTextClass}`}>{candidate.candidate_id}</span>
{String(evidence.task_id || '') && <span>{t('任务', 'Task')}: {String(evidence.task_id)}</span>}
{String(evidence.skill_version || '') && <span>{t('基线版本', 'Base version')}: {String(evidence.skill_version)}</span>}
{candidate.created_at && <span>{t('创建于', 'Created')}: {formatDateTime(candidate.created_at)}</span>}
@ -666,11 +761,12 @@ function CandidateCard({
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button size="sm" variant="outline" disabled={Boolean(actionId)} onClick={onIgnore}>
<Button size="sm" variant="outline" className="h-11" disabled={Boolean(actionId)} onClick={onIgnore}>
{t('忽略', 'Ignore')}
</Button>
<Button
size="sm"
className="h-11"
disabled={Boolean(actionId)}
onClick={() => void onSynthesize()}
>
@ -685,6 +781,7 @@ function CandidateCard({
<Button
size="sm"
variant="outline"
className="h-11"
disabled={Boolean(actionId)}
onClick={() => void onRegenerate()}
>
@ -753,7 +850,7 @@ function DraftCard({
void onPublish(isHighRisk);
};
return (
<div className="rounded-lg border border-border bg-white p-4">
<div className="min-w-0 max-w-full rounded-lg border border-border bg-white p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
@ -776,9 +873,9 @@ function DraftCard({
</div>
<div className="mt-2">
<div className="text-xs font-medium text-muted-foreground">{t('技能名', 'Skill name')}</div>
<h3 className="break-words text-lg font-semibold tracking-normal">{draft.skill_name}</h3>
<h3 className={`text-lg font-semibold tracking-normal ${containedLongTextClass}`}>{draft.skill_name}</h3>
</div>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
<p className={`mt-1 text-sm leading-6 text-muted-foreground ${containedLongTextClass}`}>
{draft.reason || description || t('没有提供草稿说明。', 'No draft notes were provided.')}
</p>
<div className="mt-3 grid gap-3 md:grid-cols-3">
@ -805,37 +902,37 @@ function DraftCard({
</div>
)}
<div className="mt-3 flex flex-wrap gap-2 text-xs text-muted-foreground">
<span className="font-mono">{draft.skill_name}/{draft.draft_id}</span>
<span className={`font-mono ${containedLongTextClass}`}>{draft.skill_name}/{draft.draft_id}</span>
<span>{t('创建者', 'Author')}: {draft.created_by}</span>
<span>{t('创建于', 'Created')}: {formatDateTime(draft.created_at)}</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" disabled={busy || submitBlocked} onClick={() => void onSubmit()}>
<Button variant="outline" size="sm" className="h-11" disabled={busy || submitBlocked} onClick={() => void onSubmit()}>
<Send className="mr-2 h-4 w-4" />
{t('送审', 'Submit')}
</Button>
<Button variant="outline" size="sm" disabled={busy || approveBlocked} onClick={() => void onApprove()}>
<Button variant="outline" size="sm" className="h-11" disabled={busy || approveBlocked} onClick={() => void onApprove()}>
<Check className="mr-2 h-4 w-4" />
{t('批准', 'Approve')}
</Button>
<Button variant="outline" size="sm" disabled={busy || rejectBlocked} onClick={() => void onReject()}>
<Button variant="outline" size="sm" className="h-11" disabled={busy || rejectBlocked} onClick={() => void onReject()}>
<XCircle className="mr-2 h-4 w-4" />
{t('拒绝', 'Reject')}
</Button>
<Button variant="outline" size="sm" disabled={busy || TERMINAL_DRAFT_STATUSES.has(draft.status)} onClick={() => void onRecheckSafety()}>
<Button variant="outline" size="sm" className="h-11" disabled={busy || TERMINAL_DRAFT_STATUSES.has(draft.status)} onClick={() => void onRecheckSafety()}>
<ShieldCheck className="mr-2 h-4 w-4" />
{t('复检', 'Recheck')}
</Button>
<Button size="sm" disabled={busy || publishBlocked} onClick={handlePublish}>
<Button size="sm" className="h-11" disabled={busy || publishBlocked} onClick={handlePublish}>
<Rocket className="mr-2 h-4 w-4" />
{t('发布', 'Publish')}
</Button>
</div>
</div>
<div className="mt-4 grid gap-3 lg:grid-cols-[minmax(0,1fr)_340px]">
<div className="rounded-md border border-border bg-muted/20 p-4">
<div className="mt-4 grid min-w-0 gap-3 lg:grid-cols-[minmax(0,1fr)_340px]">
<div className="min-w-0 max-w-full rounded-md border border-border bg-muted/20 p-3 sm:p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm font-medium">
<FileText className="h-4 w-4 text-muted-foreground" />
@ -858,7 +955,7 @@ function DraftCard({
)}
</div>
<div className="space-y-3">
<div className="min-w-0 space-y-3">
<GateSummary
title={t('发布门禁', 'Publish gates')}
summary={canPublishLabel}
@ -883,7 +980,7 @@ function DraftCard({
</div>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-2">
<div className="mt-3 grid min-w-0 gap-3 md:grid-cols-2">
<SafetyReportPanel report={safety} />
<EvalReportPanel report={evalReport} />
</div>
@ -905,7 +1002,7 @@ function SafetyReportPanel({ report }: { report?: SkillDraftSafetyReport | null
}
const problems = [...(report.blocked_reasons || []), ...(report.issues || [])];
return (
<div className="rounded-md border border-border bg-muted/20 p-4">
<div className="min-w-0 rounded-md border border-border bg-muted/20 p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm font-medium">
{report.passed ? <ShieldCheck className="h-4 w-4 text-muted-foreground" /> : <ShieldAlert className="h-4 w-4 text-destructive" />}
@ -922,7 +1019,7 @@ function SafetyReportPanel({ report }: { report?: SkillDraftSafetyReport | null
{problems.map((item, index) => (
<li key={`${item}:${index}`} className="flex gap-2">
<AlertCircle className="mt-1 h-3.5 w-3.5 shrink-0 text-destructive" />
<span>{item}</span>
<span className={containedLongTextClass}>{item}</span>
</li>
))}
</ul>
@ -933,7 +1030,7 @@ function SafetyReportPanel({ report }: { report?: SkillDraftSafetyReport | null
</p>
)}
{report.suggested_fix && (
<p className="mt-3 rounded-md border border-border bg-white p-3 text-sm">
<p className={`mt-3 rounded-md border border-border bg-white p-3 text-sm ${containedLongTextClass}`}>
<span className="font-medium">{t('建议处理', 'Suggested fix')}:</span> {report.suggested_fix}
</p>
)}
@ -957,7 +1054,7 @@ function EvalReportPanel({ report }: { report?: SkillDraftEvalReport | null }) {
}
if (report.status === 'skipped_provider_unavailable') {
return (
<div className="rounded-md border border-border bg-muted/20 p-4">
<div className="min-w-0 rounded-md border border-border bg-muted/20 p-4">
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
<BarChart3 className="h-4 w-4 text-muted-foreground" />
{t('评估报告', 'Eval report')}
@ -970,7 +1067,7 @@ function EvalReportPanel({ report }: { report?: SkillDraftEvalReport | null }) {
);
}
return (
<div className="rounded-md border border-border bg-muted/20 p-4">
<div className="min-w-0 rounded-md border border-border bg-muted/20 p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm font-medium">
<BarChart3 className="h-4 w-4 text-muted-foreground" />
@ -1002,7 +1099,19 @@ function EvalReportPanel({ report }: { report?: SkillDraftEvalReport | null }) {
<div className="border-b border-border px-3 py-2 text-xs font-medium text-muted-foreground">
{t('回放案例', 'Replay cases')}
</div>
<div className="max-h-48 overflow-auto">
<div className="space-y-2 p-3 md:hidden">
{report.cases.map((item, index) => (
<div key={`${String(item.run_id || index)}:${index}:card`} className="rounded-md border border-border bg-muted/20 p-3 text-xs">
<div className={`font-mono ${containedLongTextClass}`}>{String(item.run_id || '-')}</div>
<div className="mt-2 grid grid-cols-3 gap-2">
<MetricTile label={t('基线', 'Baseline')} value={formatScore(toNumber(item.baseline_score))} />
<MetricTile label={t('候选', 'Candidate')} value={formatScore(toNumber(item.candidate_score))} />
<MetricTile label={t('变化', 'Delta')} value={formatSignedScore(toNumber(item.delta))} />
</div>
</div>
))}
</div>
<div className="hidden max-h-48 overflow-auto md:block">
<table className="w-full text-left text-xs">
<thead className="bg-muted/40 text-muted-foreground">
<tr>
@ -1042,7 +1151,7 @@ function GateSummary({
items: Array<{ label: string; ok: boolean }>;
}) {
return (
<div className="rounded-md border border-border bg-muted/20 p-4">
<div className="min-w-0 rounded-md border border-border bg-muted/20 p-4">
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
<ListChecks className="h-4 w-4 text-muted-foreground" />
{title}
@ -1070,7 +1179,7 @@ function ReadablePanel({
empty: string;
}) {
return (
<div className="rounded-md border border-border bg-muted/20 p-4">
<div className="min-w-0 rounded-md border border-border bg-muted/20 p-4">
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
{icon}
{title}
@ -1090,7 +1199,7 @@ function ReadableFact({
value: string;
}) {
return (
<div className="rounded-md border border-border bg-white p-3">
<div className="min-w-0 rounded-md border border-border bg-white p-3">
<div className="mb-1 flex items-center gap-2 text-xs font-medium text-muted-foreground">
{icon}
{label}
@ -1111,7 +1220,7 @@ function MetricTile({
}) {
const toneClass = tone === 'bad' ? 'text-destructive' : 'text-foreground';
return (
<div className="rounded-md border border-border bg-white p-3">
<div className="min-w-0 rounded-md border border-border bg-white p-3">
<div className="text-xs font-medium text-muted-foreground">{label}</div>
<div className={`mt-1 text-lg font-semibold ${toneClass}`}>{value}</div>
</div>
@ -1121,7 +1230,7 @@ function MetricTile({
function RawDetails({ title, payload }: { title: string; payload: unknown }) {
return (
<details className="mt-3 min-w-0 max-w-full overflow-hidden rounded-md border border-border bg-white">
<summary className="flex cursor-pointer list-none items-center justify-between gap-2 px-3 py-2 text-xs font-medium text-muted-foreground">
<summary className="flex h-11 cursor-pointer list-none items-center justify-between gap-2 px-3 py-2 text-xs font-medium text-muted-foreground">
{title}
<ChevronDown className="h-3.5 w-3.5" />
</summary>
@ -1134,7 +1243,7 @@ function RawDetails({ title, payload }: { title: string; payload: unknown }) {
function MarkdownPreview({ content }: { content: string }) {
return (
<div className="prose prose-sm max-w-none text-black prose-a:text-black prose-code:rounded prose-code:bg-white prose-code:px-1 prose-code:py-0.5 prose-code:text-black prose-headings:text-black prose-headings:tracking-normal prose-li:text-black prose-p:text-black prose-pre:border prose-pre:border-border prose-pre:bg-white prose-pre:text-black prose-strong:text-black [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<div className={`prose prose-sm max-w-none text-black prose-a:text-black prose-code:rounded prose-code:bg-white prose-code:px-1 prose-code:py-0.5 prose-code:text-black prose-headings:text-black prose-headings:tracking-normal prose-li:text-black prose-p:text-black prose-pre:border prose-pre:border-border prose-pre:bg-white prose-pre:text-black prose-strong:text-black [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_*]:min-w-0 ${containedLongTextClass}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
);

View File

@ -21,6 +21,7 @@ import {
getChannelConfig,
getChannelConnectorSession,
getStatus,
listChannelConnections,
listChannelConnectors,
listChannelEvents,
restartRuntime,
@ -54,6 +55,7 @@ import { Textarea } from '@/components/ui/textarea';
import type {
ChannelConfigDetail,
ChannelConnectorDescriptor,
ChannelConnectionView,
ChannelEventRecord,
ChannelStatus,
ConnectorSessionResponse,
@ -62,6 +64,7 @@ import type {
} from '@/types';
import { AppLocale, pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { connectorChannelForKind, visibleConnectorCards } from '@/lib/channel-connector-state';
type ProviderFormState = {
enabled: boolean;
@ -137,6 +140,8 @@ const EMPTY_CHANNEL_FORM: ChannelFormState = {
const CONFIGURABLE_CHANNEL_KINDS = new Set(['telegram', 'feishu', 'qqbot', 'weixin']);
const SESSION_CONNECTOR_KINDS = new Set(['weixin', 'feishu']);
const VISIBLE_PROVIDER_IDS = new Set(['openai', 'deepseek', 'dashscope', 'vllm']);
const LOCAL_CONNECTOR_KINDS = new Set(['terminal']);
type ConnectorWizardForm = {
kind: string;
@ -146,6 +151,12 @@ type ConnectorWizardForm = {
appId: string;
appSecret: string;
verificationToken: string;
requireMentionInGroups: boolean;
respondToMentionAll: boolean;
dmMode: 'open' | 'allowlist' | 'pair' | 'disabled';
allowFrom: string;
groupAllowFrom: string;
maxMessageChars: string;
};
const EMPTY_CONNECTOR_WIZARD: ConnectorWizardForm = {
@ -156,6 +167,12 @@ const EMPTY_CONNECTOR_WIZARD: ConnectorWizardForm = {
appId: '',
appSecret: '',
verificationToken: '',
requireMentionInGroups: true,
respondToMentionAll: false,
dmMode: 'open',
allowFrom: '',
groupAllowFrom: '',
maxMessageChars: '20000',
};
export default function StatusPage() {
@ -193,6 +210,7 @@ export default function StatusPage() {
const [restarting, setRestarting] = useState(false);
const [restartError, setRestartError] = useState<string | null>(null);
const [connectors, setConnectors] = useState<ChannelConnectorDescriptor[]>([]);
const [channelConnections, setChannelConnections] = useState<ChannelConnectionView[]>([]);
const [loadingConnectors, setLoadingConnectors] = useState(false);
const [connectorDialogOpen, setConnectorDialogOpen] = useState(false);
const [connectorForm, setConnectorForm] = useState<ConnectorWizardForm>(() => ({ ...EMPTY_CONNECTOR_WIZARD }));
@ -234,8 +252,17 @@ export default function StatusPage() {
}
};
const loadChannelConnections = async () => {
try {
setChannelConnections(await listChannelConnections());
} catch {
setChannelConnections([]);
}
};
useEffect(() => {
loadConnectors();
void loadChannelConnections();
}, []);
useEffect(() => {
@ -329,15 +356,19 @@ export default function StatusPage() {
setChannelError(null);
setChannelRestartRequired(false);
setChannelEvents([]);
setLoadingChannelConfig(true);
setLoadingChannelConfig(channel.kind !== 'terminal');
setLoadingChannelEvents(true);
try {
const config = await getChannelConfig(channel.channel_id);
setChannelConfig(config);
setChannelForm(channelFormFromConfig(config));
} catch (err: any) {
setChannelError(err.message || pickAppText(locale, '加载通道配置失败', 'Failed to load channel configuration'));
} finally {
if (channel.kind !== 'terminal') {
try {
const config = await getChannelConfig(channel.channel_id);
setChannelConfig(config);
setChannelForm(channelFormFromConfig(config));
} catch (err: any) {
setChannelError(err.message || pickAppText(locale, '加载通道配置失败', 'Failed to load channel configuration'));
} finally {
setLoadingChannelConfig(false);
}
} else {
setLoadingChannelConfig(false);
}
try {
@ -396,6 +427,11 @@ export default function StatusPage() {
});
};
const openLocalConnectorDetails = (kind: string, channel?: ChannelStatus) => {
if (kind !== 'terminal') return;
void openChannelDetails(channel || terminalFallbackChannel(locale));
};
const handleStartConnectorSession = async () => {
if (!connectorForm.kind || !SESSION_CONNECTOR_KINDS.has(connectorForm.kind)) return;
setStartingConnector(true);
@ -405,6 +441,14 @@ export default function StatusPage() {
if (connectorForm.kind === 'feishu') {
options.domain = connectorForm.domain || 'feishu';
options.mode = connectorForm.mode;
options.requireMentionInGroups = connectorForm.requireMentionInGroups;
options.respondToMentionAll = connectorForm.respondToMentionAll;
options.dmMode = connectorForm.dmMode;
const allowFrom = parseList(connectorForm.allowFrom);
const groupAllowFrom = parseList(connectorForm.groupAllowFrom);
if (allowFrom.length) options.allowFrom = allowFrom;
if (groupAllowFrom.length) options.groupAllowFrom = groupAllowFrom;
if (connectorForm.maxMessageChars.trim()) options.maxMessageChars = Number(connectorForm.maxMessageChars.trim());
if (connectorForm.appId.trim()) options.appId = connectorForm.appId.trim();
if (connectorForm.appSecret.trim()) options.appSecret = connectorForm.appSecret.trim();
if (connectorForm.verificationToken.trim()) options.verificationToken = connectorForm.verificationToken.trim();
@ -415,6 +459,10 @@ export default function StatusPage() {
options,
});
setConnectorSession(response);
if (response.session.status === 'connected') {
await loadStatus();
await loadChannelConnections();
}
if (!connectorSessionDone(response.session.status)) {
window.setTimeout(() => {
void pollConnectorSession(response.session.sessionId);
@ -434,6 +482,7 @@ export default function StatusPage() {
setConnectorSession(response);
if (response.session.status === 'connected') {
await loadStatus();
await loadChannelConnections();
}
} catch (err: any) {
setConnectorError(err.message || pickAppText(locale, '刷新连接状态失败', 'Failed to refresh connector status'));
@ -452,14 +501,14 @@ export default function StatusPage() {
if (error) {
return (
<div className="max-w-4xl mx-auto p-6">
<div className="mx-auto max-w-4xl p-4 sm:p-6">
<Card className="border-destructive">
<CardContent className="pt-6">
<div className="flex items-center gap-3 text-destructive">
<AlertCircle className="w-5 h-5" />
<div>
<div className="flex items-start gap-3 text-destructive">
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0" />
<div className="min-w-0">
<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="mt-1 break-words text-sm text-muted-foreground">{error}</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>
@ -475,8 +524,11 @@ export default function StatusPage() {
if (!status) return null;
const visibleProviders = status.providers.filter(visibleProvider);
const connectorCards = visibleConnectorCards(connectors);
return (
<div className="mx-auto max-w-6xl p-6 space-y-6">
<div className="mx-auto max-w-6xl space-y-6 p-4 sm:p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-bold">{pickAppText(locale, '配置', 'Settings')}</h1>
@ -575,7 +627,7 @@ export default function StatusPage() {
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-destructive">{agentError || ''}</div>
<div className="min-w-0 break-words text-sm text-destructive">{agentError || ''}</div>
<Button onClick={handleSaveAgentConfig} disabled={savingAgent} className="sm:self-end">
{savingAgent ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{pickAppText(locale, '保存智能体配置', 'Save agent config')}
@ -594,13 +646,13 @@ export default function StatusPage() {
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3">
{status.providers.map((p) => (
{visibleProviders.map((p) => (
<button
key={p.id || p.name}
type="button"
onClick={() => openProviderDialog(p)}
className={[
'group flex min-h-[76px] w-full items-start justify-between rounded-lg border p-3 text-left transition',
'group flex min-h-[76px] w-full items-start justify-between gap-3 rounded-lg border p-3 text-left transition',
p.active
? 'border-primary bg-primary/5 shadow-sm'
: 'border-border bg-background hover:border-primary/50 hover:bg-muted/40',
@ -613,11 +665,11 @@ export default function StatusPage() {
) : (
<XCircle className="h-4 w-4 shrink-0 text-muted-foreground/40" />
)}
<span className={p.has_key ? 'truncate' : 'truncate text-muted-foreground'}>
<span className={p.has_key ? 'break-all' : 'break-all text-muted-foreground'}>
{providerLabel(p)}
</span>
</span>
<span className="block truncate text-xs text-muted-foreground">
<span className="block break-words text-xs text-muted-foreground">
{p.active
? pickAppText(locale, '当前默认', 'Current default')
: p.enabled
@ -625,7 +677,7 @@ export default function StatusPage() {
: pickAppText(locale, '点击配置', 'Click to configure')}
</span>
{(p.detail || p.api_key_masked) && (
<span className="block truncate text-xs text-muted-foreground">
<span className="block break-all text-xs text-muted-foreground">
{p.api_key_masked || p.detail}
</span>
)}
@ -639,7 +691,7 @@ export default function StatusPage() {
<Dialog open={Boolean(selectedProvider)} onOpenChange={(open) => !open && setSelectedProvider(null)}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogHeader className="pr-10">
<DialogTitle>
{pickAppText(locale, '配置提供商', 'Configure provider')}
{selectedProvider ? ` · ${providerLabel(selectedProvider)}` : ''}
@ -649,8 +701,8 @@ export default function StatusPage() {
</DialogDescription>
</DialogHeader>
<div className="space-y-5 py-2">
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
<div>
<div className="flex items-center justify-between gap-4 rounded-lg border px-3 py-2">
<div className="min-w-0">
<Label className="text-sm">{pickAppText(locale, '启用提供商', 'Enable provider')}</Label>
<p className="text-xs text-muted-foreground">
{pickAppText(locale, '关闭会从配置中移除这个提供商', 'Turning this off removes this provider from config')}
@ -709,7 +761,7 @@ export default function StatusPage() {
</div>
{providerError ? (
<p className="text-sm text-destructive">{providerError}</p>
<p className="break-words text-sm text-destructive">{providerError}</p>
) : null}
</div>
<DialogFooter>
@ -725,19 +777,22 @@ export default function StatusPage() {
</Dialog>
<Dialog open={Boolean(selectedChannel)} onOpenChange={(open) => !open && setSelectedChannel(null)}>
<DialogContent className="max-h-[92vh] overflow-y-auto sm:max-w-[820px]">
<DialogHeader>
<DialogTitle>{selectedChannel?.display_name || selectedChannel?.channel_id}</DialogTitle>
<DialogDescription>
<DialogContent className="sm:max-w-[820px]">
<DialogHeader className="pr-10">
<DialogTitle className="break-words leading-tight">{selectedChannel?.display_name || selectedChannel?.channel_id}</DialogTitle>
<DialogDescription className="break-all">
{selectedChannel ? `${selectedChannel.kind}/${selectedChannel.mode} · ${selectedChannel.channel_id}` : ''}
</DialogDescription>
</DialogHeader>
{selectedChannel ? (
<div className="space-y-5">
{selectedChannel.kind === 'terminal' ? (
<TerminalConnectionGuide channel={selectedChannel} locale={locale} />
) : null}
{CONFIGURABLE_CHANNEL_KINDS.has(channelForm.kind) ? (
<div className="space-y-5 rounded-lg border p-4">
<div className="min-w-0 space-y-5 rounded-lg border p-4">
<div className="flex items-center justify-between gap-4">
<div>
<div className="min-w-0">
<p className="text-sm font-medium">{pickAppText(locale, '连接配置', 'Connection settings')}</p>
<p className="text-xs text-muted-foreground">
{pickAppText(locale, '凭据留空会保留已保存的值。保存后重启实例才会重新连接通道。', 'Leave credentials blank to keep saved values. Restart the instance after saving to reconnect channels.')}
@ -751,15 +806,17 @@ export default function StatusPage() {
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field label={pickAppText(locale, '显示名', 'Display name')}>
<Field id="channel-display-name" label={pickAppText(locale, '显示名', 'Display name')}>
<Input
id="channel-display-name"
value={channelForm.displayName}
onChange={(event) => setChannelForm((prev) => ({ ...prev, displayName: event.target.value }))}
placeholder={selectedChannel.display_name || selectedChannel.channel_id}
/>
</Field>
<Field label="Account ID">
<Field id="channel-account-id" label="Account ID">
<Input
id="channel-account-id"
value={channelForm.accountId}
onChange={(event) => setChannelForm((prev) => ({ ...prev, accountId: event.target.value }))}
placeholder="bot-main"
@ -810,11 +867,11 @@ export default function StatusPage() {
/>
<ChannelPolicyFields form={channelForm} locale={locale} setForm={setChannelForm} />
{channelError ? <p className="text-sm text-destructive">{channelError}</p> : null}
{channelError ? <p className="break-words text-sm text-destructive">{channelError}</p> : null}
{channelRestartRequired ? (
<div className="flex flex-col gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm sm:flex-row sm:items-center sm:justify-between">
<span>{pickAppText(locale, '配置已保存。重启实例后通道会按新配置启动。', 'Configuration saved. Restart the instance to start channels with the new settings.')}</span>
<Button variant="outline" size="sm" onClick={() => setRestartOpen(true)}>
<span className="min-w-0 break-words">{pickAppText(locale, '配置已保存。重启实例后通道会按新配置启动。', 'Configuration saved. Restart the instance to start channels with the new settings.')}</span>
<Button variant="outline" size="sm" className="shrink-0" onClick={() => setRestartOpen(true)}>
<RefreshCw className="mr-2 h-4 w-4" />
{pickAppText(locale, '重启实例', 'Restart instance')}
</Button>
@ -844,11 +901,11 @@ export default function StatusPage() {
<div className="max-h-[320px] overflow-auto rounded-md border">
{channelEvents.map((event) => (
<div key={event.event_id} className="border-b px-3 py-2 text-xs last:border-b-0">
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{event.kind}</span>
<span className="text-muted-foreground">{event.created_at}</span>
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-2">
<span className="break-all font-medium">{event.kind}</span>
<span className="break-all text-muted-foreground">{event.created_at}</span>
</div>
<div className="mt-1 text-muted-foreground">
<div className="mt-1 break-words text-muted-foreground">
{event.status}{event.error ? ` · ${event.error}` : ''}
</div>
</div>
@ -868,7 +925,7 @@ export default function StatusPage() {
<Dialog open={restartOpen} onOpenChange={setRestartOpen}>
<DialogContent className="sm:max-w-[420px]">
<DialogHeader>
<DialogHeader className="pr-10">
<DialogTitle>{pickAppText(locale, '重启实例?', 'Restart instance?')}</DialogTitle>
<DialogDescription>
{pickAppText(
@ -878,7 +935,7 @@ export default function StatusPage() {
)}
</DialogDescription>
</DialogHeader>
{restartError ? <p className="text-sm text-destructive">{restartError}</p> : null}
{restartError ? <p className="break-words text-sm text-destructive">{restartError}</p> : null}
<DialogFooter>
<Button variant="outline" onClick={() => setRestartOpen(false)} disabled={restarting}>
{pickAppText(locale, '取消', 'Cancel')}
@ -898,8 +955,8 @@ export default function StatusPage() {
setConnectorError(null);
}
}}>
<DialogContent className="max-h-[92vh] overflow-y-auto sm:max-w-[560px]">
<DialogHeader>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader className="pr-10">
<DialogTitle>
{connectorForm.kind ? connectorDisplayName({ kind: connectorForm.kind }) : pickAppText(locale, '连接通道', 'Connect channel')}
</DialogTitle>
@ -913,8 +970,9 @@ export default function StatusPage() {
</DialogHeader>
<div className="space-y-5">
<Field label={pickAppText(locale, '显示名', 'Display name')}>
<Field id="connector-display-name" label={pickAppText(locale, '显示名', 'Display name')}>
<Input
id="connector-display-name"
value={connectorForm.displayName}
onChange={(event) => setConnectorForm((prev) => ({ ...prev, displayName: event.target.value }))}
disabled={Boolean(connectorSession) || startingConnector}
@ -961,6 +1019,75 @@ export default function StatusPage() {
))}
</div>
) : null}
<div className="space-y-4 rounded-lg border p-3">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<p className="text-sm font-medium">{pickAppText(locale, '群聊必须 @ Beaver', 'Require @ in groups')}</p>
<p className="text-xs text-muted-foreground">
{pickAppText(locale, '默认开启,避免群聊所有消息触发智能体。', 'Enabled by default to avoid processing every group message.')}
</p>
</div>
<Switch
checked={connectorForm.requireMentionInGroups}
onCheckedChange={(checked) => setConnectorForm((prev) => ({ ...prev, requireMentionInGroups: checked }))}
disabled={Boolean(connectorSession) || startingConnector}
/>
</div>
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<p className="text-sm font-medium">{pickAppText(locale, '响应 @所有人', 'Respond to @all')}</p>
<p className="text-xs text-muted-foreground">
{pickAppText(locale, '默认关闭,避免群公告式消息触发。', 'Disabled by default to avoid broadcast-style triggers.')}
</p>
</div>
<Switch
checked={connectorForm.respondToMentionAll}
onCheckedChange={(checked) => setConnectorForm((prev) => ({ ...prev, respondToMentionAll: checked }))}
disabled={Boolean(connectorSession) || startingConnector}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field label={pickAppText(locale, '私聊策略', 'DM policy')}>
<Select
value={connectorForm.dmMode}
onValueChange={(value) => setConnectorForm((prev) => ({ ...prev, dmMode: value as ConnectorWizardForm['dmMode'] }))}
disabled={Boolean(connectorSession) || startingConnector}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="open">{pickAppText(locale, '开放', 'Open')}</SelectItem>
<SelectItem value="allowlist">{pickAppText(locale, '白名单', 'Allowlist')}</SelectItem>
<SelectItem value="pair">{pickAppText(locale, '已配对', 'Paired')}</SelectItem>
<SelectItem value="disabled">{pickAppText(locale, '关闭', 'Disabled')}</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label={pickAppText(locale, '最大入站长度', 'Max inbound chars')}>
<Input
value={connectorForm.maxMessageChars}
onChange={(event) => setConnectorForm((prev) => ({ ...prev, maxMessageChars: event.target.value }))}
disabled={Boolean(connectorSession) || startingConnector}
placeholder="20000"
/>
</Field>
</div>
<Field label={pickAppText(locale, '允许私聊用户 Open ID', 'Allowed DM user Open IDs')}>
<Textarea
value={connectorForm.allowFrom}
onChange={(event) => setConnectorForm((prev) => ({ ...prev, allowFrom: event.target.value }))}
disabled={Boolean(connectorSession) || startingConnector}
placeholder="ou_xxx, ou_yyy"
/>
</Field>
<Field label={pickAppText(locale, '允许群 Chat ID', 'Allowed group Chat IDs')}>
<Textarea
value={connectorForm.groupAllowFrom}
onChange={(event) => setConnectorForm((prev) => ({ ...prev, groupAllowFrom: event.target.value }))}
disabled={Boolean(connectorSession) || startingConnector}
placeholder="oc_xxx, oc_yyy"
/>
</Field>
</div>
{connectorForm.mode === 'link' ? (
<div className="grid gap-4 md:grid-cols-2">
<Field label="App ID">
@ -997,8 +1124,8 @@ export default function StatusPage() {
<div className="space-y-4 rounded-lg border p-4">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{connectorSession.session.sessionId}</p>
<p className="text-xs text-muted-foreground">
<p className="break-all text-sm font-medium">{connectorSession.session.sessionId}</p>
<p className="break-all text-xs text-muted-foreground">
{connectorSession.connection?.channel_id || connectorSession.connection?.connection_id || '-'}
</p>
</div>
@ -1020,7 +1147,7 @@ export default function StatusPage() {
<div className="space-y-2 text-sm">
{connectorSession.session.instructions.map((item, index) => (
<div key={`${index}-${item}`} className="rounded-md border bg-muted/30 px-3 py-2">
{item}
<span className="break-words">{item}</span>
</div>
))}
</div>
@ -1032,12 +1159,12 @@ export default function StatusPage() {
</div>
) : null}
{connectorSession.session.error ? (
<p className="text-sm text-destructive">{connectorSession.session.error}</p>
<p className="break-words text-sm text-destructive">{connectorSession.session.error}</p>
) : null}
</div>
) : null}
{connectorError ? <p className="text-sm text-destructive">{connectorError}</p> : null}
{connectorError ? <p className="break-words text-sm text-destructive">{connectorError}</p> : null}
</div>
<DialogFooter>
@ -1073,31 +1200,60 @@ export default function StatusPage() {
</CardHeader>
<CardContent>
<div className="space-y-5">
<div className="grid gap-2 sm:grid-cols-3">
{(connectors.length ? connectors : [{ kind: 'telegram' }, { kind: 'weixin' }, { kind: 'feishu' }]).map((connector) => {
const supportsSession = SESSION_CONNECTOR_KINDS.has(connector.kind);
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{connectorCards.map((connector) => {
const channel = connectorChannelForKind(connector.kind, status.channels);
const connection = connectorConnectionForKind(connector.kind, channelConnections);
const isRunning = channel?.state === 'running' || connection?.status === 'connected';
const isLocalConnector = LOCAL_CONNECTOR_KINDS.has(connector.kind);
const canStart = SESSION_CONNECTOR_KINDS.has(connector.kind) && !channel && !isRunning;
return (
<button
key={connector.kind}
type="button"
onClick={() => supportsSession && openConnectorDialog(connector)}
disabled={!supportsSession}
onClick={() => {
if (isLocalConnector) {
openLocalConnectorDetails(connector.kind, channel);
} else if (channel) {
void openChannelDetails(channel);
} else if (canStart) {
openConnectorDialog(connector);
}
}}
disabled={!channel && !canStart && !isLocalConnector}
className={[
'flex min-h-[86px] w-full items-start justify-between rounded-lg border px-3 py-3 text-left text-sm transition',
supportsSession ? 'hover:border-primary/50 hover:bg-muted/40' : 'opacity-70',
'group flex min-h-[76px] w-full items-start justify-between gap-3 rounded-lg border p-3 text-left transition',
isRunning
? 'border-primary bg-primary/5 shadow-sm'
: canStart
? 'border-border bg-background hover:border-primary/50 hover:bg-muted/40'
: 'border-border bg-background opacity-70',
].join(' ')}
>
<span className="min-w-0 space-y-1">
<span className="flex items-center gap-2 font-medium">
{supportsSession ? <QrCode className="h-4 w-4" /> : <PlugZap className="h-4 w-4" />}
<span className="truncate">{connectorDisplayName(connector)}</span>
<span className="flex items-center gap-2 text-sm font-medium">
{isRunning ? (
<CheckCircle2 className="h-4 w-4 shrink-0 text-green-500" />
) : canStart || isLocalConnector ? (
<QrCode className="h-4 w-4 shrink-0" />
) : (
<PlugZap className="h-4 w-4 shrink-0 text-muted-foreground/50" />
)}
<span className={isRunning ? 'break-all' : 'break-all text-muted-foreground'}>
{connectorDisplayName(connector)}
</span>
</span>
<span className="block truncate text-xs text-muted-foreground">
{connectorAuthLabel(connector, locale)}
<span className="block break-words text-xs text-muted-foreground">
{connectorCardSubtitle(connector, channel, connection, locale)}
</span>
{channel || connection || isLocalConnector ? (
<span className="block break-all text-xs text-muted-foreground">
{connectorCardChannelLabel(channel, connection, locale)}
</span>
) : null}
</span>
<Badge variant={supportsSession ? 'outline' : 'secondary'}>
{supportsSession ? pickAppText(locale, '连接', 'Connect') : pickAppText(locale, '令牌', 'Token')}
<Badge variant={connectorCardBadgeVariant(channel, connection)} className="shrink-0">
{connectorCardBadgeLabel(connector, channel, connection, locale)}
</Badge>
</button>
);
@ -1109,33 +1265,6 @@ export default function StatusPage() {
{pickAppText(locale, '正在加载连接器', 'Loading connectors')}
</div>
) : null}
<div className="space-y-2">
{status.channels.length === 0 ? (
<p className="text-sm text-muted-foreground">
{pickAppText(locale, '尚未配置通道', 'No channels configured')}
</p>
) : (
status.channels.map((ch) => (
<button
key={ch.channel_id}
type="button"
onClick={() => openChannelDetails(ch)}
className="flex w-full items-center justify-between rounded-lg border px-3 py-2 text-left text-sm hover:bg-muted/40"
>
<span className="min-w-0">
<span className="block truncate font-medium">{ch.display_name || ch.channel_id}</span>
<span className="block truncate text-xs text-muted-foreground">
{ch.channel_id} · {ch.kind}/{ch.mode} · {ch.account_id}
{typeof ch.connected_peers === 'number' ? ` · ${ch.connected_peers} peer${ch.connected_peers === 1 ? '' : 's'}` : ''}
</span>
</span>
<Badge variant={channelStateBadgeVariant(ch.state)}>
{ch.state}
</Badge>
</button>
))
)}
</div>
</div>
</CardContent>
</Card>
@ -1154,10 +1283,10 @@ function InfoRow({
ok?: boolean;
}) {
return (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{label}</span>
<div className="flex items-center gap-2">
<code className="bg-muted px-2 py-0.5 rounded text-xs max-w-[400px] truncate">
<div className="grid min-w-0 gap-1 text-sm sm:grid-cols-[minmax(0,1fr)_minmax(0,auto)] sm:items-start">
<span className="min-w-0 break-words text-muted-foreground">{label}</span>
<div className="flex min-w-0 items-center gap-2 sm:justify-end">
<code className="min-w-0 max-w-full whitespace-normal break-all rounded bg-muted px-2 py-0.5 text-xs sm:max-w-[400px]">
{value}
</code>
{ok !== undefined &&
@ -1171,14 +1300,180 @@ function InfoRow({
);
}
function TerminalConnectionGuide({ channel, locale }: { channel: ChannelStatus; locale: AppLocale }) {
const connected = channel.state === 'running';
const instructions = terminalConnectionGuide(locale);
return (
<div className="space-y-4 rounded-lg border p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">
{pickAppText(locale, '小终端连接方式', 'Terminal connection method')}
</p>
<p className="text-xs text-muted-foreground">
{pickAppText(
locale,
'小终端通过本地 WebSocket 通道接入当前实例;这里展示的是连接状态和接入说明。',
'The terminal connects to this instance through the local WebSocket channel; this panel shows status and connection guidance.'
)}
</p>
</div>
<Badge variant={connected ? 'default' : 'secondary'}>{channel.state}</Badge>
</div>
<div className="grid gap-4 md:grid-cols-[220px_1fr]">
<div className="rounded-md border bg-muted/30 p-3">
<div className="mx-auto grid h-44 w-44 grid-cols-7 grid-rows-7 gap-1 rounded bg-background p-3">
{TERMINAL_FAKE_QR_CELLS.map((filled, index) => (
<span
key={index}
className={filled ? 'rounded-[2px] bg-foreground' : 'rounded-[2px] bg-transparent'}
/>
))}
</div>
<p className="mt-3 text-center text-xs font-medium">
{pickAppText(locale, '示意二维码Fake QR', 'Illustrative QR (Fake QR)')}
</p>
<p className="mt-1 text-center text-xs text-muted-foreground">
{pickAppText(locale, '不可扫码,仅用于标识终端连接入口。', 'Not scannable; it only marks the terminal connection entry.')}
</p>
</div>
<div className="space-y-2 text-sm">
{instructions.map((item) => (
<div key={item} className="rounded-md border bg-muted/20 px-3 py-2">
{item}
</div>
))}
</div>
</div>
</div>
);
}
const TERMINAL_FAKE_QR_CELLS = [
true, true, true, false, true, true, true,
true, false, true, false, true, false, true,
true, true, true, false, true, true, true,
false, false, false, true, false, true, false,
true, false, true, false, true, false, true,
false, true, false, true, false, true, false,
true, false, true, true, false, false, true,
];
function terminalConnectionGuide(locale: AppLocale): string[] {
return [
pickAppText(locale, '保持本实例页面在线,终端客户端会通过 WebSocket 连接 Beaver。', 'Keep this instance online; the terminal client connects to Beaver over WebSocket.'),
pickAppText(locale, '连接成功后,通道状态会显示 running并显示当前 connected peers 数量。', 'After connection succeeds, the channel status shows running and the connected peers count is updated.'),
pickAppText(locale, '这里的二维码是 fake 的说明图,不代表真实扫码绑定流程。', 'The QR shown here is fake guidance artwork, not a real scan-to-bind flow.'),
];
}
function terminalFallbackChannel(locale: AppLocale): ChannelStatus {
return {
channel_id: 'terminal',
kind: 'terminal',
mode: 'websocket',
display_name: pickAppText(locale, '小终端', 'Terminal'),
enabled: false,
state: 'disabled',
account_id: 'local',
capabilities: ['receive_text', 'send_text'],
connected_peers: 0,
};
}
function providerLabel(provider: ProviderStatus): string {
return provider.label || provider.name;
}
function providerIdentity(provider: ProviderStatus): string {
const identity = (provider.id || provider.name || provider.label || '').trim().toLowerCase();
if (identity === 'vllm/local') return 'vllm';
return identity;
}
function visibleProvider(provider: ProviderStatus): boolean {
return VISIBLE_PROVIDER_IDS.has(providerIdentity(provider));
}
function connectorConnectionForKind(
kind: string,
connections: ChannelConnectionView[]
): ChannelConnectionView | undefined {
const matches = connections.filter((connection) => connection.kind === kind && connection.status !== 'revoked');
return (
matches.find((connection) => connection.status === 'connected') ||
matches.find((connection) => connection.status !== 'error') ||
matches[0]
);
}
function connectorCardSubtitle(
connector: ChannelConnectorDescriptor,
channel: ChannelStatus | undefined,
connection: ChannelConnectionView | undefined,
locale: AppLocale
): string {
if (channel?.state === 'running' || connection?.status === 'connected') {
return pickAppText(locale, '已连接', 'Connected');
}
if (channel) return channel.state;
if (connection) return connection.status;
if (connector.kind === 'terminal') return pickAppText(locale, '本地终端连接', 'Local terminal connection');
return connectorAuthLabel(connector, locale);
}
function connectorCardChannelLabel(
channel: ChannelStatus | undefined,
connection: ChannelConnectionView | undefined,
locale: AppLocale
): string {
const rawChannelId = connection?.channel_id || channel?.channel_id || '';
const channelId = compactMainSuffix(rawChannelId);
const accountId = compactMainSuffix(connection?.account_id || channel?.account_id || '');
const mode = connection?.mode || channel?.mode || '';
const parts = [channelId, mode, accountId].filter(Boolean);
if (!channel && !connection) return pickAppText(locale, '连接方式说明', 'Connection instructions');
if (parts.length === 0) return pickAppText(locale, '通道已连接', 'Channel connected');
return `${pickAppText(locale, '通道', 'Channel')}: ${parts.join(' · ')}`;
}
function compactMainSuffix(value: string): string {
return value.replace(/[-_]?main$/i, '').trim();
}
function connectorCardBadgeVariant(
channel: ChannelStatus | undefined,
connection: ChannelConnectionView | undefined
): 'default' | 'secondary' | 'destructive' | 'outline' {
if (channel?.state === 'running' || connection?.status === 'connected') return 'default';
if (channel?.state === 'error' || channel?.state === 'degraded' || connection?.status === 'error') {
return 'destructive';
}
if (channel?.state === 'disabled' || channel?.state === 'stopped' || connection?.status === 'revoked') {
return 'secondary';
}
return 'outline';
}
function connectorCardBadgeLabel(
connector: ChannelConnectorDescriptor,
channel: ChannelStatus | undefined,
connection: ChannelConnectionView | undefined,
locale: AppLocale
): string {
if (channel?.state === 'running' || connection?.status === 'connected') return 'running';
if (channel) return channel.state;
if (connection) return connection.status;
if (connector.kind === 'terminal') return pickAppText(locale, '说明', 'Guide');
return pickAppText(locale, '连接', 'Connect');
}
function connectorDisplayName(connector: Pick<ChannelConnectorDescriptor, 'kind' | 'displayName' | 'display_name'>): string {
if (connector.displayName || connector.display_name) return connector.displayName || connector.display_name || connector.kind;
if (connector.kind === 'weixin') return 'Weixin';
if (connector.kind === 'feishu') return 'Feishu/Lark';
if (connector.kind === 'terminal') return 'Terminal';
if (connector.kind === 'telegram') return 'Telegram';
return connector.kind;
}
@ -1187,6 +1482,7 @@ function connectorAuthLabel(connector: ChannelConnectorDescriptor, locale: AppLo
const authType = connector.authType || connector.auth_type;
if (connector.kind === 'weixin') return authType || pickAppText(locale, 'QR', 'QR');
if (connector.kind === 'feishu') return authType || pickAppText(locale, '插件', 'Plugin');
if (connector.kind === 'terminal') return pickAppText(locale, 'Fake QR', 'Fake QR');
if (connector.kind === 'telegram') return authType || pickAppText(locale, 'Token', 'Token');
return authType || connector.kind;
}
@ -1217,19 +1513,10 @@ function connectorSessionBadgeVariant(status: string): 'default' | 'secondary' |
return 'outline';
}
function channelStateBadgeVariant(
state: ChannelStatus['state']
): 'default' | 'secondary' | 'destructive' | 'outline' {
if (state === 'running') return 'default';
if (state === 'error' || state === 'degraded') return 'destructive';
if (state === 'disabled' || state === 'stopped') return 'secondary';
return 'outline';
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
function Field({ id, label, children }: { id?: string; label: string; children: React.ReactNode }) {
return (
<div className="grid gap-2">
<Label>{label}</Label>
<div className="grid min-w-0 gap-2">
<Label htmlFor={id}>{label}</Label>
{children}
</div>
);

View File

@ -19,7 +19,7 @@ import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import { useChatStore } from '@/lib/store';
import { shouldPollTaskDetail, taskDetailDurationMs } from '@/lib/task-detail-refresh';
import { buildTaskTimelineCards } from '@/lib/task-timeline';
import { buildTaskTimelineView } from '@/lib/task-timeline-view';
import type { BackendTask } from '@/types';
const TERMINAL_TASK_STATUSES = new Set(['closed', 'abandoned', 'cancelled', 'error']);
@ -45,6 +45,7 @@ export default function TaskDetailPage() {
const mountedRef = React.useRef(true);
React.useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
@ -89,44 +90,17 @@ export default function TaskDetailPage() {
return () => window.clearInterval(id);
}, [backendTask, loadBackendTask]);
const taskRunIds = useMemo(() => {
const ids = new Set<string>();
for (const run of backendTask?.process_runs ?? []) ids.add(run.run_id);
for (const runId of backendTask?.run_ids ?? []) ids.add(runId);
return ids;
}, [backendTask]);
const liveRuns = useMemo(
() => processRuns.filter((run) => taskRunIds.has(run.run_id) || run.metadata?.task_id === taskId),
[processRuns, taskId, taskRunIds]
);
const liveEvents = useMemo(
() => processEvents.filter((event) => taskRunIds.has(event.run_id) || event.metadata?.task_id === taskId),
[processEvents, taskId, taskRunIds]
);
const liveArtifacts = useMemo(
() => processArtifacts.filter((artifact) => taskRunIds.has(artifact.run_id) || artifact.metadata?.task_id === taskId),
[processArtifacts, taskId, taskRunIds]
);
const renderedRuns = liveRuns.length > 0 ? liveRuns : backendTask?.process_runs ?? [];
const renderedEvents = liveEvents.length > 0 ? liveEvents : backendTask?.process_events ?? [];
const renderedArtifacts = liveArtifacts.length > 0 ? liveArtifacts : backendTask?.process_artifacts ?? [];
const timelineCards = useMemo(
const timelineView = useMemo(
() =>
backendTask
? buildTaskTimelineCards({
task: backendTask,
processRuns: renderedRuns,
processEvents: renderedEvents,
processArtifacts: renderedArtifacts,
})
: [],
[backendTask, renderedArtifacts, renderedEvents, renderedRuns]
buildTaskTimelineView({
task: backendTask,
liveRuns: processRuns,
liveEvents: processEvents,
liveArtifacts: processArtifacts,
}),
[backendTask, processArtifacts, processEvents, processRuns]
);
const timelineCards = timelineView?.cards ?? [];
const activeLabel =
[...timelineCards].reverse().find((card) => !['acceptance', 'task_created'].includes(card.type))?.title ?? '-';
@ -164,13 +138,13 @@ export default function TaskDetailPage() {
<div className="min-h-screen bg-background">
<TaskLiveHeader task={backendTask} activeLabel={activeLabel} durationMs={durationMs} reviewTargetId={TASK_RESULT_REVIEW_ID} />
<main className="mx-auto grid max-w-7xl gap-6 p-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="space-y-4">
<main className="mx-auto grid min-w-0 max-w-7xl gap-6 p-4 sm:p-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="min-w-0 space-y-4">
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
className="h-11 text-destructive hover:text-destructive"
disabled={Boolean(actionBusy)}
onClick={() => void deleteCurrentBackendTask()}
>
@ -217,7 +191,12 @@ export default function TaskDetailPage() {
/>
</div>
<TaskSideRail task={backendTask} runs={renderedRuns} artifacts={renderedArtifacts} cards={timelineCards} />
<TaskSideRail
task={backendTask}
runs={timelineView?.process.runs ?? []}
artifacts={timelineView?.process.artifacts ?? []}
cards={timelineCards}
/>
</main>
</div>
);
@ -225,7 +204,7 @@ export default function TaskDetailPage() {
return (
<div className="mx-auto flex max-w-4xl flex-col gap-4 p-6">
<Button asChild variant="outline" className="w-fit">
<Button asChild variant="outline" className="h-11 w-fit">
<Link href="/tasks">
<ArrowLeft className="mr-2 h-4 w-4" />
{pickAppText(locale, '返回任务列表', 'Back to tasks')}

View File

@ -46,6 +46,7 @@ export default function TasksPage() {
function OrdinaryTasks() {
const { locale } = useAppI18n();
const [backendTasks, setBackendTasks] = useState<BackendTask[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const visibleTasks = useMemo(
@ -58,17 +59,25 @@ function OrdinaryTasks() {
const loadBackendTasks = React.useCallback(() => {
let cancelled = false;
setLoading(true);
setError(null);
listBackendTasks()
.then((items) => {
if (!cancelled) setBackendTasks(Array.isArray(items) ? items : []);
})
.catch(() => {
if (!cancelled) setBackendTasks([]);
.catch((err: any) => {
if (!cancelled) {
setBackendTasks([]);
setError(err.message || pickAppText(locale, '加载任务失败', 'Failed to load tasks'));
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
}, [locale]);
useEffect(() => loadBackendTasks(), [loadBackendTasks]);
@ -86,7 +95,18 @@ function OrdinaryTasks() {
}
};
if (visibleTasks.length === 0) {
if (loading) {
return (
<Card>
<CardContent className="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{pickAppText(locale, '加载任务中', 'Loading tasks')}
</CardContent>
</Card>
);
}
if (visibleTasks.length === 0 && !error) {
return (
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
@ -113,7 +133,19 @@ function OrdinaryTasks() {
</CardContent>
</Card>
)}
<Card>
{visibleTasks.length > 0 ? (
<>
<div className="grid gap-3 xl:hidden">
{visibleTasks.map((task) => (
<OrdinaryTaskCard
key={task.task_id}
task={task}
locale={locale}
onDelete={() => void handleDeleteBackendTask(task)}
/>
))}
</div>
<Card className="hidden xl:block">
<CardContent className="p-0">
<Table>
<TableHeader>
@ -154,7 +186,7 @@ function OrdinaryTasks() {
<TableCell className="text-xs text-muted-foreground">{formatTaskRuntimeTime(task.updated_at, locale)}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button asChild size="sm" variant="outline">
<Button asChild size="sm" variant="outline" className="h-11">
<Link href={`/tasks/${encodeURIComponent(task.task_id)}`}>
{pickAppText(locale, '进入', 'Open')}
<ArrowRight className="ml-2 h-3.5 w-3.5" />
@ -163,8 +195,9 @@ function OrdinaryTasks() {
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-destructive hover:text-destructive"
className="h-11 w-11 text-destructive hover:text-destructive"
onClick={() => void handleDeleteBackendTask(task)}
aria-label={pickAppText(locale, `删除任务 ${task.short_title || task.task_id}`, `Delete task ${task.short_title || task.task_id}`)}
title={pickAppText(locale, '删除任务', 'Delete task')}
>
<Trash2 className="h-3.5 w-3.5" />
@ -177,10 +210,80 @@ function OrdinaryTasks() {
</Table>
</CardContent>
</Card>
</>
) : null}
</div>
);
}
function OrdinaryTaskCard({
task,
locale,
onDelete,
}: {
task: BackendTask;
locale: 'zh-CN' | 'en-US';
onDelete: () => void;
}) {
const title = task.short_title || String(task.metadata?.short_title || '') || task.description || task.goal || task.task_id;
return (
<Card className="rounded-md">
<CardContent className="space-y-4 p-4">
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h2 className="min-w-0 flex-1 text-base font-semibold">{title}</h2>
{task.is_open ? <Badge variant="secondary">{pickAppText(locale, '进行中', 'Active')}</Badge> : null}
</div>
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
{task.description || task.session_id}
</p>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-3 text-xs">
<div>
<div className="text-muted-foreground">{pickAppText(locale, '状态', 'Status')}</div>
<Badge className="mt-1" variant={task.status === 'awaiting_acceptance' || task.status === 'closed' ? 'default' : 'secondary'}>
{taskStatusLabel(task.status, locale)}
</Badge>
</div>
<div>
<div className="text-muted-foreground">{pickAppText(locale, '来源', 'Source')}</div>
<div className="mt-1 text-sm">{taskSourceLabel(task, locale)}</div>
</div>
<div>
<div className="text-muted-foreground">{pickAppText(locale, '运行 / 技能', 'Runs / skills')}</div>
<div className="mt-1 text-sm">{task.run_ids.length} / {task.skill_names.length}</div>
</div>
<div>
<div className="text-muted-foreground">{pickAppText(locale, '更新时间', 'Updated')}</div>
<div className="mt-1 text-sm">{formatTaskRuntimeTime(task.updated_at, locale)}</div>
</div>
</div>
<div className="flex items-center justify-end gap-2 border-t border-border pt-3">
<Button
size="icon"
variant="ghost"
className="h-11 w-11 text-destructive hover:text-destructive"
onClick={onDelete}
aria-label={pickAppText(locale, `删除任务 ${title}`, `Delete task ${title}`)}
title={pickAppText(locale, '删除任务', 'Delete task')}
>
<Trash2 className="h-4 w-4" />
</Button>
<Button asChild variant="outline" className="h-11">
<Link href={`/tasks/${encodeURIComponent(task.task_id)}`}>
{pickAppText(locale, '进入任务', 'Open task')}
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</CardContent>
</Card>
);
}
function taskStatusLabel(status: string, locale: 'zh-CN' | 'en-US') {
const labels: Record<string, [string, string]> = {
open: ['已创建', 'Open'],
@ -246,6 +349,13 @@ function ScheduledTasks() {
}
};
const handleRemoveJob = (job: CronJob) => {
if (!window.confirm(pickAppText(locale, `删除定时任务“${job.name}”?`, `Delete scheduled task "${job.name}"?`))) {
return;
}
void runJobAction(() => removeCronJob(job.id));
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
@ -254,11 +364,11 @@ function ScheduledTasks() {
{pickAppText(locale, '每次触发会生成通知记录;需要修改时再接入 Task。', 'Each trigger creates a notification record; connect it to a Task when revision is needed.')}
</div>
<div className="flex items-center gap-2">
<Button onClick={() => void loadJobs()} variant="outline" size="sm">
<Button onClick={() => void loadJobs()} variant="outline" size="sm" className="h-11">
<RefreshCw className="mr-2 h-4 w-4" />
{pickAppText(locale, '刷新', 'Refresh')}
</Button>
<Button onClick={() => setShowAdd(true)} size="sm">
<Button onClick={() => setShowAdd(true)} size="sm" className="h-11">
<Plus className="mr-2 h-4 w-4" />
{pickAppText(locale, '新建定时任务', 'New scheduled task')}
</Button>
@ -287,7 +397,23 @@ function ScheduledTasks() {
/>
)}
<Card>
{!loading && jobs.length > 0 ? (
<div className="grid gap-3 xl:hidden">
{jobs.map((job) => (
<ScheduledJobCard
key={job.id}
job={job}
locale={locale}
formatTime={formatTime}
onToggle={(checked) => void runJobAction(() => toggleCronJob(job.id, checked))}
onRun={() => void runJobAction(() => runCronJob(job.id))}
onRemove={() => handleRemoveJob(job)}
/>
))}
</div>
) : null}
<Card className={!loading && jobs.length > 0 ? 'hidden xl:block' : undefined}>
<CardContent className="p-0">
{loading ? (
<div className="flex items-center justify-center py-16 text-muted-foreground">
@ -316,7 +442,11 @@ function ScheduledTasks() {
{jobs.map((job) => (
<TableRow key={job.id}>
<TableCell>
<Switch checked={job.enabled} onCheckedChange={(checked) => void runJobAction(() => toggleCronJob(job.id, checked))} />
<Switch
checked={job.enabled}
onCheckedChange={(checked) => void runJobAction(() => toggleCronJob(job.id, checked))}
aria-label={pickAppText(locale, `切换定时任务 ${job.name}`, `Toggle scheduled task ${job.name}`)}
/>
</TableCell>
<TableCell>
<div className="font-medium">{job.name}</div>
@ -330,7 +460,7 @@ function ScheduledTasks() {
<span className="block max-w-[260px] truncate text-sm">{job.message}</span>
</TableCell>
<TableCell>
<Button asChild size="sm" variant="outline" disabled={!job.last_scheduled_run_id && !job.last_task_id}>
<Button asChild size="sm" variant="outline" className="h-11" disabled={!job.last_scheduled_run_id && !job.last_task_id}>
<Link href={job.last_scheduled_run_id ? `/notifications/${encodeURIComponent(job.last_scheduled_run_id)}` : job.last_task_id ? `/tasks/${encodeURIComponent(job.last_task_id)}` : '/tasks'}>
<FolderDown className="mr-2 h-3.5 w-3.5" />
{formatTime(job.last_run_at_ms)}
@ -348,10 +478,24 @@ function ScheduledTasks() {
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => void runJobAction(() => runCronJob(job.id))}>
<Button
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() => void runJobAction(() => runCronJob(job.id))}
aria-label={pickAppText(locale, `立即运行 ${job.name}`, `Run ${job.name} now`)}
title={pickAppText(locale, '立即运行', 'Run now')}
>
<Play className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:text-destructive" onClick={() => void runJobAction(() => removeCronJob(job.id))}>
<Button
variant="ghost"
size="icon"
className="h-11 w-11 text-destructive hover:text-destructive"
onClick={() => handleRemoveJob(job)}
aria-label={pickAppText(locale, `删除定时任务 ${job.name}`, `Delete scheduled task ${job.name}`)}
title={pickAppText(locale, '删除定时任务', 'Delete scheduled task')}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
@ -367,6 +511,112 @@ function ScheduledTasks() {
);
}
function ScheduledJobCard({
job,
locale,
formatTime,
onToggle,
onRun,
onRemove,
}: {
job: CronJob;
locale: 'zh-CN' | 'en-US';
formatTime: (ms: number | null) => string;
onToggle: (checked: boolean) => void;
onRun: () => void;
onRemove: () => void;
}) {
const historyHref = job.last_scheduled_run_id
? `/notifications/${encodeURIComponent(job.last_scheduled_run_id)}`
: job.last_task_id
? `/tasks/${encodeURIComponent(job.last_task_id)}`
: '/tasks';
return (
<Card className="rounded-md">
<CardContent className="space-y-4 p-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h2 className="text-base font-semibold">{job.name}</h2>
<p className="mt-1 break-all text-xs text-muted-foreground">{job.id}</p>
</div>
<label className="flex min-h-11 shrink-0 items-center gap-2 text-sm">
<span className="sr-only">{pickAppText(locale, '启用', 'Enabled')}</span>
<Switch
checked={job.enabled}
onCheckedChange={onToggle}
aria-label={pickAppText(locale, `切换定时任务 ${job.name}`, `Toggle scheduled task ${job.name}`)}
/>
</label>
</div>
<p className="text-sm leading-6 text-muted-foreground">{job.message}</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-3 text-xs">
<div>
<div className="text-muted-foreground">{pickAppText(locale, '计划', 'Schedule')}</div>
<code className="mt-1 inline-block rounded bg-muted px-1.5 py-0.5">{job.schedule_display}</code>
</div>
<div>
<div className="text-muted-foreground">{pickAppText(locale, '下次运行', 'Next run')}</div>
<div className="mt-1 text-sm">{formatTime(job.next_run_at_ms)}</div>
</div>
<div>
<div className="text-muted-foreground">{pickAppText(locale, '上次运行', 'Last run')}</div>
<div className="mt-1 text-sm">{formatTime(job.last_run_at_ms)}</div>
</div>
<div>
<div className="text-muted-foreground">{pickAppText(locale, '状态', 'Status')}</div>
<div className="mt-1">
{job.last_status === 'ok' ? (
<Badge>{pickAppText(locale, '成功', 'OK')}</Badge>
) : job.last_status === 'error' ? (
<Badge variant="destructive">{pickAppText(locale, '错误', 'Error')}</Badge>
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-border pt-3">
<Button
asChild
variant="outline"
className="h-11"
disabled={!job.last_scheduled_run_id && !job.last_task_id}
>
<Link href={historyHref}>
<FolderDown className="mr-2 h-4 w-4" />
{pickAppText(locale, '运行历史', 'History')}
</Link>
</Button>
<Button
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={onRun}
aria-label={pickAppText(locale, `立即运行 ${job.name}`, `Run ${job.name} now`)}
title={pickAppText(locale, '立即运行', 'Run now')}
>
<Play className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-11 w-11 text-destructive hover:text-destructive"
onClick={onRemove}
aria-label={pickAppText(locale, `删除定时任务 ${job.name}`, `Delete scheduled task ${job.name}`)}
title={pickAppText(locale, '删除定时任务', 'Delete scheduled task')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
);
}
function AddJobForm({
targetSessionKey,
onAdd,
@ -403,7 +653,14 @@ function AddJobForm({
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<CardTitle className="text-base">{pickAppText(locale, '新建定时任务', 'New scheduled task')}</CardTitle>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onCancel}>
<Button
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={onCancel}
aria-label={pickAppText(locale, '关闭新建定时任务表单', 'Close new scheduled task form')}
title={pickAppText(locale, '关闭', 'Close')}
>
<X className="h-4 w-4" />
</Button>
</div>
@ -452,8 +709,8 @@ function AddJobForm({
</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()}>
<Button type="button" variant="outline" className="h-11" onClick={onCancel}>{pickAppText(locale, '取消', 'Cancel')}</Button>
<Button type="submit" className="h-11" disabled={!name.trim() || !message.trim()}>
<Plus className="mr-2 h-4 w-4" />
{pickAppText(locale, '创建', 'Create')}
</Button>