第一次提交
This commit is contained in:
363
app-instance/frontend/app/(app)/agents/page.tsx
Normal file
363
app-instance/frontend/app/(app)/agents/page.tsx
Normal file
@ -0,0 +1,363 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Bot, Plus, RefreshCw, Trash2, Loader2, AlertCircle, Tags, ChevronDown } from 'lucide-react';
|
||||
|
||||
import { addAgent, deleteAgent, listAgents, refreshAgents } from '@/lib/api';
|
||||
import { useChatStore } from '@/lib/store';
|
||||
import type { UiAgentDescriptor } from '@/types';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
const EMPTY_FORM = {
|
||||
id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
base_url: '',
|
||||
endpoint: '',
|
||||
card_url: '',
|
||||
auth_env: '',
|
||||
auth_mode: 'none',
|
||||
auth_audience: '',
|
||||
auth_scopes: '',
|
||||
tags: '',
|
||||
aliases: '',
|
||||
};
|
||||
|
||||
export default function AgentsPage() {
|
||||
const cachedAgents = useChatStore((s) => s.agentRegistry);
|
||||
const setCachedAgents = useChatStore((s) => s.setAgentRegistry);
|
||||
const [agents, setAgents] = useState<UiAgentDescriptor[]>(cachedAgents);
|
||||
const [loading, setLoading] = useState(cachedAgents.length === 0);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
|
||||
const load = useCallback(async (background = false) => {
|
||||
if (background) {
|
||||
setRefreshing(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
const data = await listAgents();
|
||||
const nextAgents = Array.isArray(data) ? data : [];
|
||||
setAgents(nextAgents);
|
||||
setCachedAgents(nextAgents);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '加载智能体失败');
|
||||
} finally {
|
||||
if (background) {
|
||||
setRefreshing(false);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [setCachedAgents]);
|
||||
|
||||
useEffect(() => {
|
||||
void load(cachedAgents.length > 0);
|
||||
}, [cachedAgents.length, load]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setError(null);
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const data = await refreshAgents();
|
||||
const nextAgents = data.agents || [];
|
||||
setAgents(nextAgents);
|
||||
setCachedAgents(nextAgents);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '刷新智能体失败');
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDialogOpenChange = (open: boolean) => {
|
||||
setDialogOpen(open);
|
||||
if (!open) {
|
||||
setAdvancedOpen(false);
|
||||
setForm(EMPTY_FORM);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const hasAddress = [form.base_url, form.endpoint, form.card_url].some((value) => value.trim());
|
||||
if (!hasAddress) {
|
||||
setError('请至少填写 A2A 部署地址、接口地址或卡片地址');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await addAgent({
|
||||
id: form.id || undefined,
|
||||
name: form.name || undefined,
|
||||
description: form.description || undefined,
|
||||
protocol: 'a2a',
|
||||
base_url: form.base_url || undefined,
|
||||
endpoint: form.endpoint || undefined,
|
||||
card_url: form.card_url || undefined,
|
||||
auth_env: form.auth_env || undefined,
|
||||
auth_mode: form.auth_mode || 'none',
|
||||
auth_audience: form.auth_mode === 'none' ? undefined : form.auth_audience || undefined,
|
||||
auth_scopes: form.auth_mode === 'none'
|
||||
? []
|
||||
: form.auth_scopes.split(',').map((item) => item.trim()).filter(Boolean),
|
||||
tags: form.tags.split(',').map((item) => item.trim()).filter(Boolean),
|
||||
aliases: form.aliases.split(',').map((item) => item.trim()).filter(Boolean),
|
||||
});
|
||||
handleDialogOpenChange(false);
|
||||
await load();
|
||||
} catch (err: any) {
|
||||
setError(err.message || '新增智能体失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (agentId: string) => {
|
||||
try {
|
||||
await deleteAgent(agentId);
|
||||
await load();
|
||||
} catch (err: any) {
|
||||
setError(err.message || '删除智能体失败');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Bot className="w-6 h-6" />
|
||||
智能体
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
管理工作区智能体,并查看来自插件、技能和内置能力的可委派目标。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
<Dialog open={dialogOpen} onOpenChange={handleDialogOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
新增智能体
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新增工作区智能体</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" onSubmit={handleCreate}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="base_url">A2A 部署地址</Label>
|
||||
<Input
|
||||
id="base_url"
|
||||
value={form.base_url}
|
||||
onChange={(e) => setForm((s) => ({ ...s, base_url: e.target.value }))}
|
||||
placeholder="https://agent.example.com 或 agent.example.com:19090"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
默认只需要填写部署地址。保存时会自动读取
|
||||
<code className="mx-1">/.well-known/agent-card</code>
|
||||
、<code className="mx-1">/.well-known/agent-card.json</code>
|
||||
和<code className="mx-1">/.well-known/agent.json</code>
|
||||
,并补齐 ID、名称、描述、接口地址等信息。
|
||||
</p>
|
||||
</div>
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button type="button" variant="outline" className="w-full justify-between">
|
||||
高级设置(可选)
|
||||
<ChevronDown className={`w-4 h-4 transition-transform ${advancedOpen ? 'rotate-180' : ''}`} />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 pt-4">
|
||||
<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 }))} placeholder="留空则从 A2A card 自动生成" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">名称</Label>
|
||||
<Input id="name" value={form.name} onChange={(e) => setForm((s) => ({ ...s, name: e.target.value }))} placeholder="留空则从 A2A card 自动填充" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">描述</Label>
|
||||
<Textarea id="description" value={form.description} onChange={(e) => setForm((s) => ({ ...s, description: e.target.value }))} rows={3} placeholder="留空则从 A2A card 自动填充" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endpoint">接口地址</Label>
|
||||
<Input id="endpoint" value={form.endpoint} onChange={(e) => setForm((s) => ({ ...s, endpoint: e.target.value }))} placeholder="https://agent.example.com/rpc" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="card_url">卡片地址</Label>
|
||||
<Input id="card_url" value={form.card_url} onChange={(e) => setForm((s) => ({ ...s, card_url: e.target.value }))} placeholder="https://agent.example.com/.well-known/agent-card" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="auth_mode">鉴权模式</Label>
|
||||
<select
|
||||
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"
|
||||
>
|
||||
<option value="none">none</option>
|
||||
<option value="oauth_backend_token">oauth_backend_token</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="auth_audience">Audience</Label>
|
||||
<Input id="auth_audience" value={form.auth_audience} onChange={(e) => setForm((s) => ({ ...s, auth_audience: e.target.value }))} placeholder="planner 或 a2a:planner" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="auth_scopes">Scopes</Label>
|
||||
<Input id="auth_scopes" value={form.auth_scopes} onChange={(e) => setForm((s) => ({ ...s, auth_scopes: e.target.value }))} placeholder="run_task" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="auth_env">认证环境变量</Label>
|
||||
<Input id="auth_env" value={form.auth_env} onChange={(e) => setForm((s) => ({ ...s, auth_env: e.target.value }))} placeholder="例如:MY_AGENT_TOKEN" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tags">标签</Label>
|
||||
<Input id="tags" value={form.tags} onChange={(e) => setForm((s) => ({ ...s, tags: e.target.value }))} placeholder="例如:评审, 代码, 安全" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="aliases">别名</Label>
|
||||
<Input id="aliases" value={form.aliases} onChange={(e) => setForm((s) => ({ ...s, aliases: e.target.value }))} placeholder="例如:reviewer, audit-agent" />
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||
如果远端 card 需要额外鉴权信息,或者服务没有暴露标准
|
||||
<code className="mx-1">.well-known</code>
|
||||
路径,再展开高级设置手动补充。
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => handleDialogOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Plus className="w-4 h-4 mr-2" />}
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-destructive text-sm">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{error}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
{agents.map((agent) => {
|
||||
const isWorkspace = agent.source === 'workspace';
|
||||
return (
|
||||
<Card key={agent.id}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-base truncate">{agent.name}</CardTitle>
|
||||
<p className="text-xs text-muted-foreground mt-1 font-mono">{agent.id}</p>
|
||||
<p className="text-sm text-muted-foreground mt-2 leading-relaxed">
|
||||
{agent.description || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap justify-end">
|
||||
<Badge variant="outline">{agent.source === 'workspace' ? '工作区' : agent.source === 'plugin' ? '插件' : agent.source === 'skill' ? '技能' : '内置'}</Badge>
|
||||
<Badge variant="secondary">{agent.protocol || '本地'}</Badge>
|
||||
{agent.support_streaming && <Badge className="bg-sky-600">流式</Badge>}
|
||||
{agent.support_group && <Badge className="bg-emerald-600">群组</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 pt-0">
|
||||
<div className="grid grid-cols-1 gap-2 text-xs text-muted-foreground">
|
||||
{agent.base_url && <div><span className="font-medium text-foreground">基础地址:</span> {agent.base_url}</div>}
|
||||
{agent.endpoint && <div><span className="font-medium text-foreground">接口地址:</span> {agent.endpoint}</div>}
|
||||
{agent.card_url && <div><span className="font-medium text-foreground">卡片地址:</span> {agent.card_url}</div>}
|
||||
{agent.auth_env && <div><span className="font-medium text-foreground">认证变量:</span> {agent.auth_env}</div>}
|
||||
{agent.auth_mode && agent.auth_mode !== 'none' && <div><span className="font-medium text-foreground">鉴权模式:</span> {agent.auth_mode}</div>}
|
||||
{agent.auth_audience && <div><span className="font-medium text-foreground">Audience:</span> {agent.auth_audience}</div>}
|
||||
{(agent.auth_scopes || []).length > 0 && <div><span className="font-medium text-foreground">Scopes:</span> {(agent.auth_scopes || []).join(', ')}</div>}
|
||||
</div>
|
||||
{(agent.tags.length > 0 || agent.aliases.length > 0) && (
|
||||
<div className="space-y-2">
|
||||
{agent.tags.length > 0 && (
|
||||
<div className="flex items-start gap-2 flex-wrap">
|
||||
<Tags className="w-3.5 h-3.5 mt-0.5 text-muted-foreground" />
|
||||
{agent.tags.map((tag) => (
|
||||
<Badge key={tag} variant="outline" className="text-xs">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{agent.aliases.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">别名:</span>
|
||||
{agent.aliases.map((alias) => (
|
||||
<code key={alias} className="px-2 py-0.5 rounded bg-muted">{alias}</code>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
{isWorkspace ? (
|
||||
<Button variant="outline" size="sm" onClick={() => handleDelete(agent.id)}>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
删除
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">只读来源</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
400
app-instance/frontend/app/(app)/cron/page.tsx
Normal file
400
app-instance/frontend/app/(app)/cron/page.tsx
Normal file
@ -0,0 +1,400 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Clock,
|
||||
Plus,
|
||||
Trash2,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
listCronJobs,
|
||||
addCronJob,
|
||||
removeCronJob,
|
||||
toggleCronJob,
|
||||
runCronJob,
|
||||
} from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { CronJob } from '@/types';
|
||||
|
||||
export default function CronPage() {
|
||||
const [jobs, setJobs] = useState<CronJob[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
|
||||
const loadJobs = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await listCronJobs(true);
|
||||
setJobs(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '加载任务失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadJobs();
|
||||
}, []);
|
||||
|
||||
const handleToggle = async (jobId: string, enabled: boolean) => {
|
||||
try {
|
||||
await toggleCronJob(jobId, enabled);
|
||||
loadJobs();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (jobId: string) => {
|
||||
try {
|
||||
await removeCronJob(jobId);
|
||||
loadJobs();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleRun = async (jobId: string) => {
|
||||
try {
|
||||
await runCronJob(jobId);
|
||||
loadJobs();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = async (params: {
|
||||
name: string;
|
||||
message: string;
|
||||
every_seconds?: number;
|
||||
cron_expr?: string;
|
||||
}) => {
|
||||
try {
|
||||
await addCronJob(params);
|
||||
setShowAdd(false);
|
||||
loadJobs();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (ms: number | null) => {
|
||||
if (!ms) return '-';
|
||||
return new Date(ms).toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Clock className="w-6 h-6" />
|
||||
定时任务
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={loadJobs} variant="outline" size="sm">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
<Button onClick={() => setShowAdd(true)} size="sm">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
新建任务
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-destructive text-sm">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{error}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Add Job Form */}
|
||||
{showAdd && (
|
||||
<AddJobForm
|
||||
onAdd={handleAdd}
|
||||
onCancel={() => setShowAdd(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Jobs Table */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{jobs.length === 0 ? (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
<Clock className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="font-medium">暂无定时任务</p>
|
||||
<p className="text-sm mt-1">新建一个任务,让智能体按计划自动执行。</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">启用</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>计划</TableHead>
|
||||
<TableHead>消息</TableHead>
|
||||
<TableHead>上次运行</TableHead>
|
||||
<TableHead>下次运行</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead className="w-24">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{jobs.map((job) => (
|
||||
<TableRow key={job.id}>
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={job.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handleToggle(job.id, checked)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
<div>
|
||||
<span>{job.name}</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{job.id}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
|
||||
{job.schedule_display}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm truncate max-w-[200px] block">
|
||||
{job.message}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{formatTime(job.last_run_at_ms)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{formatTime(job.next_run_at_ms)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{job.last_status === 'ok' && (
|
||||
<Badge variant="default" className="text-xs bg-green-600">
|
||||
OK
|
||||
</Badge>
|
||||
)}
|
||||
{job.last_status === 'error' && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
错误
|
||||
</Badge>
|
||||
)}
|
||||
{!job.last_status && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
-
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleRun(job.id)}
|
||||
title="立即执行"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(job.id)}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddJobForm({
|
||||
onAdd,
|
||||
onCancel,
|
||||
}: {
|
||||
onAdd: (params: {
|
||||
name: string;
|
||||
message: string;
|
||||
every_seconds?: number;
|
||||
cron_expr?: string;
|
||||
}) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [scheduleType, setScheduleType] = useState<'every' | 'cron'>('every');
|
||||
const [everySeconds, setEverySeconds] = useState('3600');
|
||||
const [cronExpr, setCronExpr] = useState('0 9 * * *');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || !message.trim()) return;
|
||||
|
||||
const params: any = { name: name.trim(), message: message.trim() };
|
||||
if (scheduleType === 'every') {
|
||||
params.every_seconds = parseInt(everySeconds, 10) || 3600;
|
||||
} else {
|
||||
params.cron_expr = cronExpr.trim();
|
||||
}
|
||||
onAdd(params);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">新建定时任务</CardTitle>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onCancel}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">任务名称</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="例如:日报汇总"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="schedule-type">调度类型</Label>
|
||||
<Select
|
||||
value={scheduleType}
|
||||
onValueChange={(v) => setScheduleType(v as 'every' | 'cron')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="every">固定间隔(每 N 秒)</SelectItem>
|
||||
<SelectItem value="cron">Cron 表达式</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scheduleType === 'every' ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="every">间隔(秒)</Label>
|
||||
<Input
|
||||
id="every"
|
||||
type="number"
|
||||
value={everySeconds}
|
||||
onChange={(e) => setEverySeconds(e.target.value)}
|
||||
min="10"
|
||||
placeholder="3600"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{parseInt(everySeconds, 10) >= 3600
|
||||
? `约 ${Math.floor(parseInt(everySeconds, 10) / 3600)} 小时 ${Math.floor((parseInt(everySeconds, 10) % 3600) / 60)} 分`
|
||||
: parseInt(everySeconds, 10) >= 60
|
||||
? `约 ${Math.floor(parseInt(everySeconds, 10) / 60)} 分 ${parseInt(everySeconds, 10) % 60} 秒`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cron">Cron 表达式</Label>
|
||||
<Input
|
||||
id="cron"
|
||||
value={cronExpr}
|
||||
onChange={(e) => setCronExpr(e.target.value)}
|
||||
placeholder="0 9 * * *"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
格式:分钟 小时 日 月 周
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="message">发送给智能体的消息</Label>
|
||||
<Input
|
||||
id="message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="例如:检查我的邮件并生成摘要"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={!name.trim() || !message.trim()}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
创建任务
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
376
app-instance/frontend/app/(app)/files/page.tsx
Normal file
376
app-instance/frontend/app/(app)/files/page.tsx
Normal file
@ -0,0 +1,376 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Trash2,
|
||||
Download,
|
||||
Loader2,
|
||||
FolderOpen,
|
||||
Folder,
|
||||
FileText,
|
||||
Upload,
|
||||
FolderPlus,
|
||||
ChevronRight,
|
||||
Home,
|
||||
RefreshCw,
|
||||
FileImage,
|
||||
FileCode,
|
||||
FileArchive,
|
||||
FileSpreadsheet,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
browseWorkspace,
|
||||
getWorkspaceDownloadUrl,
|
||||
uploadToWorkspace,
|
||||
deleteWorkspacePath,
|
||||
createWorkspaceDir,
|
||||
getAccessToken,
|
||||
} from '@/lib/api';
|
||||
import type { WorkspaceItem } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
export default function FilesPage() {
|
||||
const [items, setItems] = useState<WorkspaceItem[]>([]);
|
||||
const [currentPath, setCurrentPath] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [showMkdir, setShowMkdir] = useState(false);
|
||||
const [newDirName, setNewDirName] = useState('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const mkdirInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const load = useCallback(async (path: string = currentPath) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await browseWorkspace(path);
|
||||
setItems(data.items);
|
||||
setCurrentPath(data.path);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentPath]);
|
||||
|
||||
useEffect(() => {
|
||||
load('');
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const navigateTo = (path: string) => {
|
||||
load(path);
|
||||
};
|
||||
|
||||
const handleDelete = async (item: WorkspaceItem) => {
|
||||
const label = item.type === 'directory' ? '文件夹' : '文件';
|
||||
if (!confirm(`确定删除${label} "${item.name}"?${item.type === 'directory' ? '(包含所有子文件)' : ''}`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteWorkspacePath(item.path);
|
||||
setItems((prev) => prev.filter((i) => i.path !== item.path));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async (item: WorkspaceItem) => {
|
||||
const url = getWorkspaceDownloadUrl(item.path);
|
||||
const token = getAccessToken();
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { headers });
|
||||
const blob = await res.blob();
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = item.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(a.href);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
setUploading(true);
|
||||
setUploadProgress(0);
|
||||
try {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
await uploadToWorkspace(files[i], currentPath, (pct) => {
|
||||
setUploadProgress(Math.round((i / files.length) * 100 + pct / files.length));
|
||||
});
|
||||
}
|
||||
await load();
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setUploadProgress(0);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateDir = async () => {
|
||||
const name = newDirName.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
const dirPath = currentPath ? `${currentPath}/${name}` : name;
|
||||
await createWorkspaceDir(dirPath);
|
||||
setShowMkdir(false);
|
||||
setNewDirName('');
|
||||
await load();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
// Build breadcrumbs
|
||||
const breadcrumbs = currentPath ? currentPath.split('/') : [];
|
||||
|
||||
const formatSize = (bytes: number | null) => {
|
||||
if (bytes === null || bytes === undefined) return '';
|
||||
if (bytes > 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
if (bytes > 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${bytes} B`;
|
||||
};
|
||||
|
||||
const formatDate = (iso: string) => {
|
||||
try {
|
||||
return new Date(iso).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-bold">文件管理</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowMkdir(true)}
|
||||
disabled={loading}
|
||||
>
|
||||
<FolderPlus className="w-4 h-4 mr-1" />
|
||||
新建文件夹
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||
{uploadProgress}%
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-4 h-4 mr-1" />
|
||||
上传
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={() => load()} disabled={loading}>
|
||||
{loading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumbs */}
|
||||
<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"
|
||||
>
|
||||
<Home className="w-3.5 h-3.5" />
|
||||
工作区
|
||||
</button>
|
||||
{breadcrumbs.map((segment, idx) => {
|
||||
const path = breadcrumbs.slice(0, idx + 1).join('/');
|
||||
const isLast = idx === breadcrumbs.length - 1;
|
||||
return (
|
||||
<React.Fragment key={path}>
|
||||
<ChevronRight className="w-3 h-3 flex-shrink-0" />
|
||||
<button
|
||||
onClick={() => navigateTo(path)}
|
||||
className={`px-1.5 py-0.5 rounded transition-colors ${
|
||||
isLast
|
||||
? 'text-foreground font-medium'
|
||||
: 'hover:text-foreground hover:bg-accent'
|
||||
}`}
|
||||
>
|
||||
{segment}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* New directory input */}
|
||||
{showMkdir && (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Folder className="w-4 h-4 text-muted-foreground" />
|
||||
<input
|
||||
ref={mkdirInputRef}
|
||||
type="text"
|
||||
value={newDirName}
|
||||
onChange={(e) => setNewDirName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleCreateDir();
|
||||
if (e.key === 'Escape') {
|
||||
setShowMkdir(false);
|
||||
setNewDirName('');
|
||||
}
|
||||
}}
|
||||
placeholder="文件夹名称"
|
||||
className="flex-1 px-3 py-1.5 text-sm border border-border rounded-md bg-background focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
autoFocus
|
||||
/>
|
||||
<Button size="sm" onClick={handleCreateDir}>
|
||||
创建
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setShowMkdir(false);
|
||||
setNewDirName('');
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File list */}
|
||||
{loading && items.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-20 text-muted-foreground">
|
||||
<Loader2 className="w-6 h-6 animate-spin" />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<FolderOpen className="w-12 h-12 mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium">空文件夹</p>
|
||||
<p className="text-sm">点击上方"上传"或"新建文件夹"按钮开始使用</p>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[calc(100vh-14rem)]">
|
||||
<div className="space-y-1">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.path}
|
||||
className="flex items-center gap-3 px-4 py-2.5 rounded-lg border border-border bg-card hover:bg-accent/30 transition-colors group"
|
||||
>
|
||||
{/* Icon */}
|
||||
<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} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name - clickable for directories */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{item.type === 'directory' ? (
|
||||
<button
|
||||
onClick={() => navigateTo(item.path)}
|
||||
className="text-sm font-medium truncate hover:underline text-left block w-full"
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
) : (
|
||||
<p className="text-sm font-medium truncate">{item.name}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{item.type === 'file' && formatSize(item.size)}
|
||||
{item.modified && (
|
||||
<>
|
||||
{item.type === 'file' && ' · '}
|
||||
{formatDate(item.modified)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{item.type === 'file' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleDownload(item)}
|
||||
title="下载"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(item)}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileIcon({ name, contentType }: { name: string; contentType?: string }) {
|
||||
const ext = name.split('.').pop()?.toLowerCase() || '';
|
||||
const ct = contentType || '';
|
||||
|
||||
if (ct.startsWith('image/') || ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'].includes(ext)) {
|
||||
return <FileImage className="w-5 h-5 text-green-500" />;
|
||||
}
|
||||
if (
|
||||
['js', 'ts', 'tsx', 'jsx', 'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'css', 'html', 'json', 'yaml', 'yml', 'toml', 'md', 'sh'].includes(ext)
|
||||
) {
|
||||
return <FileCode className="w-5 h-5 text-orange-500" />;
|
||||
}
|
||||
if (['zip', 'tar', 'gz', 'bz2', 'rar', '7z', 'xz'].includes(ext)) {
|
||||
return <FileArchive className="w-5 h-5 text-yellow-500" />;
|
||||
}
|
||||
if (['csv', 'xls', 'xlsx', 'tsv'].includes(ext)) {
|
||||
return <FileSpreadsheet className="w-5 h-5 text-emerald-500" />;
|
||||
}
|
||||
return <FileText className="w-5 h-5 text-muted-foreground" />;
|
||||
}
|
||||
168
app-instance/frontend/app/(app)/help/page.tsx
Normal file
168
app-instance/frontend/app/(app)/help/page.tsx
Normal file
@ -0,0 +1,168 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
MessageSquare,
|
||||
Terminal,
|
||||
Layers,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Plus,
|
||||
Trash2,
|
||||
Send,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface SectionProps {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
function Section({ icon, title, children, defaultOpen = false }: SectionProps) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<button
|
||||
className="w-full flex items-center gap-3 px-4 py-3 bg-card hover:bg-accent/50 transition-colors text-left"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<span className="text-primary">{icon}</span>
|
||||
<span className="font-medium flex-1">{title}</span>
|
||||
{open ? <ChevronDown className="w-4 h-4 text-muted-foreground" /> : <ChevronRight className="w-4 h-4 text-muted-foreground" />}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="px-4 py-4 space-y-3 text-sm text-muted-foreground border-t border-border bg-background">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Tag({ children, color = 'default' }: { children: React.ReactNode; color?: 'green' | 'yellow' | 'red' | 'default' }) {
|
||||
const cls = {
|
||||
green: 'bg-green-900/30 text-green-400 border-green-800',
|
||||
yellow: 'bg-yellow-900/30 text-yellow-400 border-yellow-800',
|
||||
red: 'bg-red-900/30 text-red-400 border-red-800',
|
||||
default: 'bg-muted text-foreground border-border',
|
||||
}[color];
|
||||
return (
|
||||
<span className={`inline-block px-2 py-0.5 rounded border text-xs font-mono ${cls}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HelpPage() {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 py-8 space-y-4">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold mb-1">使用帮助</h1>
|
||||
<p className="text-muted-foreground text-sm">了解如何使用 Boardware Genius 的各项功能</p>
|
||||
</div>
|
||||
|
||||
<Section icon={<MessageSquare className="w-5 h-5" />} title="如何开始对话" defaultOpen>
|
||||
<p>进入页面后,你会看到主界面分为左侧<strong className="text-foreground">对话列表</strong>和右侧<strong className="text-foreground">聊天区域</strong>。</p>
|
||||
<ol className="list-decimal list-inside space-y-1.5 ml-1">
|
||||
<li>在底部输入框中输入你的问题或指令</li>
|
||||
<li>按 <Tag>Enter</Tag> 发送,按 <Tag>Shift + Enter</Tag> 换行</li>
|
||||
<li>等待 Boardware Genius 回复(右上角会显示"思考中...")</li>
|
||||
</ol>
|
||||
<p className="mt-1">
|
||||
点击左上角的 <Tag>新对话</Tag> 按钮可以开启一个全新的对话,历史对话会保存在左侧列表中。
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section icon={<Terminal className="w-5 h-5" />} title="斜杠命令(/命令)">
|
||||
<p>在输入框中输入 <Tag>/</Tag> 可以呼出命令选择菜单,列出所有可用的快捷指令。</p>
|
||||
<ul className="space-y-1.5 ml-1">
|
||||
<li>输入 <Tag>/</Tag> 后继续输入关键词可以过滤命令</li>
|
||||
<li>用 <Tag>↑</Tag> <Tag>↓</Tag> 方向键选择命令</li>
|
||||
<li>按 <Tag>Enter</Tag> 或 <Tag>Tab</Tag> 确认选择</li>
|
||||
<li>按 <Tag>Esc</Tag> 关闭命令菜单</li>
|
||||
</ul>
|
||||
<p>命令由内置功能或已安装的插件提供,可在<strong className="text-foreground">技能</strong>和<strong className="text-foreground">插件</strong>页面查看详情。</p>
|
||||
</Section>
|
||||
|
||||
<Section icon={<Layers className="w-5 h-5" />} title="对话管理">
|
||||
<ul className="space-y-2 ml-1">
|
||||
<li>
|
||||
<span className="inline-flex items-center gap-1"><Plus className="w-3.5 h-3.5" /><strong className="text-foreground">新对话</strong></span>
|
||||
{' '}— 创建一个新的独立对话,不会影响现有对话
|
||||
</li>
|
||||
<li>
|
||||
<span className="inline-flex items-center gap-1"><MessageSquare className="w-3.5 h-3.5" /><strong className="text-foreground">切换对话</strong></span>
|
||||
{' '}— 点击左侧列表中的对话条目即可切换
|
||||
</li>
|
||||
<li>
|
||||
<span className="inline-flex items-center gap-1"><Trash2 className="w-3.5 h-3.5" /><strong className="text-foreground">删除对话</strong></span>
|
||||
{' '}— 鼠标悬停在对话条目上,点击右侧垃圾桶图标删除
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-xs mt-1 text-muted-foreground/70">对话历史保存在服务器端,刷新页面或重启前端后仍可查看。</p>
|
||||
</Section>
|
||||
|
||||
<Section icon={<Wifi className="w-5 h-5" />} title="连接状态说明">
|
||||
<p>右上角导航栏会显示当前连接状态:</p>
|
||||
<div className="space-y-2 ml-1 mt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 flex-shrink-0" />
|
||||
<Tag color="green">已连接</Tag>
|
||||
<span>— Boardware Genius 服务正常运行,可以正常对话</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-yellow-500 flex-shrink-0" />
|
||||
<Tag color="yellow">连接中 / 检查中</Tag>
|
||||
<span>— 正在建立连接或检测服务状态,请稍等</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0" />
|
||||
<Tag color="red">服务离线</Tag>
|
||||
<span>— 已连到后端接口,但 Boardware Genius 服务当前不可用</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0" />
|
||||
<Tag color="red">未连接</Tag>
|
||||
<span>— 无法连接到服务器,系统会自动重试</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-xs">
|
||||
<strong className="text-foreground">提示:</strong>若长时间显示"服务离线",请检查后端进程是否已启动并监听正确端口。
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section icon={<Send className="w-5 h-5" />} title="输入技巧">
|
||||
<ul className="space-y-2 ml-1">
|
||||
<li><Tag>Enter</Tag> — 发送消息</li>
|
||||
<li><Tag>Shift + Enter</Tag> — 在消息中插入换行,不发送</li>
|
||||
<li>使用中文输入法时,选字过程中按 Enter 不会误发送,选好汉字后再按 Enter 才会发送</li>
|
||||
<li>输入 <Tag>/</Tag> 可以快速调用内置命令和插件功能</li>
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
<Section icon={<WifiOff className="w-5 h-5" />} title="常见问题">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="font-medium text-foreground mb-1">Q:发送消息后一直没有回复?</p>
|
||||
<p>请检查右上角连接状态是否为"已连接"。若显示"服务离线"或"未连接",说明后端服务未运行,消息无法被处理。</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-foreground mb-1">Q:如何查看 Boardware Genius 的运行状态?</p>
|
||||
<p>点击顶部导航栏的<strong className="text-foreground">状态</strong>页面,可以查看服务配置、AI 模型、各通道和定时任务的运行状况。</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-foreground mb-1">Q:如何使用定时任务?</p>
|
||||
<p>点击顶部导航栏的<strong className="text-foreground">定时任务</strong>,可以添加、启用或禁用周期性执行的指令,例如定时提醒、数据采集等。</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-foreground mb-1">Q:什么是技能和插件?</p>
|
||||
<p><strong className="text-foreground">技能</strong>是可上传的自定义提示词包,扩展 Boardware Genius 的能力范围。<strong className="text-foreground">插件</strong>是更完整的功能扩展,可以提供新的斜杠命令、专用 Agent 等。</p>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
app-instance/frontend/app/(app)/layout.tsx
Normal file
17
app-instance/frontend/app/(app)/layout.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import Header from '@/components/Header';
|
||||
import AuthGuard from '@/components/AuthGuard';
|
||||
|
||||
export default function AppLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<Header />
|
||||
<main className="pt-16">
|
||||
<AuthGuard>{children}</AuthGuard>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
437
app-instance/frontend/app/(app)/marketplace/page.tsx
Normal file
437
app-instance/frontend/app/(app)/marketplace/page.tsx
Normal file
@ -0,0 +1,437 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Store,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Plus,
|
||||
Trash2,
|
||||
Download,
|
||||
Check,
|
||||
X,
|
||||
Globe,
|
||||
FolderOpen,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
listMarketplaces,
|
||||
addMarketplace,
|
||||
removeMarketplace,
|
||||
updateMarketplace,
|
||||
listMarketplacePlugins,
|
||||
installMarketplacePlugin,
|
||||
uninstallPlugin,
|
||||
} from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { Marketplace, MarketplacePlugin } from '@/types';
|
||||
|
||||
export default function MarketplacePage() {
|
||||
const [marketplaces, setMarketplaces] = useState<Marketplace[]>([]);
|
||||
const [selectedMarketplace, setSelectedMarketplace] = useState<string | null>(null);
|
||||
const [plugins, setPlugins] = useState<MarketplacePlugin[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pluginsLoading, setPluginsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [addSource, setAddSource] = useState('');
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [actionPlugin, setActionPlugin] = useState<string | null>(null);
|
||||
const [updatingMarketplace, setUpdatingMarketplace] = useState<string | null>(null);
|
||||
|
||||
const loadMarketplaces = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await listMarketplaces();
|
||||
const list = Array.isArray(data) ? data : [];
|
||||
setMarketplaces(list);
|
||||
// Auto-select first marketplace if none selected or selected was removed
|
||||
if (list.length > 0) {
|
||||
setSelectedMarketplace((prev) => {
|
||||
if (prev && list.some((m) => m.name === prev)) return prev;
|
||||
return list[0].name;
|
||||
});
|
||||
} else {
|
||||
setSelectedMarketplace(null);
|
||||
setPlugins([]);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || '加载市场失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadPlugins = useCallback(async (marketplaceName: string) => {
|
||||
setPluginsLoading(true);
|
||||
try {
|
||||
const data = await listMarketplacePlugins(marketplaceName);
|
||||
setPlugins(Array.isArray(data) ? data : []);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '加载插件失败');
|
||||
} finally {
|
||||
setPluginsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadMarketplaces();
|
||||
}, [loadMarketplaces]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedMarketplace) {
|
||||
loadPlugins(selectedMarketplace);
|
||||
}
|
||||
}, [selectedMarketplace, loadPlugins]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!addSource.trim()) return;
|
||||
setAdding(true);
|
||||
setError(null);
|
||||
try {
|
||||
const marketplace = await addMarketplace(addSource.trim());
|
||||
setAddSource('');
|
||||
setShowAddForm(false);
|
||||
await loadMarketplaces();
|
||||
setSelectedMarketplace(marketplace.name);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '添加市场失败');
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (name: string) => {
|
||||
setError(null);
|
||||
try {
|
||||
await removeMarketplace(name);
|
||||
if (selectedMarketplace === name) {
|
||||
setSelectedMarketplace(null);
|
||||
setPlugins([]);
|
||||
}
|
||||
await loadMarketplaces();
|
||||
} catch (err: any) {
|
||||
setError(err.message || '移除市场失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateMarketplace = async (name: string) => {
|
||||
setUpdatingMarketplace(name);
|
||||
setError(null);
|
||||
try {
|
||||
await updateMarketplace(name);
|
||||
await loadPlugins(name);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '更新市场失败');
|
||||
} finally {
|
||||
setUpdatingMarketplace(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdatePlugin = async (marketplaceName: string, pluginName: string) => {
|
||||
setActionPlugin(pluginName);
|
||||
setError(null);
|
||||
try {
|
||||
await installMarketplacePlugin(marketplaceName, pluginName);
|
||||
await loadPlugins(marketplaceName);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '更新插件失败');
|
||||
} finally {
|
||||
setActionPlugin(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstall = async (marketplaceName: string, pluginName: string) => {
|
||||
setActionPlugin(pluginName);
|
||||
setError(null);
|
||||
try {
|
||||
await installMarketplacePlugin(marketplaceName, pluginName);
|
||||
await loadPlugins(marketplaceName);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '安装插件失败');
|
||||
} finally {
|
||||
setActionPlugin(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUninstall = async (pluginName: string) => {
|
||||
setActionPlugin(pluginName);
|
||||
setError(null);
|
||||
try {
|
||||
await uninstallPlugin(pluginName);
|
||||
if (selectedMarketplace) {
|
||||
await loadPlugins(selectedMarketplace);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || '卸载插件失败');
|
||||
} finally {
|
||||
setActionPlugin(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
await loadMarketplaces();
|
||||
if (selectedMarketplace) {
|
||||
await loadPlugins(selectedMarketplace);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto p-6 space-y-6">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Store className="w-6 h-6" />
|
||||
插件市场
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
浏览并安装已注册市场中的插件
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => setShowAddForm((v) => !v)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
添加市场
|
||||
</Button>
|
||||
<Button onClick={handleRefresh} variant="outline" size="sm">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between gap-2 text-destructive text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0 h-6 w-6 p-0"
|
||||
onClick={() => setError(null)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Add marketplace form */}
|
||||
{showAddForm && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="本地路径或 Git 地址(例如 /path/to/marketplace 或 https://github.com/...)"
|
||||
value={addSource}
|
||||
onChange={(e) => setAddSource(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleAdd();
|
||||
}}
|
||||
disabled={adding}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={handleAdd} disabled={adding || !addSource.trim()} size="sm">
|
||||
{adding ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
添加
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowAddForm(false);
|
||||
setAddSource('');
|
||||
}}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Marketplace tabs */}
|
||||
{marketplaces.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{marketplaces.map((marketplace) => (
|
||||
<div key={marketplace.name} className="flex items-center gap-0.5">
|
||||
<Button
|
||||
variant={selectedMarketplace === marketplace.name ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSelectedMarketplace(marketplace.name)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{marketplace.type === 'git' ? (
|
||||
<Globe className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<FolderOpen className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{marketplace.name}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-primary"
|
||||
disabled={updatingMarketplace === marketplace.name}
|
||||
onClick={() => handleUpdateMarketplace(marketplace.name)}
|
||||
title="更新市场"
|
||||
>
|
||||
{updatingMarketplace === marketplace.name ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleRemove(marketplace.name)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{marketplaces.length === 0 && !error && (
|
||||
<Card>
|
||||
<CardContent className="py-16 text-center text-muted-foreground">
|
||||
<Store className="w-12 h-12 mx-auto mb-4 opacity-30" />
|
||||
<p className="font-medium">还没有注册任何市场</p>
|
||||
<p className="text-sm mt-2 max-w-sm mx-auto">
|
||||
点击上方的<strong>添加市场</strong>,填入本地路径或 Git 地址即可开始使用。
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Plugin list */}
|
||||
{selectedMarketplace && (
|
||||
<>
|
||||
{pluginsLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : plugins.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
<Store className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="font-medium">暂无可用插件</p>
|
||||
<p className="text-sm mt-1">这个市场里暂时还没有插件。</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{plugins.map((plugin) => (
|
||||
<Card key={plugin.name}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<CardTitle className="text-base font-semibold">
|
||||
{plugin.name}
|
||||
</CardTitle>
|
||||
{plugin.installed && (
|
||||
<Badge variant="secondary" className="text-xs gap-1">
|
||||
<Check className="w-3 h-3" />
|
||||
已安装
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{plugin.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
|
||||
{plugin.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
{plugin.installed ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={actionPlugin === plugin.name}
|
||||
onClick={() =>
|
||||
handleUpdatePlugin(plugin.marketplace_name, plugin.name)
|
||||
}
|
||||
>
|
||||
{actionPlugin === plugin.name ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
更新
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={actionPlugin === plugin.name}
|
||||
onClick={() => handleUninstall(plugin.name)}
|
||||
>
|
||||
{actionPlugin === plugin.name ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
卸载
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={actionPlugin === plugin.name}
|
||||
onClick={() =>
|
||||
handleInstall(plugin.marketplace_name, plugin.name)
|
||||
}
|
||||
>
|
||||
{actionPlugin === plugin.name ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
安装
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
589
app-instance/frontend/app/(app)/mcp/page.tsx
Normal file
589
app-instance/frontend/app/(app)/mcp/page.tsx
Normal file
@ -0,0 +1,589 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { AlertCircle, Loader2, Plus, RefreshCw, ServerCog, TestTube2, Trash2, Wrench } from 'lucide-react';
|
||||
|
||||
import { addMcpServer, deleteMcpServer, getAuthzStatus, listMcpServers, listMcpTools, testMcpServer, updateMcpServer } from '@/lib/api';
|
||||
import { useChatStore } from '@/lib/store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AuthzStatus, UiMcpServerDescriptor } from '@/types';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
type McpFormMode = 'remote' | 'install';
|
||||
|
||||
const EMPTY_FORM = {
|
||||
mode: 'remote' as McpFormMode,
|
||||
id: '',
|
||||
command: '',
|
||||
args: '',
|
||||
url: '',
|
||||
headers: '{}',
|
||||
auth_mode: 'none',
|
||||
tool_timeout: '30',
|
||||
};
|
||||
|
||||
function createEmptyForm() {
|
||||
return { ...EMPTY_FORM };
|
||||
}
|
||||
|
||||
function resolveFormMode(server?: UiMcpServerDescriptor): McpFormMode {
|
||||
if (server?.transport === 'stdio') return 'install';
|
||||
if (server?.transport === 'http') return 'remote';
|
||||
if (server?.url) return 'remote';
|
||||
return 'install';
|
||||
}
|
||||
|
||||
function resolveAuthAudience(serverId: string) {
|
||||
const trimmed = serverId.trim();
|
||||
return trimmed ? `mcp:${trimmed}` : '';
|
||||
}
|
||||
|
||||
function resolveAuthzMcpScopes(authzStatus: AuthzStatus | null, serverId: string): { available: boolean; enabled: boolean; scopes: string[] } {
|
||||
const trimmed = serverId.trim();
|
||||
if (!trimmed) {
|
||||
return { available: false, enabled: false, scopes: [] };
|
||||
}
|
||||
|
||||
const permissions = authzStatus?.permissions;
|
||||
if (!permissions || typeof permissions !== 'object') {
|
||||
return { available: false, enabled: false, scopes: [] };
|
||||
}
|
||||
|
||||
const mcpPermissions = (permissions as Record<string, unknown>).mcp;
|
||||
if (!mcpPermissions || typeof mcpPermissions !== 'object') {
|
||||
return { available: false, enabled: false, scopes: [] };
|
||||
}
|
||||
|
||||
const serverPermission = (mcpPermissions as Record<string, unknown>)[trimmed];
|
||||
if (!serverPermission || typeof serverPermission !== 'object') {
|
||||
return { available: false, enabled: false, scopes: [] };
|
||||
}
|
||||
|
||||
const enabled = Boolean((serverPermission as Record<string, unknown>).enabled);
|
||||
const rawTools: unknown[] = Array.isArray((serverPermission as Record<string, unknown>).tools)
|
||||
? ((serverPermission as Record<string, unknown>).tools as unknown[])
|
||||
: [];
|
||||
const toolScopes = rawTools
|
||||
.filter((tool: unknown): tool is string => typeof tool === 'string' && tool.trim().length > 0)
|
||||
.map((tool) => `tool:${tool.trim()}`);
|
||||
|
||||
return {
|
||||
available: true,
|
||||
enabled,
|
||||
scopes: enabled ? ['list_tools', ...toolScopes] : [],
|
||||
};
|
||||
}
|
||||
|
||||
function serverStatusLabel(status?: string | null) {
|
||||
if (status === 'connected') return '已连接';
|
||||
if (status === 'error') return '异常';
|
||||
if (status === 'disconnected' || !status) return '未连接';
|
||||
return status;
|
||||
}
|
||||
|
||||
function transportLabel(transport?: string) {
|
||||
if (transport === 'stdio') return '标准输入输出';
|
||||
if (transport === 'http') return 'HTTP';
|
||||
return transport || '-';
|
||||
}
|
||||
|
||||
export default function MCPPage() {
|
||||
const cachedServers = useChatStore((s) => s.mcpRegistry);
|
||||
const cachedTools = useChatStore((s) => s.mcpToolRegistry);
|
||||
const setCachedServers = useChatStore((s) => s.setMcpRegistry);
|
||||
const setCachedTools = useChatStore((s) => s.setMcpToolRegistry);
|
||||
const [servers, setServers] = useState<UiMcpServerDescriptor[]>(cachedServers);
|
||||
const [tools, setTools] = useState<Array<{ server_id: string; tools: Array<Record<string, unknown>> }>>(cachedTools);
|
||||
const [loading, setLoading] = useState(cachedServers.length === 0 && cachedTools.length === 0);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [testingId, setTestingId] = useState<string | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState(createEmptyForm());
|
||||
const [authzStatus, setAuthzStatus] = useState<AuthzStatus | null>(null);
|
||||
const [selectedServerId, setSelectedServerId] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async (background = false) => {
|
||||
if (background) {
|
||||
setRefreshing(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
const [serverData, toolData, authzData] = await Promise.all([
|
||||
listMcpServers(),
|
||||
listMcpTools(),
|
||||
getAuthzStatus().catch(() => null),
|
||||
]);
|
||||
const nextServers = Array.isArray(serverData) ? serverData : [];
|
||||
const nextTools = Array.isArray(toolData) ? toolData : [];
|
||||
setServers(nextServers);
|
||||
setTools(nextTools);
|
||||
setCachedServers(nextServers);
|
||||
setCachedTools(nextTools);
|
||||
setAuthzStatus(authzData);
|
||||
setSelectedServerId((current) => (current && nextServers.some((server) => server.id === current) ? current : null));
|
||||
} catch (err: any) {
|
||||
setError(err.message || '加载 MCP 服务失败');
|
||||
} finally {
|
||||
if (background) {
|
||||
setRefreshing(false);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [setCachedServers, setCachedTools]);
|
||||
|
||||
useEffect(() => {
|
||||
void load(cachedServers.length > 0 || cachedTools.length > 0);
|
||||
}, [cachedServers.length, cachedTools.length, load]);
|
||||
|
||||
const resetForm = () => {
|
||||
setEditingId(null);
|
||||
setForm(createEmptyForm());
|
||||
};
|
||||
|
||||
const openEdit = (server: UiMcpServerDescriptor) => {
|
||||
setEditingId(server.id);
|
||||
setForm({
|
||||
mode: resolveFormMode(server),
|
||||
id: server.id,
|
||||
command: server.command || '',
|
||||
args: (server.args || []).join(' '),
|
||||
url: server.url || '',
|
||||
headers: JSON.stringify(server.headers || {}, null, 2),
|
||||
auth_mode: server.auth_mode || 'none',
|
||||
tool_timeout: String(server.tool_timeout || 30),
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const parseObjectField = (label: string, value: string) => {
|
||||
if (!value.trim()) return {};
|
||||
const parsed = JSON.parse(value);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error(`${label} 必须是 JSON 对象`);
|
||||
}
|
||||
return parsed as Record<string, string>;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const id = form.id.trim();
|
||||
const url = form.url.trim();
|
||||
const command = form.command.trim();
|
||||
const toolTimeout = Number(form.tool_timeout || 30);
|
||||
if (!id) {
|
||||
throw new Error('ID 不能为空');
|
||||
}
|
||||
if (!Number.isFinite(toolTimeout) || toolTimeout < 1) {
|
||||
throw new Error('工具超时必须大于 0');
|
||||
}
|
||||
if (form.mode === 'remote' && !url) {
|
||||
throw new Error('请输入 MCP Server 地址');
|
||||
}
|
||||
if (form.mode === 'install' && !command) {
|
||||
throw new Error('请输入安装或启动命令');
|
||||
}
|
||||
|
||||
const authMode = form.mode === 'remote' ? (form.auth_mode || 'none') : 'none';
|
||||
const authAudience = authMode === 'oauth_backend_token' ? resolveAuthAudience(id) : '';
|
||||
const payload = {
|
||||
id,
|
||||
command: form.mode === 'install' ? command : '',
|
||||
args: form.mode === 'install'
|
||||
? form.args.split(/\s+/).map((item) => item.trim()).filter(Boolean)
|
||||
: [],
|
||||
env: {},
|
||||
url: form.mode === 'remote' ? url : '',
|
||||
headers: form.mode === 'remote' ? parseObjectField('请求头', form.headers) : {},
|
||||
auth_mode: authMode,
|
||||
auth_audience: authAudience,
|
||||
auth_scopes: [],
|
||||
tool_timeout: toolTimeout,
|
||||
};
|
||||
if (editingId) {
|
||||
await updateMcpServer(editingId, payload);
|
||||
} else {
|
||||
await addMcpServer(payload);
|
||||
}
|
||||
setDialogOpen(false);
|
||||
resetForm();
|
||||
await load();
|
||||
} catch (err: any) {
|
||||
setError(err.message || '保存 MCP 服务失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (serverId: string) => {
|
||||
try {
|
||||
await deleteMcpServer(serverId);
|
||||
setSelectedServerId((current) => (current === serverId ? null : current));
|
||||
await load();
|
||||
} catch (err: any) {
|
||||
setError(err.message || '删除 MCP 服务失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async (serverId: string) => {
|
||||
setTestingId(serverId);
|
||||
try {
|
||||
await testMcpServer(serverId);
|
||||
await load(true);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '测试 MCP 服务失败');
|
||||
} finally {
|
||||
setTestingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const authAudience = resolveAuthAudience(form.id);
|
||||
const authzMcpScopes = resolveAuthzMcpScopes(authzStatus, form.id);
|
||||
const showAuthzPreview = form.auth_mode === 'oauth_backend_token';
|
||||
const selectedServer = selectedServerId ? servers.find((server) => server.id === selectedServerId) || null : null;
|
||||
const selectedToolGroup = selectedServerId ? tools.find((group) => group.server_id === selectedServerId) || null : null;
|
||||
let authzHint = '无需手动填写。Audience 会按 MCP ID 自动生成,Scopes 按 AuthZ 当前权限动态决定。';
|
||||
if (showAuthzPreview) {
|
||||
if (!form.id.trim()) {
|
||||
authzHint = '先填写 MCP ID,Audience 会自动生成为 mcp:<id>。';
|
||||
} else if (!authzStatus?.enabled) {
|
||||
authzHint = '当前 workspace 没启用 AuthZ,选择 oauth_backend_token 后将无法申请访问 token。';
|
||||
} else if (!authzStatus.local_backend.registered) {
|
||||
authzHint = '当前 backend 还没有在 AuthZ 注册,暂时无法读取权限或申请 token。';
|
||||
} else if (authzStatus.error) {
|
||||
authzHint = `读取 AuthZ 权限失败:${authzStatus.error}`;
|
||||
} else if (!authzMcpScopes.available || !authzMcpScopes.enabled) {
|
||||
authzHint = `AuthZ 里还没有为 ${authAudience || '这个 MCP'} 开启权限,保存后调用会返回 403。`;
|
||||
} else {
|
||||
authzHint = `已从 AuthZ 读取到 ${authAudience} 的当前权限。`;
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<ServerCog className="w-6 h-6" />
|
||||
MCP 服务
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
管理 MCP 服务配置、连通性和当前已发现的工具。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => void load(true)}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
<Dialog open={dialogOpen} onOpenChange={(open) => {
|
||||
setDialogOpen(open);
|
||||
if (!open) resetForm();
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
新增 MCP
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingId ? '编辑 MCP 服务' : '新增 MCP 服务'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<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} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tool_timeout">工具超时</Label>
|
||||
<Input id="tool_timeout" type="number" min="1" value={form.tool_timeout} onChange={(e) => setForm((s) => ({ ...s, tool_timeout: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<Tabs
|
||||
value={form.mode}
|
||||
onValueChange={(value) => setForm((s) => ({ ...s, mode: value as McpFormMode }))}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label>接入方式</Label>
|
||||
<TabsList className="grid h-auto w-full grid-cols-1 gap-2 bg-transparent p-0 sm:grid-cols-2">
|
||||
<TabsTrigger
|
||||
value="remote"
|
||||
className="h-full flex-col items-start gap-1 rounded-lg border border-border/70 bg-background/80 px-4 py-3 text-left whitespace-normal data-[state=active]:border-primary"
|
||||
>
|
||||
<span className="text-sm font-medium">连接已有 MCP Server</span>
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
适合已经部署好的远程 MCP 服务,填写 URL、请求头和鉴权即可。
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="install"
|
||||
className="h-full flex-col items-start gap-1 rounded-lg border border-border/70 bg-background/80 px-4 py-3 text-left whitespace-normal data-[state=active]:border-primary"
|
||||
>
|
||||
<span className="text-sm font-medium">命令安装并启动</span>
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
适合本机通过 `npx`、`uvx` 或其他命令启动 MCP 进程。
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="remote" className="mt-0 rounded-lg border border-border/70 p-4 space-y-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
连接一个已经存在的 MCP Server,前端只保存访问地址、请求头和鉴权配置。
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="url">MCP Server 地址</Label>
|
||||
<Input
|
||||
id="url"
|
||||
value={form.url}
|
||||
onChange={(e) => setForm((s) => ({ ...s, url: e.target.value }))}
|
||||
placeholder="http://localhost:3001/mcp"
|
||||
required={form.mode === 'remote'}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="auth_mode">鉴权模式</Label>
|
||||
<select
|
||||
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"
|
||||
>
|
||||
<option value="none">none</option>
|
||||
<option value="oauth_backend_token">oauth_backend_token</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label>AuthZ 权限</Label>
|
||||
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-3 text-sm space-y-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground">Audience</span>
|
||||
<span className="font-mono text-xs break-all">
|
||||
{showAuthzPreview ? (authAudience || '填写 MCP ID 后自动生成') : '关闭鉴权时无需配置'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground">Scopes</span>
|
||||
<span className="text-xs break-words">
|
||||
{showAuthzPreview
|
||||
? (authzMcpScopes.scopes.length > 0 ? authzMcpScopes.scopes.join(', ') : '由 AuthZ 当前权限动态决定')
|
||||
: '关闭鉴权时无需配置'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{authzHint}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="headers">请求头 JSON</Label>
|
||||
<Textarea
|
||||
id="headers"
|
||||
rows={8}
|
||||
value={form.headers}
|
||||
onChange={(e) => setForm((s) => ({ ...s, headers: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="install" className="mt-0 rounded-lg border border-border/70 p-4 space-y-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
通过命令安装并启动本地 MCP 进程,适合 `npx`、`uvx`、脚本或容器方式。
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="command">命令</Label>
|
||||
<Input
|
||||
id="command"
|
||||
value={form.command}
|
||||
onChange={(e) => setForm((s) => ({ ...s, command: e.target.value }))}
|
||||
placeholder="npx"
|
||||
required={form.mode === 'install'}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="args">参数</Label>
|
||||
<Input
|
||||
id="args"
|
||||
value={form.args}
|
||||
onChange={(e) => setForm((s) => ({ ...s, args: e.target.value }))}
|
||||
placeholder="-y @modelcontextprotocol/server-github"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Plus className="w-4 h-4 mr-2" />}
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-destructive text-sm">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{error}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)] gap-4">
|
||||
<div className="space-y-4">
|
||||
{servers.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',
|
||||
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>
|
||||
<div className="flex items-center gap-2 flex-wrap justify-end">
|
||||
<Badge variant="outline">{transportLabel(server.transport)}</Badge>
|
||||
<Badge variant={server.status === 'connected' ? 'default' : server.status === 'error' ? 'destructive' : 'secondary'}>
|
||||
{serverStatusLabel(server.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-3 text-sm">
|
||||
{server.url && <div><span className="font-medium">URL:</span> <span className="text-muted-foreground break-all">{server.url}</span></div>}
|
||||
{server.command && <div><span className="font-medium">命令:</span> <span className="text-muted-foreground">{server.command} {(server.args || []).join(' ')}</span></div>}
|
||||
{server.auth_mode && server.auth_mode !== 'none' && <div><span className="font-medium">鉴权:</span> <span className="text-muted-foreground">{server.auth_mode}</span></div>}
|
||||
{(server.auth_audience || server.auth_mode === 'oauth_backend_token') && (
|
||||
<div><span className="font-medium">Audience:</span> <span className="text-muted-foreground">{server.auth_audience || resolveAuthAudience(server.id)}</span></div>
|
||||
)}
|
||||
{(server.auth_scopes || []).length > 0 && <div><span className="font-medium">Scopes:</span> <span className="text-muted-foreground break-all">{(server.auth_scopes || []).join(', ')}</span></div>}
|
||||
{server.auth_mode === 'oauth_backend_token' && (!server.auth_scopes || server.auth_scopes.length === 0) && (
|
||||
<div><span className="font-medium">Scopes:</span> <span className="text-muted-foreground">由 AuthZ 动态决定</span></div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
|
||||
<span>{server.tool_count || 0} 个工具</span>
|
||||
<span>{selectedServerId === server.id ? '已选中' : '点击查看工具'}</span>
|
||||
{server.last_error && <span className="text-rose-300">{server.last_error}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Button variant="outline" size="sm" onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openEdit(server);
|
||||
}}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleTest(server.id);
|
||||
}} disabled={testingId === server.id}>
|
||||
{testingId === server.id ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <TestTube2 className="w-4 h-4 mr-2" />}
|
||||
测试
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleDelete(server.id);
|
||||
}}>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{servers.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
暂无 MCP 服务。
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Wrench className="w-4 h-4" />
|
||||
{selectedServer ? `${selectedServer.name} 的工具` : 'MCP 工具'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!selectedServer && (
|
||||
<div className="py-10 text-sm text-muted-foreground text-center">
|
||||
点击左侧 MCP 服务后,这里才会显示对应的已发现工具。
|
||||
</div>
|
||||
)}
|
||||
{selectedServer && !selectedToolGroup && (
|
||||
<div className="text-sm text-muted-foreground">这个 MCP 暂时还没有发现任何工具。</div>
|
||||
)}
|
||||
{selectedToolGroup && (
|
||||
<div className="space-y-2">
|
||||
<div className="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">
|
||||
{String(tool.description || '—')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1243
app-instance/frontend/app/(app)/outlook/page.tsx
Normal file
1243
app-instance/frontend/app/(app)/outlook/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
637
app-instance/frontend/app/(app)/page.tsx
Normal file
637
app-instance/frontend/app/(app)/page.tsx
Normal file
@ -0,0 +1,637 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { MessageSquare, Paperclip, Plus, Send, Trash2, X } from 'lucide-react';
|
||||
|
||||
import { ChatWorkbench } from '@/components/chat-workbench/ChatWorkbench';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
cancelDelegation,
|
||||
deleteSession,
|
||||
getSession,
|
||||
getStatus,
|
||||
listCommands,
|
||||
listSessions,
|
||||
sendMessage,
|
||||
uploadFile,
|
||||
wsManager,
|
||||
} from '@/lib/api';
|
||||
import { useChatStore } from '@/lib/store';
|
||||
import type { ChatMessage, FileAttachment, ProcessWsEvent, SlashCommand, WsEvent } from '@/types';
|
||||
|
||||
function scheduleWhenIdle(task: () => void, timeout = 1200): () => void {
|
||||
if (typeof window === 'undefined') {
|
||||
task();
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const idleWindow = window as Window &
|
||||
typeof globalThis & {
|
||||
requestIdleCallback?: (callback: IdleRequestCallback, options?: IdleRequestOptions) => number;
|
||||
cancelIdleCallback?: (handle: number) => void;
|
||||
};
|
||||
|
||||
if (typeof idleWindow.requestIdleCallback === 'function') {
|
||||
const id = idleWindow.requestIdleCallback(() => task(), { timeout });
|
||||
return () => idleWindow.cancelIdleCallback?.(id);
|
||||
}
|
||||
|
||||
const id = globalThis.setTimeout(task, 250);
|
||||
return () => globalThis.clearTimeout(id);
|
||||
}
|
||||
|
||||
function messageFingerprint(msg: ChatMessage): string {
|
||||
const attachmentKey = (msg.attachments ?? [])
|
||||
.map((a) => `${a.file_id ?? ''}:${a.name}:${a.content_type}:${a.size ?? ''}`)
|
||||
.join('|');
|
||||
return `${msg.role}::${String(msg.content)}::${attachmentKey}`;
|
||||
}
|
||||
|
||||
function mergeServerWithPendingUsers(serverMessages: ChatMessage[], localMessages: ChatMessage[]): ChatMessage[] {
|
||||
const counts = new Map<string, number>();
|
||||
for (const message of serverMessages) {
|
||||
const key = messageFingerprint(message);
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const pendingUsers: ChatMessage[] = [];
|
||||
for (const message of localMessages) {
|
||||
const key = messageFingerprint(message);
|
||||
const count = counts.get(key) ?? 0;
|
||||
if (count > 0) {
|
||||
counts.set(key, count - 1);
|
||||
continue;
|
||||
}
|
||||
if (message.role === 'user') {
|
||||
pendingUsers.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
return [...serverMessages, ...pendingUsers];
|
||||
}
|
||||
|
||||
function isProcessEvent(data: WsEvent | Record<string, unknown>): data is ProcessWsEvent {
|
||||
const type = typeof data.type === 'string' ? data.type : '';
|
||||
return type.startsWith('process_') || type === 'process_cancel_ack';
|
||||
}
|
||||
|
||||
export default function ChatPage() {
|
||||
const {
|
||||
sessionId,
|
||||
messages,
|
||||
isLoading,
|
||||
isThinking,
|
||||
sessions,
|
||||
processRuns,
|
||||
processEvents,
|
||||
processArtifacts,
|
||||
selectedRunId,
|
||||
setSessionId,
|
||||
setMessages,
|
||||
addMessage,
|
||||
setIsLoading,
|
||||
setSessions,
|
||||
clearMessages,
|
||||
setWsStatus,
|
||||
setIsThinking,
|
||||
setNanobotReady,
|
||||
resetProcessState,
|
||||
ingestProcessEvent,
|
||||
setSelectedRunId,
|
||||
} = useChatStore();
|
||||
|
||||
const [input, setInput] = useState('');
|
||||
const [commands, setCommands] = useState<SlashCommand[]>([]);
|
||||
const [showCommandPicker, setShowCommandPicker] = useState(false);
|
||||
const [pickerIndex, setPickerIndex] = useState(0);
|
||||
const [pendingFiles, setPendingFiles] = useState<Array<{ file: File; id?: string; progress: number; error?: string }>>([]);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const messageViewportRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const pickerRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const loadSessionReqSeq = useRef(0);
|
||||
const commandsLoadedRef = useRef(false);
|
||||
const refreshSessionOnReconnectRef = useRef(false);
|
||||
const hasConnectedRef = useRef(false);
|
||||
const statusCheckCleanupRef = useRef<(() => void) | null>(null);
|
||||
const statusCheckInFlightRef = useRef(false);
|
||||
const shouldSnapToLatestRef = useRef(true);
|
||||
|
||||
const filteredCommands = useMemo(() => {
|
||||
if (!input.startsWith('/') || input.includes(' ')) return [];
|
||||
const filter = input.slice(1).toLowerCase();
|
||||
return commands.filter(
|
||||
(command) => command.name.startsWith(filter) || (filter === '' ? true : command.name.includes(filter))
|
||||
);
|
||||
}, [commands, input]);
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
try {
|
||||
const list = await listSessions();
|
||||
setSessions(list);
|
||||
} catch {
|
||||
// backend may be offline during first render
|
||||
}
|
||||
}, [setSessions]);
|
||||
|
||||
const loadSessionMessages = useCallback(async (key: string) => {
|
||||
const reqSeq = ++loadSessionReqSeq.current;
|
||||
const localSnapshot = useChatStore.getState().messages;
|
||||
const waitingForReply = useChatStore.getState().isLoading || useChatStore.getState().isThinking;
|
||||
try {
|
||||
const detail = await getSession(key);
|
||||
if (reqSeq !== loadSessionReqSeq.current) return;
|
||||
if (useChatStore.getState().sessionId !== key) return;
|
||||
const nextMessages = waitingForReply
|
||||
? mergeServerWithPendingUsers(detail.messages, localSnapshot)
|
||||
: detail.messages;
|
||||
setMessages(nextMessages);
|
||||
const last = nextMessages[nextMessages.length - 1];
|
||||
if (last?.role === 'assistant') {
|
||||
setIsThinking(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch {
|
||||
if (reqSeq !== loadSessionReqSeq.current) return;
|
||||
if (useChatStore.getState().sessionId !== key) return;
|
||||
}
|
||||
}, [setIsLoading, setIsThinking, setMessages]);
|
||||
|
||||
const loadCommands = useCallback(async () => {
|
||||
if (commandsLoadedRef.current) return;
|
||||
commandsLoadedRef.current = true;
|
||||
try {
|
||||
const nextCommands = await listCommands();
|
||||
setCommands(nextCommands);
|
||||
} catch {
|
||||
commandsLoadedRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scheduleStatusCheck = useCallback(() => {
|
||||
if (statusCheckInFlightRef.current) return;
|
||||
|
||||
statusCheckCleanupRef.current?.();
|
||||
statusCheckCleanupRef.current = scheduleWhenIdle(async () => {
|
||||
statusCheckInFlightRef.current = true;
|
||||
try {
|
||||
await getStatus();
|
||||
setNanobotReady(true);
|
||||
} catch {
|
||||
setNanobotReady(false);
|
||||
} finally {
|
||||
statusCheckInFlightRef.current = false;
|
||||
}
|
||||
});
|
||||
}, [setNanobotReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (input.startsWith('/') && !input.includes(' ')) {
|
||||
void loadCommands();
|
||||
}
|
||||
}, [input, loadCommands]);
|
||||
|
||||
useEffect(() => {
|
||||
setShowCommandPicker(filteredCommands.length > 0);
|
||||
setPickerIndex(0);
|
||||
}, [filteredCommands]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
}, [loadSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
resetProcessState();
|
||||
const wsSessionId = sessionId.startsWith('web:') ? sessionId.slice(4) : sessionId;
|
||||
wsManager.connect(wsSessionId);
|
||||
loadSessionMessages(sessionId);
|
||||
}, [loadSessionMessages, resetProcessState, sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubStatus = wsManager.onStatusChange(async (status) => {
|
||||
setWsStatus(status);
|
||||
if (status === 'connected') {
|
||||
if (hasConnectedRef.current && refreshSessionOnReconnectRef.current) {
|
||||
refreshSessionOnReconnectRef.current = false;
|
||||
void loadSessionMessages(useChatStore.getState().sessionId);
|
||||
}
|
||||
hasConnectedRef.current = true;
|
||||
scheduleStatusCheck();
|
||||
} else {
|
||||
if (status === 'disconnected' && hasConnectedRef.current) {
|
||||
refreshSessionOnReconnectRef.current = true;
|
||||
}
|
||||
statusCheckCleanupRef.current?.();
|
||||
statusCheckCleanupRef.current = null;
|
||||
setNanobotReady(null);
|
||||
}
|
||||
});
|
||||
|
||||
const unsubMessage = wsManager.onMessage((data) => {
|
||||
if (isProcessEvent(data)) {
|
||||
ingestProcessEvent(data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'status' && data.status === 'thinking') {
|
||||
setIsThinking(true);
|
||||
} else if (data.type === 'message' && data.role === 'assistant') {
|
||||
setIsThinking(false);
|
||||
setIsLoading(false);
|
||||
addMessage({
|
||||
role: 'assistant',
|
||||
content: typeof data.content === 'string' ? data.content : '',
|
||||
timestamp: new Date().toISOString(),
|
||||
attachments: Array.isArray(data.attachments) ? data.attachments : undefined,
|
||||
});
|
||||
loadSessions();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
statusCheckCleanupRef.current?.();
|
||||
statusCheckCleanupRef.current = null;
|
||||
unsubStatus();
|
||||
unsubMessage();
|
||||
};
|
||||
}, [addMessage, ingestProcessEvent, loadSessionMessages, loadSessions, scheduleStatusCheck, setIsLoading, setIsThinking, setNanobotReady, setWsStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isThinking) {
|
||||
return;
|
||||
}
|
||||
const timer = setInterval(() => {
|
||||
loadSessionMessages(useChatStore.getState().sessionId);
|
||||
}, 1500);
|
||||
return () => clearInterval(timer);
|
||||
}, [isLoading, isThinking, loadSessionMessages]);
|
||||
|
||||
const scrollMessagesToLatest = useCallback((behavior: ScrollBehavior) => {
|
||||
const viewport = messageViewportRef.current;
|
||||
if (!viewport) return;
|
||||
viewport.scrollTo({ top: viewport.scrollHeight, behavior });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
shouldSnapToLatestRef.current = true;
|
||||
}, [sessionId]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (messages.length === 0 && !isThinking && processEvents.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollMessagesToLatest(shouldSnapToLatestRef.current ? 'auto' : 'smooth');
|
||||
shouldSnapToLatestRef.current = false;
|
||||
}, [isThinking, messages, processEvents, scrollMessagesToLatest]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showCommandPicker || !pickerRef.current) return;
|
||||
const item = pickerRef.current.children[pickerIndex] as HTMLElement | undefined;
|
||||
item?.scrollIntoView({ block: 'nearest' });
|
||||
}, [pickerIndex, showCommandPicker]);
|
||||
|
||||
const selectCommand = useCallback((command: SlashCommand) => {
|
||||
setInput(command.argument_hint ? `/${command.name} ` : `/${command.name}`);
|
||||
setShowCommandPicker(false);
|
||||
textareaRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const text = input.trim();
|
||||
if ((!text && pendingFiles.length === 0) || isLoading) return;
|
||||
|
||||
const readyFiles = pendingFiles.filter((p) => p.id && !p.error);
|
||||
const attachments: FileAttachment[] = readyFiles.map((item) => ({
|
||||
file_id: item.id!,
|
||||
name: item.file.name,
|
||||
content_type: item.file.type || 'application/octet-stream',
|
||||
size: item.file.size,
|
||||
}));
|
||||
|
||||
setInput('');
|
||||
setPendingFiles([]);
|
||||
setShowCommandPicker(false);
|
||||
|
||||
const msgContent = text || '(仅附件)';
|
||||
addMessage({
|
||||
role: 'user',
|
||||
content: msgContent,
|
||||
timestamp: new Date().toISOString(),
|
||||
attachments: attachments.length > 0 ? attachments : undefined,
|
||||
});
|
||||
setIsLoading(true);
|
||||
setIsThinking(false);
|
||||
|
||||
if (wsManager.getStatus() === 'connected') {
|
||||
const wsPayload: Record<string, unknown> = { type: 'message', content: msgContent };
|
||||
if (attachments.length > 0) {
|
||||
wsPayload.attachments = attachments;
|
||||
}
|
||||
wsManager.sendRaw(wsPayload);
|
||||
} else {
|
||||
try {
|
||||
const result = await sendMessage(msgContent, sessionId, attachments.length > 0 ? attachments : undefined);
|
||||
setIsThinking(false);
|
||||
setIsLoading(false);
|
||||
if (result.response) {
|
||||
addMessage({
|
||||
role: 'assistant',
|
||||
content: result.response,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
loadSessions();
|
||||
} else {
|
||||
await loadSessionMessages(sessionId);
|
||||
loadSessions();
|
||||
}
|
||||
} catch {
|
||||
setIsThinking(false);
|
||||
setIsLoading(false);
|
||||
addMessage({
|
||||
role: 'assistant',
|
||||
content: '发送失败,请检查后端服务是否正在运行。',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [addMessage, input, isLoading, loadSessionMessages, loadSessions, pendingFiles, sessionId, setIsLoading, setIsThinking]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (showCommandPicker && filteredCommands.length > 0) {
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setPickerIndex((i) => (i <= 0 ? filteredCommands.length - 1 : i - 1));
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setPickerIndex((i) => (i >= filteredCommands.length - 1 ? 0 : i + 1));
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing)) {
|
||||
e.preventDefault();
|
||||
selectCommand(filteredCommands[pickerIndex]);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setShowCommandPicker(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (!files.length) return;
|
||||
e.target.value = '';
|
||||
|
||||
for (const file of files) {
|
||||
if (file.size > 50 * 1024 * 1024) {
|
||||
setPendingFiles((prev) => [...prev, { file, progress: 0, error: '文件过大(最大 50MB)' }]);
|
||||
continue;
|
||||
}
|
||||
|
||||
setPendingFiles((prev) => [...prev, { file, progress: 0 }]);
|
||||
try {
|
||||
const result = await uploadFile(file, sessionId, (pct) => {
|
||||
setPendingFiles((prev) => prev.map((item) => (item.file === file ? { ...item, progress: pct } : item)));
|
||||
});
|
||||
setPendingFiles((prev) => prev.map((item) => (item.file === file ? { ...item, id: result.file_id, progress: 100 } : item)));
|
||||
} catch (err: any) {
|
||||
setPendingFiles((prev) => prev.map((item) => (item.file === file ? { ...item, error: err.message || '上传失败' } : item)));
|
||||
}
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
const handleNewSession = () => {
|
||||
const id = `web:${Date.now()}`;
|
||||
setSessionId(id);
|
||||
clearMessages();
|
||||
resetProcessState();
|
||||
};
|
||||
|
||||
const handleDeleteSession = async (key: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await deleteSession(key);
|
||||
if (key === sessionId) {
|
||||
setSessionId('web:default');
|
||||
clearMessages();
|
||||
resetProcessState();
|
||||
}
|
||||
loadSessions();
|
||||
} catch {
|
||||
// ignore transient errors
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectSession = (key: string) => {
|
||||
setSessionId(key);
|
||||
};
|
||||
|
||||
const handleCancelRun = async (runId: string) => {
|
||||
try {
|
||||
await cancelDelegation(runId);
|
||||
} catch (err: any) {
|
||||
addMessage({
|
||||
role: 'assistant',
|
||||
content: `取消任务 ${runId} 失败:${err.message || '未知错误'}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const removePendingFile = useCallback((file: File) => {
|
||||
setPendingFiles((prev) => prev.filter((item) => item.file !== file));
|
||||
}, []);
|
||||
|
||||
const formatSessionName = (key: string) => {
|
||||
if (key.startsWith('web:')) {
|
||||
const id = key.slice(4);
|
||||
if (id === 'default') return '默认';
|
||||
const numeric = Number(id);
|
||||
if (!Number.isNaN(numeric)) {
|
||||
return new Date(numeric).toLocaleDateString('zh-CN', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
return id;
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-3.5rem)] bg-background">
|
||||
<div className="w-64 border-r border-border flex flex-col bg-card">
|
||||
<div className="p-3">
|
||||
<Button onClick={handleNewSession} variant="outline" className="w-full justify-start gap-2" size="sm">
|
||||
<Plus className="w-4 h-4" />
|
||||
新对话
|
||||
</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2 space-y-1">
|
||||
{sessions.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground px-2 py-4 text-center">暂无对话记录</p>
|
||||
)}
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.key}
|
||||
onClick={() => handleSelectSession(session.key)}
|
||||
className={`group flex items-center justify-between px-2 py-1.5 rounded-md cursor-pointer text-sm ${
|
||||
session.key === sessionId
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<MessageSquare className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="truncate">{formatSessionName(session.key)}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(event) => handleDeleteSession(session.key, event)}
|
||||
className="opacity-0 group-hover:opacity-100 p-0.5 hover:text-destructive transition-opacity"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="flex-1 min-h-0">
|
||||
<ChatWorkbench
|
||||
messages={messages}
|
||||
isThinking={isThinking || (isLoading && messages[messages.length - 1]?.role === 'user')}
|
||||
messagesEndRef={messagesEndRef}
|
||||
messageViewportRef={messageViewportRef}
|
||||
processRuns={processRuns}
|
||||
processEvents={processEvents}
|
||||
processArtifacts={processArtifacts}
|
||||
selectedRunId={selectedRunId}
|
||||
onSelectRun={setSelectedRunId}
|
||||
onCancelRun={handleCancelRun}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border p-4 bg-background/95 backdrop-blur">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{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">
|
||||
<span className="truncate flex-1">
|
||||
{item.file.name}{' '}
|
||||
<span className="text-muted-foreground">({(item.file.size / 1024).toFixed(0)}KB)</span>
|
||||
</span>
|
||||
{item.error ? (
|
||||
<span className="text-destructive text-xs">{item.error}</span>
|
||||
) : item.progress < 100 ? (
|
||||
<div className="w-20 h-1.5 bg-muted-foreground/20 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-primary rounded-full transition-all" style={{ width: `${item.progress}%` }} />
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-green-500 text-xs">就绪</span>
|
||||
)}
|
||||
<button onClick={() => removePendingFile(item.file)} className="text-muted-foreground hover:text-foreground">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative flex gap-2">
|
||||
{showCommandPicker && filteredCommands.length > 0 && (
|
||||
<div
|
||||
ref={pickerRef}
|
||||
className="absolute bottom-full left-0 right-10 mb-2 bg-popover border border-border rounded-lg shadow-lg overflow-y-auto max-h-60 z-50"
|
||||
>
|
||||
{filteredCommands.map((command, index) => (
|
||||
<button
|
||||
key={command.name}
|
||||
className={`w-full text-left px-3 py-2 flex items-center gap-2 text-sm transition-colors ${
|
||||
index === pickerIndex
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'hover:bg-accent/50 text-foreground'
|
||||
}`}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
selectCommand(command);
|
||||
}}
|
||||
onMouseEnter={() => setPickerIndex(index)}
|
||||
>
|
||||
<span className="font-mono font-semibold text-primary shrink-0">/{command.name}</span>
|
||||
{command.argument_hint && (
|
||||
<span className="text-muted-foreground text-xs shrink-0">{command.argument_hint}</span>
|
||||
)}
|
||||
<span className="text-muted-foreground text-xs truncate ml-auto">{command.description}</span>
|
||||
{command.plugin_name !== 'builtin' && (
|
||||
<span className={`text-xs px-1 rounded shrink-0 ${command.plugin_name === 'skill' ? 'bg-blue-500/10 text-blue-500' : 'bg-muted'}`}>
|
||||
{command.plugin_name === 'skill' ? '技能' : command.plugin_name}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileSelect} />
|
||||
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10 flex-shrink-0"
|
||||
title="添加附件"
|
||||
>
|
||||
<Paperclip className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="输入消息或 / 呼出命令…(回车发送,Shift+回车换行)"
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
style={{ minHeight: '40px', maxHeight: '200px' }}
|
||||
onInput={(e) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
target.style.height = 'auto';
|
||||
target.style.height = `${Math.min(target.scrollHeight, 200)}px`;
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={(!input.trim() && pendingFiles.filter((item) => item.id && !item.error).length === 0) || isLoading}
|
||||
size="icon"
|
||||
className="h-10 w-10 flex-shrink-0"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
289
app-instance/frontend/app/(app)/plugins/page.tsx
Normal file
289
app-instance/frontend/app/(app)/plugins/page.tsx
Normal file
@ -0,0 +1,289 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Blocks,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Bot,
|
||||
Terminal,
|
||||
Wrench,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Globe,
|
||||
FolderOpen,
|
||||
} from 'lucide-react';
|
||||
import { listPlugins } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { PluginInfo } from '@/types';
|
||||
|
||||
export default function PluginsPage() {
|
||||
const [plugins, setPlugins] = useState<PluginInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await listPlugins();
|
||||
setPlugins(Array.isArray(data) ? data : []);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '加载插件失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto p-6 space-y-6">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Blocks className="w-6 h-6" />
|
||||
插件
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
已安装位置:{' '}
|
||||
<code className="text-xs bg-muted px-1 py-0.5 rounded">~/.nanobot/plugins/</code>
|
||||
{' '}或{' '}
|
||||
<code className="text-xs bg-muted px-1 py-0.5 rounded"><workspace>/plugins/</code>
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={load} variant="outline" size="sm">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-destructive text-sm">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{error}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!error && plugins.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-16 text-center text-muted-foreground">
|
||||
<Blocks className="w-12 h-12 mx-auto mb-4 opacity-30" />
|
||||
<p className="font-medium">还没有安装任何插件</p>
|
||||
<p className="text-sm mt-2 max-w-sm mx-auto">
|
||||
把插件目录放到 <code className="text-xs bg-muted px-1 py-0.5 rounded">~/.nanobot/plugins/</code>,
|
||||
然后重启 Boardware Genius。
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Plugin cards */}
|
||||
<div className="space-y-4">
|
||||
{plugins.map((plugin) => (
|
||||
<PluginCard key={plugin.name} plugin={plugin} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginCard({ plugin }: { plugin: PluginInfo }) {
|
||||
const [agentsOpen, setAgentsOpen] = useState(true);
|
||||
const [commandsOpen, setCommandsOpen] = useState(true);
|
||||
const [skillsOpen, setSkillsOpen] = useState(false);
|
||||
|
||||
const totalItems = plugin.agents.length + plugin.commands.length + plugin.skills.length;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<CardTitle className="text-base font-semibold">{plugin.name}</CardTitle>
|
||||
<SourceBadge source={plugin.source} />
|
||||
</div>
|
||||
{plugin.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1 leading-relaxed">
|
||||
{plugin.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Summary chips */}
|
||||
<div className="flex items-center gap-1.5 shrink-0 flex-wrap justify-end">
|
||||
{plugin.agents.length > 0 && (
|
||||
<span className="flex items-center gap-1 text-xs bg-muted px-2 py-0.5 rounded-full">
|
||||
<Bot className="w-3 h-3" />
|
||||
{plugin.agents.length} 个智能体
|
||||
</span>
|
||||
)}
|
||||
{plugin.commands.length > 0 && (
|
||||
<span className="flex items-center gap-1 text-xs bg-muted px-2 py-0.5 rounded-full">
|
||||
<Terminal className="w-3 h-3" />
|
||||
{plugin.commands.length} 条命令
|
||||
</span>
|
||||
)}
|
||||
{plugin.skills.length > 0 && (
|
||||
<span className="flex items-center gap-1 text-xs bg-muted px-2 py-0.5 rounded-full">
|
||||
<Wrench className="w-3 h-3" />
|
||||
{plugin.skills.length} 个技能
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{totalItems > 0 && (
|
||||
<CardContent className="pt-0 space-y-3">
|
||||
{/* Agents */}
|
||||
{plugin.agents.length > 0 && (
|
||||
<Section
|
||||
icon={<Bot className="w-3.5 h-3.5" />}
|
||||
label="智能体"
|
||||
count={plugin.agents.length}
|
||||
open={agentsOpen}
|
||||
onToggle={() => setAgentsOpen((v) => !v)}
|
||||
>
|
||||
<div className="divide-y divide-border rounded-md border">
|
||||
{plugin.agents.map((agent) => (
|
||||
<div key={agent.name} className="px-3 py-2 flex items-start gap-3">
|
||||
<code className="text-xs font-mono text-primary shrink-0 mt-0.5">
|
||||
{agent.name}
|
||||
</code>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">{agent.description || '—'}</p>
|
||||
</div>
|
||||
{agent.model && (
|
||||
<Badge variant="outline" className="text-xs shrink-0">
|
||||
{agent.model}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Commands */}
|
||||
{plugin.commands.length > 0 && (
|
||||
<Section
|
||||
icon={<Terminal className="w-3.5 h-3.5" />}
|
||||
label="命令"
|
||||
count={plugin.commands.length}
|
||||
open={commandsOpen}
|
||||
onToggle={() => setCommandsOpen((v) => !v)}
|
||||
>
|
||||
<div className="divide-y divide-border rounded-md border">
|
||||
{plugin.commands.map((cmd) => (
|
||||
<div key={cmd.name} className="px-3 py-2 flex items-start gap-3">
|
||||
<div className="flex items-center gap-1.5 shrink-0 mt-0.5">
|
||||
<code className="text-xs font-mono text-primary">/{cmd.name}</code>
|
||||
{cmd.argument_hint && (
|
||||
<span className="text-xs text-muted-foreground">{cmd.argument_hint}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
|
||||
{cmd.description || '—'}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Skills */}
|
||||
{plugin.skills.length > 0 && (
|
||||
<Section
|
||||
icon={<Wrench className="w-3.5 h-3.5" />}
|
||||
label="技能"
|
||||
count={plugin.skills.length}
|
||||
open={skillsOpen}
|
||||
onToggle={() => setSkillsOpen((v) => !v)}
|
||||
>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{plugin.skills.map((skill) => (
|
||||
<Badge key={skill} variant="secondary" className="text-xs font-mono">
|
||||
{skill}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceBadge({ source }: { source: 'global' | 'workspace' }) {
|
||||
if (source === 'workspace') {
|
||||
return (
|
||||
<Badge variant="default" className="text-xs gap-1">
|
||||
<FolderOpen className="w-3 h-3" />
|
||||
工作区
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="secondary" className="text-xs gap-1">
|
||||
<Globe className="w-3 h-3" />
|
||||
全局
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
icon,
|
||||
label,
|
||||
count,
|
||||
open,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
count: number;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors mb-2 w-full text-left"
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{icon}
|
||||
{label}
|
||||
<span className="ml-1 text-muted-foreground/60">({count})</span>
|
||||
</button>
|
||||
{open && children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
311
app-instance/frontend/app/(app)/skills/page.tsx
Normal file
311
app-instance/frontend/app/(app)/skills/page.tsx
Normal file
@ -0,0 +1,311 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
Puzzle,
|
||||
Upload,
|
||||
Download,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { listSkills, deleteSkill, uploadSkill, downloadSkill } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import type { Skill } from '@/types';
|
||||
|
||||
export default function SkillsPage() {
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
|
||||
const loadSkills = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await listSkills();
|
||||
setSkills(Array.isArray(data) ? data : []);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '加载技能失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadSkills();
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (name: string) => {
|
||||
setDeleting(name);
|
||||
};
|
||||
|
||||
const confirmDelete = async (name: string) => {
|
||||
try {
|
||||
await deleteSkill(name);
|
||||
setDeleting(null);
|
||||
loadSkills();
|
||||
} catch (err: any) {
|
||||
setError(err.message || '删除技能失败');
|
||||
setDeleting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadDone = () => {
|
||||
setShowUpload(false);
|
||||
loadSkills();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Puzzle className="w-6 h-6" />
|
||||
技能
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={loadSkills} variant="outline" size="sm">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
<Button onClick={() => setShowUpload(true)} size="sm">
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
上传技能
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-destructive text-sm">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{error}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Upload Dialog */}
|
||||
{showUpload && (
|
||||
<UploadSkillForm
|
||||
onDone={handleUploadDone}
|
||||
onCancel={() => setShowUpload(false)}
|
||||
onError={(msg) => setError(msg)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
{deleting && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm">
|
||||
确定删除技能 <strong>{deleting}</strong> 吗?此操作不可撤销。
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDeleting(null)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => confirmDelete(deleting)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Skills Table */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{skills.length === 0 ? (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
<Puzzle className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="font-medium">暂无技能</p>
|
||||
<p className="text-sm mt-1">上传一个技能 zip 包即可开始使用。</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
<TableHead>来源</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead className="w-24">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{skills.map((skill) => (
|
||||
<TableRow key={`${skill.source}:${skill.name}`}>
|
||||
<TableCell className="font-medium">{skill.name}</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-muted-foreground truncate max-w-[300px] block">
|
||||
{skill.description}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{skill.source === 'builtin' ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
内置
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="default" className="text-xs">
|
||||
工作区
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{skill.available ? (
|
||||
<Badge variant="default" className="text-xs bg-green-600">
|
||||
可用
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs text-muted-foreground">
|
||||
不可用
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
title="下载"
|
||||
onClick={() => downloadSkill(skill.name).catch((e) => setError(e.message))}
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
{skill.source === 'workspace' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(skill.name)}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadSkillForm({
|
||||
onDone,
|
||||
onCancel,
|
||||
onError,
|
||||
}: {
|
||||
onDone: () => void;
|
||||
onCancel: () => void;
|
||||
onError: (msg: string) => void;
|
||||
}) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const file = fileRef.current?.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
await uploadSkill(file);
|
||||
onDone();
|
||||
} catch (err: any) {
|
||||
onError(err.message || '上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">上传技能</CardTitle>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onCancel}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="skill-zip">
|
||||
技能压缩包
|
||||
</label>
|
||||
<input
|
||||
id="skill-zip"
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
className="block w-full text-sm text-muted-foreground file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary file:text-primary-foreground hover:file:bg-primary/90 cursor-pointer"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
压缩包中必须包含 `SKILL.md` 文件
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={uploading}>
|
||||
{uploading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
上传中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
上传
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
235
app-instance/frontend/app/(app)/status/page.tsx
Normal file
235
app-instance/frontend/app/(app)/status/page.tsx
Normal file
@ -0,0 +1,235 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Cpu,
|
||||
Radio,
|
||||
Key,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { getStatus } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import type { SystemStatus } from '@/types';
|
||||
|
||||
export default function StatusPage() {
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadStatus = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getStatus();
|
||||
setStatus(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '连接后端失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadStatus();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto 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>
|
||||
<p className="font-medium">无法连接到 Boardware Genius 后端</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">{error}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
请确认后端已启动:<code className="bg-muted px-1 rounded">nanobot web</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={loadStatus} variant="outline" size="sm" className="mt-4">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
重试
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status) return null;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">系统状态</h1>
|
||||
<Button onClick={loadStatus} variant="outline" size="sm">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* System Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Server className="w-4 h-4" />
|
||||
系统信息
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<InfoRow
|
||||
label="配置"
|
||||
value={status.config_path}
|
||||
ok={status.config_exists}
|
||||
/>
|
||||
<InfoRow
|
||||
label="工作区"
|
||||
value={status.workspace}
|
||||
ok={status.workspace_exists}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Model Config */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Cpu className="w-4 h-4" />
|
||||
智能体配置
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<InfoRow label="模型" value={status.model} />
|
||||
<InfoRow label="最大令牌数" value={String(status.max_tokens)} />
|
||||
<InfoRow label="温度" value={String(status.temperature)} />
|
||||
<InfoRow
|
||||
label="最大工具迭代次数"
|
||||
value={String(status.max_tool_iterations)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Providers */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Key className="w-4 h-4" />
|
||||
提供商
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{status.providers.map((p) => (
|
||||
<div
|
||||
key={p.name}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
{p.has_key ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="w-4 h-4 text-muted-foreground/40" />
|
||||
)}
|
||||
<span className={p.has_key ? '' : 'text-muted-foreground'}>
|
||||
{p.name}
|
||||
</span>
|
||||
{p.detail && (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{p.detail}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Channels */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Radio className="w-4 h-4" />
|
||||
通道
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{status.channels.map((ch) => (
|
||||
<div key={ch.name} className="flex items-center gap-2 text-sm">
|
||||
<Badge
|
||||
variant={ch.enabled ? 'default' : 'secondary'}
|
||||
className="text-xs"
|
||||
>
|
||||
{ch.enabled ? '开启' : '关闭'}
|
||||
</Badge>
|
||||
<span className="capitalize">{ch.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Cron Summary */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
调度器
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<InfoRow
|
||||
label="状态"
|
||||
value={status.cron.enabled ? '运行中' : '已停止'}
|
||||
ok={status.cron.enabled}
|
||||
/>
|
||||
<InfoRow label="任务数" value={String(status.cron.jobs)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({
|
||||
label,
|
||||
value,
|
||||
ok,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
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">
|
||||
{value}
|
||||
</code>
|
||||
{ok !== undefined &&
|
||||
(ok ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="w-4 h-4 text-destructive" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user