diff --git a/sundynix-desktop/frontend/src/App.tsx b/sundynix-desktop/frontend/src/App.tsx index 981b48f..0582008 100644 --- a/sundynix-desktop/frontend/src/App.tsx +++ b/sundynix-desktop/frontend/src/App.tsx @@ -1,87 +1,83 @@ import { useCallback, useRef, useState } from "react"; -import { AgentCanvas } from "./canvas/AgentCanvas"; -import { MemoryPanel } from "./panels/MemoryPanel"; +import { TopBar } from "./shell/TopBar"; +import { LeftNav, type ViewKey } from "./shell/LeftNav"; +import { BottomDrawer } from "./shell/BottomDrawer"; +import { StudioView } from "./studio/StudioView"; +import { MemoryView } from "./views/MemoryView"; +import { Placeholder } from "./views/Placeholder"; import { submitTask, streamTokens, type Identity } from "./lib/api"; import type { TaskDsl } from "./lib/dsl"; +import { emptyRun, type RunState } from "./lib/run"; + +const PLACEHOLDERS: Partial> = { + home: { title: "工作台", desc: "概览:知识库 / 文档 / 近期运行 / 待办报告 / 配额计费 + 快捷入口。" }, + kb: { title: "知识库 (RAG)", desc: "入库流水线监控 · 检索调试台(带来源徽标) · 文档/块浏览 · 知识图谱 · 检索评测。依赖 embedding + 入库 worker + 真实混合检索。" }, + report: { title: "报告生成", desc: "模板库 · 大纲编辑 · 章节并行生成进度 · 实时预览(含引用) · 导出 docx/pdf。依赖 RAG 核心链 + UniOffice。" }, + runs: { title: "运行 · 观测", desc: "实时执行 · 节点轨迹 · 工具调用 · 运行历史复盘。当前运行结果见底部抽屉。" }, + market: { title: "市场 · Packs", desc: "垂直包(法律/医疗/金融) · Agent 模板 · 开通向导(建租户→入库→注册模板→应用配置)。依赖多租户 + Pack 格式。" }, + admin: { title: "管理", desc: "租户/工作区 · 用户计费 · 护栏 · 模型与连接 · 设置。" }, +}; -// 顶层布局:左侧 React Flow 编排画布 + 右侧 身份 / 偏好记忆 / 运行输出。 export default function App() { + const [view, setView] = useState("studio"); const [identity, setIdentity] = useState({ userId: "wt", sessionId: "sess-ui" }); - const [output, setOutput] = useState(""); - const [status, setStatus] = useState("就绪"); - const [running, setRunning] = useState(false); + const [run, setRun] = useState(emptyRun); const closeRef = useRef<(() => void) | null>(null); const onRun = useCallback( async (dsl: TaskDsl) => { closeRef.current?.(); - setOutput(""); - setRunning(true); - setStatus("提交任务…"); + const t0 = Date.now(); + setRun({ phase: "submitting", output: "", events: [{ t: 0, label: "提交任务" }] }); try { const taskId = await submitTask(dsl, identity); - setStatus(`流式中 · ${taskId}`); + let first = true; + setRun((r) => ({ + ...r, + phase: "streaming", + taskId, + events: [...r.events, { t: Date.now() - t0, label: `已发布 ${taskId}` }], + })); closeRef.current = streamTokens( taskId, - (t) => setOutput((o) => o + t), - () => { - setStatus("完成 ✓"); - setRunning(false); - }, - () => { - setStatus("连接中断"); - setRunning(false); - }, + (tok) => + setRun((r) => { + const ev = first ? [...r.events, { t: Date.now() - t0, label: "首 token" }] : r.events; + first = false; + return { ...r, output: r.output + tok, events: ev }; + }), + () => + setRun((r) => ({ + ...r, + phase: "done", + events: [...r.events, { t: Date.now() - t0, label: "完成" }], + })), + () => setRun((r) => ({ ...r, phase: "error", error: "连接中断" })), ); } catch (e) { - setStatus(`✗ ${(e as Error).message}`); - setRunning(false); + setRun((r) => ({ ...r, phase: "error", error: (e as Error).message })); } }, [identity], ); return ( -
-
- -
- +
+ +
+ +
+ {view === "studio" ? ( + + ) : view === "memory" ? ( + + ) : ( + + )} +
+
+
); } diff --git a/sundynix-desktop/frontend/src/canvas/AgentCanvas.tsx b/sundynix-desktop/frontend/src/canvas/AgentCanvas.tsx deleted file mode 100644 index ddfe1f2..0000000 --- a/sundynix-desktop/frontend/src/canvas/AgentCanvas.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { useCallback, useState } from "react"; -import { - ReactFlow, - Background, - Controls, - addEdge, - useNodesState, - useEdgesState, - type Connection, - type Node, -} from "@xyflow/react"; -import "@xyflow/react/dist/style.css"; - -import { exportDsl, type TaskDsl } from "../lib/dsl"; - -let seq = 0; - -// React Flow Canvas —— Agent 编排:加节点、连线、导出 JSON DSL 并交给上层运行。 -export function AgentCanvas({ - onRun, - running, -}: { - onRun: (dsl: TaskDsl) => void; - running: boolean; -}) { - const [nodes, setNodes, onNodesChange] = useNodesState([]); - const [edges, setEdges, onEdgesChange] = useEdgesState([]); - const [prompt, setPrompt] = useState("总结这段文本"); - - const onConnect = useCallback( - (c: Connection) => setEdges((eds) => addEdge(c, eds)), - [setEdges], - ); - - const addNode = useCallback(() => { - const id = `n${++seq}`; - setNodes((ns) => [ - ...ns, - { - id, - type: "default", - position: { x: 80 + ((seq * 40) % 320), y: 60 + seq * 70 }, - data: { label: `${id}: ${prompt}`, prompt }, - }, - ]); - }, [prompt, setNodes]); - - const run = useCallback(() => onRun(exportDsl(nodes, edges)), [nodes, edges, onRun]); - - return ( -
-
- setPrompt(e.target.value)} - placeholder="节点提示词" - /> - - -
- - - - -
- ); -} diff --git a/sundynix-desktop/frontend/src/lib/dsl.ts b/sundynix-desktop/frontend/src/lib/dsl.ts index 6938dde..fafbd7f 100644 --- a/sundynix-desktop/frontend/src/lib/dsl.ts +++ b/sundynix-desktop/frontend/src/lib/dsl.ts @@ -1,17 +1,56 @@ import type { Edge, Node } from "@xyflow/react"; +import { NODE_KINDS } from "../studio/nodeCatalog"; // Task DSL —— React Flow 画布的可序列化表示,提交给 Gateway 解析组装。 export interface TaskDsl { version: "1"; - nodes: Array<{ id: string; type?: string; data: unknown }>; + nodes: Array<{ id: string; kind: string; label?: string; config: unknown }>; edges: Array<{ source: string; target: string }>; } -// exportDsl 把画布的节点/连线导出为 JSON DSL。 +// exportDsl 把画布的节点/连线导出为类型化 JSON DSL。 export function exportDsl(nodes: Node[], edges: Edge[]): TaskDsl { return { version: "1", - nodes: nodes.map((n) => ({ id: n.id, type: n.type, data: n.data })), + nodes: nodes.map((n) => { + const d = n.data as { kind: string; label?: string; config?: unknown }; + return { id: n.id, kind: d.kind, label: d.label, config: d.config ?? {} }; + }), edges: edges.map((e) => ({ source: e.source, target: e.target })), }; } + +export interface Issue { + level: "error" | "warn"; + msg: string; +} + +// validate 轻量校验(前端先行;真实 schema 校验应由后端兜底)。 +export function validate(nodes: Node[], edges: Edge[]): Issue[] { + const issues: Issue[] = []; + if (nodes.length === 0) { + issues.push({ level: "error", msg: "画布为空:至少添加一个节点" }); + return issues; + } + const connected = new Set(); + edges.forEach((e) => { + connected.add(e.source); + connected.add(e.target); + }); + for (const n of nodes) { + const d = n.data as { kind: string; label?: string; config?: Record }; + const k = NODE_KINDS[d.kind]; + if (nodes.length > 1 && !connected.has(n.id)) { + issues.push({ level: "warn", msg: `节点「${d.label || k?.label}」未连线(孤立)` }); + } + k?.fields + .filter((f) => f.required) + .forEach((f) => { + const v = d.config?.[f.key]; + if (v === undefined || v === "" || String(v).startsWith("(未")) { + issues.push({ level: "warn", msg: `节点「${d.label || k.label}」缺必填项:${f.label}` }); + } + }); + } + return issues; +} diff --git a/sundynix-desktop/frontend/src/lib/health.ts b/sundynix-desktop/frontend/src/lib/health.ts new file mode 100644 index 0000000..f4ca2bf --- /dev/null +++ b/sundynix-desktop/frontend/src/lib/health.ts @@ -0,0 +1,32 @@ +import { useEffect, useState } from "react"; +import { GATEWAY } from "./api"; + +export interface Health { + gateway: boolean; + persisted: boolean; // Postgres 是否在线(billing.persisted) +} + +// useHealth 轮询 Gateway 健康(billing 端点同时回报持久化是否就绪)。 +// NATS/Milvus/Neo4j 暂未由网关透出,UI 以"未知"呈现(规划:加 /health 聚合)。 +export function useHealth(intervalMs = 4000): Health { + const [h, setH] = useState({ gateway: false, persisted: false }); + useEffect(() => { + let alive = true; + const ping = async () => { + try { + const res = await fetch(`${GATEWAY}/api/v1/billing`); + const data = (await res.json()) as { persisted?: boolean }; + if (alive) setH({ gateway: res.ok, persisted: Boolean(data.persisted) }); + } catch { + if (alive) setH({ gateway: false, persisted: false }); + } + }; + ping(); + const id = setInterval(ping, intervalMs); + return () => { + alive = false; + clearInterval(id); + }; + }, [intervalMs]); + return h; +} diff --git a/sundynix-desktop/frontend/src/lib/run.ts b/sundynix-desktop/frontend/src/lib/run.ts new file mode 100644 index 0000000..6191180 --- /dev/null +++ b/sundynix-desktop/frontend/src/lib/run.ts @@ -0,0 +1,17 @@ +// 运行状态 —— 跨 Studio 与底部抽屉共享。 +export type RunPhase = "idle" | "submitting" | "streaming" | "done" | "error"; + +export interface RunEvent { + t: number; // 相对开始的毫秒 + label: string; +} + +export interface RunState { + phase: RunPhase; + taskId?: string; + output: string; + events: RunEvent[]; + error?: string; +} + +export const emptyRun: RunState = { phase: "idle", output: "", events: [] }; diff --git a/sundynix-desktop/frontend/src/shell/BottomDrawer.tsx b/sundynix-desktop/frontend/src/shell/BottomDrawer.tsx new file mode 100644 index 0000000..3b5fc0d --- /dev/null +++ b/sundynix-desktop/frontend/src/shell/BottomDrawer.tsx @@ -0,0 +1,87 @@ +import { useState } from "react"; +import type { RunState } from "../lib/run"; + +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 }) { + const [open, setOpen] = useState(true); + const [tab, setTab] = useState("output"); + + return ( +
+
+ {TABS.map((t) => ( + + ))} + + {run.phase === "streaming" + ? "流式中…" + : run.phase === "done" + ? "完成 ✓" + : run.phase === "error" + ? `✗ ${run.error ?? "出错"}` + : run.phase === "submitting" + ? "提交中…" + : "就绪"} + + +
+ {open && ( +
+ {tab === "output" && ( +
+              {run.output || "在编排页搭图 → 运行,模型注入画像与历史后流式作答,token 在此呈现。"}
+            
+ )} + {tab === "trace" && ( +
    + {run.events.length === 0 &&
  • 尚无运行。
  • } + {run.events.map((e, i) => ( +
  • + +{e.t}ms · {e.label} +
  • + ))} +
  • + (节点级轨迹待后端回流节点事件后逐节点点亮) +
  • +
+ )} + {tab === "tools" && ( +

+ 工具调用日志:每次 sundynix.tools.* 的请求/响应。需后端把工具事件回流到流通道。 +

+ )} + {tab === "cite" && ( +

引用列表:RAG 答案的来源块(源文档 + 分数 + 来源徽标)。需 RAG 链路就绪。

+ )} + {tab === "eval" && ( +

评测:忠实度 / 完整度质量门结果。需 harness eval 接入。

+ )} +
+ )} +
+ ); +} diff --git a/sundynix-desktop/frontend/src/shell/LeftNav.tsx b/sundynix-desktop/frontend/src/shell/LeftNav.tsx new file mode 100644 index 0000000..7dedf45 --- /dev/null +++ b/sundynix-desktop/frontend/src/shell/LeftNav.tsx @@ -0,0 +1,63 @@ +export type ViewKey = + | "home" + | "studio" + | "kb" + | "report" + | "runs" + | "memory" + | "market" + | "admin"; + +interface Item { + key: ViewKey; + label: string; + icon: string; + group?: string; + ready?: boolean; +} + +const ITEMS: Item[] = [ + { key: "home", label: "工作台", icon: "■" }, + { key: "studio", label: "编排", icon: "◆", group: "BUILD", ready: true }, + { key: "kb", label: "知识库", icon: "▣", group: "BUILD" }, + { key: "report", label: "报告", icon: "▤", group: "BUILD" }, + { key: "runs", label: "运行", icon: "▸", group: "RUN" }, + { key: "memory", label: "记忆", icon: "◇", group: "MANAGE", ready: true }, + { key: "market", label: "市场", icon: "⌧", group: "MANAGE" }, + { key: "admin", label: "管理", icon: "⚙", group: "MANAGE" }, +]; + +// 左导航栏:模块切换,分组 BUILD / RUN / MANAGE;未就绪模块灰显(规划中)。 +export function LeftNav({ active, onSelect }: { active: ViewKey; onSelect: (v: ViewKey) => void }) { + let lastGroup: string | undefined; + return ( + + ); +} diff --git a/sundynix-desktop/frontend/src/shell/TopBar.tsx b/sundynix-desktop/frontend/src/shell/TopBar.tsx new file mode 100644 index 0000000..518c13d --- /dev/null +++ b/sundynix-desktop/frontend/src/shell/TopBar.tsx @@ -0,0 +1,54 @@ +import type { Identity } from "../lib/api"; +import { useHealth } from "../lib/health"; + +function Light({ on, label, unknown }: { on?: boolean; label: string; unknown?: boolean }) { + const color = unknown ? "bg-gray-300" : on ? "bg-emerald-500" : "bg-rose-500"; + return ( + + + {label} + + ); +} + +// 顶栏:垂直切换 · 健康灯 · 身份/会话。 +export function TopBar({ + identity, + setIdentity, +}: { + identity: Identity; + setIdentity: (id: Identity) => void; +}) { + const h = useHealth(); + return ( +
+ sundynix-agentix + +
+ + + + + +
+
+ setIdentity({ ...identity, userId: e.target.value })} + title="用户" + /> + setIdentity({ ...identity, sessionId: e.target.value })} + title="会话" + /> +
+
+ ); +} diff --git a/sundynix-desktop/frontend/src/studio/Inspector.tsx b/sundynix-desktop/frontend/src/studio/Inspector.tsx new file mode 100644 index 0000000..6742085 --- /dev/null +++ b/sundynix-desktop/frontend/src/studio/Inspector.tsx @@ -0,0 +1,94 @@ +import type { Node } from "@xyflow/react"; +import { NODE_KINDS } from "./nodeCatalog"; + +// 右检查器:按选中节点的类型渲染配置表单;空选时显示图级提示。 +export function Inspector({ + node, + onChange, + onDelete, +}: { + node: Node | null; + onChange: (id: string, patch: Record) => void; + onDelete: (id: string) => void; +}) { + if (!node) { + return ( +
+ 选中一个节点查看/编辑配置。 +
+ 从左侧面板添加节点,拖动连线编排。 +
+ ); + } + const data = node.data as { kind: string; label?: string; config?: Record }; + const k = NODE_KINDS[data.kind] ?? NODE_KINDS.output; + const config = data.config ?? {}; + + const setConfig = (key: string, value: unknown) => + onChange(node.id, { config: { ...config, [key]: value } }); + + return ( +
+
+ {k.label} + +
+
+ + {k.fields.map((f) => { + const v = config[f.key]; + return ( +