From dee48884636afc117e4ba1f386d623b6ca953816 Mon Sep 17 00:00:00 2001 From: chenboyu06 Date: Tue, 14 Jul 2026 19:18:46 +0800 Subject: [PATCH] feat: integrate og-memory-graph plugin (Outline Graph memory graph) Add og-memory-graph as a pluggable memory-graph service: - og-memory-graph/: OG Framework A (agents/core/pipeline/storage) + FastAPI server + React embed page + PilotDeck plugin package - ui/MainAreaV2.tsx: surface enabled plugin tabs in the top bar (generic plugin capability, not og-specific) Plugin is self-contained under og-memory-graph/; PilotDeck source has only one change (MainAreaV2 plugin tabs). See INTEGRATION.md for setup. Co-Authored-By: Claude Opus 4.8 (1M context) --- INTEGRATION.md | 73 + og-memory-graph/.gitignore | 30 + og-memory-graph/README.md | 88 + og-memory-graph/frontend/.gitignore | 24 + og-memory-graph/frontend/.oxlintrc.json | 8 + og-memory-graph/frontend/README.md | 32 + og-memory-graph/frontend/index.html | 13 + og-memory-graph/frontend/package.json | 31 + og-memory-graph/frontend/public/favicon.svg | 1 + og-memory-graph/frontend/public/icons.svg | 24 + og-memory-graph/frontend/src/App.css | 184 ++ og-memory-graph/frontend/src/App.tsx | 39 + og-memory-graph/frontend/src/api/client.ts | 384 +++ og-memory-graph/frontend/src/assets/hero.png | 3 + og-memory-graph/frontend/src/assets/react.svg | 1 + og-memory-graph/frontend/src/assets/vite.svg | 1 + .../frontend/src/components/ChatMessage.tsx | 80 + .../frontend/src/components/ChatPanel.tsx | 260 ++ .../frontend/src/components/ClusterForm.tsx | 161 + .../frontend/src/components/GraphSearch.tsx | 72 + .../frontend/src/components/GraphView.tsx | 754 +++++ .../frontend/src/components/RefsView.tsx | 480 +++ .../frontend/src/components/RefsViewLite.tsx | 109 + .../frontend/src/components/ReportView.tsx | 166 + .../frontend/src/components/RunPanel.tsx | 232 ++ .../frontend/src/components/Sidebar.tsx | 97 + .../frontend/src/context/ModelContext.tsx | 24 + .../src/context/PreferenceContext.tsx | 61 + og-memory-graph/frontend/src/index.css | 385 +++ og-memory-graph/frontend/src/main.tsx | 10 + .../frontend/src/pages/ClusterDetail.tsx | 79 + .../frontend/src/pages/ConfigPage.tsx | 321 ++ .../frontend/src/pages/EmbedCluster.tsx | 92 + .../frontend/src/pages/EmbedMemoryGraph.tsx | 410 +++ og-memory-graph/frontend/src/pages/Home.tsx | 81 + og-memory-graph/frontend/src/types.ts | 128 + og-memory-graph/frontend/tsconfig.app.json | 26 + og-memory-graph/frontend/tsconfig.json | 7 + og-memory-graph/frontend/tsconfig.node.json | 23 + og-memory-graph/frontend/vite.config.ts | 12 + og-memory-graph/og/__init__.py | 0 og-memory-graph/og/__main__.py | 5 + og-memory-graph/og/agents/__init__.py | 0 og-memory-graph/og/agents/build_agent.py | 155 + og-memory-graph/og/agents/direction_agent.py | 131 + .../og/agents/literature_analysis_agent.py | 2666 +++++++++++++++++ og-memory-graph/og/agents/locate_agent.py | 491 +++ og-memory-graph/og/agents/modify_agent.py | 278 ++ .../og/agents/node_reparent_agent.py | 448 +++ .../og/agents/paragraph_rewrite_agent.py | 560 ++++ og-memory-graph/og/agents/propagate_agent.py | 169 ++ og-memory-graph/og/agents/refresh_agent.py | 518 ++++ og-memory-graph/og/agents/render_agent.py | 387 +++ og-memory-graph/og/agents/rewrite_agent.py | 159 + .../og/agents/section_merge_agent.py | 354 +++ .../og/agents/section_renumber_agent.py | 118 + og-memory-graph/og/agents/structure_agent.py | 64 + og-memory-graph/og/agents/table_agent.py | 475 +++ .../og/agents/table_curation_agent.py | 868 ++++++ .../og/agents/table_naming_agent.py | 364 +++ og-memory-graph/og/agents/validate_agent.py | 215 ++ og-memory-graph/og/cli/__init__.py | 0 og-memory-graph/og/cli/__main__.py | 44 + og-memory-graph/og/cli/_common.py | 145 + og-memory-graph/og/cli/run.py | 209 ++ og-memory-graph/og/config/__init__.py | 0 og-memory-graph/og/config/constants.py | 28 + og-memory-graph/og/config/models.py | 465 +++ og-memory-graph/og/config/paths.py | 199 ++ og-memory-graph/og/core/__init__.py | 0 og-memory-graph/og/core/edge.py | 61 + og-memory-graph/og/core/graph.py | 199 ++ og-memory-graph/og/core/node.py | 188 ++ og-memory-graph/og/mcp_server/__init__.py | 1 + og-memory-graph/og/mcp_server/__main__.py | 122 + og-memory-graph/og/mcp_server/locks.py | 110 + og-memory-graph/og/mcp_server/tools.py | 710 +++++ og-memory-graph/og/mcp_server/watcher.py | 520 ++++ og-memory-graph/og/pipeline/__init__.py | 0 og-memory-graph/og/pipeline/balanced.py | 240 ++ og-memory-graph/og/pipeline/curate.py | 238 ++ og-memory-graph/og/pipeline/polish.py | 1055 +++++++ og-memory-graph/og/pipeline/run_cluster.py | 451 +++ og-memory-graph/og/pipeline/simulation.py | 1364 +++++++++ .../og/pipeline/update_pipeline.py | 137 + og-memory-graph/og/storage/__init__.py | 0 og-memory-graph/og/storage/graph_store.py | 55 + og-memory-graph/og/storage/vector_store.py | 486 +++ og-memory-graph/og/storage/yaml_store.py | 66 + og-memory-graph/plugin.json | 61 + .../plugins/og-memory-graph/config.json | 6 + .../plugins/og-memory-graph/index.js | 81 + .../plugins/og-memory-graph/manifest.json | 9 + .../plugins/og-memory-graph/server.js | 135 + og-memory-graph/pyproject.toml | 36 + og-memory-graph/server/__init__.py | 0 og-memory-graph/server/api/__init__.py | 0 og-memory-graph/server/api/chat.py | 385 +++ og-memory-graph/server/api/chat_tools.py | 933 ++++++ og-memory-graph/server/api/clusters.py | 96 + og-memory-graph/server/api/config.py | 269 ++ og-memory-graph/server/api/graphs.py | 63 + og-memory-graph/server/api/memory.py | 137 + og-memory-graph/server/api/pilotdeck.py | 253 ++ og-memory-graph/server/api/preferences.py | 106 + og-memory-graph/server/api/references.py | 361 +++ og-memory-graph/server/api/reports.py | 113 + og-memory-graph/server/api/tasks.py | 226 ++ og-memory-graph/server/db/__init__.py | 0 og-memory-graph/server/db/crud.py | 128 + og-memory-graph/server/db/models.py | 65 + og-memory-graph/server/db/session.py | 33 + og-memory-graph/server/filestore.py | 654 ++++ og-memory-graph/server/main.py | 114 + og-memory-graph/server/schemas.py | 135 + og-memory-graph/setup.py | 378 +++ ui/src/components/app-shell/MainAreaV2.tsx | 25 + 117 files changed, 25326 insertions(+) create mode 100644 INTEGRATION.md create mode 100644 og-memory-graph/.gitignore create mode 100644 og-memory-graph/README.md create mode 100644 og-memory-graph/frontend/.gitignore create mode 100644 og-memory-graph/frontend/.oxlintrc.json create mode 100644 og-memory-graph/frontend/README.md create mode 100644 og-memory-graph/frontend/index.html create mode 100644 og-memory-graph/frontend/package.json create mode 100644 og-memory-graph/frontend/public/favicon.svg create mode 100644 og-memory-graph/frontend/public/icons.svg create mode 100644 og-memory-graph/frontend/src/App.css create mode 100644 og-memory-graph/frontend/src/App.tsx create mode 100644 og-memory-graph/frontend/src/api/client.ts create mode 100644 og-memory-graph/frontend/src/assets/hero.png create mode 100644 og-memory-graph/frontend/src/assets/react.svg create mode 100644 og-memory-graph/frontend/src/assets/vite.svg create mode 100644 og-memory-graph/frontend/src/components/ChatMessage.tsx create mode 100644 og-memory-graph/frontend/src/components/ChatPanel.tsx create mode 100644 og-memory-graph/frontend/src/components/ClusterForm.tsx create mode 100644 og-memory-graph/frontend/src/components/GraphSearch.tsx create mode 100644 og-memory-graph/frontend/src/components/GraphView.tsx create mode 100644 og-memory-graph/frontend/src/components/RefsView.tsx create mode 100644 og-memory-graph/frontend/src/components/RefsViewLite.tsx create mode 100644 og-memory-graph/frontend/src/components/ReportView.tsx create mode 100644 og-memory-graph/frontend/src/components/RunPanel.tsx create mode 100644 og-memory-graph/frontend/src/components/Sidebar.tsx create mode 100644 og-memory-graph/frontend/src/context/ModelContext.tsx create mode 100644 og-memory-graph/frontend/src/context/PreferenceContext.tsx create mode 100644 og-memory-graph/frontend/src/index.css create mode 100644 og-memory-graph/frontend/src/main.tsx create mode 100644 og-memory-graph/frontend/src/pages/ClusterDetail.tsx create mode 100644 og-memory-graph/frontend/src/pages/ConfigPage.tsx create mode 100644 og-memory-graph/frontend/src/pages/EmbedCluster.tsx create mode 100644 og-memory-graph/frontend/src/pages/EmbedMemoryGraph.tsx create mode 100644 og-memory-graph/frontend/src/pages/Home.tsx create mode 100644 og-memory-graph/frontend/src/types.ts create mode 100644 og-memory-graph/frontend/tsconfig.app.json create mode 100644 og-memory-graph/frontend/tsconfig.json create mode 100644 og-memory-graph/frontend/tsconfig.node.json create mode 100644 og-memory-graph/frontend/vite.config.ts create mode 100644 og-memory-graph/og/__init__.py create mode 100644 og-memory-graph/og/__main__.py create mode 100644 og-memory-graph/og/agents/__init__.py create mode 100644 og-memory-graph/og/agents/build_agent.py create mode 100644 og-memory-graph/og/agents/direction_agent.py create mode 100644 og-memory-graph/og/agents/literature_analysis_agent.py create mode 100644 og-memory-graph/og/agents/locate_agent.py create mode 100644 og-memory-graph/og/agents/modify_agent.py create mode 100644 og-memory-graph/og/agents/node_reparent_agent.py create mode 100644 og-memory-graph/og/agents/paragraph_rewrite_agent.py create mode 100644 og-memory-graph/og/agents/propagate_agent.py create mode 100644 og-memory-graph/og/agents/refresh_agent.py create mode 100644 og-memory-graph/og/agents/render_agent.py create mode 100644 og-memory-graph/og/agents/rewrite_agent.py create mode 100644 og-memory-graph/og/agents/section_merge_agent.py create mode 100644 og-memory-graph/og/agents/section_renumber_agent.py create mode 100644 og-memory-graph/og/agents/structure_agent.py create mode 100644 og-memory-graph/og/agents/table_agent.py create mode 100644 og-memory-graph/og/agents/table_curation_agent.py create mode 100644 og-memory-graph/og/agents/table_naming_agent.py create mode 100644 og-memory-graph/og/agents/validate_agent.py create mode 100644 og-memory-graph/og/cli/__init__.py create mode 100644 og-memory-graph/og/cli/__main__.py create mode 100644 og-memory-graph/og/cli/_common.py create mode 100644 og-memory-graph/og/cli/run.py create mode 100644 og-memory-graph/og/config/__init__.py create mode 100644 og-memory-graph/og/config/constants.py create mode 100644 og-memory-graph/og/config/models.py create mode 100644 og-memory-graph/og/config/paths.py create mode 100644 og-memory-graph/og/core/__init__.py create mode 100644 og-memory-graph/og/core/edge.py create mode 100644 og-memory-graph/og/core/graph.py create mode 100644 og-memory-graph/og/core/node.py create mode 100644 og-memory-graph/og/mcp_server/__init__.py create mode 100644 og-memory-graph/og/mcp_server/__main__.py create mode 100644 og-memory-graph/og/mcp_server/locks.py create mode 100644 og-memory-graph/og/mcp_server/tools.py create mode 100644 og-memory-graph/og/mcp_server/watcher.py create mode 100644 og-memory-graph/og/pipeline/__init__.py create mode 100644 og-memory-graph/og/pipeline/balanced.py create mode 100644 og-memory-graph/og/pipeline/curate.py create mode 100644 og-memory-graph/og/pipeline/polish.py create mode 100644 og-memory-graph/og/pipeline/run_cluster.py create mode 100644 og-memory-graph/og/pipeline/simulation.py create mode 100644 og-memory-graph/og/pipeline/update_pipeline.py create mode 100644 og-memory-graph/og/storage/__init__.py create mode 100644 og-memory-graph/og/storage/graph_store.py create mode 100644 og-memory-graph/og/storage/vector_store.py create mode 100644 og-memory-graph/og/storage/yaml_store.py create mode 100644 og-memory-graph/plugin.json create mode 100644 og-memory-graph/plugins/og-memory-graph/config.json create mode 100644 og-memory-graph/plugins/og-memory-graph/index.js create mode 100644 og-memory-graph/plugins/og-memory-graph/manifest.json create mode 100644 og-memory-graph/plugins/og-memory-graph/server.js create mode 100644 og-memory-graph/pyproject.toml create mode 100644 og-memory-graph/server/__init__.py create mode 100644 og-memory-graph/server/api/__init__.py create mode 100644 og-memory-graph/server/api/chat.py create mode 100644 og-memory-graph/server/api/chat_tools.py create mode 100644 og-memory-graph/server/api/clusters.py create mode 100644 og-memory-graph/server/api/config.py create mode 100644 og-memory-graph/server/api/graphs.py create mode 100644 og-memory-graph/server/api/memory.py create mode 100644 og-memory-graph/server/api/pilotdeck.py create mode 100644 og-memory-graph/server/api/preferences.py create mode 100644 og-memory-graph/server/api/references.py create mode 100644 og-memory-graph/server/api/reports.py create mode 100644 og-memory-graph/server/api/tasks.py create mode 100644 og-memory-graph/server/db/__init__.py create mode 100644 og-memory-graph/server/db/crud.py create mode 100644 og-memory-graph/server/db/models.py create mode 100644 og-memory-graph/server/db/session.py create mode 100644 og-memory-graph/server/filestore.py create mode 100644 og-memory-graph/server/main.py create mode 100644 og-memory-graph/server/schemas.py create mode 100644 og-memory-graph/setup.py diff --git a/INTEGRATION.md b/INTEGRATION.md new file mode 100644 index 000000000..4fe14a5d5 --- /dev/null +++ b/INTEGRATION.md @@ -0,0 +1,73 @@ +# og-memory-graph 集成说明 + +本仓库在 PilotDeck 基础上集成了 **og-memory-graph**(Outline Graph 记忆图谱服务)作为即插即用插件。 + +## 改动概览 + +相对 upstream PilotDeck,本仓库的改动: + +1. **`og-memory-graph/`**(新增子目录):OG Framework A 服务 + PilotDeck 插件包。独立 Python FastAPI 服务,与 PilotDeck 主进程解耦。 +2. **`ui/src/components/app-shell/MainAreaV2.tsx`**(唯一 PD 源码改动):顶栏追加 enabled 插件的 Tab 渲染,让 `plugin:` 能从顶栏进入。这是通用插件能力增强,不局限于 og-memory-graph。 + +> 其余 PilotDeck 源码均未修改。 + +## og-memory-graph 子目录 + +``` +og-memory-graph/ +├── og/ # Framework A(agents/core/pipeline/storage/config/cli) +├── server/ # FastAPI(embed 页 + REST) +├── frontend/ # React embed 页(dist/ 已构建) +├── plugins/og-memory-graph/ # PilotDeck 插件包 +│ ├── manifest.json +│ ├── config.json # ← 部署时填写本机路径 +│ ├── server.js # Node:og6 生命周期 + /config +│ └── index.js # 前端 bundle:rpc /config → 渲染 iframe +├── plugin.json +├── pyproject.toml +└── README.md +``` + +## 启用步骤 + +### 1. 安装 og-memory-graph 服务 + +```bash +cd og-memory-graph +pip install -e . +cp .env.template .env # 填 API key(DeepSeek 等) +``` + +构建前端(若 frontend/dist 缺失): +```bash +cd frontend && npm install && npm run build && cd .. +``` + +### 2. 部署插件包到 PilotDeck + +```bash +cp -R plugins/og-memory-graph ~/.pilotdeck/plugins/ +``` + +编辑 `~/.pilotdeck/plugins/og-memory-graph/config.json`,填本机路径: +```json +{ + "og_root": "", + "python": "", + "port": 8000, + "model": "deepseek" +} +``` + +### 3. 运行 + +启动 PilotDeck → 顶栏出现「记忆图谱」Tab → 选中项目 → 插件 server.js 自动 healthcheck og6 服务(已运行则复用,否则 spawn uvicorn)→ iframe 加载 embed 页 → 自动 init cluster + 全量 build → 显示图谱/报告。 + +## 机制 + +- **同步与 PilotDeck memory 轮询对齐**:og6 服务启动时为所有 `pd-*` cluster 注册 60s 轮询,检测 memory `.md` 变化即触发增量同步。PilotDeck 侧无需改动。 +- **跨进程互斥**:fcntl.flock + manifest pid 保证 init/sync/rebuild 不并发。 +- **5 次同步后自动重建**:累计 5 次实质性同步后下次改做全量 rebuild,重置基线。 +- **cluster_id**:`pd-{md5(workspace_path)[:8]}`,同一 project 路径稳定唯一。 + +详见 `og-memory-graph/README.md`。 diff --git a/og-memory-graph/.gitignore b/og-memory-graph/.gitignore new file mode 100644 index 000000000..40c140f9c --- /dev/null +++ b/og-memory-graph/.gitignore @@ -0,0 +1,30 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.eggs/ +build/ +dist/ +*.egg + +# 虚拟环境 +.venv/ +venv/ +env/ + +# 运行产物(部署时动态生成,不 commit) +data/ +logs/ +outputs/ +*.log + +# 敏感配置(不 commit 真实 key) +.env + +# 前端 +frontend/node_modules/ +frontend/dist/ +frontend/.vite/ + +# 系统 +.DS_Store diff --git a/og-memory-graph/README.md b/og-memory-graph/README.md new file mode 100644 index 000000000..e6fde6397 --- /dev/null +++ b/og-memory-graph/README.md @@ -0,0 +1,88 @@ +# og-pilotdeck-integration + +Outline Graph (OG) Framework A 服务 + PilotDeck「记忆图谱」即插即用插件。 + +本仓库是一个**独立服务**:跑 FastAPI(端口 8000)提供 OG 图谱生成 + embed 页面,PilotDeck 通过插件包以 iframe 嵌入。PilotDeck 侧仅需 1 处通用改动(顶栏显示 plugin tabs)。 + +## 架构 + +``` +PilotDeck (顶栏「记忆图谱」Tab = plugin:og-memory-graph) + │ iframe + ▼ +og-pilotdeck-integration (FastAPI @ 8000) + ├── /embed/memory-graph ← iframe 加载的页面(frontend/) + ├── /api/pd/* ← PilotDeck 专用 REST(init/sync/rebuild/status/graph) + ├── /api/clusters/*/graph ← 图谱 JSON + ├── /api/clusters/*/reports ← polished7 报告 + └── og run a (子进程) ← Framework A pipeline:build→curate→balanced→polish +``` + +**插件包**(`plugins/og-memory-graph/`)部署到 `~/.pilotdeck/plugins/og-memory-graph/`: +- `server.js`:PilotDeck spawn 的 Node 进程,healthcheck og6 服务(已运行则复用,否则 spawn uvicorn),暴露 `/config`。 +- `index.js`:前端 bundle,rpc 拿 og6 地址后渲染 iframe。 + +## 目录 + +``` +og-pilotdeck-integration/ +├── og/ # Framework A +│ ├── agents/ # 19 个 LLM Agent(build/modify/propagate/render/rewrite…) +│ ├── core/ # OutlineGraph / OGNode / OGEdge +│ ├── pipeline/ # run_cluster / curate / balanced / polish / simulation +│ ├── storage/ # GraphStore / VectorStore(ChromaDB+BM25+rerank) / YamlStore +│ ├── config/ # paths / models / constants +│ ├── cli/ # python -m og run a(仅 Framework A) +│ └── mcp_server/ # MCP stdio 工具 + watcher(60s 轮询同步) + locks(跨进程互斥) +├── server/ # FastAPI(embed 页 + REST) +├── frontend/ # React embed 页(dist/ 已构建) +├── plugins/og-memory-graph/ # PilotDeck 插件包 +├── plugin.json # PilotDeck 插件清单(声明) +├── pyproject.toml +└── .env.template +``` + +## 安装 + +```bash +# 1. 安装 og 服务 +cd og-pilotdeck-integration +pip install -e . +cp .env.template .env # 填 API key + +# 2. 构建前端(若 frontend/dist 缺失) +cd frontend && npm install && npm run build && cd .. + +# 3. 部署插件包到 PilotDeck +cp -R plugins/og-memory-graph ~/.pilotdeck/plugins/ +# 编辑 ~/.pilotdeck/plugins/og-memory-graph/config.json:og_root / python / port / model +``` + +## 运行 + +```bash +# 方式 A:手动起 og6 服务(开发) +uvicorn server.main:app --port 8000 --host 127.0.0.1 + +# 方式 B:由 PilotDeck 插件自动管理(生产) +# 启用 PilotDeck 插件后,server.js 自动 healthcheck/spawn og6 +``` + +启动 PilotDeck → 顶栏出现「记忆图谱」Tab → 选中项目 → iframe 加载 og6 embed 页 → 自动 init cluster + 全量 build → 显示图谱/报告。 + +## 核心机制 + +- **同步与 PilotDeck memory 轮询对齐**:og6 服务启动时 `rebuild_watchers_from_clusters()` 为所有 `pd-*` cluster 注册 60s 轮询,检测 memory `.md` 变化即触发增量 `sync_changes`。PilotDeck 侧无需改动。 +- **跨进程互斥**:`og/mcp_server/locks.py`(fcntl.flock + manifest pid)保证 init/sync/rebuild 不并发;pipeline pid 死亡时 `reap_if_dead` 自动推断终态。 +- **5 次同步后自动重建**:累计 5 次实质性同步后置 `pending_rebuild`,下次 sync 改做全量 `rebuild`(清空历史 + vector DB,从当前 memory 重建),重置计数。 +- **cluster_id**:`pd-{md5(workspace_path)[:8]}`,同一 project 路径稳定唯一。 + +## 配置 + +`og/config/models.py` 顶部可改 API key / 模型路由(优先级高于 `.env`)。默认: +- 主模型 `deepseek-v4-pro`,辅助/裁判 `deepseek-v4-flash`。 + +`~/.pilotdeck/plugins/og-memory-graph/config.json`: +```json +{ "og_root": "<本仓库路径>", "python": "", "port": 8000, "model": "deepseek" } +``` diff --git a/og-memory-graph/frontend/.gitignore b/og-memory-graph/frontend/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/og-memory-graph/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/og-memory-graph/frontend/.oxlintrc.json b/og-memory-graph/frontend/.oxlintrc.json new file mode 100644 index 000000000..6fa991dad --- /dev/null +++ b/og-memory-graph/frontend/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/og-memory-graph/frontend/README.md b/og-memory-graph/frontend/README.md new file mode 100644 index 000000000..d6af7e39f --- /dev/null +++ b/og-memory-graph/frontend/README.md @@ -0,0 +1,32 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the Oxlint configuration + +If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`: + +```json +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "options": { + "typeAware": true + }, + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} +``` + +See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. diff --git a/og-memory-graph/frontend/index.html b/og-memory-graph/frontend/index.html new file mode 100644 index 000000000..0fca6f043 --- /dev/null +++ b/og-memory-graph/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + frontend + + +
+ + + diff --git a/og-memory-graph/frontend/package.json b/og-memory-graph/frontend/package.json new file mode 100644 index 000000000..0f2863856 --- /dev/null +++ b/og-memory-graph/frontend/package.json @@ -0,0 +1,31 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "@dagrejs/dagre": "^3.0.0", + "@types/dagre": "^0.7.54", + "@xyflow/react": "^12.11.1", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-markdown": "^10.1.0", + "react-router-dom": "^7.18.1", + "remark-gfm": "^4.0.1" + }, + "devDependencies": { + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "oxlint": "^1.71.0", + "typescript": "~6.0.2", + "vite": "^8.1.1" + } +} diff --git a/og-memory-graph/frontend/public/favicon.svg b/og-memory-graph/frontend/public/favicon.svg new file mode 100644 index 000000000..6893eb132 --- /dev/null +++ b/og-memory-graph/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/og-memory-graph/frontend/public/icons.svg b/og-memory-graph/frontend/public/icons.svg new file mode 100644 index 000000000..e9522193d --- /dev/null +++ b/og-memory-graph/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/og-memory-graph/frontend/src/App.css b/og-memory-graph/frontend/src/App.css new file mode 100644 index 000000000..f90339d8f --- /dev/null +++ b/og-memory-graph/frontend/src/App.css @@ -0,0 +1,184 @@ +.counter { + font-size: 16px; + padding: 5px 10px; + border-radius: 5px; + color: var(--accent); + background: var(--accent-bg); + border: 2px solid transparent; + transition: border-color 0.3s; + margin-bottom: 24px; + + &:hover { + border-color: var(--accent-border); + } + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } +} + +.hero { + position: relative; + + .base, + .framework, + .vite { + inset-inline: 0; + margin: 0 auto; + } + + .base { + width: 170px; + position: relative; + z-index: 0; + } + + .framework, + .vite { + position: absolute; + } + + .framework { + z-index: 1; + top: 34px; + height: 28px; + transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) + scale(1.4); + } + + .vite { + z-index: 0; + top: 107px; + height: 26px; + width: auto; + transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) + scale(0.8); + } +} + +#center { + display: flex; + flex-direction: column; + gap: 25px; + place-content: center; + place-items: center; + flex-grow: 1; + + @media (max-width: 1024px) { + padding: 32px 20px 24px; + gap: 18px; + } +} + +#next-steps { + display: flex; + border-top: 1px solid var(--border); + text-align: left; + + & > div { + flex: 1 1 0; + padding: 32px; + @media (max-width: 1024px) { + padding: 24px 20px; + } + } + + .icon { + margin-bottom: 16px; + width: 22px; + height: 22px; + } + + @media (max-width: 1024px) { + flex-direction: column; + text-align: center; + } +} + +#docs { + border-right: 1px solid var(--border); + + @media (max-width: 1024px) { + border-right: none; + border-bottom: 1px solid var(--border); + } +} + +#next-steps ul { + list-style: none; + padding: 0; + display: flex; + gap: 8px; + margin: 32px 0 0; + + .logo { + height: 18px; + } + + a { + color: var(--text-h); + font-size: 16px; + border-radius: 6px; + background: var(--social-bg); + display: flex; + padding: 6px 12px; + align-items: center; + gap: 8px; + text-decoration: none; + transition: box-shadow 0.3s; + + &:hover { + box-shadow: var(--shadow); + } + .button-icon { + height: 18px; + width: 18px; + } + } + + @media (max-width: 1024px) { + margin-top: 20px; + flex-wrap: wrap; + justify-content: center; + + li { + flex: 1 1 calc(50% - 8px); + } + + a { + width: 100%; + justify-content: center; + box-sizing: border-box; + } + } +} + +#spacer { + height: 88px; + border-top: 1px solid var(--border); + @media (max-width: 1024px) { + height: 48px; + } +} + +.ticks { + position: relative; + width: 100%; + + &::before, + &::after { + content: ''; + position: absolute; + top: -4.5px; + border: 5px solid transparent; + } + + &::before { + left: 0; + border-left-color: var(--border); + } + &::after { + right: 0; + border-right-color: var(--border); + } +} diff --git a/og-memory-graph/frontend/src/App.tsx b/og-memory-graph/frontend/src/App.tsx new file mode 100644 index 000000000..54d37cc60 --- /dev/null +++ b/og-memory-graph/frontend/src/App.tsx @@ -0,0 +1,39 @@ +import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { ModelProvider } from './context/ModelContext'; +import { PreferenceProvider } from './context/PreferenceContext'; +import Sidebar from './components/Sidebar'; +import Home from './pages/Home'; +import ClusterDetail from './pages/ClusterDetail'; +import ConfigPage from './pages/ConfigPage'; +import EmbedCluster from './pages/EmbedCluster'; +import EmbedMemoryGraph from './pages/EmbedMemoryGraph'; + +export default function App() { + return ( + + + + + {} + } /> + } /> + + {} + + +
+ + } /> + } /> + } /> + +
+ + } /> +
+
+
+
); + +} diff --git a/og-memory-graph/frontend/src/api/client.ts b/og-memory-graph/frontend/src/api/client.ts new file mode 100644 index 000000000..576c90719 --- /dev/null +++ b/og-memory-graph/frontend/src/api/client.ts @@ -0,0 +1,384 @@ +import type { + ClusterOut, GraphVersionsOut, GraphData, ReportItem, ReferenceOut, + TaskOut, TaskLogOut, ConfigOut, TestResult, DistributePlan } from +'../types'; + +const BASE = '/api'; + +async function get(path: string): Promise { + const res = await fetch(BASE + path); + if (!res.ok) throw new Error(`${res.status} ${res.statusText}: ${path}`); + return res.json(); +} + +async function getText(path: string): Promise { + const res = await fetch(BASE + path); + if (!res.ok) throw new Error(`${res.status} ${res.statusText}: ${path}`); + return res.text(); +} + +async function post(path: string, body: unknown): Promise { + const res = await fetch(BASE + path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + if (!res.ok) { + const msg = await res.text().catch(() => res.statusText); + throw new Error(`${res.status}: ${msg}`); + } + return res.json(); +} + +async function patch(path: string, body: unknown): Promise { + const res = await fetch(BASE + path, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + if (!res.ok) { + const msg = await res.text().catch(() => res.statusText); + throw new Error(`${res.status}: ${msg}`); + } + return res.json(); +} + +async function put(path: string, body: unknown): Promise { + const res = await fetch(BASE + path, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + if (!res.ok) { + const msg = await res.text().catch(() => res.statusText); + throw new Error(`${res.status}: ${msg}`); + } + return res.json(); +} + +async function del(path: string): Promise { + const res = await fetch(BASE + path, { method: 'DELETE' }); + if (!res.ok && res.status !== 204) { + throw new Error(`${res.status} ${res.statusText}: ${path}`); + } +} + + + +export const fetchModels = (): Promise<{models: string[];}> => +get('/models'); + + + +export const fetchClusters = (model: string): Promise => +get(`/clusters?model=${model}`); + +export const fetchCluster = (id: string, model: string): Promise => +get(`/clusters/${id}?model=${model}`); + +export const createCluster = ( +body: {id: string;topic: string;description?: string;period_year_ranges: Record;}, +model: string) +: Promise => +post(`/clusters?model=${model}`, body); + +export const updateCluster = ( +id: string, +body: {topic?: string;description?: string;period_year_ranges?: Record;}, +model: string) +: Promise => +patch(`/clusters/${id}?model=${model}`, body); + +export const deleteCluster = (id: string, model: string): Promise => +del(`/clusters/${id}?model=${model}`); + + + +export const fetchGraphVersions = ( +clusterId: string, +model: string) +: Promise => +get(`/clusters/${clusterId}/graph/versions?model=${model}`); + +export const fetchGraph = ( +clusterId: string, +version: string, +model: string) +: Promise => +get(`/clusters/${clusterId}/graph?version=${version}&model=${model}`); + + + +export const fetchReports = (clusterId: string, model: string): Promise => +get(`/clusters/${clusterId}/reports?model=${model}`); + +export const fetchReportContent = ( +clusterId: string, +filename: string, +model: string) +: Promise => +getText(`/clusters/${clusterId}/reports/${encodeURIComponent(filename)}?model=${model}`); + +export const deleteReport = ( +clusterId: string, +filename: string, +model: string) +: Promise => +del(`/clusters/${clusterId}/reports/${encodeURIComponent(filename)}?model=${model}`); + +export interface ReportDiffChunk {type: 'equal' | 'del' | 'ins';text: string;} +export interface ReportDiffResult { + filename_a: string; + filename_b: string; + left: ReportDiffChunk[]; + right: ReportDiffChunk[]; + stats: {del: number;ins: number;equal: number;}; +} + +export const fetchReportDiff = ( +clusterId: string, +filenameA: string, +filenameB: string, +model: string) +: Promise => +get(`/clusters/${clusterId}/reports/${encodeURIComponent(filenameA)}/diff/${encodeURIComponent(filenameB)}?model=${model}`); + +export const detectPeriods = ( +clusterId: string, +model: string) +: Promise<{suggested: Record;}> => +get(`/clusters/${clusterId}/references/detect-periods?model=${model}`); + + + +export const fetchRefs = ( +clusterId: string, +model: string, +version?: number) +: Promise => { + const q = version != null ? `&version=${version}` : ''; + return get(`/clusters/${clusterId}/references?model=${model}${q}`); +}; + +export const fetchRefContent = ( +clusterId: string, +refId: string, +model: string) +: Promise<{content: string;}> => +get(`/clusters/${clusterId}/references/${encodeURIComponent(refId)}/content?model=${model}`); + +export const updateRefContent = ( +clusterId: string, +refId: string, +content: string, +model: string) +: Promise<{ok: boolean;word_count: number;}> => +put(`/clusters/${clusterId}/references/${encodeURIComponent(refId)}/content?model=${model}`, +{ content }); + +export const deleteRef = ( +clusterId: string, +refId: string, +model: string) +: Promise => +del(`/clusters/${clusterId}/references/${encodeURIComponent(refId)}?model=${model}`); + +export const pasteRef = ( +clusterId: string, +content: string, +year: number, +model: string) +: Promise => +post(`/clusters/${clusterId}/references/paste?model=${model}`, { content, year }); + +export const distributeRefs = ( +clusterId: string, +model: string, +dryRun: boolean) +: Promise => +post(`/clusters/${clusterId}/references/distribute?model=${model}`, { dry_run: dryRun }); + + + +export const createTask = (body: { + cluster_id: string; + type: string; + config: Record; +}): Promise => post('/tasks', body); + +export const fetchTasks = (clusterId?: string): Promise => { + const q = clusterId ? `?cluster_id=${clusterId}` : ''; + return get(`/tasks${q}`); +}; + +export const fetchTask = (taskId: string): Promise => +get(`/tasks/${taskId}`); + +export const cancelTask = (taskId: string): Promise => +del(`/tasks/${taskId}`); + + +export function streamTask(taskId: string): EventSource { + return new EventSource(`${BASE}/tasks/${taskId}/stream`); +} + +export const uploadRefsBatch = async ( +clusterId: string, +files: File[], +model: string) +: Promise> => { + const fd = new FormData(); + for (const f of files) fd.append('files', f); + const res = await fetch(`${BASE}/clusters/${clusterId}/references/upload-batch?model=${model}`, { + method: 'POST', body: fd + }); + if (!res.ok) throw new Error(`${res.status}: ${await res.text().catch(() => res.statusText)}`); + return res.json(); +}; + + + +export const fetchConfig = (): Promise => +get('/config'); + +export const updateConfig = (updates: Record): Promise => +put('/config', { updates }); + +export const testConnection = (provider: string): Promise => +post('/config/test', { provider }); + + + +export interface CustomProvider { + id: string;label: string;api_base: string;api_key: string;test_model: string; +} + +export const fetchCustomProviders = (): Promise => +get('/config/custom'); + +export const addCustomProvider = (body: Omit): Promise => +post('/config/custom', body); + +export const updateCustomProvider = (id: string, body: Omit): Promise => +put(`/config/custom/${id}`, body); + +export const deleteCustomProvider = (id: string): Promise => +del(`/config/custom/${id}`); + +export const testCustomProvider = (id: string): Promise => +post(`/config/custom/${id}/test`, {}); + + + +export const fetchPreferences = (): Promise> => +get('/preferences'); + +export const setPreference = (key: string, value: unknown): Promise<{ok: boolean;}> => +put(`/preferences/${key}`, { value }); + + + +export const fetchMemory = ( +level: 'global' | 'user' | 'project', +clusterId?: string) +: Promise<{level: string;content: string;}> => { + const q = clusterId ? `?cluster_id=${clusterId}` : ''; + return get(`/memory/${level}${q}`); +}; + +export const putMemory = ( +level: 'global' | 'user' | 'project', +content: string, +clusterId?: string) +: Promise<{ok: boolean;}> => +put(`/memory/${level}`, { content, cluster_id: clusterId }); + +export const appendMemory = ( +level: 'global' | 'user' | 'project', +content: string, +opts?: {section?: string;cluster_id?: string;}) +: Promise<{ok: boolean;}> => +post(`/memory/${level}/append`, { content, ...opts }); + +export const fetchMemoryList = (): Promise<{files: Array<{level: string;cluster_id?: string;exists: boolean;size: number;}>;}> => +get('/memory'); + + + +export interface ConvMeta { + id: string;cluster_id: string | null;title: string; + model: string;msg_count: number;updated_at: string; +} + +export interface ConvMessage { + role: 'user' | 'assistant'; + content: string; + ts: string; + tool_events?: Array<{type: string;name: string;input?: unknown;result?: unknown;}>; +} + +export interface ConvFull { + id: string;cluster_id: string | null;title: string; + model: string;created_at: string;updated_at: string; + messages: ConvMessage[]; +} + +export const fetchConversations = (clusterId?: string): Promise<{conversations: ConvMeta[];}> => { + const q = clusterId ? `?cluster_id=${clusterId}` : ''; + return get(`/chat/conversations${q}`); +}; + +export const createConversation = (clusterId?: string, model?: string): Promise => +post('/chat/conversations', { cluster_id: clusterId, model }); + +export const fetchConversation = (id: string): Promise => +get(`/chat/conversations/${id}`); + +export const deleteConversation = (id: string): Promise => +del(`/chat/conversations/${id}`); + +export const clearConversationMessages = (id: string): Promise => +del(`/chat/conversations/${id}/messages`); + + +export function sendChatMessage( +convId: string, +content: string, +context: Record = {}) +: EventSource { + + + return new EventSource( + `${BASE}/chat/conversations/${convId}/messages?` + + `content=${encodeURIComponent(content)}&ctx=${encodeURIComponent(JSON.stringify(context))}` + ); +} + + +export async function* streamChatMessage( +convId: string, +content: string, +context: Record = {}) +: AsyncGenerator<{type: string;[k: string]: unknown;}> { + const res = await fetch(`${BASE}/chat/conversations/${convId}/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content, context }) + }); + if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`); + const reader = res.body!.getReader(); + const dec = new TextDecoder(); + let buf = ''; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buf += dec.decode(value, { stream: true }); + const lines = buf.split('\n'); + buf = lines.pop() ?? ''; + for (const line of lines) { + if (line.startsWith('data: ')) { + try {yield JSON.parse(line.slice(6));} catch {} + } + } + } +} diff --git a/og-memory-graph/frontend/src/assets/hero.png b/og-memory-graph/frontend/src/assets/hero.png new file mode 100644 index 000000000..9b398ea2e --- /dev/null +++ b/og-memory-graph/frontend/src/assets/hero.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:881ffbcaafc212e49addad08846a5b82761355fa20624253af3477ba33262c5c +size 13057 diff --git a/og-memory-graph/frontend/src/assets/react.svg b/og-memory-graph/frontend/src/assets/react.svg new file mode 100644 index 000000000..6c87de9bb --- /dev/null +++ b/og-memory-graph/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/og-memory-graph/frontend/src/assets/vite.svg b/og-memory-graph/frontend/src/assets/vite.svg new file mode 100644 index 000000000..5101b674d --- /dev/null +++ b/og-memory-graph/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/og-memory-graph/frontend/src/components/ChatMessage.tsx b/og-memory-graph/frontend/src/components/ChatMessage.tsx new file mode 100644 index 000000000..2a0b30c94 --- /dev/null +++ b/og-memory-graph/frontend/src/components/ChatMessage.tsx @@ -0,0 +1,80 @@ +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import type { ConvMessage } from '../api/client'; + +interface Props { + msg: ConvMessage; + isStreaming?: boolean; +} + +const TOOL_LABEL: Record = { + get_cluster_info: '📊 集群信息', + get_graph_summary: '🔮 图谱摘要', + search_nodes: '🔍 节点搜索', + get_report_content: '📄 报告内容', + list_clusters: '📋 集群列表', + run_pipeline: '▶ 启动流水线', + get_task_status: '⏳ 任务状态', + write_memory: '💾 写入记忆', + set_preference: '⚙ 更新偏好', + read_memory: '📖 读取记忆' +}; + +function ToolCard({ ev }: {ev: NonNullable[number];}) { + const label = TOOL_LABEL[ev.name] ?? `🔧 ${ev.name}`; + const isResult = ev.type === 'tool_result'; + + return ( +
+
+ {label} + {isResult && } +
+ {ev.type === 'tool_call' && !!ev.input && +
+ {Object.entries(ev.input as Record).map(([k, v]) => +
+ {k} + {String(v)} +
+ )} +
+ } + {isResult && ev.result !== undefined && +
+ {typeof ev.result === 'string' ? + ev.result : + JSON.stringify(ev.result, null, 2).slice(0, 300) + } + {JSON.stringify(ev.result ?? '').length > 300 ? '…' : ''} +
+ } +
); + +} + +export default function ChatMessage({ msg, isStreaming }: Props) { + const isUser = msg.role === 'user'; + + return ( +
+
+ {} + {!isUser && (msg.tool_events ?? []).map((ev, i) => + + )} + + {} + {msg.content && +
+ {isUser ? + {msg.content} : + {msg.content} + } + {isStreaming && } +
+ } +
+
); + +} diff --git a/og-memory-graph/frontend/src/components/ChatPanel.tsx b/og-memory-graph/frontend/src/components/ChatPanel.tsx new file mode 100644 index 000000000..9a9bf2f05 --- /dev/null +++ b/og-memory-graph/frontend/src/components/ChatPanel.tsx @@ -0,0 +1,260 @@ +import { useEffect, useRef, useState } from 'react'; +import { + fetchConversations, createConversation, fetchConversation, + deleteConversation, clearConversationMessages, streamChatMessage, + type ConvMeta, type ConvFull, type ConvMessage } from +'../api/client'; +import { useModel } from '../context/ModelContext'; +import { usePreferences } from '../context/PreferenceContext'; +import ChatMessage from './ChatMessage'; + +interface Props { + clusterId?: string; + tab?: string; + version?: string; + onClose: () => void; + onNavigate?: (detail: Record) => void; +} + +export default function ChatPanel({ clusterId, tab, version, onClose, onNavigate }: Props) { + const { model } = useModel(); + const { prefs, update } = usePreferences(); + + + const [convList, setConvList] = useState([]); + const [conv, setConv] = useState(null); + const [input, setInput] = useState(''); + const [streaming, setStreaming] = useState(false); + const [streamMsg, setStreamMsg] = useState(''); + const [streamTools, setStreamTools] = useState([]); + const [error, setError] = useState(''); + + + const [showCfg, setShowCfg] = useState(false); + + + const [pos, setPos] = useState({ x: 0, y: 0 }); + const dragging = useRef(false); + const dragOrigin = useRef({ mx: 0, my: 0, px: 0, py: 0 }); + const panelRef = useRef(null); + + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + + const scrollToBottom = () => messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + + + const handleHeaderMouseDown = (e: React.MouseEvent) => { + if ((e.target as HTMLElement).closest('button')) return; + dragging.current = true; + dragOrigin.current = { mx: e.clientX, my: e.clientY, px: pos.x, py: pos.y }; + e.preventDefault(); + }; + + useEffect(() => { + const onMove = (e: MouseEvent) => { + if (!dragging.current) return; + const dx = e.clientX - dragOrigin.current.mx; + const dy = e.clientY - dragOrigin.current.my; + setPos({ x: dragOrigin.current.px + dx, y: dragOrigin.current.py + dy }); + }; + const onUp = () => {dragging.current = false;}; + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + return () => {window.removeEventListener('mousemove', onMove);window.removeEventListener('mouseup', onUp);}; + }, []); + + + const loadList = () => { + fetchConversations(clusterId).then((r) => setConvList(r.conversations)).catch(() => {}); + }; + useEffect(() => {loadList();}, [clusterId]); + + const selectConv = async (id: string) => { + const c = await fetchConversation(id).catch(() => null); + if (c) {setConv(c);setTimeout(scrollToBottom, 100);} + }; + + const newConv = async () => { + const chatModel = prefs.chat_model || 'deepseek-v4-pro'; + const c = await createConversation(clusterId, chatModel).catch(() => null); + if (c) {setConv(c);loadList();inputRef.current?.focus();} + }; + + const deleteConv = async (id: string) => { + await deleteConversation(id).catch(() => {}); + if (conv?.id === id) setConv(null); + loadList(); + }; + + const clearConv = async () => { + if (!conv) return; + await clearConversationMessages(conv.id).catch(() => {}); + setConv((prev) => prev ? { ...prev, messages: [], title: '' } : null); + }; + + + const handleSend = async () => { + const text = input.trim(); + if (!text || streaming) return; + setInput('');setError(''); + + let activeConv = conv; + if (!activeConv) { + const chatModel = prefs.chat_model || 'deepseek-v4-pro'; + activeConv = await createConversation(clusterId, chatModel).catch(() => null); + if (!activeConv) {setError('新建会话失败');return;} + setConv(activeConv);loadList(); + } + + const userMsg: ConvMessage = { role: 'user', content: text, ts: new Date().toISOString() }; + setConv((prev) => prev ? { ...prev, messages: [...prev.messages, userMsg] } : null); + setTimeout(scrollToBottom, 50); + + setStreaming(true);setStreamMsg('');setStreamTools([]); + + const ctx = { cluster_id: clusterId, model, tab, version }; + try { + for await (const ev of streamChatMessage(activeConv.id, text, ctx)) { + if (ev.type === 'text') { + setStreamMsg((prev) => prev + (ev.delta as string || '')); + setTimeout(scrollToBottom, 20); + } else if (ev.type === 'navigate') { + + onNavigate?.(ev as Record); + } else if (ev.type === 'tool_call') { + setStreamTools((prev) => [...(prev ?? []), { type: 'tool_call', name: ev.name as string, input: ev.input }]); + } else if (ev.type === 'tool_result') { + setStreamTools((prev) => [...(prev ?? []), { type: 'tool_result', name: ev.name as string, result: ev.result }]); + } else if (ev.type === 'done') { + break; + } + } + } catch (e: unknown) { + setError(e instanceof Error ? e.message : '发送失败'); + } + + setStreaming(false);setStreamMsg('');setStreamTools([]); + const updated = await fetchConversation(activeConv.id).catch(() => null); + if (updated) {setConv(updated);loadList();} + setTimeout(scrollToBottom, 100); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) {e.preventDefault();handleSend();} + }; + + const streamingPreviewMsg: ConvMessage | null = streaming ? + { role: 'assistant', content: streamMsg, ts: '', tool_events: streamTools } : + null; + + + const panelStyle: React.CSSProperties = { + position: 'fixed', + right: `calc(20px - ${pos.x}px)`, + bottom: `calc(20px - ${pos.y}px)`, + width: 560, + height: '82vh', + maxWidth: 'calc(100vw - 40px)', + maxHeight: 'calc(100vh - 40px)' + }; + + return ( +
+ {} +
+ 💬 研究助手 + {clusterId && {clusterId}{tab ? ` · ${tab}` : ''}} +
+ + {conv && } + + +
+
+ + {} + {showCfg && +
+
助手设置
+ + + +
设置实时生效,下次对话起使用
+
+ } + + {} + {convList.length > 0 && +
+ {convList.slice(0, 6).map((c) => +
selectConv(c.id)}> + {c.title || '新对话'} + +
+ )} +
+ } + + {} +
+ {!conv && +
+
🤖
+
og 研究助手
+
+ 分析图谱、对比报告、启动流水线
测试 API 连通性、管理参考文献… +
+ +
+ } + {conv?.messages.map((msg, i) => )} + {streamingPreviewMsg && } + {error &&
{error}
} +
+
+ + {} +
+