feat(desktop): 工业化升级 A —— 设计系统地基(primitives + lucide + 语义令牌)
把"手搓内联 class + Unicode 字符图标"换成统一组件与真实图标,为后续工业化打底。 - 依赖:装 lucide-react(描线图标,按需 tree-shake) - 令牌:tailwind.config 加语义色 brand/accent/success/warn/danger + 圆角档位; 强调色字面量(violet/cyan/emerald…)收敛到令牌,便于整体换肤 - primitives(src/ui,零重依赖自建):Button/Input/Textarea/Select/Field/Card/Panel/ Badge/Dot/Tabs/Skeleton/EmptyState/Dialog/Toast(+useToast)/cn,桶文件统一引入 - 迁移:TopBar/LeftNav/BottomDrawer + Home/Report/Runs/Kb/Placeholder/ExecTrace/ MemoryPanel/StudioView 全部换 primitives + lucide 图标;导航/能力卡/按钮告别 ▤◆▣▦ 等 Unicode 字符;错误改用全局 Toast;空状态用 EmptyState - App 包 ToastProvider 验证:tsc + vite build 通过;浏览器(Preview)走查工作台/报告页——真实图标、统一卡片/ 按钮/输入;跑报告端到端正常(执行轨迹 lucide 状态图标点亮、章节耗时/字数/检索片段、 完成弹 Toast + 下载 Word)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,20 +1,23 @@
|
||||
import { Workflow, Database, Bookmark, FileText, Plus, Upload, ArrowRight, type LucideIcon } from "lucide-react";
|
||||
import { useHealth } from "../lib/health";
|
||||
import type { ViewKey } from "../shell/LeftNav";
|
||||
import { Button, cn } from "../ui";
|
||||
|
||||
function Stat({ label, value, sub, accent }: { label: string; value: string; sub: string; accent: string }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-line bg-ink-850 p-4">
|
||||
<div className="rounded-lg border border-line bg-ink-850 p-4">
|
||||
<div className="text-xs text-slate-500">{label}</div>
|
||||
<div className={`mt-1 text-2xl font-semibold ${accent}`}>{value}</div>
|
||||
<div className={cn("mt-1 text-2xl font-semibold", accent)}>{value}</div>
|
||||
<div className="mt-0.5 text-[11px] text-slate-500">{sub}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CAPS = [
|
||||
{ icon: "◆", title: "Agent 编排", desc: "React Flow 画布 → Eino 动态图 → 流式执行", to: "studio" as ViewKey, color: "text-violet-400" },
|
||||
{ icon: "▣", title: "RAG 知识库", desc: "多文件入库 + 向量/全文/图谱 三路混合检索", to: "kb" as ViewKey, color: "text-cyan-400" },
|
||||
{ icon: "◇", title: "偏好记忆", desc: "画像 + 多轮历史,让模型“知道是你”", to: "memory" as ViewKey, color: "text-emerald-400" },
|
||||
const CAPS: { icon: LucideIcon; title: string; desc: string; to: ViewKey; color: string }[] = [
|
||||
{ icon: Workflow, title: "Agent 编排", desc: "React Flow 画布 → Eino 动态图 → 流式执行", to: "studio", color: "text-brand-400" },
|
||||
{ icon: Database, title: "RAG 知识库", desc: "多文件入库 + 向量/全文/图谱 三路混合检索", to: "kb", color: "text-accent-400" },
|
||||
{ icon: FileText, title: "报告生成", desc: "规划大纲 → 各章并行检索撰写 → 渲染 Word", to: "report", color: "text-indigo-300" },
|
||||
{ icon: Bookmark, title: "偏好记忆", desc: "画像 + 多轮历史,让模型“知道是你”", to: "memory", color: "text-success" },
|
||||
];
|
||||
|
||||
// 工作台:平台概览 + 快捷入口(深色 AI 控制台首页)。
|
||||
@@ -24,49 +27,57 @@ export function Home({ onSelect }: { onSelect: (v: ViewKey) => void }) {
|
||||
<div className="h-full overflow-auto p-8">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500 to-cyan-500 text-lg font-bold text-white shadow-glow">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-gradient-to-br from-brand to-accent text-lg font-bold text-white shadow-glow">
|
||||
S
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="brand-gradient text-2xl font-bold leading-tight">sundynix-agentix</h1>
|
||||
<p className="text-sm text-slate-500">分层式 AI Agent 平台 · 编排 / 知识库 / 记忆</p>
|
||||
<p className="text-sm text-slate-500">分层式 AI Agent 平台 · 编排 / 知识库 / 报告 / 记忆</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<Stat label="对话模型" value="DeepSeek" sub="chat · 控制面" accent="text-violet-300" />
|
||||
<Stat label="向量模型" value="百炼 v3" sub="embedding · 1024维" accent="text-cyan-300" />
|
||||
<Stat label="混合检索" value="3 路" sub="向量+全文+图谱" accent="text-emerald-300" />
|
||||
<Stat label="网关" value={h.gateway ? "在线" : "离线"} sub={h.persisted ? "持久化就绪" : "降级"} accent={h.gateway ? "text-emerald-300" : "text-rose-300"} />
|
||||
<Stat label="对话模型" value="DeepSeek" sub="chat · 控制面" accent="text-brand-400" />
|
||||
<Stat label="向量模型" value="百炼 v3" sub="embedding · 1024维" accent="text-accent-400" />
|
||||
<Stat label="混合检索" value="3 路" sub="向量+全文+图谱" accent="text-success" />
|
||||
<Stat label="网关" value={h.gateway ? "在线" : "离线"} sub={h.persisted ? "持久化就绪" : "降级"} accent={h.gateway ? "text-success" : "text-danger"} />
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{CAPS.map((c) => (
|
||||
<button
|
||||
key={c.to}
|
||||
onClick={() => onSelect(c.to)}
|
||||
className="group rounded-xl border border-line bg-ink-850 p-5 text-left transition hover:border-violet-500/50 hover:bg-ink-800"
|
||||
>
|
||||
<div className={`text-2xl ${c.color}`}>{c.icon}</div>
|
||||
<div className="mt-3 font-medium text-slate-100">{c.title}</div>
|
||||
<div className="mt-1 text-xs leading-relaxed text-slate-500">{c.desc}</div>
|
||||
<div className="mt-3 text-xs text-violet-400 opacity-0 transition group-hover:opacity-100">进入 →</div>
|
||||
</button>
|
||||
))}
|
||||
<div className="mt-8 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{CAPS.map((c) => {
|
||||
const Icon = c.icon;
|
||||
return (
|
||||
<button
|
||||
key={c.to}
|
||||
onClick={() => onSelect(c.to)}
|
||||
className="group rounded-lg border border-line bg-ink-850 p-5 text-left transition hover:border-brand/50 hover:bg-ink-800"
|
||||
>
|
||||
<Icon className={cn("h-6 w-6", c.color)} strokeWidth={1.8} />
|
||||
<div className="mt-3 font-medium text-slate-100">{c.title}</div>
|
||||
<div className="mt-1 text-xs leading-relaxed text-slate-500">{c.desc}</div>
|
||||
<div className="mt-3 flex items-center gap-1 text-xs text-brand-400 opacity-0 transition group-hover:opacity-100">
|
||||
进入 <ArrowRight className="h-3 w-3" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 rounded-xl border border-line bg-ink-900 p-5">
|
||||
<div className="mt-8 rounded-lg border border-line bg-ink-900 p-5">
|
||||
<div className="mb-3 text-sm font-medium text-slate-300">快速开始</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={() => onSelect("studio")} className="rounded-lg bg-violet-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-violet-500">
|
||||
+ 新建 Agent 编排
|
||||
</button>
|
||||
<button onClick={() => onSelect("kb")} className="rounded-lg border border-line px-3 py-1.5 text-sm text-slate-300 hover:bg-ink-800">
|
||||
⬆ 入库知识
|
||||
</button>
|
||||
<button onClick={() => onSelect("memory")} className="rounded-lg border border-line px-3 py-1.5 text-sm text-slate-300 hover:bg-ink-800">
|
||||
◇ 管理记忆
|
||||
</button>
|
||||
<Button variant="primary" size="sm" icon={Plus} onClick={() => onSelect("studio")}>
|
||||
新建 Agent 编排
|
||||
</Button>
|
||||
<Button size="sm" icon={Upload} onClick={() => onSelect("kb")}>
|
||||
入库知识
|
||||
</Button>
|
||||
<Button size="sm" icon={FileText} onClick={() => onSelect("report")}>
|
||||
生成报告
|
||||
</Button>
|
||||
<Button size="sm" icon={Bookmark} onClick={() => onSelect("memory")}>
|
||||
管理记忆
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Upload, Search, Network, FileUp } from "lucide-react";
|
||||
import { ingestKb, ingestFile, streamIngest, searchKb, graphKb, type IngestEvent, type KbHit, type Triple } from "../lib/api";
|
||||
import { Button, Input, Textarea, Badge, cn, useToast } from "../ui";
|
||||
|
||||
interface IngestLog {
|
||||
t: string;
|
||||
@@ -18,6 +20,7 @@ interface Progress {
|
||||
|
||||
// 知识库管理:实时入库监控(解析→切块→向量化→写入 + 拆分可视化)+ 检索调试台。
|
||||
export function KbView() {
|
||||
const toast = useToast();
|
||||
const [kb, setKb] = useState("docs");
|
||||
const [text, setText] = useState("");
|
||||
const [logs, setLogs] = useState<IngestLog[]>([]);
|
||||
@@ -28,21 +31,19 @@ export function KbView() {
|
||||
const [topK, setTopK] = useState(5);
|
||||
const [hits, setHits] = useState<KbHit[] | null>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const [graph, setGraph] = useState<Triple[] | null>(null);
|
||||
|
||||
const onGraph = async () => {
|
||||
try {
|
||||
setGraph(await graphKb(kb));
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
toast.push("error", (e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const stamp = () => new Date().toLocaleTimeString();
|
||||
const ingesting = prog?.active ?? false;
|
||||
|
||||
// 订阅某入库 job 的进度流。
|
||||
const follow = (job: string, label: string) => {
|
||||
setProg({ active: true, stage: "提交", chunks: [] });
|
||||
streamIngest(
|
||||
@@ -59,10 +60,8 @@ export function KbView() {
|
||||
() =>
|
||||
setProg((p) => {
|
||||
const ok = p?.stage !== "失败";
|
||||
setLogs((l) => [
|
||||
{ t: stamp(), msg: ok ? `${label}:${p?.total ?? 0} 块入库完成` : `${label}:${p?.error ?? "失败"}`, ok },
|
||||
...l,
|
||||
]);
|
||||
setLogs((l) => [{ t: stamp(), msg: ok ? `${label}:${p?.total ?? 0} 块入库完成` : `${label}:${p?.error ?? "失败"}`, ok }, ...l]);
|
||||
toast.push(ok ? "success" : "error", ok ? `${label} 入库完成` : `${label} 入库失败`);
|
||||
return p ? { ...p, active: false } : null;
|
||||
}),
|
||||
() => setProg((p) => (p ? { ...p, active: false, stage: "连接中断" } : null)),
|
||||
@@ -95,11 +94,10 @@ export function KbView() {
|
||||
const onSearch = async () => {
|
||||
if (!q.trim()) return;
|
||||
setSearching(true);
|
||||
setErr("");
|
||||
try {
|
||||
setHits(await searchKb(kb, q, topK));
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
toast.push("error", (e as Error).message);
|
||||
setHits(null);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
@@ -112,13 +110,7 @@ export function KbView() {
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-2 border-b border-line bg-ink-900 px-4 py-2">
|
||||
<span className="text-sm font-semibold text-slate-300">知识库</span>
|
||||
<input
|
||||
className="w-40 rounded-md border border-line bg-ink-800 px-2 py-1 text-sm text-slate-200 focus:border-violet-500/60 focus:outline-none"
|
||||
value={kb}
|
||||
onChange={(e) => setKb(e.target.value)}
|
||||
placeholder="知识库名"
|
||||
title="知识库(Milvus kb 字段分区)"
|
||||
/>
|
||||
<Input className="h-8 w-40" value={kb} onChange={(e) => setKb(e.target.value)} placeholder="知识库名" title="知识库(Milvus kb 字段分区)" />
|
||||
<span className="text-[11px] text-slate-500">入库 → 解析 / 切块 / 向量化 / 写入;检索 → 混合召回</span>
|
||||
</div>
|
||||
|
||||
@@ -126,28 +118,22 @@ export function KbView() {
|
||||
{/* 左:入库 + 实时监控 */}
|
||||
<section className="flex w-1/2 flex-col border-r border-line p-4">
|
||||
<h3 className="mb-2 text-xs font-semibold text-slate-400">入库</h3>
|
||||
<textarea
|
||||
className="h-24 w-full resize-none rounded-md border border-line bg-ink-800 p-2 text-sm text-slate-200 focus:border-violet-500/60 focus:outline-none"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder={"每行一条知识,或上传文件"}
|
||||
/>
|
||||
<Textarea className="h-24 resize-none" value={text} onChange={(e) => setText(e.target.value)} placeholder="每行一条知识,或上传文件" />
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<button
|
||||
onClick={onIngest}
|
||||
disabled={ingesting || !text.trim()}
|
||||
className="rounded-md bg-emerald-600 px-3 py-1 text-sm text-white hover:bg-emerald-500 disabled:opacity-40"
|
||||
>
|
||||
{ingesting ? "入库中…" : "⬆ 入库文本"}
|
||||
</button>
|
||||
<Button variant="primary" size="sm" icon={Upload} onClick={onIngest} disabled={ingesting || !text.trim()}>
|
||||
{ingesting ? "入库中…" : "入库文本"}
|
||||
</Button>
|
||||
<span className="text-[11px] text-slate-500">或</span>
|
||||
<Button size="sm" icon={FileUp} onClick={() => fileRef.current?.click()} disabled={ingesting}>
|
||||
选择文件
|
||||
</Button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".txt,.md,.csv,.docx,.xlsx,.pdf"
|
||||
onChange={(e) => onFile(e.target.files?.[0])}
|
||||
disabled={ingesting}
|
||||
className="text-xs text-slate-400 file:mr-2 file:rounded file:border-0 file:bg-ink-700 file:px-2 file:py-1 file:text-xs file:text-slate-300"
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
<span className="mt-1 text-[10px] text-slate-500">支持 txt/md/csv/docx/xlsx/pdf(docx/xlsx/pdf 经 mcp-py 解析)</span>
|
||||
@@ -155,35 +141,38 @@ export function KbView() {
|
||||
{/* 实时流水线进度 */}
|
||||
{prog && (
|
||||
<div className="mt-3 rounded-lg border border-line bg-ink-850 p-2.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{["解析", "切块", "向量化", "写Milvus", "写Bleve", "完成"].map((s) => {
|
||||
const active = prog.stage.startsWith(s) || (s === "完成" && prog.stage === "完成");
|
||||
const passed = stageOrder(prog.stage) > stageOrder(s);
|
||||
return (
|
||||
<span
|
||||
key={s}
|
||||
className={`rounded px-1.5 py-0.5 text-[10px] ${
|
||||
className={cn(
|
||||
"rounded px-1.5 py-0.5 text-[10px]",
|
||||
prog.stage === "失败"
|
||||
? "bg-ink-800 text-slate-600"
|
||||
: active
|
||||
? "bg-violet-600 text-white shadow-glow"
|
||||
? "bg-brand text-white shadow-glow"
|
||||
: passed
|
||||
? "bg-emerald-500/15 text-emerald-400"
|
||||
: "bg-ink-800 text-slate-600"
|
||||
}`}
|
||||
? "bg-success/15 text-success"
|
||||
: "bg-ink-800 text-slate-600",
|
||||
)}
|
||||
>
|
||||
{s}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{prog.error && <p className="mt-1 text-[11px] text-rose-400">✗ {prog.error}</p>}
|
||||
{prog.error && <p className="mt-1 text-[11px] text-danger">✗ {prog.error}</p>}
|
||||
{prog.total ? (
|
||||
<div className="mt-2">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-ink-700">
|
||||
<div className="h-full rounded-full bg-gradient-to-r from-violet-500 to-cyan-400 transition-all" style={{ width: `${pct}%` }} />
|
||||
<div className="h-full rounded-full bg-gradient-to-r from-brand to-accent transition-all" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] text-slate-500">
|
||||
向量化 {prog.done ?? 0}/{prog.total} 块({pct}%)
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] text-slate-500">向量化 {prog.done ?? 0}/{prog.total} 块({pct}%)</div>
|
||||
</div>
|
||||
) : null}
|
||||
{prog.chunks.length > 0 && (
|
||||
@@ -205,7 +194,7 @@ export function KbView() {
|
||||
<ul className="flex-1 space-y-1 overflow-auto">
|
||||
{logs.length === 0 && <li className="text-xs text-slate-600">尚无入库记录。</li>}
|
||||
{logs.map((l, i) => (
|
||||
<li key={i} className={`text-xs ${l.ok ? "text-emerald-400" : "text-rose-400"}`}>
|
||||
<li key={i} className={cn("text-xs", l.ok ? "text-success" : "text-danger")}>
|
||||
<span className="text-slate-600">{l.t}</span> {l.ok ? "✓" : "✗"} {l.msg}
|
||||
</li>
|
||||
))}
|
||||
@@ -216,42 +205,21 @@ export function KbView() {
|
||||
<section className="flex w-1/2 flex-col p-4">
|
||||
<h3 className="mb-2 text-xs font-semibold text-slate-400">检索调试台(混合召回 + rerank)</h3>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="flex-1 rounded-md border border-line bg-ink-800 px-2 py-1 text-sm text-slate-200 focus:border-violet-500/60 focus:outline-none"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && onSearch()}
|
||||
placeholder="输入查询,语义召回相关片段…"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
className="w-16 rounded-md border border-line bg-ink-800 px-2 py-1 text-sm text-slate-200 focus:border-violet-500/60 focus:outline-none"
|
||||
value={topK}
|
||||
min={1}
|
||||
max={20}
|
||||
onChange={(e) => setTopK(Number(e.target.value))}
|
||||
title="TopK"
|
||||
/>
|
||||
<button
|
||||
onClick={onSearch}
|
||||
disabled={searching || !q.trim()}
|
||||
className="rounded-md bg-violet-600 px-3 py-1 text-sm text-white hover:bg-violet-500 disabled:opacity-40"
|
||||
>
|
||||
<Input className="flex-1" value={q} onChange={(e) => setQ(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onSearch()} placeholder="输入查询,语义召回相关片段…" />
|
||||
<Input type="number" className="w-16" value={topK} min={1} max={20} onChange={(e) => setTopK(Number(e.target.value))} title="TopK" />
|
||||
<Button variant="primary" size="md" icon={Search} onClick={onSearch} disabled={searching || !q.trim()}>
|
||||
{searching ? "检索中…" : "检索"}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
{err && <p className="mt-2 text-xs text-rose-400">✗ {err}</p>}
|
||||
<ul className="mt-3 max-h-[40%] space-y-2 overflow-auto">
|
||||
{hits === null && <li className="text-xs text-slate-600">输入查询后展示命中片段与分数。</li>}
|
||||
{hits !== null && hits.length === 0 && (
|
||||
<li className="text-xs text-slate-600">无命中(知识库为空或 RAG 未配置)。</li>
|
||||
)}
|
||||
{hits !== null && hits.length === 0 && <li className="text-xs text-slate-600">无命中(知识库为空或 RAG 未配置)。</li>}
|
||||
{hits?.map((h, i) => (
|
||||
<li key={i} className="rounded-lg border border-line bg-ink-850 p-2">
|
||||
<div className="mb-1 flex items-center gap-2 text-[10px]">
|
||||
<span className="rounded bg-sky-500/15 px-1.5 py-0.5 text-sky-400">混合检索</span>
|
||||
<Badge tone="accent">混合检索</Badge>
|
||||
<span className="text-slate-600">#{i + 1}</span>
|
||||
<span className="ml-auto font-mono text-violet-400">{h.score.toFixed(3)}</span>
|
||||
<span className="ml-auto font-mono text-brand-400">{h.score.toFixed(3)}</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-300">{h.text}</div>
|
||||
</li>
|
||||
@@ -261,20 +229,18 @@ export function KbView() {
|
||||
{/* 知识图谱(Neo4j / GraphRAG) */}
|
||||
<div className="mt-3 flex items-center justify-between border-t border-line pt-2">
|
||||
<h3 className="text-xs font-semibold text-slate-400">知识图谱(Neo4j)</h3>
|
||||
<button onClick={onGraph} className="rounded-md border border-line px-2 py-0.5 text-xs text-slate-300 hover:bg-ink-800">
|
||||
<Button size="sm" icon={Network} onClick={onGraph}>
|
||||
查看图谱
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="mt-2 flex-1 space-y-1 overflow-auto">
|
||||
{graph === null && <li className="text-[11px] text-slate-600">点「查看图谱」展示入库抽取的实体关系。</li>}
|
||||
{graph !== null && graph.length === 0 && (
|
||||
<li className="text-[11px] text-slate-600">该库暂无图谱(需配置 chat 模型 + 入库触发抽取)。</li>
|
||||
)}
|
||||
{graph !== null && graph.length === 0 && <li className="text-[11px] text-slate-600">该库暂无图谱(需配置 chat 模型 + 入库触发抽取)。</li>}
|
||||
{graph?.map((t, i) => (
|
||||
<li key={i} className="flex items-center gap-1 text-[11px]">
|
||||
<span className="rounded bg-amber-500/15 px-1.5 py-0.5 text-amber-300">{t.s}</span>
|
||||
<Badge tone="warn">{t.s}</Badge>
|
||||
<span className="text-slate-500">—{t.p}→</span>
|
||||
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-emerald-300">{t.o}</span>
|
||||
<Badge tone="success">{t.o}</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { Hammer } from "lucide-react";
|
||||
import { Badge } from "../ui";
|
||||
|
||||
// 规划中模块占位 —— 深色,露出信息架构与定位。
|
||||
export function Placeholder({ title, desc }: { title: string; desc: string }) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-8">
|
||||
<div className="max-w-md rounded-xl border border-dashed border-line bg-ink-900 p-8 text-center">
|
||||
<div className="max-w-md rounded-lg border border-dashed border-line bg-ink-900 p-8 text-center">
|
||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-xl border border-line bg-ink-850 text-slate-500">
|
||||
<Hammer className="h-6 w-6" strokeWidth={1.5} />
|
||||
</div>
|
||||
<div className="mb-2 text-base font-medium text-slate-200">{title}</div>
|
||||
<p className="text-sm leading-relaxed text-slate-500">{desc}</p>
|
||||
<span className="mt-4 inline-block rounded-md bg-ink-800 px-2.5 py-1 text-[11px] text-slate-400">规划中</span>
|
||||
<div className="mt-4">
|
||||
<Badge tone="warn">规划中</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Play, Download, FileText } from "lucide-react";
|
||||
import { generateReport, streamTokens, streamExec, reportDownloadUrl, type Identity, type ExecEvent } from "../lib/api";
|
||||
import { ExecTrace } from "../components/ExecTrace";
|
||||
import { Button, Input, Field, Panel, Dot, EmptyState, useToast } from "../ui";
|
||||
|
||||
type Phase = "idle" | "running" | "done" | "error";
|
||||
|
||||
// 报告生成:输入主题(+可选知识库) → 触发后端专用编排
|
||||
// (规划大纲 → 各章并行检索+撰写 → 渲染 Word),实时看进度与正文,完成后下载 .docx。
|
||||
export function ReportView({ identity }: { identity: Identity }) {
|
||||
const toast = useToast();
|
||||
const [topic, setTopic] = useState("");
|
||||
const [kb, setKb] = useState("");
|
||||
const [phase, setPhase] = useState<Phase>("idle");
|
||||
const [out, setOut] = useState("");
|
||||
const [exec, setExec] = useState<ExecEvent[]>([]);
|
||||
const [taskId, setTaskId] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
const closeRef = useRef<(() => void) | null>(null);
|
||||
const execCloseRef = useRef<(() => void) | null>(null);
|
||||
|
||||
@@ -26,7 +28,6 @@ export function ReportView({ identity }: { identity: Identity }) {
|
||||
setPhase("running");
|
||||
setOut("");
|
||||
setExec([]);
|
||||
setErr("");
|
||||
setTaskId("");
|
||||
try {
|
||||
const id = await generateReport(identity, topic.trim(), kb.trim() || undefined);
|
||||
@@ -40,20 +41,25 @@ export function ReportView({ identity }: { identity: Identity }) {
|
||||
closeRef.current = streamTokens(
|
||||
id,
|
||||
(tok) => setOut((o) => o + tok),
|
||||
() => setPhase("done"),
|
||||
() => {
|
||||
setErr("连接中断");
|
||||
setPhase("done");
|
||||
toast.push("success", "报告已生成,可下载 Word");
|
||||
},
|
||||
() => {
|
||||
setPhase("error");
|
||||
toast.push("error", "报告流连接中断");
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
setPhase("error");
|
||||
toast.push("error", (e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const tracePhase = running ? "streaming" : phase === "done" ? "done" : phase === "error" ? "error" : "idle";
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col gap-4 overflow-y-auto p-6">
|
||||
<div className="flex h-full min-h-0 flex-col gap-4 overflow-hidden p-6">
|
||||
<header>
|
||||
<h1 className="text-lg font-semibold text-slate-100">报告生成</h1>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
@@ -61,75 +67,53 @@ export function ReportView({ identity }: { identity: Identity }) {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* 输入区 */}
|
||||
<div className="grid grid-cols-[1fr_220px] gap-3 rounded-xl border border-line bg-ink-900 p-4 shadow-card">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-[11px] font-medium text-slate-400">报告主题</label>
|
||||
<input
|
||||
<div className="grid grid-cols-[1fr_220px_auto_auto] items-end gap-3 rounded-lg border border-line bg-ink-900 p-4 shadow-card">
|
||||
<Field label="报告主题">
|
||||
<Input
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && onGenerate()}
|
||||
placeholder="如:2026 年国产大模型产业现状与趋势分析"
|
||||
className="rounded-lg border border-line bg-ink-950 px-3 py-2 text-sm text-slate-200 outline-none placeholder:text-slate-600 focus:border-violet-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-[11px] font-medium text-slate-400">知识库(可选)</label>
|
||||
<input
|
||||
value={kb}
|
||||
onChange={(e) => setKb(e.target.value)}
|
||||
placeholder="如 docs,留空则不挂检索"
|
||||
className="rounded-lg border border-line bg-ink-950 px-3 py-2 text-sm text-slate-200 outline-none placeholder:text-slate-600 focus:border-violet-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 flex items-center gap-3">
|
||||
<button
|
||||
onClick={onGenerate}
|
||||
disabled={running || !topic.trim()}
|
||||
className="rounded-lg bg-gradient-to-r from-violet-500 to-cyan-500 px-4 py-2 text-sm font-medium text-white shadow-glow transition disabled:cursor-not-allowed disabled:opacity-40"
|
||||
</Field>
|
||||
<Field label="知识库(可选)">
|
||||
<Input value={kb} onChange={(e) => setKb(e.target.value)} placeholder="如 docs,留空则不挂检索" />
|
||||
</Field>
|
||||
<Button variant="primary" icon={Play} onClick={onGenerate} disabled={running || !topic.trim()}>
|
||||
{running ? "生成中…" : "生成报告"}
|
||||
</Button>
|
||||
{phase === "done" && taskId ? (
|
||||
<a
|
||||
href={reportDownloadUrl(taskId)}
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-md border border-brand/60 px-4 text-sm font-medium text-brand-400 transition hover:bg-brand/10"
|
||||
>
|
||||
{running ? "生成中…" : "生成报告"}
|
||||
</button>
|
||||
{phase === "done" && taskId && (
|
||||
<a
|
||||
href={reportDownloadUrl(taskId)}
|
||||
className="rounded-lg border border-violet-500/60 px-4 py-2 text-sm font-medium text-violet-300 transition hover:bg-violet-500/10"
|
||||
>
|
||||
⬇ 下载 Word
|
||||
</a>
|
||||
)}
|
||||
<span className="text-xs text-slate-500">
|
||||
{running && "正在编排,实时进度见下方…"}
|
||||
{phase === "done" && "已完成"}
|
||||
{phase === "error" && <span className="text-rose-400">出错:{err}</span>}
|
||||
</span>
|
||||
</div>
|
||||
<Download className="h-4 w-4" />
|
||||
下载 Word
|
||||
</a>
|
||||
) : (
|
||||
<span className="h-9" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 执行轨迹 + 报告正文 */}
|
||||
<div className="grid min-h-0 flex-1 grid-cols-[340px_1fr] gap-4">
|
||||
<section className="flex min-h-0 flex-col rounded-xl border border-line bg-ink-900 shadow-card">
|
||||
<div className="border-b border-line px-4 py-2.5 text-[11px] font-medium text-slate-400">执行轨迹</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
<ExecTrace events={exec} phase={running ? "streaming" : phase === "done" ? "done" : phase === "error" ? "error" : "idle"} />
|
||||
</div>
|
||||
</section>
|
||||
<Panel title="执行轨迹" icon={FileText}>
|
||||
<ExecTrace events={exec} phase={tracePhase} />
|
||||
</Panel>
|
||||
|
||||
<section className="flex min-h-0 flex-col rounded-xl border border-line bg-ink-900 shadow-card">
|
||||
<div className="flex items-center gap-2 border-b border-line px-4 py-2.5 text-[11px] text-slate-500">
|
||||
<span className={`h-2 w-2 rounded-full ${running ? "animate-pulse bg-cyan-400" : phase === "done" ? "bg-emerald-400" : "bg-slate-600"}`} />
|
||||
报告正文 · {taskId || "未开始"}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
{out ? (
|
||||
<pre className="whitespace-pre-wrap break-words font-sans text-sm leading-relaxed text-slate-300">{out}</pre>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-slate-600">
|
||||
输入主题并点击「生成报告」,这里将实时显示规划与撰写过程。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<Panel
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<Dot tone={running ? "running" : phase === "done" ? "success" : "neutral"} pulse={running} />
|
||||
报告正文 · {taskId || "未开始"}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{out ? (
|
||||
<pre className="whitespace-pre-wrap break-words font-sans text-sm leading-relaxed text-slate-300">{out}</pre>
|
||||
) : (
|
||||
<EmptyState icon={FileText} title="尚未生成报告" desc="输入主题并点击「生成报告」,这里将实时显示规划与撰写过程。" />
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Activity, FileText } from "lucide-react";
|
||||
import { ExecTrace } from "../components/ExecTrace";
|
||||
import { deriveNodes, type RunState } from "../lib/run";
|
||||
import { Panel, Dot, EmptyState } from "../ui";
|
||||
|
||||
// 运行·观测:把最近一次运行的执行轨迹实时可视化(节点逐个点亮 + 工具入参/产出 + 耗时),
|
||||
// 右侧并列模型输出。数据来自 Studio 运行时订阅的 sundynix.exec.<id> 事件流。
|
||||
@@ -8,8 +10,7 @@ export function RunsView({ run }: { run: RunState }) {
|
||||
const tools = nodes.filter((n) => n.kind === "tool");
|
||||
const phaseText =
|
||||
run.phase === "streaming" ? "执行中" : run.phase === "done" ? "完成" : run.phase === "error" ? "出错" : run.phase === "submitting" ? "提交中" : "就绪";
|
||||
const phaseCls =
|
||||
run.phase === "streaming" ? "text-cyan-400" : run.phase === "done" ? "text-emerald-400" : run.phase === "error" ? "text-rose-400" : "text-slate-500";
|
||||
const tone = run.phase === "streaming" ? "running" : run.phase === "done" ? "success" : run.phase === "error" ? "danger" : "neutral";
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col gap-4 overflow-hidden p-6">
|
||||
@@ -24,33 +25,32 @@ export function RunsView({ run }: { run: RunState }) {
|
||||
<span className="text-slate-500">
|
||||
任务 <span className="font-mono text-slate-300">{run.taskId ?? "—"}</span>
|
||||
</span>
|
||||
<span className={phaseCls}>● {phaseText}</span>
|
||||
<span className="text-slate-500">{nodes.length} 节点 · {tools.length} 次工具调用</span>
|
||||
<span className="flex items-center gap-1.5 text-slate-400">
|
||||
<Dot tone={tone} pulse={run.phase === "streaming"} />
|
||||
{phaseText}
|
||||
</span>
|
||||
<span className="text-slate-500">
|
||||
{nodes.length} 节点 · {tools.length} 次工具调用
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-[1.2fr_1fr] gap-4">
|
||||
{/* 执行轨迹 */}
|
||||
<section className="flex min-h-0 flex-col rounded-xl border border-line bg-ink-900 shadow-card">
|
||||
<div className="border-b border-line px-4 py-2.5 text-[11px] font-medium text-slate-400">执行轨迹</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
<ExecTrace events={run.exec} phase={run.phase} />
|
||||
</div>
|
||||
</section>
|
||||
<Panel title="执行轨迹" icon={Activity}>
|
||||
<ExecTrace events={run.exec} phase={run.phase} />
|
||||
</Panel>
|
||||
|
||||
{/* 模型输出 */}
|
||||
<section className="flex min-h-0 flex-col rounded-xl border border-line bg-ink-900 shadow-card">
|
||||
<div className="border-b border-line px-4 py-2.5 text-[11px] font-medium text-slate-400">模型输出</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
{run.output ? (
|
||||
<pre className="whitespace-pre-wrap break-words font-sans text-sm leading-relaxed text-slate-300">{run.output}</pre>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-center text-xs text-slate-600">
|
||||
在「编排」页搭图并运行,或在「报告」页生成报告,<br />执行轨迹与输出会实时出现在这里。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<Panel title="模型输出" icon={FileText}>
|
||||
{run.output ? (
|
||||
<pre className="whitespace-pre-wrap break-words font-sans text-sm leading-relaxed text-slate-300">{run.output}</pre>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Activity}
|
||||
title="尚无运行"
|
||||
desc="在「编排」页搭图并运行,或在「报告」页生成报告,执行轨迹与输出会实时出现在这里。"
|
||||
/>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user