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:
+10
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// cn 合并 className —— 过滤掉 falsy,空格连接(零依赖的轻量 clsx)。
|
||||||
|
export function cn(...parts: Array<string | false | null | undefined>): string {
|
||||||
|
return parts.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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/pdf(docx/xlsx/pdf 经 mcp-py 解析)</span>
|
<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 && (
|
{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)",
|
||||||
|
|||||||
Reference in New Issue
Block a user