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:
Blizzard
2026-06-12 16:39:42 +08:00
parent 190c191ce4
commit 72bd43965f
25 changed files with 715 additions and 348 deletions
+10
View File
@@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@xyflow/react": "^12.3.0", "@xyflow/react": "^12.3.0",
"lucide-react": "^1.17.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
}, },
@@ -2054,6 +2055,15 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lucide-react": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.17.0.tgz",
"integrity": "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/merge2": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+7 -6
View File
@@ -9,18 +9,19 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@xyflow/react": "^12.3.0",
"lucide-react": "^1.17.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0"
"@xyflow/react": "^12.3.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0", "@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.6.0",
"vite": "^5.4.0",
"tailwindcss": "^3.4.0",
"autoprefixer": "^10.4.0", "autoprefixer": "^10.4.0",
"postcss": "^8.4.0" "postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.6.0",
"vite": "^5.4.0"
} }
} }
+3
View File
@@ -13,6 +13,7 @@ import { Placeholder } from "./views/Placeholder";
import { submitTask, streamTokens, streamExec, type Identity } from "./lib/api"; import { submitTask, streamTokens, streamExec, type Identity } from "./lib/api";
import type { TaskDsl } from "./lib/dsl"; import type { TaskDsl } from "./lib/dsl";
import { emptyRun, type RunState } from "./lib/run"; import { emptyRun, type RunState } from "./lib/run";
import { ToastProvider } from "./ui";
const PLACEHOLDERS: Partial<Record<ViewKey, { title: string; desc: string }>> = { const PLACEHOLDERS: Partial<Record<ViewKey, { title: string; desc: string }>> = {
home: { title: "工作台", desc: "概览:知识库 / 文档 / 近期运行 / 待办报告 / 配额计费 + 快捷入口。" }, home: { title: "工作台", desc: "概览:知识库 / 文档 / 近期运行 / 待办报告 / 配额计费 + 快捷入口。" },
@@ -77,6 +78,7 @@ export default function App() {
); );
return ( return (
<ToastProvider>
<div className="relative flex h-screen w-screen flex-col bg-ink-950 text-slate-200"> <div className="relative flex h-screen w-screen flex-col bg-ink-950 text-slate-200">
{/* 顶部柔光,增加纵深 */} {/* 顶部柔光,增加纵深 */}
<div <div
@@ -106,5 +108,6 @@ export default function App() {
</div> </div>
<BottomDrawer run={run} /> <BottomDrawer run={run} />
</div> </div>
</ToastProvider>
); );
} }
@@ -1,47 +1,53 @@
import {
Cpu,
Bookmark,
Wrench,
MessageSquareText,
Sparkles,
ListTree,
FileText,
FileOutput,
CheckCircle2,
XCircle,
Loader2,
Circle,
type LucideIcon,
} from "lucide-react";
import { deriveNodes, type NodeTrace, type RunPhase } from "../lib/run"; import { deriveNodes, type NodeTrace, type RunPhase } from "../lib/run";
import type { ExecEvent } from "../lib/api"; import type { ExecEvent } from "../lib/api";
import { cn } from "../ui";
// 各节点类别的图标与配色(与后端 ExecEvent.kind 对应)。 // 各节点类别的图标与配色(与后端 ExecEvent.kind 对应)。
const KIND: Record<string, { icon: string; cls: string; name: string }> = { const KIND: Record<string, { icon: LucideIcon; cls: string; name: string }> = {
system: { icon: "▸", cls: "text-slate-400", name: "系统" }, system: { icon: Cpu, cls: "text-slate-400", name: "系统" },
memory: { icon: "◇", cls: "text-violet-300", name: "记忆召回" }, memory: { icon: Bookmark, cls: "text-brand-400", name: "记忆召回" },
tool: { icon: "⚙", cls: "text-amber-300", name: "工具调用" }, tool: { icon: Wrench, cls: "text-warn", name: "工具调用" },
prompt: { icon: "▤", cls: "text-sky-300", name: "提示词" }, prompt: { icon: MessageSquareText, cls: "text-sky-300", name: "提示词" },
model: { icon: "✦", cls: "text-emerald-300", name: "模型推理" }, model: { icon: Sparkles, cls: "text-success", name: "模型推理" },
plan: { icon: "◷", cls: "text-cyan-300", name: "规划" }, plan: { icon: ListTree, cls: "text-accent-400", name: "规划" },
section: { icon: "¶", cls: "text-indigo-300", name: "章节" }, section: { icon: FileText, cls: "text-indigo-300", name: "章节" },
render: { icon: "▦", cls: "text-rose-300", name: "渲染" }, render: { icon: FileOutput, cls: "text-danger", name: "渲染" },
}; };
function meta(kind: string) { function meta(kind: string) {
return KIND[kind] ?? { icon: "•", cls: "text-slate-400", name: kind }; return KIND[kind] ?? { icon: Circle, cls: "text-slate-400", name: kind };
} }
function StatusDot({ status }: { status: NodeTrace["status"] }) { function StatusDot({ status }: { status: NodeTrace["status"] }) {
if (status === "running") if (status === "running") return <Loader2 className="h-4 w-4 animate-spin text-accent-400" strokeWidth={2.4} />;
return <span className="h-2.5 w-2.5 animate-pulse rounded-full bg-cyan-400 shadow-[0_0_8px_rgba(34,211,238,0.8)]" />; if (status === "done") return <CheckCircle2 className="h-4 w-4 text-success" strokeWidth={2.2} />;
if (status === "done") return <span className="h-2.5 w-2.5 rounded-full bg-emerald-400" />; if (status === "error") return <XCircle className="h-4 w-4 text-danger" strokeWidth={2.2} />;
if (status === "error") return <span className="h-2.5 w-2.5 rounded-full bg-rose-500" />; return <Circle className="h-4 w-4 text-slate-600" strokeWidth={2} />;
return <span className="h-2.5 w-2.5 rounded-full bg-slate-600" />;
} }
// ExecTrace 把执行事件流渲染为竖向轨道:每个节点一颗灯,实时点亮 + 耗时 + 入参/产出。 // ExecTrace 把执行事件流渲染为竖向轨道:每个节点一颗灯,实时点亮 + 耗时 + 入参/产出。
export function ExecTrace({ export function ExecTrace({ events, phase, compact }: { events: ExecEvent[]; phase?: RunPhase; compact?: boolean }) {
events,
phase,
compact,
}: {
events: ExecEvent[];
phase?: RunPhase;
compact?: boolean;
}) {
const nodes = deriveNodes(events); const nodes = deriveNodes(events);
if (nodes.length === 0) { if (nodes.length === 0) {
return ( return (
<div className="flex h-full items-center justify-center px-4 py-8 text-center text-xs text-slate-600"> <div className="flex h-full flex-col items-center justify-center gap-1 px-4 py-8 text-center text-xs text-slate-600">
/ <span></span>
<br /> <span className="text-slate-700"> / </span>
Word
</div> </div>
); );
} }
@@ -53,28 +59,27 @@ export function ExecTrace({
<span>{nodes.length} </span> <span>{nodes.length} </span>
<span>·</span> <span>·</span>
<span> {total} ms</span> <span> {total} ms</span>
{phase === "streaming" && <span className="text-cyan-400"> </span>} {phase === "streaming" && <span className="text-accent-400"> </span>}
{phase === "done" && <span className="text-emerald-400"> </span>} {phase === "done" && <span className="text-success"> </span>}
{phase === "error" && <span className="text-rose-400"> </span>} {phase === "error" && <span className="text-danger"> </span>}
</div> </div>
)} )}
<ol className="relative ml-2 space-y-2 border-l border-line pl-4"> <ol className="relative ml-2 space-y-2 border-l border-line pl-4">
{nodes.map((n) => { {nodes.map((n) => {
const m = meta(n.kind); const m = meta(n.kind);
const Icon = m.icon;
return ( return (
<li key={n.node} className="relative"> <li key={n.node} className="relative">
<span className="absolute -left-[22px] top-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-ink-900"> <span className="absolute -left-[22px] top-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-ink-900">
<StatusDot status={n.status} /> <StatusDot status={n.status} />
</span> </span>
<div className="rounded-lg border border-line bg-ink-950/60 px-3 py-2"> <div className="rounded-md border border-line bg-ink-950/60 px-3 py-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`text-sm leading-none ${m.cls}`}>{m.icon}</span> <Icon className={cn("h-3.5 w-3.5 shrink-0", m.cls)} strokeWidth={2} />
<span className="text-xs font-medium text-slate-200">{n.label}</span> <span className="text-xs font-medium text-slate-200">{n.label}</span>
<span className={`rounded px-1.5 py-0.5 text-[9px] ${m.cls} bg-white/5`}>{m.name}</span> <span className={cn("rounded bg-white/5 px-1.5 py-0.5 text-[9px]", m.cls)}>{m.name}</span>
{n.ms != null && n.ms > 0 && ( {n.ms != null && n.ms > 0 && <span className="ml-auto font-mono text-[10px] text-slate-500">{n.ms} ms</span>}
<span className="ml-auto font-mono text-[10px] text-slate-500">{n.ms} ms</span> {n.status === "running" && <span className="ml-auto text-[10px] text-accent-400"></span>}
)}
{n.status === "running" && <span className="ml-auto text-[10px] text-cyan-400"></span>}
</div> </div>
{n.detail && <p className="mt-1 break-words text-[11px] leading-relaxed text-slate-400">{n.detail}</p>} {n.detail && <p className="mt-1 break-words text-[11px] leading-relaxed text-slate-400">{n.detail}</p>}
{n.notes.map((note, i) => ( {n.notes.map((note, i) => (
@@ -1,40 +1,44 @@
import { useState } from "react"; import { useState } from "react";
import { Check } from "lucide-react";
import { setMemory, type Identity } from "../lib/api"; import { setMemory, type Identity } from "../lib/api";
import { Button, Input, Textarea, Field, useToast } from "../ui";
// 偏好记忆面板 —— 让用户显式登记/纠正模型对自己的记忆(→ PUT /api/v1/memory)。 // 偏好记忆面板 —— 让用户显式登记/纠正模型对自己的记忆(→ PUT /api/v1/memory)。
export function MemoryPanel({ identity }: { identity: Identity }) { export function MemoryPanel({ identity }: { identity: Identity }) {
const toast = useToast();
const [key, setKey] = useState("回答偏好"); const [key, setKey] = useState("回答偏好");
const [value, setValue] = useState("简洁、中文、多给要点"); const [value, setValue] = useState("简洁、中文、多给要点");
const [saved, setSaved] = useState<Array<{ key: string; value: string }>>([]); const [saved, setSaved] = useState<Array<{ key: string; value: string }>>([]);
const [msg, setMsg] = useState("");
const save = async () => { const save = async () => {
if (!key.trim()) return; if (!key.trim()) return;
try { try {
const m = await setMemory(identity, key.trim(), value.trim()); const m = await setMemory(identity, key.trim(), value.trim());
setSaved((s) => [{ key: key.trim(), value: value.trim() }, ...s.filter((x) => x.key !== key.trim())]); setSaved((s) => [{ key: key.trim(), value: value.trim() }, ...s.filter((x) => x.key !== key.trim())]);
setMsg(m); toast.push("success", m);
} catch (e) { } catch (e) {
setMsg(`${(e as Error).message}`); toast.push("error", (e as Error).message);
} }
}; };
const inputCls = "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";
return ( return (
<section className="border-b border-line p-4"> <section className="border-b border-line p-4">
<h2 className="mb-2 text-sm font-semibold text-slate-300"></h2> <h2 className="mb-3 text-sm font-semibold text-slate-300"></h2>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-3">
<input className={inputCls} value={key} onChange={(e) => setKey(e.target.value)} placeholder="键,如 称呼 / 回答偏好" /> <Field label="键">
<textarea className={`${inputCls} h-16 resize-none`} value={value} onChange={(e) => setValue(e.target.value)} placeholder="" /> <Input value={key} onChange={(e) => setKey(e.target.value)} placeholder="如 称呼 / 回答偏好" />
<button onClick={save} className="self-end rounded-md bg-emerald-600 px-3 py-1 text-sm text-white hover:bg-emerald-500"> </Field>
<Field label="值">
<Textarea className="h-16 resize-none" value={value} onChange={(e) => setValue(e.target.value)} placeholder="值" />
</Field>
<Button variant="primary" size="sm" icon={Check} className="self-end" onClick={save}>
</button> </Button>
</div> </div>
{msg && <p className="mt-2 text-xs text-emerald-400">{msg}</p>}
{saved.length > 0 && ( {saved.length > 0 && (
<ul className="mt-3 space-y-1"> <ul className="mt-3 space-y-1">
{saved.map((s) => ( {saved.map((s) => (
<li key={s.key} className="rounded-md bg-ink-800 px-2 py-1 text-xs text-slate-400"> <li key={s.key} className="rounded-md bg-ink-800 px-2 py-1.5 text-xs text-slate-400">
<span className="font-medium text-slate-200">{s.key}</span>{s.value} <span className="font-medium text-slate-200">{s.key}</span>{s.value}
</li> </li>
))} ))}
@@ -1,46 +1,46 @@
import { useState } from "react"; import { useState } from "react";
import { ChevronDown, ChevronUp, Wrench } from "lucide-react";
import { deriveNodes, type RunState } from "../lib/run"; import { deriveNodes, type RunState } from "../lib/run";
import { ExecTrace } from "../components/ExecTrace"; import { ExecTrace } from "../components/ExecTrace";
import { Tabs, Badge, cn, type TabDef } from "../ui";
type Tab = "output" | "trace" | "tools" | "cite" | "eval"; type Tab = "output" | "trace" | "tools" | "cite" | "eval";
const TABS: Array<{ key: Tab; label: string }> = [
{ key: "output", label: "输出" },
{ key: "trace", label: "轨迹" },
{ key: "tools", label: "工具调用" },
{ key: "cite", label: "引用" },
{ key: "eval", label: "评测" },
];
// 底部抽屉:运行输出 / 轨迹 / 工具调用 / 引用 / 评测(深色,全局常驻)。 // 底部抽屉:运行输出 / 轨迹 / 工具调用 / 引用 / 评测(深色,全局常驻)。
export function BottomDrawer({ run }: { run: RunState }) { export function BottomDrawer({ run }: { run: RunState }) {
const [open, setOpen] = useState(true); const [open, setOpen] = useState(true);
const [tab, setTab] = useState<Tab>("output"); const [tab, setTab] = useState<Tab>("output");
const status = const nodes = deriveNodes(run.exec);
run.phase === "streaming" ? "text-cyan-400" : run.phase === "done" ? "text-emerald-400" : run.phase === "error" ? "text-rose-400" : "text-slate-500"; const toolCount = nodes.filter((n) => n.kind === "tool").length;
const tabs: TabDef<Tab>[] = [
{ key: "output", label: "输出" },
{ key: "trace", label: "轨迹", count: nodes.length },
{ key: "tools", label: "工具调用", count: toolCount },
{ key: "cite", label: "引用" },
{ key: "eval", label: "评测" },
];
const statusCls =
run.phase === "streaming" ? "text-accent-400" : run.phase === "done" ? "text-success" : run.phase === "error" ? "text-danger" : "text-slate-500";
const statusText = const statusText =
run.phase === "streaming" ? "流式中…" : run.phase === "done" ? "完成 ✓" : run.phase === "error" ? `${run.error ?? "出错"}` : run.phase === "submitting" ? "提交中…" : "就绪"; run.phase === "streaming" ? "流式中…" : run.phase === "done" ? "完成 ✓" : run.phase === "error" ? `${run.error ?? "出错"}` : run.phase === "submitting" ? "提交中…" : "就绪";
return ( return (
<div className="shrink-0 border-t border-line bg-ink-900"> <div className="shrink-0 border-t border-line bg-ink-900">
<div className="flex items-center gap-1 border-b border-line px-2"> <div className="flex items-center border-b border-line px-2">
{TABS.map((t) => ( <Tabs
<button tabs={tabs}
key={t.key} value={tab}
onClick={() => { onChange={(t) => {
setTab(t.key); setTab(t);
setOpen(true); setOpen(true);
}} }}
className={`px-3 py-2 text-xs transition ${ />
tab === t.key && open ? "border-b-2 border-violet-500 font-medium text-violet-300" : "text-slate-500 hover:text-slate-300" <span className={cn("ml-2 text-[11px]", statusCls)}>{statusText}</span>
}`} <button onClick={() => setOpen((o) => !o)} className="ml-auto flex items-center gap-1 px-2 py-2 text-xs text-slate-500 hover:text-slate-300">
> {open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronUp className="h-3.5 w-3.5" />}
{t.label} {open ? "收起" : "展开"}
</button>
))}
<span className={`ml-2 text-[11px] ${status}`}>{statusText}</span>
<button onClick={() => setOpen((o) => !o)} className="ml-auto px-2 text-xs text-slate-500 hover:text-slate-300">
{open ? "▾ 收起" : "▴ 展开"}
</button> </button>
</div> </div>
{open && ( {open && (
@@ -69,17 +69,13 @@ function ToolCalls({ run }: { run: RunState }) {
return ( return (
<ul className="space-y-1.5"> <ul className="space-y-1.5">
{tools.map((t) => ( {tools.map((t) => (
<li key={t.node} className="rounded-lg border border-line bg-ink-950/60 px-3 py-2"> <li key={t.node} className="rounded-md border border-line bg-ink-950/60 px-3 py-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-amber-300"></span> <Wrench className="h-3.5 w-3.5 text-warn" strokeWidth={2} />
<span className="font-mono text-[11px] text-slate-200">{t.node.replace(/^tool:/, "")}</span> <span className="font-mono text-[11px] text-slate-200">{t.node.replace(/^tool:/, "")}</span>
<span <Badge tone={t.status === "error" ? "danger" : t.status === "running" ? "accent" : "success"}>
className={`rounded px-1.5 py-0.5 text-[9px] ${
t.status === "error" ? "bg-rose-500/15 text-rose-300" : t.status === "running" ? "bg-cyan-500/15 text-cyan-300" : "bg-emerald-500/15 text-emerald-300"
}`}
>
{t.status === "error" ? "失败" : t.status === "running" ? "调用中" : "成功"} {t.status === "error" ? "失败" : t.status === "running" ? "调用中" : "成功"}
</span> </Badge>
{t.ms != null && t.ms > 0 && <span className="ml-auto font-mono text-[10px] text-slate-500">{t.ms} ms</span>} {t.ms != null && t.ms > 0 && <span className="ml-auto font-mono text-[10px] text-slate-500">{t.ms} ms</span>}
</div> </div>
{t.detail && <p className="mt-1 break-words text-[11px] leading-relaxed text-slate-400">{t.detail}</p>} {t.detail && <p className="mt-1 break-words text-[11px] leading-relaxed text-slate-400">{t.detail}</p>}
+37 -25
View File
@@ -1,53 +1,65 @@
export type ViewKey = import {
| "home" LayoutDashboard,
| "studio" Workflow,
| "kb" Database,
| "report" FileText,
| "runs" Activity,
| "memory" Bookmark,
| "market" Boxes,
| "admin"; Settings,
type LucideIcon,
} from "lucide-react";
import { cn } from "../ui";
export type ViewKey = "home" | "studio" | "kb" | "report" | "runs" | "memory" | "market" | "admin";
interface Item { interface Item {
key: ViewKey; key: ViewKey;
label: string; label: string;
icon: string; icon: LucideIcon;
group?: string; group?: string;
ready?: boolean; ready?: boolean;
} }
const ITEMS: Item[] = [ const ITEMS: Item[] = [
{ key: "home", label: "工作台", icon: "▤", ready: true }, { key: "home", label: "工作台", icon: LayoutDashboard, ready: true },
{ key: "studio", label: "编排", icon: "◆", group: "BUILD", ready: true }, { key: "studio", label: "编排", icon: Workflow, group: "BUILD", ready: true },
{ key: "kb", label: "知识库", icon: "▣", group: "BUILD", ready: true }, { key: "kb", label: "知识库", icon: Database, group: "BUILD", ready: true },
{ key: "report", label: "报告", icon: "▦", group: "BUILD", ready: true }, { key: "report", label: "报告", icon: FileText, group: "BUILD", ready: true },
{ key: "runs", label: "运行", icon: "▸", group: "RUN", ready: true }, { key: "runs", label: "运行", icon: Activity, group: "RUN", ready: true },
{ key: "memory", label: "记忆", icon: "◇", group: "MANAGE", ready: true }, { key: "memory", label: "记忆", icon: Bookmark, group: "MANAGE", ready: true },
{ key: "market", label: "市场", icon: "⌧", group: "MANAGE" }, { key: "market", label: "市场", icon: Boxes, group: "MANAGE" },
{ key: "admin", label: "管理", icon: "⚙", group: "MANAGE" }, { key: "admin", label: "管理", icon: Settings, group: "MANAGE" },
]; ];
// 左导航:深色,激活态紫色高亮 + 左侧光条。 // 左导航:深色,激活态紫色高亮 + 左侧光条,描线图标
export function LeftNav({ active, onSelect }: { active: ViewKey; onSelect: (v: ViewKey) => void }) { export function LeftNav({ active, onSelect }: { active: ViewKey; onSelect: (v: ViewKey) => void }) {
let lastGroup: string | undefined; let lastGroup: string | undefined;
return ( return (
<nav className="flex w-[72px] shrink-0 flex-col gap-0.5 border-r border-line bg-ink-900 py-2"> <nav className="flex w-[68px] shrink-0 flex-col gap-0.5 border-r border-line bg-ink-900 py-2">
{ITEMS.map((it) => { {ITEMS.map((it) => {
const header = it.group && it.group !== lastGroup ? it.group : null; const header = it.group && it.group !== lastGroup ? it.group : null;
lastGroup = it.group ?? lastGroup; lastGroup = it.group ?? lastGroup;
const isActive = active === it.key; const isActive = active === it.key;
const Icon = it.icon;
return ( return (
<div key={it.key}> <div key={it.key}>
{header && <div className="mt-3 px-2 text-[9px] font-semibold tracking-widest text-slate-600">{header}</div>} {header && <div className="mt-3 px-2 text-[9px] font-semibold tracking-widest text-slate-600">{header}</div>}
<button <button
onClick={() => onSelect(it.key)} onClick={() => onSelect(it.key)}
className={`relative flex w-full flex-col items-center gap-1 py-2.5 text-[11px] transition ${ className={cn(
isActive ? "text-violet-300" : "text-slate-500 hover:bg-ink-800 hover:text-slate-300" "relative flex w-full flex-col items-center gap-1 py-2.5 text-[11px] transition",
}`} isActive ? "text-brand-400" : "text-slate-500 hover:bg-ink-800 hover:text-slate-300",
)}
title={it.ready === false ? `${it.label}(规划中)` : it.label} title={it.ready === false ? `${it.label}(规划中)` : it.label}
> >
{isActive && <span className="absolute left-0 top-1/2 h-6 w-0.5 -translate-y-1/2 rounded-r bg-gradient-to-b from-violet-400 to-cyan-400" />} {isActive && (
<span className={`text-lg leading-none ${isActive ? "drop-shadow-[0_0_6px_rgba(139,92,246,0.6)]" : ""}`}>{it.icon}</span> <span className="absolute left-0 top-1/2 h-6 w-0.5 -translate-y-1/2 rounded-r bg-gradient-to-b from-brand-400 to-accent-400" />
)}
<Icon
className={cn("h-[18px] w-[18px]", isActive && "drop-shadow-[0_0_6px_rgba(139,92,246,0.6)]")}
strokeWidth={isActive ? 2.2 : 1.8}
/>
{it.label} {it.label}
{it.ready === false && <span className="text-[8px] text-slate-600"></span>} {it.ready === false && <span className="text-[8px] text-slate-600"></span>}
</button> </button>
+27 -16
View File
@@ -1,11 +1,13 @@
import { User, ChevronDown } from "lucide-react";
import type { Identity } from "../lib/api"; import type { Identity } from "../lib/api";
import { useHealth } from "../lib/health"; import { useHealth } from "../lib/health";
import { cn } from "../ui";
function Light({ on, label, unknown }: { on?: boolean; label: string; unknown?: boolean }) { function Light({ on, label, unknown }: { on?: boolean; label: string; unknown?: boolean }) {
const dot = unknown ? "bg-slate-600" : on ? "bg-emerald-400 shadow-[0_0_8px_rgba(52,211,153,0.7)]" : "bg-rose-500"; const dot = unknown ? "bg-slate-600" : on ? "bg-success shadow-[0_0_8px_rgba(52,211,153,0.7)]" : "bg-danger";
return ( return (
<span className="flex items-center gap-1.5 text-[11px] text-slate-500" title={label}> <span className="flex items-center gap-1.5 text-[11px] text-slate-500" title={unknown ? `${label}(状态未透出)` : label}>
<span className={`h-1.5 w-1.5 rounded-full ${dot}`} /> <span className={cn("h-1.5 w-1.5 rounded-full", dot)} />
{label} {label}
</span> </span>
); );
@@ -17,16 +19,22 @@ export function TopBar({ identity, setIdentity }: { identity: Identity; setIdent
return ( return (
<header className="flex h-12 shrink-0 items-center gap-3 border-b border-line bg-ink-900/80 px-3 backdrop-blur"> <header className="flex h-12 shrink-0 items-center gap-3 border-b border-line bg-ink-900/80 px-3 backdrop-blur">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-gradient-to-br from-violet-500 to-cyan-500 text-xs font-bold text-white"> <div className="flex h-6 w-6 items-center justify-center rounded-md bg-gradient-to-br from-brand to-accent text-xs font-bold text-white">
S S
</div> </div>
<span className="brand-gradient text-sm font-semibold">sundynix-agentix</span> <span className="brand-gradient text-sm font-semibold">sundynix-agentix</span>
</div> </div>
<select className="rounded-md border border-line bg-ink-800 px-2 py-0.5 text-xs text-slate-300" defaultValue="通用版"> <div className="relative">
<option></option> <select
<option></option> className="appearance-none rounded-md border border-line bg-ink-800 py-1 pl-2.5 pr-7 text-xs text-slate-300 focus:border-brand focus:outline-none"
<option></option> defaultValue="通用版"
</select> >
<option></option>
<option></option>
<option></option>
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
</div>
<div className="ml-2 flex items-center gap-3"> <div className="ml-2 flex items-center gap-3">
<Light on={h.gateway} label="Gateway" /> <Light on={h.gateway} label="Gateway" />
<Light on={h.persisted} label="DB" /> <Light on={h.persisted} label="DB" />
@@ -35,14 +43,17 @@ export function TopBar({ identity, setIdentity }: { identity: Identity; setIdent
<Light unknown label="Neo4j" /> <Light unknown label="Neo4j" />
</div> </div>
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
<div className="relative">
<User className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
<input
className="w-24 rounded-md border border-line bg-ink-800 py-1 pl-7 pr-2 text-xs text-slate-200 focus:border-brand focus:outline-none"
value={identity.userId}
onChange={(e) => setIdentity({ ...identity, userId: e.target.value })}
title="用户"
/>
</div>
<input <input
className="w-20 rounded-md border border-line bg-ink-800 px-2 py-1 text-xs text-slate-200 focus:border-violet-500/60 focus:outline-none" className="w-24 rounded-md border border-line bg-ink-800 px-2 py-1 text-xs text-slate-200 focus:border-brand focus:outline-none"
value={identity.userId}
onChange={(e) => setIdentity({ ...identity, userId: e.target.value })}
title="用户"
/>
<input
className="w-24 rounded-md border border-line bg-ink-800 px-2 py-1 text-xs text-slate-200 focus:border-violet-500/60 focus:outline-none"
value={identity.sessionId} value={identity.sessionId}
onChange={(e) => setIdentity({ ...identity, sessionId: e.target.value })} onChange={(e) => setIdentity({ ...identity, sessionId: e.target.value })}
title="会话" title="会话"
@@ -13,11 +13,13 @@ import {
} from "@xyflow/react"; } from "@xyflow/react";
import "@xyflow/react/dist/style.css"; import "@xyflow/react/dist/style.css";
import { Play, ShieldCheck } from "lucide-react";
import { nodeTypes, type NodeStatus } from "./TypedNode"; import { nodeTypes, type NodeStatus } from "./TypedNode";
import { Inspector } from "./Inspector"; import { Inspector } from "./Inspector";
import { NODE_KINDS, NODE_ORDER } from "./nodeCatalog"; import { NODE_KINDS, NODE_ORDER } from "./nodeCatalog";
import { exportDsl, validate, type Issue, type TaskDsl } from "../lib/dsl"; import { exportDsl, validate, type Issue, type TaskDsl } from "../lib/dsl";
import type { RunPhase } from "../lib/run"; import type { RunPhase } from "../lib/run";
import { Button } from "../ui";
let seq = 0; let seq = 0;
@@ -113,28 +115,21 @@ export function StudioView({
{/* 中画布 */} {/* 中画布 */}
<div className="relative flex-1 bg-ink-950"> <div className="relative flex-1 bg-ink-950">
<div className="absolute left-0 right-0 top-0 z-10 flex items-center gap-2 border-b border-line bg-ink-900/90 px-2 py-1.5 backdrop-blur"> <div className="absolute left-0 right-0 top-0 z-10 flex items-center gap-2 border-b border-line bg-ink-900/90 px-2 py-1.5 backdrop-blur">
<button <Button variant="primary" size="sm" icon={Play} onClick={run} disabled={running || nodes.length === 0}>
onClick={run} {running ? "运行中…" : "运行"}
disabled={running || nodes.length === 0} </Button>
className="rounded-md bg-violet-600 px-3 py-1 text-xs font-medium text-white hover:bg-violet-500 disabled:opacity-40" <Button size="sm" icon={ShieldCheck} onClick={() => setIssues(validate(nodes, edges))}>
>
{running ? "运行中…" : "▶ 运行"}
</button>
<button
onClick={() => setIssues(validate(nodes, edges))}
className="rounded-md border border-line px-3 py-1 text-xs text-slate-300 hover:bg-ink-800"
>
</button> </Button>
<span className="ml-1 text-[11px] text-slate-500"> <span className="ml-1 text-[11px] text-slate-500">
{nodes.length} · {edges.length} 线 {nodes.length} · {edges.length} 线
</span> </span>
{issues && ( {issues && (
<span className="ml-auto text-[11px]"> <span className="ml-auto text-[11px]">
{issues.length === 0 ? ( {issues.length === 0 ? (
<span className="text-emerald-400"> </span> <span className="text-success"> </span>
) : ( ) : (
<span className="text-amber-400">{issues.length} </span> <span className="text-warn">{issues.length} </span>
)} )}
</span> </span>
)} )}
@@ -160,7 +155,7 @@ export function StudioView({
{issues && issues.length > 0 && ( {issues && issues.length > 0 && (
<div className="absolute bottom-2 left-2 max-w-md rounded-lg border border-line bg-ink-850 p-2 text-[11px] shadow-card"> <div className="absolute bottom-2 left-2 max-w-md rounded-lg border border-line bg-ink-850 p-2 text-[11px] shadow-card">
{issues.map((i, idx) => ( {issues.map((i, idx) => (
<div key={idx} className={i.level === "error" ? "text-rose-400" : "text-amber-400"}> <div key={idx} className={i.level === "error" ? "text-danger" : "text-warn"}>
{i.level === "error" ? "✗" : "⚠"} {i.msg} {i.level === "error" ? "✗" : "⚠"} {i.msg}
</div> </div>
))} ))}
@@ -0,0 +1,38 @@
import type { ReactNode } from "react";
import { cn } from "./cn";
type Tone = "neutral" | "brand" | "accent" | "success" | "warn" | "danger";
const tones: Record<Tone, string> = {
neutral: "bg-white/5 text-slate-400",
brand: "bg-brand/15 text-brand-400",
accent: "bg-accent/15 text-accent-400",
success: "bg-success/15 text-success",
warn: "bg-warn/15 text-warn",
danger: "bg-danger/15 text-danger",
};
export function Badge({ tone = "neutral", className, children }: { tone?: Tone; className?: string; children: ReactNode }) {
return (
<span className={cn("inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[9px] font-medium leading-none", tones[tone], className)}>
{children}
</span>
);
}
// Dot 状态小圆点(可脉冲)。
export function Dot({ tone = "neutral", pulse }: { tone?: Tone | "running"; pulse?: boolean }) {
const color =
tone === "success"
? "bg-success"
: tone === "danger"
? "bg-danger"
: tone === "warn"
? "bg-warn"
: tone === "running" || tone === "accent"
? "bg-accent"
: tone === "brand"
? "bg-brand"
: "bg-slate-600";
return <span className={cn("h-2 w-2 shrink-0 rounded-full", color, pulse && "animate-pulse")} />;
}
@@ -0,0 +1,37 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { type LucideIcon } from "lucide-react";
import { cn } from "./cn";
type Variant = "primary" | "secondary" | "ghost" | "danger";
type Size = "sm" | "md";
const base =
"inline-flex items-center justify-center gap-1.5 rounded-md font-medium transition select-none disabled:cursor-not-allowed disabled:opacity-40 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand/50";
const variants: Record<Variant, string> = {
primary: "bg-brand text-white hover:bg-brand-500 active:bg-brand-600 shadow-glow",
secondary: "border border-line bg-ink-800 text-slate-200 hover:bg-ink-700 hover:border-ink-600",
ghost: "text-slate-400 hover:bg-ink-800 hover:text-slate-200",
danger: "border border-danger/50 text-danger hover:bg-danger/10",
};
const sizes: Record<Size, string> = {
sm: "h-8 px-3 text-xs",
md: "h-9 px-4 text-sm",
};
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
icon?: LucideIcon;
children?: ReactNode;
}
export function Button({ variant = "secondary", size = "md", icon: Icon, className, children, ...rest }: Props) {
return (
<button className={cn(base, variants[variant], sizes[size], className)} {...rest}>
{Icon && <Icon className={size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4"} strokeWidth={2} />}
{children}
</button>
);
}
+36
View File
@@ -0,0 +1,36 @@
import type { ReactNode } from "react";
import { type LucideIcon } from "lucide-react";
import { cn } from "./cn";
// Card 基础卡片容器。
export function Card({ className, children }: { className?: string; children: ReactNode }) {
return <div className={cn("rounded-lg border border-line bg-ink-900 shadow-card", className)}>{children}</div>;
}
// Panel 带表头的分区面板(表头 icon+标题+右侧动作,主体可滚动)。
export function Panel({
title,
icon: Icon,
actions,
className,
bodyClassName,
children,
}: {
title: ReactNode;
icon?: LucideIcon;
actions?: ReactNode;
className?: string;
bodyClassName?: string;
children: ReactNode;
}) {
return (
<section className={cn("flex min-h-0 flex-col rounded-lg border border-line bg-ink-900 shadow-card", className)}>
<div className="flex items-center gap-2 border-b border-line px-4 py-2.5">
{Icon && <Icon className="h-3.5 w-3.5 text-slate-500" strokeWidth={2} />}
<span className="text-[11px] font-medium text-slate-400">{title}</span>
{actions && <div className="ml-auto flex items-center gap-2">{actions}</div>}
</div>
<div className={cn("min-h-0 flex-1 overflow-y-auto p-4", bodyClassName)}>{children}</div>
</section>
);
}
@@ -0,0 +1,51 @@
import type { ReactNode } from "react";
import { X } from "lucide-react";
import { Button } from "./Button";
// Dialog 轻量模态:遮罩 + 居中卡片。open=false 不渲染。
export function Dialog({
open,
onClose,
title,
children,
footer,
}: {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
footer?: ReactNode;
}) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 p-6" onClick={onClose}>
<div
className="w-full max-w-md rounded-lg border border-line bg-ink-900 shadow-card"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center border-b border-line px-4 py-3">
<h3 className="text-sm font-medium text-slate-100">{title}</h3>
<button onClick={onClose} className="ml-auto text-slate-500 hover:text-slate-300">
<X className="h-4 w-4" />
</button>
</div>
<div className="px-4 py-4 text-sm text-slate-300">{children}</div>
{footer && <div className="flex justify-end gap-2 border-t border-line px-4 py-3">{footer}</div>}
</div>
</div>
);
}
// ConfirmFooter 常用的取消/确认按钮组。
export function ConfirmFooter({ onCancel, onConfirm, confirmLabel = "确认", danger }: { onCancel: () => void; onConfirm: () => void; confirmLabel?: string; danger?: boolean }) {
return (
<>
<Button variant="ghost" size="sm" onClick={onCancel}>
</Button>
<Button variant={danger ? "danger" : "primary"} size="sm" onClick={onConfirm}>
{confirmLabel}
</Button>
</>
);
}
@@ -0,0 +1,34 @@
import type { ReactNode } from "react";
import { type LucideIcon } from "lucide-react";
import { cn } from "./cn";
// Skeleton 占位骨架(加载态)。
export function Skeleton({ className }: { className?: string }) {
return <div className={cn("animate-pulse rounded-md bg-ink-800", className)} />;
}
// EmptyState 空状态:图标 + 标题 + 说明 + 可选动作。
export function EmptyState({
icon: Icon,
title,
desc,
action,
className,
}: {
icon: LucideIcon;
title: string;
desc?: ReactNode;
action?: ReactNode;
className?: string;
}) {
return (
<div className={cn("flex h-full flex-col items-center justify-center gap-3 px-6 py-10 text-center", className)}>
<div className="flex h-12 w-12 items-center justify-center rounded-xl border border-line bg-ink-850 text-slate-500">
<Icon className="h-6 w-6" strokeWidth={1.5} />
</div>
<div className="text-sm font-medium text-slate-300">{title}</div>
{desc && <div className="max-w-sm text-xs leading-relaxed text-slate-500">{desc}</div>}
{action && <div className="mt-1">{action}</div>}
</div>
);
}
@@ -0,0 +1,32 @@
import type { InputHTMLAttributes, TextareaHTMLAttributes, SelectHTMLAttributes, ReactNode } from "react";
import { cn } from "./cn";
const fieldBase =
"w-full rounded-md border border-line bg-ink-950 text-sm text-slate-200 placeholder:text-slate-600 transition focus:border-brand focus:outline-none focus:ring-1 focus:ring-brand/40 disabled:opacity-50";
export function Input({ className, ...rest }: InputHTMLAttributes<HTMLInputElement>) {
return <input className={cn(fieldBase, "h-9 px-3", className)} {...rest} />;
}
export function Textarea({ className, ...rest }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
return <textarea className={cn(fieldBase, "px-3 py-2 leading-relaxed", className)} {...rest} />;
}
export function Select({ className, children, ...rest }: SelectHTMLAttributes<HTMLSelectElement>) {
return (
<select className={cn(fieldBase, "h-9 cursor-pointer px-3", className)} {...rest}>
{children}
</select>
);
}
// Field 包一层标签 + 控件,统一表单纵向节奏。
export function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<label className="flex flex-col gap-1.5">
<span className="text-[11px] font-medium text-slate-400">{label}</span>
{children}
{hint && <span className="text-[10px] text-slate-600">{hint}</span>}
</label>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { cn } from "./cn";
export interface TabDef<T extends string> {
key: T;
label: string;
count?: number;
}
// Tabs 受控标签条(下划线高亮)。
export function Tabs<T extends string>({
tabs,
value,
onChange,
className,
}: {
tabs: TabDef<T>[];
value: T;
onChange: (v: T) => void;
className?: string;
}) {
return (
<div className={cn("flex items-center gap-1", className)}>
{tabs.map((t) => {
const active = t.key === value;
return (
<button
key={t.key}
onClick={() => onChange(t.key)}
className={cn(
"relative px-3 py-2 text-xs transition",
active ? "font-medium text-brand-400" : "text-slate-500 hover:text-slate-300",
)}
>
{t.label}
{t.count != null && t.count > 0 && (
<span className="ml-1 rounded bg-white/5 px-1 text-[9px] text-slate-400">{t.count}</span>
)}
{active && <span className="absolute inset-x-2 -bottom-px h-0.5 rounded bg-brand" />}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,64 @@
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from "react";
import { CheckCircle2, AlertTriangle, Info, X } from "lucide-react";
import { cn } from "./cn";
type ToastTone = "success" | "error" | "info";
interface Toast {
id: number;
tone: ToastTone;
msg: string;
}
interface ToastCtx {
push: (tone: ToastTone, msg: string) => void;
}
const Ctx = createContext<ToastCtx>({ push: () => {} });
// useToast 在任意组件里弹出全局通知。
export function useToast() {
return useContext(Ctx);
}
const icons = { success: CheckCircle2, error: AlertTriangle, info: Info };
const accent = {
success: "text-success",
error: "text-danger",
info: "text-accent-400",
};
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const seq = useRef(0);
const push = useCallback((tone: ToastTone, msg: string) => {
const id = ++seq.current;
setToasts((t) => [...t, { id, tone, msg }]);
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 4200);
}, []);
const dismiss = (id: number) => setToasts((t) => t.filter((x) => x.id !== id));
return (
<Ctx.Provider value={{ push }}>
{children}
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-80 flex-col gap-2">
{toasts.map((t) => {
const Icon = icons[t.tone];
return (
<div
key={t.id}
className="pointer-events-auto flex items-start gap-2.5 rounded-lg border border-line bg-ink-850 px-3 py-2.5 shadow-card"
>
<Icon className={cn("mt-0.5 h-4 w-4 shrink-0", accent[t.tone])} strokeWidth={2} />
<span className="flex-1 text-xs leading-relaxed text-slate-200">{t.msg}</span>
<button onClick={() => dismiss(t.id)} className="text-slate-600 hover:text-slate-300">
<X className="h-3.5 w-3.5" />
</button>
</div>
);
})}
</div>
</Ctx.Provider>
);
}
+4
View File
@@ -0,0 +1,4 @@
// cn 合并 className —— 过滤掉 falsy,空格连接(零依赖的轻量 clsx)。
export function cn(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
+10
View File
@@ -0,0 +1,10 @@
// UI primitives 桶文件 —— 统一从 "../ui" 引入。
export { cn } from "./cn";
export { Button } from "./Button";
export { Input, Textarea, Select, Field } from "./Input";
export { Card, Panel } from "./Card";
export { Badge, Dot } from "./Badge";
export { Tabs, type TabDef } from "./Tabs";
export { Skeleton, EmptyState } from "./Feedback";
export { Dialog, ConfirmFooter } from "./Dialog";
export { ToastProvider, useToast } from "./Toast";
+46 -35
View File
@@ -1,20 +1,23 @@
import { Workflow, Database, Bookmark, FileText, Plus, Upload, ArrowRight, type LucideIcon } from "lucide-react";
import { useHealth } from "../lib/health"; import { useHealth } from "../lib/health";
import type { ViewKey } from "../shell/LeftNav"; 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 }) { function Stat({ label, value, sub, accent }: { label: string; value: string; sub: string; accent: string }) {
return ( 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="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 className="mt-0.5 text-[11px] text-slate-500">{sub}</div>
</div> </div>
); );
} }
const CAPS = [ const CAPS: { icon: LucideIcon; title: string; desc: string; to: ViewKey; color: string }[] = [
{ icon: "◆", title: "Agent 编排", desc: "React Flow 画布 → Eino 动态图 → 流式执行", to: "studio" as ViewKey, color: "text-violet-400" }, { icon: Workflow, title: "Agent 编排", desc: "React Flow 画布 → Eino 动态图 → 流式执行", to: "studio", color: "text-brand-400" },
{ icon: "▣", title: "RAG 知识库", desc: "多文件入库 + 向量/全文/图谱 三路混合检索", to: "kb" as ViewKey, color: "text-cyan-400" }, { icon: Database, title: "RAG 知识库", desc: "多文件入库 + 向量/全文/图谱 三路混合检索", to: "kb", color: "text-accent-400" },
{ icon: "◇", title: "偏好记忆", desc: "画像 + 多轮历史,让模型“知道是你”", to: "memory" as ViewKey, color: "text-emerald-400" }, { icon: FileText, title: "报告生成", desc: "规划大纲 → 各章并行检索撰写 → 渲染 Word", to: "report", color: "text-indigo-300" },
{ icon: Bookmark, title: "偏好记忆", desc: "画像 + 多轮历史,让模型“知道是你”", to: "memory", color: "text-success" },
]; ];
// 工作台:平台概览 + 快捷入口(深色 AI 控制台首页)。 // 工作台:平台概览 + 快捷入口(深色 AI 控制台首页)。
@@ -24,49 +27,57 @@ export function Home({ onSelect }: { onSelect: (v: ViewKey) => void }) {
<div className="h-full overflow-auto p-8"> <div className="h-full overflow-auto p-8">
<div className="mx-auto max-w-5xl"> <div className="mx-auto max-w-5xl">
<div className="flex items-center gap-3"> <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 S
</div> </div>
<div> <div>
<h1 className="brand-gradient text-2xl font-bold leading-tight">sundynix-agentix</h1> <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> </div>
<div className="mt-6 grid grid-cols-2 gap-3 md:grid-cols-4"> <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="DeepSeek" sub="chat · 控制面" accent="text-brand-400" />
<Stat label="向量模型" value="百炼 v3" sub="embedding · 1024维" accent="text-cyan-300" /> <Stat label="向量模型" value="百炼 v3" sub="embedding · 1024维" accent="text-accent-400" />
<Stat label="混合检索" value="3 路" sub="向量+全文+图谱" accent="text-emerald-300" /> <Stat label="混合检索" value="3 路" sub="向量+全文+图谱" accent="text-success" />
<Stat label="网关" value={h.gateway ? "在线" : "离线"} sub={h.persisted ? "持久化就绪" : "降级"} accent={h.gateway ? "text-emerald-300" : "text-rose-300"} /> <Stat label="网关" value={h.gateway ? "在线" : "离线"} sub={h.persisted ? "持久化就绪" : "降级"} accent={h.gateway ? "text-success" : "text-danger"} />
</div> </div>
<div className="mt-8 grid grid-cols-1 gap-4 md:grid-cols-3"> <div className="mt-8 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
{CAPS.map((c) => ( {CAPS.map((c) => {
<button const Icon = c.icon;
key={c.to} return (
onClick={() => onSelect(c.to)} <button
className="group rounded-xl border border-line bg-ink-850 p-5 text-left transition hover:border-violet-500/50 hover:bg-ink-800" key={c.to}
> onClick={() => onSelect(c.to)}
<div className={`text-2xl ${c.color}`}>{c.icon}</div> className="group rounded-lg border border-line bg-ink-850 p-5 text-left transition hover:border-brand/50 hover:bg-ink-800"
<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> <Icon className={cn("h-6 w-6", c.color)} strokeWidth={1.8} />
<div className="mt-3 text-xs text-violet-400 opacity-0 transition group-hover:opacity-100"> </div> <div className="mt-3 font-medium text-slate-100">{c.title}</div>
</button> <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>
<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="mb-3 text-sm font-medium text-slate-300"></div>
<div className="flex flex-wrap gap-2"> <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"> <Button variant="primary" size="sm" icon={Plus} onClick={() => onSelect("studio")}>
Agent Agent
</button> </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 size="sm" icon={Upload} onClick={() => onSelect("kb")}>
</button> </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 size="sm" icon={FileText} onClick={() => onSelect("report")}>
</button> </Button>
<Button size="sm" icon={Bookmark} onClick={() => onSelect("memory")}>
</Button>
</div> </div>
</div> </div>
</div> </div>
+41 -75
View File
@@ -1,5 +1,7 @@
import { useRef, useState } from "react"; 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 { 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 { interface IngestLog {
t: string; t: string;
@@ -18,6 +20,7 @@ interface Progress {
// 知识库管理:实时入库监控(解析→切块→向量化→写入 + 拆分可视化)+ 检索调试台。 // 知识库管理:实时入库监控(解析→切块→向量化→写入 + 拆分可视化)+ 检索调试台。
export function KbView() { export function KbView() {
const toast = useToast();
const [kb, setKb] = useState("docs"); const [kb, setKb] = useState("docs");
const [text, setText] = useState(""); const [text, setText] = useState("");
const [logs, setLogs] = useState<IngestLog[]>([]); const [logs, setLogs] = useState<IngestLog[]>([]);
@@ -28,21 +31,19 @@ export function KbView() {
const [topK, setTopK] = useState(5); const [topK, setTopK] = useState(5);
const [hits, setHits] = useState<KbHit[] | null>(null); const [hits, setHits] = useState<KbHit[] | null>(null);
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [err, setErr] = useState("");
const [graph, setGraph] = useState<Triple[] | null>(null); const [graph, setGraph] = useState<Triple[] | null>(null);
const onGraph = async () => { const onGraph = async () => {
try { try {
setGraph(await graphKb(kb)); setGraph(await graphKb(kb));
} catch (e) { } catch (e) {
setErr((e as Error).message); toast.push("error", (e as Error).message);
} }
}; };
const stamp = () => new Date().toLocaleTimeString(); const stamp = () => new Date().toLocaleTimeString();
const ingesting = prog?.active ?? false; const ingesting = prog?.active ?? false;
// 订阅某入库 job 的进度流。
const follow = (job: string, label: string) => { const follow = (job: string, label: string) => {
setProg({ active: true, stage: "提交", chunks: [] }); setProg({ active: true, stage: "提交", chunks: [] });
streamIngest( streamIngest(
@@ -59,10 +60,8 @@ export function KbView() {
() => () =>
setProg((p) => { setProg((p) => {
const ok = p?.stage !== "失败"; const ok = p?.stage !== "失败";
setLogs((l) => [ setLogs((l) => [{ t: stamp(), msg: ok ? `${label}${p?.total ?? 0} 块入库完成` : `${label}${p?.error ?? "失败"}`, ok }, ...l]);
{ t: stamp(), msg: ok ? `${label}${p?.total ?? 0} 块入库完成` : `${label}${p?.error ?? "失败"}`, ok }, toast.push(ok ? "success" : "error", ok ? `${label} 入库完成` : `${label} 入库失败`);
...l,
]);
return p ? { ...p, active: false } : null; return p ? { ...p, active: false } : null;
}), }),
() => setProg((p) => (p ? { ...p, active: false, stage: "连接中断" } : null)), () => setProg((p) => (p ? { ...p, active: false, stage: "连接中断" } : null)),
@@ -95,11 +94,10 @@ export function KbView() {
const onSearch = async () => { const onSearch = async () => {
if (!q.trim()) return; if (!q.trim()) return;
setSearching(true); setSearching(true);
setErr("");
try { try {
setHits(await searchKb(kb, q, topK)); setHits(await searchKb(kb, q, topK));
} catch (e) { } catch (e) {
setErr((e as Error).message); toast.push("error", (e as Error).message);
setHits(null); setHits(null);
} finally { } finally {
setSearching(false); setSearching(false);
@@ -112,13 +110,7 @@ export function KbView() {
<div className="flex h-full flex-col"> <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"> <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> <span className="text-sm font-semibold text-slate-300"></span>
<input <Input className="h-8 w-40" value={kb} onChange={(e) => setKb(e.target.value)} placeholder="知识库名" title="知识库(Milvus kb 字段分区)" />
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 字段分区)"
/>
<span className="text-[11px] text-slate-500"> / / / </span> <span className="text-[11px] text-slate-500"> / / / </span>
</div> </div>
@@ -126,28 +118,22 @@ export function KbView() {
{/* 左:入库 + 实时监控 */} {/* 左:入库 + 实时监控 */}
<section className="flex w-1/2 flex-col border-r border-line p-4"> <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> <h3 className="mb-2 text-xs font-semibold text-slate-400"></h3>
<textarea <Textarea className="h-24 resize-none" value={text} onChange={(e) => setText(e.target.value)} placeholder="每行一条知识,或上传文件" />
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={"每行一条知识,或上传文件"}
/>
<div className="mt-2 flex items-center gap-2"> <div className="mt-2 flex items-center gap-2">
<button <Button variant="primary" size="sm" icon={Upload} onClick={onIngest} disabled={ingesting || !text.trim()}>
onClick={onIngest} {ingesting ? "入库中…" : "入库文本"}
disabled={ingesting || !text.trim()} </Button>
className="rounded-md bg-emerald-600 px-3 py-1 text-sm text-white hover:bg-emerald-500 disabled:opacity-40"
>
{ingesting ? "入库中…" : "⬆ 入库文本"}
</button>
<span className="text-[11px] text-slate-500"></span> <span className="text-[11px] text-slate-500"></span>
<Button size="sm" icon={FileUp} onClick={() => fileRef.current?.click()} disabled={ingesting}>
</Button>
<input <input
ref={fileRef} ref={fileRef}
type="file" type="file"
accept=".txt,.md,.csv,.docx,.xlsx,.pdf" accept=".txt,.md,.csv,.docx,.xlsx,.pdf"
onChange={(e) => onFile(e.target.files?.[0])} onChange={(e) => onFile(e.target.files?.[0])}
disabled={ingesting} 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> </div>
<span className="mt-1 text-[10px] text-slate-500"> txt/md/csv/docx/xlsx/pdfdocx/xlsx/pdf mcp-py </span> <span className="mt-1 text-[10px] text-slate-500"> txt/md/csv/docx/xlsx/pdfdocx/xlsx/pdf mcp-py </span>
@@ -155,35 +141,38 @@ export function KbView() {
{/* 实时流水线进度 */} {/* 实时流水线进度 */}
{prog && ( {prog && (
<div className="mt-3 rounded-lg border border-line bg-ink-850 p-2.5"> <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) => { {["解析", "切块", "向量化", "写Milvus", "写Bleve", "完成"].map((s) => {
const active = prog.stage.startsWith(s) || (s === "完成" && prog.stage === "完成"); const active = prog.stage.startsWith(s) || (s === "完成" && prog.stage === "完成");
const passed = stageOrder(prog.stage) > stageOrder(s); const passed = stageOrder(prog.stage) > stageOrder(s);
return ( return (
<span <span
key={s} 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 === "失败" prog.stage === "失败"
? "bg-ink-800 text-slate-600" ? "bg-ink-800 text-slate-600"
: active : active
? "bg-violet-600 text-white shadow-glow" ? "bg-brand text-white shadow-glow"
: passed : passed
? "bg-emerald-500/15 text-emerald-400" ? "bg-success/15 text-success"
: "bg-ink-800 text-slate-600" : "bg-ink-800 text-slate-600",
}`} )}
> >
{s} {s}
</span> </span>
); );
})} })}
</div> </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 ? ( {prog.total ? (
<div className="mt-2"> <div className="mt-2">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-ink-700"> <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>
<div className="mt-1 text-[10px] text-slate-500"> {prog.done ?? 0}/{prog.total} {pct}%</div>
</div> </div>
) : null} ) : null}
{prog.chunks.length > 0 && ( {prog.chunks.length > 0 && (
@@ -205,7 +194,7 @@ export function KbView() {
<ul className="flex-1 space-y-1 overflow-auto"> <ul className="flex-1 space-y-1 overflow-auto">
{logs.length === 0 && <li className="text-xs text-slate-600"></li>} {logs.length === 0 && <li className="text-xs text-slate-600"></li>}
{logs.map((l, i) => ( {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} <span className="text-slate-600">{l.t}</span> {l.ok ? "✓" : "✗"} {l.msg}
</li> </li>
))} ))}
@@ -216,42 +205,21 @@ export function KbView() {
<section className="flex w-1/2 flex-col p-4"> <section className="flex w-1/2 flex-col p-4">
<h3 className="mb-2 text-xs font-semibold text-slate-400"> + rerank</h3> <h3 className="mb-2 text-xs font-semibold text-slate-400"> + rerank</h3>
<div className="flex gap-2"> <div className="flex gap-2">
<input <Input className="flex-1" value={q} onChange={(e) => setQ(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onSearch()} placeholder="输入查询,语义召回相关片段…" />
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" <Input type="number" className="w-16" value={topK} min={1} max={20} onChange={(e) => setTopK(Number(e.target.value))} title="TopK" />
value={q} <Button variant="primary" size="md" icon={Search} onClick={onSearch} disabled={searching || !q.trim()}>
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"
>
{searching ? "检索中…" : "检索"} {searching ? "检索中…" : "检索"}
</button> </Button>
</div> </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"> <ul className="mt-3 max-h-[40%] space-y-2 overflow-auto">
{hits === null && <li className="text-xs text-slate-600"></li>} {hits === null && <li className="text-xs text-slate-600"></li>}
{hits !== null && hits.length === 0 && ( {hits !== null && hits.length === 0 && <li className="text-xs text-slate-600"> RAG </li>}
<li className="text-xs text-slate-600"> RAG </li>
)}
{hits?.map((h, i) => ( {hits?.map((h, i) => (
<li key={i} className="rounded-lg border border-line bg-ink-850 p-2"> <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]"> <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="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>
<div className="text-xs text-slate-300">{h.text}</div> <div className="text-xs text-slate-300">{h.text}</div>
</li> </li>
@@ -261,20 +229,18 @@ export function KbView() {
{/* 知识图谱(Neo4j / GraphRAG */} {/* 知识图谱(Neo4j / GraphRAG */}
<div className="mt-3 flex items-center justify-between border-t border-line pt-2"> <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> <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> </div>
<ul className="mt-2 flex-1 space-y-1 overflow-auto"> <ul className="mt-2 flex-1 space-y-1 overflow-auto">
{graph === null && <li className="text-[11px] text-slate-600"></li>} {graph === null && <li className="text-[11px] text-slate-600"></li>}
{graph !== null && graph.length === 0 && ( {graph !== null && graph.length === 0 && <li className="text-[11px] text-slate-600"> chat + </li>}
<li className="text-[11px] text-slate-600"> chat + </li>
)}
{graph?.map((t, i) => ( {graph?.map((t, i) => (
<li key={i} className="flex items-center gap-1 text-[11px]"> <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="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> </li>
))} ))}
</ul> </ul>
@@ -1,11 +1,19 @@
import { Hammer } from "lucide-react";
import { Badge } from "../ui";
// 规划中模块占位 —— 深色,露出信息架构与定位。 // 规划中模块占位 —— 深色,露出信息架构与定位。
export function Placeholder({ title, desc }: { title: string; desc: string }) { export function Placeholder({ title, desc }: { title: string; desc: string }) {
return ( return (
<div className="flex h-full items-center justify-center p-8"> <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> <div className="mb-2 text-base font-medium text-slate-200">{title}</div>
<p className="text-sm leading-relaxed text-slate-500">{desc}</p> <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>
</div> </div>
); );
@@ -1,19 +1,21 @@
import { useRef, useState } from "react"; 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 { generateReport, streamTokens, streamExec, reportDownloadUrl, type Identity, type ExecEvent } from "../lib/api";
import { ExecTrace } from "../components/ExecTrace"; import { ExecTrace } from "../components/ExecTrace";
import { Button, Input, Field, Panel, Dot, EmptyState, useToast } from "../ui";
type Phase = "idle" | "running" | "done" | "error"; type Phase = "idle" | "running" | "done" | "error";
// 报告生成:输入主题(+可选知识库) → 触发后端专用编排 // 报告生成:输入主题(+可选知识库) → 触发后端专用编排
// (规划大纲 → 各章并行检索+撰写 → 渲染 Word),实时看进度与正文,完成后下载 .docx。 // (规划大纲 → 各章并行检索+撰写 → 渲染 Word),实时看进度与正文,完成后下载 .docx。
export function ReportView({ identity }: { identity: Identity }) { export function ReportView({ identity }: { identity: Identity }) {
const toast = useToast();
const [topic, setTopic] = useState(""); const [topic, setTopic] = useState("");
const [kb, setKb] = useState(""); const [kb, setKb] = useState("");
const [phase, setPhase] = useState<Phase>("idle"); const [phase, setPhase] = useState<Phase>("idle");
const [out, setOut] = useState(""); const [out, setOut] = useState("");
const [exec, setExec] = useState<ExecEvent[]>([]); const [exec, setExec] = useState<ExecEvent[]>([]);
const [taskId, setTaskId] = useState(""); const [taskId, setTaskId] = useState("");
const [err, setErr] = useState("");
const closeRef = useRef<(() => void) | null>(null); const closeRef = useRef<(() => void) | null>(null);
const execCloseRef = useRef<(() => void) | null>(null); const execCloseRef = useRef<(() => void) | null>(null);
@@ -26,7 +28,6 @@ export function ReportView({ identity }: { identity: Identity }) {
setPhase("running"); setPhase("running");
setOut(""); setOut("");
setExec([]); setExec([]);
setErr("");
setTaskId(""); setTaskId("");
try { try {
const id = await generateReport(identity, topic.trim(), kb.trim() || undefined); const id = await generateReport(identity, topic.trim(), kb.trim() || undefined);
@@ -40,20 +41,25 @@ export function ReportView({ identity }: { identity: Identity }) {
closeRef.current = streamTokens( closeRef.current = streamTokens(
id, id,
(tok) => setOut((o) => o + tok), (tok) => setOut((o) => o + tok),
() => setPhase("done"),
() => { () => {
setErr("连接中断"); setPhase("done");
toast.push("success", "报告已生成,可下载 Word");
},
() => {
setPhase("error"); setPhase("error");
toast.push("error", "报告流连接中断");
}, },
); );
} catch (e) { } catch (e) {
setErr((e as Error).message);
setPhase("error"); setPhase("error");
toast.push("error", (e as Error).message);
} }
}; };
const tracePhase = running ? "streaming" : phase === "done" ? "done" : phase === "error" ? "error" : "idle";
return ( 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> <header>
<h1 className="text-lg font-semibold text-slate-100"></h1> <h1 className="text-lg font-semibold text-slate-100"></h1>
<p className="mt-1 text-xs text-slate-500"> <p className="mt-1 text-xs text-slate-500">
@@ -61,75 +67,53 @@ export function ReportView({ identity }: { identity: Identity }) {
</p> </p>
</header> </header>
{/* 输入区 */} <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">
<div className="grid grid-cols-[1fr_220px] gap-3 rounded-xl border border-line bg-ink-900 p-4 shadow-card"> <Field label="报告主题">
<div className="flex flex-col gap-2"> <Input
<label className="text-[11px] font-medium text-slate-400"></label>
<input
value={topic} value={topic}
onChange={(e) => setTopic(e.target.value)} onChange={(e) => setTopic(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onGenerate()} onKeyDown={(e) => e.key === "Enter" && onGenerate()}
placeholder="如:2026 年国产大模型产业现状与趋势分析" 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> </Field>
<div className="flex flex-col gap-2"> <Field label="知识库(可选)">
<label className="text-[11px] font-medium text-slate-400"></label> <Input value={kb} onChange={(e) => setKb(e.target.value)} placeholder="如 docs,留空则不挂检索" />
<input </Field>
value={kb} <Button variant="primary" icon={Play} onClick={onGenerate} disabled={running || !topic.trim()}>
onChange={(e) => setKb(e.target.value)} {running ? "生成中…" : "生成报告"}
placeholder="如 docs,留空则不挂检索" </Button>
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" {phase === "done" && taskId ? (
/> <a
</div> href={reportDownloadUrl(taskId)}
<div className="col-span-2 flex items-center gap-3"> 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"
<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"
> >
{running ? "生成中…" : "生成报告"} <Download className="h-4 w-4" />
</button> Word
{phase === "done" && taskId && ( </a>
<a ) : (
href={reportDownloadUrl(taskId)} <span className="h-9" />
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>
</div> </div>
{/* 执行轨迹 + 报告正文 */}
<div className="grid min-h-0 flex-1 grid-cols-[340px_1fr] gap-4"> <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"> <Panel title="执行轨迹" icon={FileText}>
<div className="border-b border-line px-4 py-2.5 text-[11px] font-medium text-slate-400"></div> <ExecTrace events={exec} phase={tracePhase} />
<div className="min-h-0 flex-1 overflow-y-auto p-4"> </Panel>
<ExecTrace events={exec} phase={running ? "streaming" : phase === "done" ? "done" : phase === "error" ? "error" : "idle"} />
</div>
</section>
<section className="flex min-h-0 flex-col rounded-xl border border-line bg-ink-900 shadow-card"> <Panel
<div className="flex items-center gap-2 border-b border-line px-4 py-2.5 text-[11px] text-slate-500"> title={
<span className={`h-2 w-2 rounded-full ${running ? "animate-pulse bg-cyan-400" : phase === "done" ? "bg-emerald-400" : "bg-slate-600"}`} /> <span className="flex items-center gap-2">
· {taskId || "未开始"} <Dot tone={running ? "running" : phase === "done" ? "success" : "neutral"} pulse={running} />
</div> · {taskId || "未开始"}
<div className="min-h-0 flex-1 overflow-y-auto p-4"> </span>
{out ? ( }
<pre className="whitespace-pre-wrap break-words font-sans text-sm leading-relaxed text-slate-300">{out}</pre> >
) : ( {out ? (
<div className="flex h-full items-center justify-center text-sm text-slate-600"> <pre className="whitespace-pre-wrap break-words font-sans text-sm leading-relaxed text-slate-300">{out}</pre>
) : (
</div> <EmptyState icon={FileText} title="尚未生成报告" desc="输入主题并点击「生成报告」,这里将实时显示规划与撰写过程。" />
)} )}
</div> </Panel>
</section>
</div> </div>
</div> </div>
); );
@@ -1,5 +1,7 @@
import { Activity, FileText } from "lucide-react";
import { ExecTrace } from "../components/ExecTrace"; import { ExecTrace } from "../components/ExecTrace";
import { deriveNodes, type RunState } from "../lib/run"; import { deriveNodes, type RunState } from "../lib/run";
import { Panel, Dot, EmptyState } from "../ui";
// 运行·观测:把最近一次运行的执行轨迹实时可视化(节点逐个点亮 + 工具入参/产出 + 耗时), // 运行·观测:把最近一次运行的执行轨迹实时可视化(节点逐个点亮 + 工具入参/产出 + 耗时),
// 右侧并列模型输出。数据来自 Studio 运行时订阅的 sundynix.exec.<id> 事件流。 // 右侧并列模型输出。数据来自 Studio 运行时订阅的 sundynix.exec.<id> 事件流。
@@ -8,8 +10,7 @@ export function RunsView({ run }: { run: RunState }) {
const tools = nodes.filter((n) => n.kind === "tool"); const tools = nodes.filter((n) => n.kind === "tool");
const phaseText = const phaseText =
run.phase === "streaming" ? "执行中" : run.phase === "done" ? "完成" : run.phase === "error" ? "出错" : run.phase === "submitting" ? "提交中" : "就绪"; run.phase === "streaming" ? "执行中" : run.phase === "done" ? "完成" : run.phase === "error" ? "出错" : run.phase === "submitting" ? "提交中" : "就绪";
const phaseCls = const tone = run.phase === "streaming" ? "running" : run.phase === "done" ? "success" : run.phase === "error" ? "danger" : "neutral";
run.phase === "streaming" ? "text-cyan-400" : run.phase === "done" ? "text-emerald-400" : run.phase === "error" ? "text-rose-400" : "text-slate-500";
return ( return (
<div className="flex h-full min-h-0 flex-col gap-4 overflow-hidden p-6"> <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="text-slate-500">
<span className="font-mono text-slate-300">{run.taskId ?? "—"}</span> <span className="font-mono text-slate-300">{run.taskId ?? "—"}</span>
</span> </span>
<span className={phaseCls}> {phaseText}</span> <span className="flex items-center gap-1.5 text-slate-400">
<span className="text-slate-500">{nodes.length} · {tools.length} </span> <Dot tone={tone} pulse={run.phase === "streaming"} />
{phaseText}
</span>
<span className="text-slate-500">
{nodes.length} · {tools.length}
</span>
</div> </div>
</header> </header>
<div className="grid min-h-0 flex-1 grid-cols-[1.2fr_1fr] gap-4"> <div className="grid min-h-0 flex-1 grid-cols-[1.2fr_1fr] gap-4">
{/* 执行轨迹 */} <Panel title="执行轨迹" icon={Activity}>
<section className="flex min-h-0 flex-col rounded-xl border border-line bg-ink-900 shadow-card"> <ExecTrace events={run.exec} phase={run.phase} />
<div className="border-b border-line px-4 py-2.5 text-[11px] font-medium text-slate-400"></div> </Panel>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<ExecTrace events={run.exec} phase={run.phase} />
</div>
</section>
{/* 模型输出 */} <Panel title="模型输出" icon={FileText}>
<section className="flex min-h-0 flex-col rounded-xl border border-line bg-ink-900 shadow-card"> {run.output ? (
<div className="border-b border-line px-4 py-2.5 text-[11px] font-medium text-slate-400"></div> <pre className="whitespace-pre-wrap break-words font-sans text-sm leading-relaxed text-slate-300">{run.output}</pre>
<div className="min-h-0 flex-1 overflow-y-auto p-4"> ) : (
{run.output ? ( <EmptyState
<pre className="whitespace-pre-wrap break-words font-sans text-sm leading-relaxed text-slate-300">{run.output}</pre> icon={Activity}
) : ( title="尚无运行"
<div className="flex h-full items-center justify-center text-center text-xs text-slate-600"> desc="在「编排」页搭图并运行,或在「报告」页生成报告,执行轨迹与输出会实时出现在这里。"
<br /> />
</div> )}
)} </Panel>
</div>
</section>
</div> </div>
</div> </div>
); );
@@ -14,6 +14,17 @@ export default {
600: "#323a4f", 600: "#323a4f",
}, },
line: "#242b3c", line: "#242b3c",
// 语义令牌 —— 强调色统一从这里取,便于整体换肤(紫=brand,青=accent)。
brand: { DEFAULT: "#7c5cf6", 400: "#a78bfa", 500: "#8b5cf6", 600: "#6d28d9" },
accent: { DEFAULT: "#22d3ee", 400: "#22d3ee", 500: "#06b6d4" },
success: { DEFAULT: "#34d399", 500: "#10b981" },
warn: { DEFAULT: "#fbbf24", 500: "#f59e0b" },
danger: { DEFAULT: "#fb7185", 500: "#f43f5e" },
},
borderRadius: {
// 统一圆角档位
lg: "0.625rem", // 10px —— 卡片/面板
md: "0.5rem", // 8px —— 控件
}, },
boxShadow: { boxShadow: {
glow: "0 0 0 1px rgba(139,92,246,0.45), 0 0 18px rgba(139,92,246,0.28)", glow: "0 0 0 1px rgba(139,92,246,0.45), 0 0 18px rgba(139,92,246,0.28)",