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:
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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')}
|
||||
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user