feat: 前端跑通 React Flow 画布→DSL→提交→SSE + 偏好记忆面板 (④)
桌面端前端从骨架变为可用:画布编排 Agent → 导出 DSL → 提交 Gateway → 逐 token 流式展示;偏好记忆面板让用户登记画像(→ memory_upsert)。 - lib/api.ts: submitTask(POST) / streamTokens(EventSource SSE token/done) / setMemory(PUT) - canvas/AgentCanvas: 加节点(提示词)/连线/运行(导出DSL交上层), React Flow 工具栏 - panels/MemoryPanel: 登记偏好(key/value)→PUT /api/v1/memory - App: 身份(user/session)+记忆面板+SSE 输出面板,串起提交→流式 - postcss.config + vite-env.d.ts(import.meta.env) 补齐构建;删 WikiPanel(stale Qdrant) - Gateway: 加 CORS 中间件(放开跨源 + X-User-ID/X-Session-ID 头 + OPTIONS) - Makefile: web 目标(Vite dev); .claude/launch.json(preview 配置), 忽略 settings.local 验证: npm run build(tsc+vite)✓; 真实浏览器跑通——加节点→运行→SSE 流出含 注入画像(称呼:老王)的回答, 第2轮 UI 显示'已有1轮历史'(短期历史经 Eino 图回灌) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "desktop-frontend",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev", "--", "--port", "5173"],
|
||||||
|
"cwd": "sundynix-desktop/frontend",
|
||||||
|
"port": 5173
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -22,3 +22,6 @@ data/
|
|||||||
.bin/
|
.bin/
|
||||||
|
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
# Claude Code 本地机器配置(launch.json 可共享,settings.local 不提交)
|
||||||
|
.claude/settings.local.json
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: infra infra-down devnats demo e2e gateway dispatcher mcp-go mcp-py mcp-py-setup desktop tidy
|
.PHONY: infra infra-down devnats demo e2e gateway dispatcher mcp-go mcp-py mcp-py-setup web desktop tidy
|
||||||
|
|
||||||
infra: ## 启动基础设施 (NATS / Postgres / Redis / Milvus / Neo4j)
|
infra: ## 启动基础设施 (NATS / Postgres / Redis / Milvus / Neo4j)
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
@@ -31,6 +31,9 @@ mcp-py: ## 运行 Python 算法型 MCP 工具服务(缺 venv 则自动 s
|
|||||||
cd sundynix-mcp-py && [ -x .venv/bin/python ] || $(MAKE) mcp-py-setup
|
cd sundynix-mcp-py && [ -x .venv/bin/python ] || $(MAKE) mcp-py-setup
|
||||||
cd sundynix-mcp-py && .venv/bin/python -m sundynix_mcp_py.main
|
cd sundynix-mcp-py && .venv/bin/python -m sundynix_mcp_py.main
|
||||||
|
|
||||||
|
web: ## 仅前端 dev(Vite,连本地 Gateway:8080;先 npm install)
|
||||||
|
cd sundynix-desktop/frontend && npm install && npm run dev
|
||||||
|
|
||||||
desktop:
|
desktop:
|
||||||
cd sundynix-desktop && wails dev
|
cd sundynix-desktop && wails dev
|
||||||
|
|
||||||
|
|||||||
+2894
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,15 +1,86 @@
|
|||||||
import { AgentCanvas } from "./canvas/AgentCanvas";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { WikiPanel } from "./wiki/WikiPanel";
|
|
||||||
|
|
||||||
// UI Representation Layer —— 顶层布局:左侧编排画布 + 右侧 Wiki 面板。
|
import { AgentCanvas } from "./canvas/AgentCanvas";
|
||||||
|
import { MemoryPanel } from "./panels/MemoryPanel";
|
||||||
|
import { submitTask, streamTokens, type Identity } from "./lib/api";
|
||||||
|
import type { TaskDsl } from "./lib/dsl";
|
||||||
|
|
||||||
|
// 顶层布局:左侧 React Flow 编排画布 + 右侧 身份 / 偏好记忆 / 运行输出。
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const [identity, setIdentity] = useState<Identity>({ userId: "wt", sessionId: "sess-ui" });
|
||||||
|
const [output, setOutput] = useState("");
|
||||||
|
const [status, setStatus] = useState("就绪");
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const closeRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
|
const onRun = useCallback(
|
||||||
|
async (dsl: TaskDsl) => {
|
||||||
|
closeRef.current?.();
|
||||||
|
setOutput("");
|
||||||
|
setRunning(true);
|
||||||
|
setStatus("提交任务…");
|
||||||
|
try {
|
||||||
|
const taskId = await submitTask(dsl, identity);
|
||||||
|
setStatus(`流式中 · ${taskId}`);
|
||||||
|
closeRef.current = streamTokens(
|
||||||
|
taskId,
|
||||||
|
(t) => setOutput((o) => o + t),
|
||||||
|
() => {
|
||||||
|
setStatus("完成 ✓");
|
||||||
|
setRunning(false);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
setStatus("连接中断");
|
||||||
|
setRunning(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(`✗ ${(e as Error).message}`);
|
||||||
|
setRunning(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[identity],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-screen">
|
<div className="flex h-screen w-screen text-gray-900">
|
||||||
<main className="flex-1 border-r">
|
<main className="flex-1 border-r">
|
||||||
<AgentCanvas />
|
<AgentCanvas onRun={onRun} running={running} />
|
||||||
</main>
|
</main>
|
||||||
<aside className="w-96 overflow-auto">
|
<aside className="flex w-[26rem] flex-col overflow-auto">
|
||||||
<WikiPanel />
|
<section className="border-b p-4">
|
||||||
|
<h2 className="mb-2 text-sm font-semibold text-gray-700">身份 / 会话</h2>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<label className="flex-1 text-xs text-gray-500">
|
||||||
|
用户
|
||||||
|
<input
|
||||||
|
className="mt-1 w-full rounded border px-2 py-1 text-sm text-gray-900"
|
||||||
|
value={identity.userId}
|
||||||
|
onChange={(e) => setIdentity((id) => ({ ...id, userId: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex-1 text-xs text-gray-500">
|
||||||
|
会话
|
||||||
|
<input
|
||||||
|
className="mt-1 w-full rounded border px-2 py-1 text-sm text-gray-900"
|
||||||
|
value={identity.sessionId}
|
||||||
|
onChange={(e) => setIdentity((id) => ({ ...id, sessionId: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<MemoryPanel identity={identity} />
|
||||||
|
|
||||||
|
<section className="flex flex-1 flex-col p-4">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-700">运行输出(SSE Token 流)</h2>
|
||||||
|
<span className="text-xs text-gray-400">{status}</span>
|
||||||
|
</div>
|
||||||
|
<pre className="flex-1 whitespace-pre-wrap rounded bg-gray-900 p-3 text-xs leading-relaxed text-emerald-300">
|
||||||
|
{output || "在左侧加节点 → 运行,模型会注入你的偏好与历史后流式作答。"}
|
||||||
|
</pre>
|
||||||
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
Background,
|
Background,
|
||||||
@@ -7,32 +7,66 @@ import {
|
|||||||
useNodesState,
|
useNodesState,
|
||||||
useEdgesState,
|
useEdgesState,
|
||||||
type Connection,
|
type Connection,
|
||||||
|
type Node,
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
import "@xyflow/react/dist/style.css";
|
import "@xyflow/react/dist/style.css";
|
||||||
|
|
||||||
import { exportDsl } from "../lib/dsl";
|
import { exportDsl, type TaskDsl } from "../lib/dsl";
|
||||||
|
|
||||||
// React Flow Canvas —— Agent 编排,可导出 JSON DSL 提交到 Gateway。
|
let seq = 0;
|
||||||
export function AgentCanvas() {
|
|
||||||
const [nodes, , onNodesChange] = useNodesState([]);
|
// React Flow Canvas —— Agent 编排:加节点、连线、导出 JSON DSL 并交给上层运行。
|
||||||
|
export function AgentCanvas({
|
||||||
|
onRun,
|
||||||
|
running,
|
||||||
|
}: {
|
||||||
|
onRun: (dsl: TaskDsl) => void;
|
||||||
|
running: boolean;
|
||||||
|
}) {
|
||||||
|
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
||||||
|
const [prompt, setPrompt] = useState("总结这段文本");
|
||||||
|
|
||||||
const onConnect = useCallback(
|
const onConnect = useCallback(
|
||||||
(c: Connection) => setEdges((eds) => addEdge(c, eds)),
|
(c: Connection) => setEdges((eds) => addEdge(c, eds)),
|
||||||
[setEdges],
|
[setEdges],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onExport = useCallback(() => {
|
const addNode = useCallback(() => {
|
||||||
const dsl = exportDsl(nodes, edges); // → JSON DSL export
|
const id = `n${++seq}`;
|
||||||
// TODO: 经 Wails 强绑定调用 App.SubmitDSL(dsl)
|
setNodes((ns) => [
|
||||||
console.log(dsl);
|
...ns,
|
||||||
}, [nodes, edges]);
|
{
|
||||||
|
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 (
|
return (
|
||||||
<div className="h-full w-full">
|
<div className="relative h-full w-full">
|
||||||
<button onClick={onExport} className="absolute z-10 m-2 rounded border px-3 py-1">
|
<div className="absolute left-0 right-0 top-0 z-10 flex items-center gap-2 border-b bg-white/90 p-2">
|
||||||
导出 DSL
|
<input
|
||||||
</button>
|
className="flex-1 rounded border px-2 py-1 text-sm"
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
placeholder="节点提示词"
|
||||||
|
/>
|
||||||
|
<button onClick={addNode} className="rounded border px-3 py-1 text-sm hover:bg-gray-50">
|
||||||
|
+ 加 Agent 节点
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={run}
|
||||||
|
disabled={running || nodes.length === 0}
|
||||||
|
className="rounded bg-violet-600 px-3 py-1 text-sm font-medium text-white disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{running ? "运行中…" : "▶ 运行"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Gateway HTTP/SSE 客户端:提交 DSL 任务、订阅 Token 流、登记偏好记忆。
|
||||||
|
import type { TaskDsl } from "./dsl";
|
||||||
|
|
||||||
|
// 开发期直连 Gateway;Wails 打包后可改为本地后端地址或经 Go 绑定。
|
||||||
|
export const GATEWAY: string =
|
||||||
|
(import.meta.env.VITE_GATEWAY as string | undefined) ?? "http://localhost:8080";
|
||||||
|
|
||||||
|
export interface Identity {
|
||||||
|
userId: string;
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// submitTask: POST /api/v1/tasks,返回 task_id。
|
||||||
|
export async function submitTask(dsl: TaskDsl, id: Identity): Promise<string> {
|
||||||
|
const res = await fetch(`${GATEWAY}/api/v1/tasks`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-User-ID": id.userId,
|
||||||
|
"X-Session-ID": id.sessionId,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(dsl),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`submit failed: ${res.status} ${await res.text()}`);
|
||||||
|
const data = (await res.json()) as { task_id: string };
|
||||||
|
return data.task_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// streamTokens: 订阅 SSE /api/v1/tasks/:id/stream,逐 token 回调,done 收尾。
|
||||||
|
// 返回关闭函数。注意 EventSource 无法带请求头,但流按 task_id 寻址,无需身份头。
|
||||||
|
export function streamTokens(
|
||||||
|
taskId: string,
|
||||||
|
onToken: (t: string) => void,
|
||||||
|
onDone: () => void,
|
||||||
|
onError?: (e: unknown) => void,
|
||||||
|
): () => void {
|
||||||
|
const es = new EventSource(`${GATEWAY}/api/v1/tasks/${taskId}/stream`);
|
||||||
|
es.addEventListener("token", (e) => onToken((e as MessageEvent).data));
|
||||||
|
es.addEventListener("done", () => {
|
||||||
|
es.close();
|
||||||
|
onDone();
|
||||||
|
});
|
||||||
|
es.onerror = (e) => {
|
||||||
|
es.close();
|
||||||
|
onError?.(e);
|
||||||
|
};
|
||||||
|
return () => es.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// setMemory: PUT /api/v1/memory,登记一条用户偏好(→ mcp-go memory_upsert)。
|
||||||
|
export async function setMemory(
|
||||||
|
id: Identity,
|
||||||
|
key: string,
|
||||||
|
value: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const res = await fetch(`${GATEWAY}/api/v1/memory`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-User-ID": id.userId,
|
||||||
|
"X-Session-ID": id.sessionId,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ key, value }),
|
||||||
|
});
|
||||||
|
const data = (await res.json()) as { message?: string; error?: string };
|
||||||
|
if (!res.ok) throw new Error(data.error ?? `memory failed: ${res.status}`);
|
||||||
|
return data.message ?? "ok";
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { setMemory, type Identity } from "../lib/api";
|
||||||
|
|
||||||
|
// 偏好记忆面板 —— 让用户显式登记/纠正模型对自己的记忆(→ PUT /api/v1/memory)。
|
||||||
|
export function MemoryPanel({ identity }: { identity: Identity }) {
|
||||||
|
const [key, setKey] = useState("回答偏好");
|
||||||
|
const [value, setValue] = useState("简洁、中文、多给要点");
|
||||||
|
const [saved, setSaved] = useState<Array<{ key: string; value: string }>>([]);
|
||||||
|
const [msg, setMsg] = useState("");
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!key.trim()) return;
|
||||||
|
try {
|
||||||
|
const m = await setMemory(identity, key.trim(), value.trim());
|
||||||
|
setSaved((s) => [{ key: key.trim(), value: value.trim() }, ...s.filter((x) => x.key !== key.trim())]);
|
||||||
|
setMsg(m);
|
||||||
|
} catch (e) {
|
||||||
|
setMsg(`✗ ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="border-b p-4">
|
||||||
|
<h2 className="mb-2 text-sm font-semibold text-gray-700">偏好记忆(让模型知道是我)</h2>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<input
|
||||||
|
className="rounded border px-2 py-1 text-sm"
|
||||||
|
value={key}
|
||||||
|
onChange={(e) => setKey(e.target.value)}
|
||||||
|
placeholder="键,如 称呼 / 回答偏好"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
className="h-16 resize-none rounded border px-2 py-1 text-sm"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
placeholder="值"
|
||||||
|
/>
|
||||||
|
<button onClick={save} className="self-end rounded bg-emerald-600 px-3 py-1 text-sm text-white">
|
||||||
|
记住
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{msg && <p className="mt-2 text-xs text-emerald-700">{msg}</p>}
|
||||||
|
{saved.length > 0 && (
|
||||||
|
<ul className="mt-3 space-y-1">
|
||||||
|
{saved.map((s) => (
|
||||||
|
<li key={s.key} className="rounded bg-gray-50 px-2 py-1 text-xs text-gray-600">
|
||||||
|
<span className="font-medium">{s.key}</span>:{s.value}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
// LLM Wiki Management Panel —— 管理知识库条目,触发第 5 层混合检索。
|
|
||||||
export function WikiPanel() {
|
|
||||||
return (
|
|
||||||
<div className="p-4">
|
|
||||||
<h2 className="mb-2 text-lg font-semibold">LLM Wiki</h2>
|
|
||||||
<input
|
|
||||||
className="mb-3 w-full rounded border px-2 py-1"
|
|
||||||
placeholder="搜索 Wiki(Hybrid: Bleve + Qdrant + Neo4j)"
|
|
||||||
/>
|
|
||||||
{/* TODO: 检索结果列表 / 条目编辑 */}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
// New 构建带有 Guardrail / 限流中间件的 Gin 引擎。
|
// New 构建带有 Guardrail / 限流中间件的 Gin 引擎。
|
||||||
func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus) *gin.Engine {
|
func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus) *gin.Engine {
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
|
r.Use(cors()) // 桌面端/浏览器跨源访问(开发期放开)
|
||||||
r.Use(middleware.RateLimit(cache))
|
r.Use(middleware.RateLimit(cache))
|
||||||
r.Use(middleware.Guardrail()) // Harness: Input/Output Guardrail
|
r.Use(middleware.Guardrail()) // Harness: Input/Output Guardrail
|
||||||
|
|
||||||
@@ -26,3 +27,17 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus) *gin.Engine {
|
|||||||
}
|
}
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cors 放开跨源访问,允许桌面端/浏览器带自定义身份头与 SSE 访问网关(开发期)。
|
||||||
|
func cors() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.Header("Access-Control-Allow-Origin", "*")
|
||||||
|
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")
|
||||||
|
c.Header("Access-Control-Allow-Headers", "Content-Type, X-User-ID, X-Session-ID")
|
||||||
|
if c.Request.Method == "OPTIONS" {
|
||||||
|
c.AbortWithStatus(204)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user