From 4a76f22b4d3f8a71928ad8dbae2fbaa4fdcfe34a Mon Sep 17 00:00:00 2001 From: JAYcodr <66018853+JAYcodr@users.noreply.github.com> Date: Sat, 23 May 2026 06:45:20 +0800 Subject: [PATCH] docs(i18n): add zh-CN translations for developing modules (C2) (#2505) Co-authored-by: agent:skill-master --- .../developing/agent-observability.zh-CN.md | 81 + .../architecture/agent-harness.zh-CN.md | 311 +++ .../architecture/desktop-companion.zh-CN.md | 129 + .../developing/architecture/frontend.zh-CN.md | 2295 +++++++++++++++++ .../architecture/tauri-shell.zh-CN.md | 209 ++ gitbooks/developing/cef.zh-CN.md | 172 ++ .../integrations/polymarket.zh-CN.md | 128 + gitbooks/developing/mcp-server.zh-CN.md | 87 + gitbooks/developing/release-policy.zh-CN.md | 81 + 9 files changed, 3493 insertions(+) create mode 100644 gitbooks/developing/agent-observability.zh-CN.md create mode 100644 gitbooks/developing/architecture/agent-harness.zh-CN.md create mode 100644 gitbooks/developing/architecture/desktop-companion.zh-CN.md create mode 100644 gitbooks/developing/architecture/frontend.zh-CN.md create mode 100644 gitbooks/developing/architecture/tauri-shell.zh-CN.md create mode 100644 gitbooks/developing/cef.zh-CN.md create mode 100644 gitbooks/developing/integrations/polymarket.zh-CN.md create mode 100644 gitbooks/developing/mcp-server.zh-CN.md create mode 100644 gitbooks/developing/release-policy.zh-CN.md diff --git a/gitbooks/developing/agent-observability.zh-CN.md b/gitbooks/developing/agent-observability.zh-CN.md new file mode 100644 index 000000000..93736a8ac --- /dev/null +++ b/gitbooks/developing/agent-observability.zh-CN.md @@ -0,0 +1,81 @@ +--- +description: 使 E2E 测试可调试的工件捕获层。日志、跟踪、截图。 +icon: eye +--- + +# E2E 的 Agent 可观测性 + +本文档描述了使桌面应用可通过现有 WDIO/Appium/tauri-driver harness 被编码智能体(Codex、Claude Code、Cursor)检查的工件捕获层。 + +它有意保持精简:一个规范的 onboarding + 隐私流程,包含磁盘截图、页面源码 dump 和 mock 后端请求日志。更广泛的计划见仓库根目录的 `AGENT_OBSERVABILITY_PLAN.md`。 + +## TL;DR + +```bash +bash app/scripts/e2e-agent-review.sh +``` + +工件落在: + +```text +app/test/e2e/artifacts/-agent-review/ + 01-welcome.png + 01-welcome.source.xml + 02-post-welcome.png + 02-post-welcome.source.xml + 03-post-onboarding.png + 03-post-onboarding.source.xml + 04-privacy-panel.png + 04-privacy-panel.source.xml + mock-requests-after-welcome.json + mock-requests-after-onboarding.json + mock-requests-after-privacy.json + failure-.png # 仅在失败时 + failure-.source.xml # 仅在失败时 + meta.json # 运行元数据 + 检查点索引 +``` + +脚本最后会打印解析后的工件目录。 + +## 组成部分 + +| 组件 | 路径 | 作用 | +|-------|------|------| +| 辅助函数 | `app/test/e2e/helpers/artifacts.ts` | 运行目录、`captureCheckpoint`、`captureFailureArtifacts`、`saveMockRequestLog` | +| WDIO hook | `app/test/wdio.conf.ts` (`afterTest`) | 任何失败测试都会 dump 截图 + 源码 | +| 规范 spec | `app/test/e2e/specs/agent-review.spec.ts` | Welcome → onboarding → 隐私面板,带命名检查点 | +| Wrapper 脚本 | `app/scripts/e2e-agent-review.sh` | 构建 + 运行 + 打印工件目录 | +| 稳定选择器 | `OnboardingNextButton`、`Onboarding` 遮罩层 + 跳过按钮、`WelcomeStep`、`PrivacyPanel` 上的 `data-testid` | 智能体可靠的导航锚点 | + +## 环境覆盖 + +| 变量 | 效果 | +|----------|--------| +| `E2E_ARTIFACT_DIR` | 强制指定运行目录(跳过自动时间戳命名) | +| `E2E_ARTIFACT_ROOT` | 自动生成运行目录的父目录(默认:`app/test/e2e/artifacts`) | +| `E2E_ARTIFACT_LABEL` | 自动生成的运行目录名中使用的标签(默认:`run`;wrapper 设为 `agent-review`) | + +## 在新 spec 中使用辅助函数 + +```ts +import { + captureCheckpoint, + saveMockRequestLog, +} from '../helpers/artifacts'; +import { getRequestLog } from '../mock-server'; + +await captureCheckpoint('after-connect-click'); +saveMockRequestLog('after-connect-click', getRequestLog()); +``` + +`captureCheckpoint` 会对捕获进行编号,使运行目录按时间顺序阅读。 +`captureFailureArtifacts` 已接入 `wdio.conf.ts`,在任何失败测试中自动触发,spec 不应直接调用它。 + +## 有意排除的范围 + +- 跨每个组件状态的视觉基线 / 图像差异。 +- 每次点击都截图(太吵)。 +- 实时集成(Gmail、Notion、Telegram);仅 mock 服务器。 +- 新测试框架 / reporter。 + +仅在证明此循环有效后才扩展到更多流程。 diff --git a/gitbooks/developing/architecture/agent-harness.zh-CN.md b/gitbooks/developing/architecture/agent-harness.zh-CN.md new file mode 100644 index 000000000..106a520b2 --- /dev/null +++ b/gitbooks/developing/architecture/agent-harness.zh-CN.md @@ -0,0 +1,311 @@ +--- +description: >- + 智能体轮次实际如何运行 —— 工具调用循环、子智能体分派、原型、分类、hook,以及围绕它们的成本/预算机制。 +icon: layer-group +--- + +# Agent Harness + +Agent Harness 是将用户消息(或 webhook 触发、cron tick)转变为完整的、使用工具的 LLM 交互的运行时。它拥有工具调用循环、子智能体分派、触发器-分类流水线和围绕它们的 hook 表面。它**不**拥有提供商 HTTP 传输、工具实现、提示部分组装或记忆存储 —— 那些是 harness 组合起来的独立领域。 + +本页先走过一个轮次中发生了什么,然后放大每个活动部件。 + +## 轮次的形态 + +每个轮次 —— 无论是用户刚输入消息、Telegram webhook 刚触发,还是 9am cron 刚 tick —— 都流经相同的生命周期: + +```text +┌─ 入站 ─────────────────────────────────────────────────────────┐ +│ 用户消息 · 渠道入站 · webhook · cron · composio 事件 │ +└──────────────────────────┬────────────────────────────────────────┘ + │ + ▼ (仅外部触发器) + ┌──────────────────────┐ + │ 触发器分类 │ 分类 → 丢弃 / 通知 / + │ (小型本地 LLM) │ 生成 reactor / 生成 orchestrator + └──────────┬───────────┘ + │ + ▼ + ┌──────────────────────────────┐ + │ Agent::turn() │ + │ 1. 恢复转录 │ + │ 2. 构建系统提示* │ + │ 3. 注入记忆上下文 │ + │ 4. 进入工具调用循环 ────┼──► 提供商调用 + │ 5. 分派工具调用 ────┼──► 工具执行 / 子智能体生成 + │ 6. 上下文守卫 / 压缩 │ + │ 7. 停止 hook 检查 │ + │ 8. 最终助手文本 │ + └──────────┬───────────────────┘ + │ 异步,在用户看到回复后 + ▼ + ┌─────────────────┐ + │ 轮次后 │ archivist · learning · 成本日志 · + │ hook │ 情景记忆索引 + └─────────────────┘ + +* 系统提示仅在第一轮构建 —— 后续轮次逐字复用渲染后的提示, + 以便推理后端的 KV-cache 前缀保持有效。 +``` + +本页其余部分就是同一个图表,展开版。 + +## 会话和 `Agent::turn` + +**会话**是 `Agent` 实例正在运行的实时对话。`Agent` 结构体拥有: + +* 对话历史(系统 + 用户 + 助手 + 工具消息)。 +* 要调用的提供商客户端(由[模型路由器](../../features/model-routing/)解析模型)。 +* 模型可见的工具注册表。 +* 在每条用户消息前为相关记忆补水的记忆加载器。 +* 每轮预算 —— 最大工具迭代次数、最大 payload 大小、最大 USD 成本。 + +`Agent::turn(user_message)` 是热路径。在一个轮次中它: + +1. **恢复会话转录**,如果这是一个新进程 —— 从磁盘重新加载精确的提供商消息,以便推理后端的 KV-cache 前缀仍然命中。 +2. **构建系统提示**(仅在第一轮)。这拉入身份、soul、profile、记忆、已连接集成、可用工具、安全前言 —— 由提示部分构建器组装。 +3. **注入记忆上下文**,通过记忆加载器为新用户消息注入:[记忆树](../../features/obsidian-wiki/memory-tree.zh-CN.md) 中的相关块,附带引用,使 UI 可以展示来源。 +4. **进入工具调用循环**(下一节)。 +5. **在后台生成轮次后 hook** —— 用户在 archivist / learning / 成本日志完成前就得到答案。 + +系统提示在后续轮次中**不**重建。即使是微小的字节变化也会使 KV-cache 前缀失效并强制完整重新 prefill,因此动态每轮上下文(记忆召回、新学习片段)作为用户可见的消息内容追加,而非拼接到系统提示中。 + +## 工具调用循环 + +在 `Agent::turn` 内部,工具调用循环是内部引擎。它最多运行 `max_tool_iterations` 轮(默认 10): + +```text +loop { + 1. 上下文守卫 - 如果历史太长,microcompact / autocompact + 2. 停止 hook 检查 - 预算上限、最大迭代次数、自定义 kill switch + 3. 提供商调用 - 发送消息 + 工具 spec,流式响应 + 4. 解析响应 - 将助手文本与工具调用分离 + 5. 如果没有工具调用 - 返回最终文本 + 6. 执行工具调用 - 分派每个(下一节) + 7. 总结超大结果 - 将巨大工具输出路由到 summarizer 智能体 + 8. 追加结果 - 将工具结果推入历史,再次循环 +} +``` + +每次迭代都会发出实时 `AgentProgress` 事件,以便 UI 可以逐 token 渲染流式传输、"正在调用工具 X" 状态和每轮成本更新。 + +### 工具分派和工具调用方言 + +不同的 LLM 说不同的工具调用方言。harness 通过 `ToolDispatcher` trait 抽象了这一点,它有三个具体实现: + +* **Native** —— 拥有一等工具调用 API 的提供商(Anthropic、OpenAI)。工具调用以结构化字段返回,不在文本体中。 +* **XML** —— 未原生训练工具调用但可遵循指令的模型的 fallback。工具被包装在助手文本中的 `{...}` 标签内。 +* **P-Format** —— 某些较小模型使用的紧凑文本格式。 + +dispatcher 按提供商选择,使循环本身方言无关。相同的循环代码驱动 Claude、GPT、Gemini 和本地 Ollama 模型。 + +### 循环中的上下文管理 + +长工具调用链可能超出上下文窗口。两层处理: + +* **工具结果预算** —— 每个工具结果都对照每调用字节预算检查。任何超出的内容都会被硬截断,并附带解释性标记,以便模型知道它没有看到完整输出。 +* **Microcompact / autocompact** —— 当总历史接近上下文窗口时,harness 在下次提供商调用前将旧轮次压缩为摘要。压缩后的历史保持系统提示和最近轮次不变(KV-cache 稳定性),并重写中间部分。 + +### 超大工具结果 —— summarizer 绕道 + +某些工具调用返回巨大的 payload —— Composio action dump 200 KB JSON、网页抓取返回 50 KB markdown、跨越数千行的日志上的 `file_read`。在 payload 中间硬截断会丢弃恰好落在截断点之后的任何内容。 + +当工具结果超过 summarizer 阈值时,它在进入父历史之前通过专用的 `summarizer` 子智能体路由。summarizer 按照保留标识符和关键事实的提取合约压缩 payload,父智能体只看到压缩后的摘要。当 summarization 失败或 payload 大到在其上支付 LLM 调用在经济上没有意义时,硬截断仍是下游的备用方案。 + +### 缺失命令的自愈 + +当代码执行器子智能体运行 shell 命令且运行时回答 "command not found" 时,自愈拦截器捕获错误,生成一个 `ToolMaker` 子智能体为缺失命令编写 polyfill 脚本,然后重试原始调用。每个命令有尝试上限,因此真正不可能的命令不会无限循环。 + +## 子智能体 —— orchestrator 模式 + +OpenHuman 是**多智能体**的。与用户聊天的智能体是 **Orchestrator** —— 一个高级别的、策略层面的智能体,决定何时直接回答、何时使用直接工具、何时生成专家子智能体。 + +### 为什么多智能体 + +一个知道一切的单个智能体也有一个小书大小的系统提示。将工作拆分到专家意味着: + +* 每个子智能体获得一个**窄系统提示**,只有它需要的部分(可以剥离身份 / 记忆 / 安全前言)。 +* 每个子智能体获得一个**过滤后的工具注册表** —— 集成智能体不需要文件系统工具,coder 不需要 Composio 目录。 +* 子智能体历史永远不会泄露回父级 —— 父级看到一个紧凑的工具结果,而非内部对话。 +* 更便宜的模型可以做叶子工作。Orchestrator 使用强推理模型;研究子智能体可能使用更快、更便宜的模型。 + +### 内置原型 + +每个原型位于 `agents//` 下,带一个 `agent.toml`(元数据、工具范围、模型提示)和一个提示: + +| 原型 | Orchestrator 何时选择它 | +| ------------------- | --------------------------------------------------------------------------------------- | +| `orchestrator` | 顶层智能体。永远不会被另一个 orchestrator 生成。 | +| `planner` | 多步分解 —— 将复杂请求分解为有序子任务。 | +| `researcher` | 网页/文档查找、引用搜寻。 | +| `code_executor` | 在工作区中编写、运行和调试代码。 | +| `critic` | 代码审查、对另一个智能体输出的质量检查。 | +| `summarizer` | 压缩超大工具结果(由 harness 调用,通常不是模型调用)。 | +| `archivist` | 记忆蒸馏 —— 持久化什么、遗忘什么。 | +| `tool_maker` | 自愈 —— 为缺失的 shell 命令编写 polyfill。 | +| `tools_agent` | 任意工具绑定任务的通用专家。 | +| `integrations_agent`| 绑定到特定 Composio 工具包(Gmail、GitHub、Slack…)以执行该工具包的动作。| +| `trigger_triage` | 将传入的外部事件分类为丢弃 / 通知 / 生成 reactor / 生成智能体。 | +| `trigger_reactor` | 对分类后的触发器的轻量级反应,不需要完整的 orchestrator 轮次。 | +| `morning_briefing` | 由 cron 运行的精选每日摘要。 | +| `welcome` / `help` | Onboarding 流程。 | + +自定义原型作为 TOML 文件发布在 `$OPENHUMAN_WORKSPACE/agents/*.toml`(或 `~/.openhuman/agents/*.toml` 用于用户全局专家)。自定义定义在 id 冲突时覆盖内置定义。 + +### 运行子智能体 + +当 orchestrator 调用 `spawn_subagent`(或 `delegate_*` 便捷工具之一)时,runner: + +1. 从 task-local 读取父执行上下文 —— 父提供商、sandbox 模式、取消围栏、转录根。 +2. 解析子智能体的模型 —— 继承父级、遵循提示(`fast` / `reasoning` / `summarization`),或固定到精确模型。 +3. 按定义的 `tools`、`disallowed_tools` 和 `skill_filter` 过滤父级的工具注册表。在 `fork` 模式下,父级的完整注册表逐字继承。 +4. 构建窄系统提示,省略定义要求剥离的部分。 +5. 使用与父级相同的机制运行内部工具调用循环。 +6. 返回一个紧凑的文本结果。子智能体内部历史永远不会拼接到父级中 —— orchestrator 看到一个单一的工具结果并继续。 + +对于不需要阻塞 orchestrator 轮次的任务,`spawn_worker_thread` 在后台运行子智能体,orchestrator 立即继续。 + +### 生成层级和 tiers + +并非每个智能体都被允许生成每个其他智能体。harness 建模了一个三层层级,镜像模型之间的成本 / 延迟 / 思考深度拆分: + +```text +Chat (快速,UX 聚焦 —— 例如 orchestrator 使用 `chat` 提示) + │ + ├─► Worker ◄─── 快速路径:一次委托,叶子做工作 + │ + └─► Reasoning (慢速,深度思考 —— 例如 planner 使用 `reasoning` 提示) + │ + └─► Worker ◄─── 深度路径:reasoning 分解,workers 执行 +``` + +每个 `AgentDefinition` 携带一个 `agent_tier` 字段(`chat` / `reasoning` / `worker`,默认 `worker`)。契约: + +| Tier | 可以生成 | 禁止生成 | 典型成员 | +| ------------ | ----------------- | ---------------------------- | -------------------------------------------------------- | +| `chat` | `reasoning`, `worker` | 另一个 `chat` | `orchestrator` | +| `reasoning` | `worker` | 另一个 `reasoning`、任何 `chat` | `planner`(当今的规范代表) | +| `worker` | nothing[^1] | 任何东西 | researcher、code_executor、critic、archivist、tool_maker、integrations_agent、… | + +[^1]: Skill-wildcard 条目(`{ skills = "*" }`)被豁免,因为它们坍缩为单个 `delegate_to_integrations_agent` 工具,其目标是 worker —— 它们是扇出委托表面,不是递归生成。 + +**为什么有这些规则。** +- *Chat → chat 毫无意义。* Chat tier 存在是为了 snappy UX。Chat 智能体生成另一个 chat 智能体只是加倍 TTFT 并燃烧 token 而不购买任何新能力。 +- *Reasoning → reasoning 会爆炸深度。* Reasoning tier 很昂贵。Reasoning 智能体链倾向于重新分解相同问题并创建失控的层级。 +- *Worker → anything 混合执行和编排。* Workers 是叶子,因此父级总是看到一个紧凑结果,而非嵌套委托的转录。 + +**强制执行。** 两层: + +1. **加载时(静态)。** [`agents::loader::validate_tier_hierarchy`](../../../src/openhuman/agent/agents/loader.rs) 在合并的注册表(内置 + workspace TOML)上运行,并拒绝启动列出同级或 worker-with-subagents 条目的注册表。内置原型在编译测试时检查;用户发布的 TOML 在 workspace 加载时检查。 +2. **运行时深度门禁(动态)。** 独立于 tier,子智能体 runner 通过 task-local 计数器将总生成链深度限制为 `MAX_SPAWN_DEPTH = 3`,该计数器在 `run_subagent` 之间递增,作为 `SpawnDepthExceeded` 智能体错误展示。这使得一个删除了 tier 注释的用户发布 TOML 仍然无法递归超过三跳。 + +> **状态:** 加载时 tier 检查、`agent_tier` 字段和运行时深度计数器 task-local 已上线。深度由静态加载器契约和运行时 `MAX_SPAWN_DEPTH = 3` 守卫共同限制。 + +### 工具包特定专家 + +对于具有数百个动作的 Composio 工具包(仅 GitHub 就有 500+),将每个动作加载到子智能体的工具集中会膨胀提示大小。harness 通过廉价的纯 CPU 过滤器(动词检测、token 重叠、动词对齐提升)将工具包的动作与父级精炼的任务提示进行排名,并仅将排名靠前的子集加载到子智能体中。无需模型调用,纯启发式 —— 快速且可解释。 + +## 分类 —— 处理外部触发器 + +当 webhook 触发、cron tick 或 Composio 事件到达时,系统不能直接将它们交给 orchestrator。大多数触发器是噪音;有些值得通知;只有少数值得完整的智能体轮次。**触发器-分类流水线**是门禁。 + +```text +TriggerEnvelope ──► run_triage ──► TriageDecision ──► apply_decision + │ │ + │ ├─► 丢弃 (噪音) + │ ├─► 仅通知 + │ ├─► 生成 trigger_reactor + │ └─► 生成 orchestrator + │ + └── 小型本地 LLM(云端 LLM 重试 fallback) +``` + +evaluator 有意保持廉价 —— 在可用时使用小型本地模型,重试时 fallback 到远程模型。决策被缓存,因此相同的触发器不会重新分类。只有升级到"生成 orchestrator"的触发器才会通过完整的 `Agent::turn` 机制。 + +## Hook —— 可观测性和策略杠杆 + +两个 hook 表面包裹循环,位于两端: + +### 停止 hook(轮次中) + +停止 hook 在工具调用循环的**迭代之间**触发。它们是预算上限、速率限制和自定义 kill switch 的策略杠杆。内置 hook: + +* **预算停止 hook** —— 使用每轮成本累加器限制轮次的累计 USD 成本。 +* **最大迭代次数停止 hook** —— 从智能体持久配置外部限制迭代次数。 + +返回 `Stop` 的 hook 会以清晰的原因中止循环,调用者可以将该原因展示给用户。停止 hook 与中断(下一节)不同:它们是策略驱动的,不是用户驱动的。 + +### 轮次后 hook + +轮次后 hook 在轮次**完成后**触发,在后台。它们获得 `TurnContext` 快照 —— 用户消息、助手响应、每个工具调用及其参数和结果、总 wall-clock、迭代次数、会话 ID。内置消费者: + +* **Archivist** —— 蒸馏轮次中哪些事实值得持久化到长期记忆。 +* **Learning** —— 为 reflection、工具跟踪器和用户 profile 更新提供输入。 +* **成本日志** —— 最终每轮成本行。 +* **情景记忆索引** —— 将轮次作为块写入[记忆树](../../features/obsidian-wiki/memory-tree.zh-CN.md)以供未来召回。 + +Hook 通过 `tokio::spawn` 运行,因此用户在它们完成前就得到了答案。 + +## 中断 —— 优雅取消 + +`InterruptFence` 在循环的固定安全点检查 —— 每次工具执行前、每次子智能体生成前、每次提供商调用前。当用户按下 Ctrl+C 或发送 `/stop`: + +* 围栏翻转。 +* 每个正在运行的子智能体看到相同的 flag(通过 `Arc` 共享)并在其下一个检查点退出。 +* 进行中的提供商流被丢弃。 +* Archivist 仍然使用任何存在的部分上下文触发,因此对话不会丢失。 + +中断是用户驱动的;停止 hook 是策略驱动的。它们共享底层的"干净停止循环"管道,但从不同侧面进入。 + +## 成本核算 + +每个提供商响应携带一个 `UsageInfo` 块 —— 输入 token、输出 token、缓存输入 token,以及由 OpenHuman 后端填充的权威 `charged_amount_usd`。`TurnCost` 在一个轮次内对每个提供商调用求和,以便 harness 可以: + +* 通过进度通道发出每轮成本遥测。 +* 为预算停止 hook 提供输入,使失控的轮次在循环中自我切断。 +* 记录精确的轮次结束成本行。 + +当后端不展示收费金额时(旧构建、不通过它计费的提供商),一个小的每 tier 费率表提供 token 费率 floor 估计。后端直接成本在可用时总是优先。 + +## Fork 上下文 —— 跨 harness 的 KV-cache 复用 + +harness 使用 task-local `ParentExecutionContext` 将父状态线程化到子智能体中,而不会爆炸每个函数签名。相同的模式携带当前 sandbox 模式、中断围栏和停止 hook 列表。继承父级提供商、模型和提示前缀的子智能体可以在推理后端上**共享父级的 KV-cache 前缀** —— 比从头重新 prefill 明显更便宜。 + +## 自愈回顾 + +几个小型自适应系统位于主循环之上: + +* **缺失命令的自愈** —— `ToolMaker` polyfill,有上限的重试尝试。 +* **Payload summarizer 断路器** —— 会话中连续三次子智能体失败会禁用 summarization,fallback 到截断。 +* **分类本地-vs-远程重试** —— 本地 LLM 优先;解析失败时远程 fallback。 + +这些都不会改变循环的形状 —— 它们只是让常见故障模式无需用户干预即可恢复。 + +## 代码中该看哪里 + +harness 完全位于 `src/openhuman/agent/` 下。该目录中的 README 枚举了公共表面;负载最重的文件是: + +| 文件 / 目录 | 里面有什么 | +| ----------------------------- | ----------------------------------------------------------------- | +| `harness/session/turn.rs` | `Agent::turn` —— 上述生命周期。 | +| `harness/tool_loop.rs` | 内部工具调用循环。 | +| `harness/subagent_runner/` | `run_subagent`、fork 模式、超大结果交接。 | +| `harness/definition.rs` | `AgentDefinition` —— 原型声明的内容。 | +| `harness/tool_filter.rs` | 集成子智能体的工具包动作排名。 | +| `harness/payload_summarizer.rs` | 超大工具结果绕道。 | +| `harness/self_healing.rs` | 缺失命令拦截器。 | +| `harness/interrupt.rs` | 取消围栏。 | +| `dispatcher.rs` | 工具调用方言抽象。 | +| `triage/` | 外部触发器分类 + 升级。 | +| `agents/` | 内置原型 —— 每个智能体一个子目录。 | +| `hooks.rs` / `stop_hooks.rs` | 轮次后和轮次中 hook 表面。 | +| `cost.rs` | 每轮 USD/token 核算。 | +| `progress.rs` | 到 UI 的实时进度事件。 | +| `memory_loader.rs` | 每条用户消息的记忆树上下文注入。 | + +## 另请参阅 + +* [架构概览](README.zh-CN.md) —— harness 在更大图景中的位置。 +* [记忆树](../../features/obsidian-wiki/memory-tree.zh-CN.md) —— 记忆加载器从中读取、轮次后 hook 写入的内容。 +* [自动模型路由](../../features/model-routing/README.zh-CN.md) —— `model: "hint:reasoning"` 如何解析为具体的提供商+模型。 +* [原生工具 —— 智能体协调](../../features/native-tools/agent-coordination.zh-CN.md) —— `spawn_subagent`、`delegate_*`、`todo_write` 的用户可见表面。 diff --git a/gitbooks/developing/architecture/desktop-companion.zh-CN.md b/gitbooks/developing/architecture/desktop-companion.zh-CN.md new file mode 100644 index 000000000..e6b44637c --- /dev/null +++ b/gitbooks/developing/architecture/desktop-companion.zh-CN.md @@ -0,0 +1,129 @@ +--- +description: Desktop Companion 领域 —— Clicky 风格的交互循环,将热键、语音、屏幕智能、LLM、TTS 和视觉指向整合为单一产品体验。 +icon: robot +--- + +# Desktop Companion (`src/openhuman/desktop_companion/`) + +Desktop Companion 编排一个 Clicky 风格的交互循环:热键激活、麦克风捕获、屏幕上下文、LLM 推理、语音合成和视觉指向。它复用现有构建块,而非重新实现它们。 + +## 构建块 + +| 模块 | 提供的能力 | 路径 | +|--------|-----------------|------| +| **screen_intelligence** | 权限门控的捕获会话、`capture_now()`、`VisionSummary`、`AppContextInfo` | `src/openhuman/screen_intelligence/` | +| **voice** | 热键监听器(push/tap)、音频捕获、云端 STT(Whisper)、TTS (`reply_speech`) | `src/openhuman/voice/` | +| **meet_agent** | LLM 编排模式(STT -> LLM -> TTS)、WAV 打包 | `src/openhuman/meet_agent/` | +| **overlay** | 浮动 UI 表面、注意力事件、打字机气泡 | `src/openhuman/overlay/` | +| **provider_surfaces** | 连接应用事件队列 (`ingest_event`, `list_queue`) | `src/openhuman/provider_surfaces/` | +| **accessibility** | 前台应用上下文 (`foreground_context()`) | `src/openhuman/accessibility/` | + +## 模块布局 + +```text +src/openhuman/desktop_companion/ + mod.rs — 模块导出(轻量) + types.rs — CompanionState enum、CompanionConfig、ConversationTurn、会话 param/result 类型 + session.rs — 单例会话生命周期、状态机、TTL、对话历史 + pipeline.rs — STT -> 屏幕上下文 -> LLM -> TTS -> 指向编排 + pointing.rs — [POINT:x,y:label:screenN] 标签解析器、多显示器坐标映射 + handoff.rs — 连接应用动作的 provider-surface 队列匹配 + bus.rs — CompanionStateChangedEvent 的广播通道 + schemas.rs — RPC 控制器 (companion_start_session, companion_stop_session 等) +``` + +## 状态机 + +```text +Idle -> Listening -> Thinking -> Speaking -> Pointing -> Idle + | | + v v + Listening Listening (中断) + +任何状态 -> Error -> Idle (重置) +``` + +有效转换由 `session::is_valid_transition()` 强制执行。关键路径: + +- **Happy path**:Idle -> Listening -> Thinking -> Speaking -> Pointing -> Idle +- **无指向**:Thinking -> Speaking -> Idle(响应中没有 POINT 标签) +- **中断**:Speaking/Pointing -> Listening(用户重新激活热键) +- **取消**:Thinking -> Idle(用户在思考中途取消) +- **错误恢复**:Any -> Error -> Idle + +## 交互流水线 + +`pipeline.rs` 编排单个轮次: + +1. **激活** —— 状态转换为 Listening(将由 Tauri 壳层热键桥接驱动,见 PR 2) +2. **STT** —— 通过 `voice::cloud_transcribe`(Whisper)转录音频样本 +3. **屏幕上下文** —— `accessibility::foreground_context()` 获取应用名称 + 窗口标题 +4. **LLM** —— 通过 `BackendOAuthClient` 进行聊天补全,携带系统提示、屏幕上下文和滚动对话历史(最近 20 轮作为上下文) +5. **解析响应** —— 通过 `pointing::parse_and_map()` 提取 `[POINT:x,y:label:screenN]` 标签 +6. **Handoff 检查** —— 扫描响应中的提供商关键词,与 `provider_surfaces` 队列匹配 +7. **TTS** —— 通过 `voice::reply_speech`(ElevenLabs)合成语音 +8. **指向** —— 为 overlay 动画发射指向目标 +9. **返回 Idle** + +流水线通过 `CancellationToken` 支持取消 —— Tauri 壳层可以在任何检查点取消(STT、LLM、TTS 阶段之间)。 + +文本输入也通过 `run_text_turn()` 支持,跳过 STT。 + +## 会话生命周期 + +- **一次一个会话** —— 由进程级 `Mutex>` 强制执行 +- **需要同意** —— `start_session` 拒绝 `consent=false` +- **TTL 强制执行** —— 当 `status()` 检测到 TTL 已过时,会话自动过期 +- **对话历史** —— 上限 50 轮,溢出时最旧的被丢弃 + +## RPC 表面 + +命名空间:`companion`。所有方法都通过标准控制器注册表。 + +| 方法 | 说明 | +|--------|-------------| +| `companion_start_session` | 以显式同意 + 可选 TTL 启动会话 | +| `companion_stop_session` | 结束活跃会话 | +| `companion_status` | 当前状态、会话信息、剩余 TTL | +| `companion_config_get` | 读取 companion 配置 | +| `companion_config_set` | 更新 companion 配置 | + +## 事件总线 + +`CompanionStateChangedEvent` 通过 `tokio::sync::broadcast` 通道广播(与 `overlay::bus` 相同模式)。三个 `DomainEvent` 变体路由到 `"companion"` 领域: + +- `CompanionSessionStarted { session_id }` +- `CompanionStateChanged { session_id, state, previous_state }` +- `CompanionSessionEnded { session_id, reason }` + +## 指向系统 + +LLM 响应可以嵌入 `[POINT:x,y:label:screenN]` 标签。`pointing.rs`: + +- 通过正则解析标签 +- 使用 `ScreenGeometry` 将屏幕相对坐标映射为绝对桌面坐标 +- 将坐标钳制到屏幕边界 +- 索引越界时回退到 screen 0 +- 从显示文本中剥离标签 + +## Provider-surface handoff + +`handoff.rs` 扫描清理后的 LLM 响应文本中的提供商关键词(slack、discord、telegram 等),并将它们与 `provider_surfaces` 队列中的条目匹配。当找到匹配时,`HandoffEvent` 被包含在 `TurnResult` 中,供 Tauri 壳层 / overlay 展示。 + +## 平台范围 + +- **macOS**:完整支持 —— 热键、屏幕捕获、指向、TTS、overlay +- **Windows/Linux**:部分 —— 热键可用(rdev),屏幕上下文 stub,无指向 + +平台特定代码通过 `#[cfg(target_os = "macos")]` 门控。 + +## 测试 + +| 文件 | 覆盖范围 | +|------|----------| +| `session_tests.rs` | 会话 CRUD、状态机转换、TTL、同意、对话历史 | +| `pipeline_tests.rs` | 轮次编排、取消、输入验证、系统提示 | +| `pointing_tests.rs` | 标签解析、坐标映射、多显示器、边界情况 | +| `handoff.rs` (inline) | 关键词匹配、空队列、提供商覆盖 | +| `schemas.rs` (inline) | 控制器计数、schema 字段验证 | +| `tests/json_rpc_e2e.rs` | 完整 RPC 往返:start -> status -> config -> stop | diff --git a/gitbooks/developing/architecture/frontend.zh-CN.md b/gitbooks/developing/architecture/frontend.zh-CN.md new file mode 100644 index 000000000..87a312b59 --- /dev/null +++ b/gitbooks/developing/architecture/frontend.zh-CN.md @@ -0,0 +1,2295 @@ +--- +description: >- + React + Vite 前端 (`app/src/`) —— 架构、状态、服务、 + 提供商、路由、组件、hook。 +icon: browsers +--- + +# 前端 (app/src/) + +OpenHuman 桌面 UI:`app/src/` 下的 Vite + React 19 树(Yarn workspace `openhuman-app`)。它使用 Redux Toolkit 配合持久化来管理会话状态,通过 REST + Socket.io 与后端通信,并通过 JSON-RPC 调用 Rust core sidecar(`coreRpcClient` / Tauri `core_rpc_relay`)。重逻辑在核心中,不在此处。 + +这是一份整合的参考。使用上方目录(或你的阅读器大纲)在章节间跳转。 + +## 快速参考 + +| 章节 | 涵盖内容 | +| ------------------------------------------------- | --------------------------------------------- | +| [架构](frontend.zh-CN.md#architecture-overview) | Provider 链、构建、布局、规范 | +| [状态管理](frontend.zh-CN.md#state-management) | Redux Toolkit slice、selector、持久化 | +| [服务层](frontend.zh-CN.md#services-layer) | `apiClient`、`socketService`、`coreRpcClient` | +| [Providers](frontend.zh-CN.md#providers) | `User`、`Socket`、`AI`、`Skill` providers | +| [页面与路由](frontend.zh-CN.md#pages-routing) | `HashRouter`、路由守卫、主路由 | +| [组件](frontend.zh-CN.md#components) | UI / 设置组件模式 | +| [Hook 与工具](frontend.zh-CN.md#hooks-utilities) | 共享 hook、辅助函数、配置 | + +## 规模 + +| 指标 | 值 | +| --------------------------------------- | ------------------------------------------------------------------------ | +| `app/src/` 下的 TypeScript / TSX 文件 | \~285 (`find app/src -name '*.ts' -o -name '*.tsx' \| wc -l` 刷新) | +| 测试 runner | Vitest (`app/test/vitest.config.ts`) | + +## 目录布局 + +```text +app/src/ +├── App.tsx # Provider 链 + HashRouter shell +├── AppRoutes.tsx # 路由表 + 守卫 +├── main.tsx # 入口 (Sentry、store、样式) +├── store/ # Redux slice 和 selector +├── providers/ # UserProvider、SocketProvider、AIProvider、SkillProvider +├── services/ # apiClient、socketService、coreRpcClient、api/* +├── lib/ # AI loader、MCP 辅助函数、技能同步等 +├── pages/ # 路由级页面 +├── components/ # 共享 UI +├── hooks/ # 应用 hook +├── utils/ # 配置、Tauri 辅助函数、路由工具 +└── assets/ # 图标和静态资源 +``` + +## 架构概览 + +### 系统架构 + +OpenHuman 的桌面 UI 是一个 **React 19** 应用 (`app/src/`),它: + +* 使用 **Redux Toolkit** 配合持久化来管理与会话相关的状态 +* 通过 **REST** (`apiClient`) 和 **Socket.io** (`socketService`) 连接后端 +* 通过 **`coreRpcClient`** / Tauri **`core_rpc_relay`** 调用 **Rust 核心**进程(JSON-RPC 方法实现在仓库根目录 `src/openhuman/` 中,通过 `core_server` 暴露) +* 从捆绑的 `src/openhuman/agent/prompts`(仓库根目录)和打包时的 Tauri **`ai_get_config`** 加载 **AI 提示** +* 在 `lib/mcp/` 下使用 **最小 MCP 风格**辅助层(传输、验证),而非大型的仓库内 Telegram MCP 工具包 + +### 入口点 + +| 文件 | 用途 | +| ----------------------- | ------------------------------------------------------------------------------------ | +| `app/src/main.tsx` | React 根节点、Sentry 边界、store、全局样式 | +| `app/src/App.tsx` | Provider 链:Redux → PersistGate → User → Socket → AI → Skill → Router | +| `app/src/AppRoutes.tsx` | `HashRouter` 路由、`ProtectedRoute` / `PublicRoute`、onboarding 和 mnemonic 门禁 | + +### Provider 链 + +```text +Redux Provider + └─ PersistGate + └─ UserProvider + └─ SocketProvider + └─ AIProvider + └─ SkillProvider + └─ HashRouter + └─ AppRoutes (pages + settings) +``` + +**为什么是这个顺序** + +1. Redux 在最外层,以便到处使用 `useAppSelector` / dispatch。 +2. `PersistGate` 在子组件假设稳定认证前重新水合持久化的 slice。 +3. `SocketProvider` 使用 auth token 进行 Socket.io。 +4. `AIProvider` / `SkillProvider` 包装依赖 socket 和 store 状态的功能。 +5. `HashRouter` 为所有路由提供导航。 + +### 模块关系(简化) + +```text +App.tsx + ├─ Redux store + persistor + ├─ UserProvider - 用户 profile / workspace 上下文 + ├─ SocketProvider - token 存在时连接 socketService + ├─ AIProvider - AI 会话 / 记忆客户端协调 + ├─ SkillProvider - 技能目录和同步 + └─ AppRoutes + ├─ PublicRoute - 例如 `/` 上的 Welcome + ├─ ProtectedRoute - onboarding、home、skills、settings、… + └─ DefaultRedirect - 未认证用户 +``` + +### 服务层(概念性) + +```text +services/ + ├─ apiClient → 通过运行时解析的 URL 的 REST,使用 `services/backendUrl#getBackendUrl` + ├─ backendUrl → 调用 `openhuman.config_resolve_api_url`;仅在 Tauri 外 fallback 到 VITE_BACKEND_URL + ├─ socketService → Socket.io;实时 + MCP 风格信封 + └─ coreRpcClient → 本地 openhuman 核心的 HTTP (JSON-RPC),配合 Tauri relay 使用 +``` + +#### 运行时配置优先级 + +桌面应用不会将核心 RPC URL 或 API 主机作为硬性要求烘焙到 bundle 中。运行时应用按此顺序解析它们(最高优先): + +1. **登录屏幕 RPC URL 字段**,通过 `utils/configPersistence` 保存并在下次启动时恢复。终端用户在此配置 sidecar 地址,而非手动编辑 `config.toml` 或 `.env` 文件。 +2. **Tauri `core_rpc_url` 命令**,bundled sidecar 为本进程监听的端口。 +3. **`VITE_OPENHUMAN_CORE_RPC_URL`**,开发时的构建时 fallback。 +4. 硬编码的 `http://127.0.0.1:7788/rpc` 默认值。 + +RPC 握手成功后,`services/backendUrl` 调用 `openhuman.config_resolve_api_url` 从加载的核心 `Config` 中拉取 `api_url`(和其他安全客户端字段)。`VITE_BACKEND_URL` 仅在应用运行在 Tauri 外时作为 Web fallback 使用。 + +需要后端 URL 的组件应调用 `useBackendUrl()`(或非 React 代码调用 `getBackendUrl()`),它们绝不能从 `utils/config` 导入静态的 `BACKEND_URL` 常量,那只代表构建时值。 + +### 相关文档 + +* Rust 架构:[架构](../architecture.zh-CN.md) +* Tauri 壳层:[Tauri Shell](tauri-shell.zh-CN.md) + +## 状态管理 + +应用使用 Redux Toolkit 配合 Redux-Persist 进行健壮的状态管理。 + +### Store 配置 + +**文件:** `store/index.ts` + +```typescript +// 合并所有 slice 并持久化 +const persistConfig = { + key: 'root', + storage, + whitelist: ['auth', 'telegram'], // 持久化的 slice +}; +``` + +### Redux 状态结构 + +```typescript +RootState = { + auth: { + token: string | null, // JWT (持久化) + isOnboardedByUser: Record, // 每用户 flag (持久化) + }, + socket: { + byUser: Record< + string, + { + // 每用户 ID + status: 'connecting' | 'connected' | 'disconnected'; + socketId: string | null; + } + >, + }, + user: { profile: User | null, loading: boolean, error: string | null }, + telegram: { + byUser: Record, // 每 Telegram 用户 (持久化) + }, +}; +``` + +### Slice + +#### Auth Slice (`store/authSlice.ts`) + +管理 JWT token 和每用户 onboarding 状态。 + +**状态:** + +```typescript +interface AuthState { + token: string | null; + isOnboardedByUser: Record; +} +``` + +**Actions:** + +* `setToken(token: string)` - 登录后存储 JWT +* `clearToken()` - 登出时移除 token +* `setOnboarded({ userId, isOnboarded })` - 将用户标记为已 onboard + +**Selectors (`store/authSelectors.ts`):** + +* `selectToken` - 获取当前 JWT +* `selectIsOnboarded(userId)` - 检查用户是否完成 onboarding + +#### Socket Slice (`store/socketSlice.ts`) + +跟踪每用户的 Socket.io 连接状态。 + +**状态:** + +```typescript +interface SocketState { + byUser: Record< + string, + { status: 'connecting' | 'connected' | 'disconnected'; socketId: string | null } + >; +} +``` + +**Actions:** + +* `setSocketStatus({ userId, status })` - 更新连接状态 +* `setSocketId({ userId, socketId })` - 存储 socket ID +* `clearSocketState(userId)` - 清除用户 socket 状态 + +**Selectors (`store/socketSelectors.ts`):** + +* `selectSocketStatus(userId)` - 获取连接状态 +* `selectIsSocketConnected(userId)` - 布尔连接检查 + +#### User Slice (`store/userSlice.ts`) + +存储用户 profile 数据。 + +**状态:** + +```typescript +interface UserState { + profile: User | null; + loading: boolean; + error: string | null; +} +``` + +**Actions:** + +* `setUser(user)` - 存储用户 profile +* `setUserLoading(loading)` - 设置加载状态 +* `setUserError(error)` - 设置错误状态 +* `clearUser()` - 登出时清除 profile + +#### Telegram Slice (`store/telegram/`) + +Telegram 集成的复杂嵌套状态管理。 + +**文件:** + +* `index.ts` - Slice 导出(actions、thunks) +* `types.ts` - 实体和状态接口 +* `reducers.ts` - 同步 reducers +* `extraReducers.ts` - 异步 thunk handlers +* `thunks.ts` - 异步操作 + +**状态结构:** + +```typescript +telegram.byUser[telegramUserId] = { + connectionStatus: "disconnected" | "connecting" | "connected" | "error", + authStatus: "not_authenticated" | "authenticating" | "authenticated" | "error", + currentUser: TelegramUser | null, + sessionString: string | null, // 存储在这里,而非 localStorage + chats: Record, + chatsOrder: string[], + messages: Record>, + threads: Record +} +``` + +**Reducers:** + +* `setCurrentUser` - 存储已认证的 Telegram 用户 +* `setSessionString` - 存储 MTProto 会话(用于持久化) +* `setConnectionStatus` - 更新连接状态 +* `setAuthStatus` - 更新认证状态 +* `addChat` / `updateChat` - 管理聊天列表 +* `addMessage` / `updateMessage` - 管理消息历史 +* `setThreads` - 存储 thread 数据 + +**Thunks (`store/telegram/thunks.ts`):** + +* `initializeTelegram(userId)` - 初始化 MTProto 客户端 +* `connectTelegram(userId)` - 建立 Telegram 连接 +* `fetchChats(userId)` - 加载聊天列表 +* `fetchMessages({ userId, chatId })` - 加载消息历史 +* `disconnectTelegram(userId)` - 干净断开 + +**Selectors (`store/telegramSelectors.ts`):** + +* `selectTelegramState(userId)` - 获取完整 Telegram 状态 +* `selectTelegramConnectionStatus(userId)` - 获取连接状态 +* `selectTelegramAuthStatus(userId)` - 获取 auth 状态 +* `selectTelegramChats(userId)` - 获取聊天列表 +* `selectTelegramMessages(userId, chatId)` - 获取聊天的消息 + +### Typed Hooks + +**文件:** `store/hooks.ts` + +```typescript +// 使用这些代替普通的 useDispatch/useSelector +export const useAppDispatch: () => AppDispatch = useDispatch; +export const useAppSelector: TypedUseSelectorHook = useSelector; +``` + +### 持久化配置 + +#### 什么被持久化 + +* `auth.token` - 用于认证的 JWT +* `auth.isOnboardedByUser` - 每用户 onboarding 状态 +* `telegram.byUser` - Telegram 状态(会话、聊天等) + +#### 什么**不**被持久化 + +* `socket` - 连接状态(应用启动时重连) +* `user.loading` / `user.error` - 瞬态 UI 状态 +* Telegram 加载/错误状态 + +#### 存储后端 + +Redux-Persist 默认使用 localStorage adapter。这是应用中唯一可接受的 localStorage 使用。 + +### 使用示例 + +#### 读取状态 + +```typescript +import { useAppSelector } from '../store/hooks'; + +function MyComponent() { + const token = useAppSelector(state => state.auth.token); + const isConnected = useAppSelector(state => state.socket.byUser[userId]?.status === 'connected'); + const chats = useAppSelector(state => state.telegram.byUser[userId]?.chats); +} +``` + +#### Dispatch Actions + +```typescript +import { clearToken, setToken } from '../store/authSlice'; +import { useAppDispatch } from '../store/hooks'; +import { initializeTelegram } from '../store/telegram/thunks'; + +function MyComponent() { + const dispatch = useAppDispatch(); + + // 同步 action + const handleLogin = (token: string) => { + dispatch(setToken(token)); + }; + + // 异步 thunk + const handleConnect = async () => { + await dispatch(initializeTelegram(userId)).unwrap(); + }; +} +``` + +#### 使用 Selectors + +```typescript +import { selectIsOnboarded } from '../store/authSelectors'; +import { useAppSelector } from '../store/hooks'; +import { selectTelegramConnectionStatus } from '../store/telegramSelectors'; + +function MyComponent({ userId }) { + const isOnboarded = useAppSelector(state => selectIsOnboarded(state, userId)); + const connectionStatus = useAppSelector(state => selectTelegramConnectionStatus(state, userId)); +} +``` + +### 最佳实践 + +1. **始终使用 typed hooks** - `useAppDispatch` 和 `useAppSelector` +2. **使用 selector 处理派生状态** - 可记忆且可测试 +3. **将 thunks 放在单独文件中** - 更好的组织 +4. **每用户状态作用域** - 按用户 ID 键控状态 +5. **避免 localStorage** - 改用 Redux-Persist + +*** + +## 服务层 + +应用使用单例服务进行外部通信。这防止连接泄漏并提供一致的 API 访问。 + +### 服务架构 + +```text +app/src/services/ + ├─ apiClient (HTTP REST) + │ ├─ 从 Redux 读取 auth.token + �� └─ 调用 VITE_BACKEND_URL(见 utils/config.ts) + ├─ socketService (Socket.io) + │ ├─ web: JS 客户端 + │ └─ Tauri: 通过 utils/tauriSocket.ts 与 Rust 端 socket 协调 + ├─ coreRpcClient.ts + │ └─ invoke('core_rpc_relay', …) → 本地 openhuman 核心 (JSON-RPC) + └─ services/api/* - 领域 REST 模块 (auth、user、teams、…) +``` + +### API Client (`services/apiClient.ts`) + +用于后端通信的 HTTP REST 客户端。 + +#### 特性 + +* 基于 Fetch 的实现 +* 自动从 Redux store 注入 JWT +* 类型化的请求/响应处理 +* 带类型错误的错误处理 + +#### 用法 + +```typescript +import apiClient from "../services/apiClient"; + +// GET 请求 +const user = await apiClient.get("/users/me"); + +// POST 请求 +const result = await apiClient.post("/auth/login", { + email, + password, +}); + +// 带自定义头 +const data = await apiClient.get("/endpoint", { + headers: { "X-Custom": "value" }, +}); +``` + +#### 配置 + +从环境读取 `VITE_BACKEND_URL` 或使用默认值: + +```typescript +const BACKEND_URL = + import.meta.env.VITE_BACKEND_URL || "https://api.example.com"; +``` + +### API Endpoints (`services/api/`) + +#### Auth API (`services/api/authApi.ts`) + +认证相关端点。 + +```typescript +import { authApi } from "../services/api/authApi"; + +// 登录 +const { token, user } = await authApi.login(credentials); + +// Token 交换(用于深度链接流程) +const { sessionToken, user } = await authApi.exchangeToken(loginToken); + +// 登出 +await authApi.logout(); +``` + +#### User API (`services/api/userApi.ts`) + +用户 profile 端点。 + +```typescript +import { userApi } from "../services/api/userApi"; + +// 获取当前用户 +const user = await userApi.getCurrentUser(); + +// 更新 profile +const updated = await userApi.updateProfile({ firstName, lastName }); + +// 获取设置 +const settings = await userApi.getSettings(); +``` + +### Socket Service (`services/socketService.ts`) + +用于实时通信的 Socket.io 客户端单例。 + +#### 特性 + +* 单例模式 - 每应用一个连接 +* Auth token 通过 socket `auth` 对象传递 +* 传输:先 polling,然后 WebSocket 升级 +* 自动重连处理 + +#### API + +```typescript +import socketService from "../services/socketService"; + +// 用 auth token 连接 +socketService.connect(token); + +// 断开 +socketService.disconnect(); + +// 发射事件 +socketService.emit("event-name", data); + +// 监听事件 +socketService.on("event-name", (data) => { + // 处理事件 +}); + +// 移除监听器 +socketService.off("event-name", handler); + +// 一次性监听器 +socketService.once("event-name", (data) => { + // 处理一次 +}); + +// 获取 socket 实例 +const socket = socketService.getSocket(); + +// 检查连接状态 +const isConnected = socketService.isConnected(); +``` + +#### 连接流程 + +```typescript +// 在 SocketProvider.tsx 中 +useEffect(() => { + if (token) { + socketService.connect(token); + + socketService.on("connect", () => { + dispatch(setSocketStatus({ userId, status: "connected" })); + dispatch(setSocketId({ userId, socketId: socket.id })); + // 初始化 MCP 服务器 + initMCPServer(socketService.getSocket()); + }); + + socketService.on("disconnect", () => { + dispatch(setSocketStatus({ userId, status: "disconnected" })); + }); + } + + return () => { + socketService.disconnect(); + }; +}, [token]); +``` + +#### 配置 + +```typescript +const socket = io(BACKEND_URL, { + auth: { token }, + transports: ["polling", "websocket"], + reconnection: true, + reconnectionAttempts: 5, + reconnectionDelay: 1000, +}); +``` + +#### Socket 事件契约 (Tauri) + +在 Tauri 模式下,连接和事件通过 **`utils/tauriSocket.ts`** (`setupTauriSocketListeners`、`connectRustSocket` 等) 桥接。见 `providers/SocketProvider.tsx` 获取完整流程(包括 daemon 生命周期 hook)。 + +### Core RPC (`services/coreRpcClient.ts`) + +桌面应用运行一个单独的 **`openhuman`** Rust 二进制文件(staging 在 `app/src-tauri/binaries/` 下)。UI 通过 Tauri 调用该进程上的 JSON-RPC 方法: + +```typescript +import { callCoreRpc } from "../services/coreRpcClient"; + +const result = await callCoreRpc({ + method: "some.openhuman.method", + params: { + /* … */ + }, + serviceManaged: false, // true 如果 relay 应确保 systemd/launchd 风格服务 +}); +``` + +实现:`invoke('core_rpc_relay', { request: { method, params, serviceManaged } })` → `app/src-tauri/src/commands/core_relay.rs` → `app/src-tauri/src/core_rpc.rs` 中的 HTTP 客户端。 + +### 服务与 provider 集成 + +#### SocketProvider + +`app/src/providers/SocketProvider.tsx` 在 `auth.token` 存在时连接。在 **Tauri** 中,它优先使用 Rust-backed socket 路径;在 **web** 中,它使用 JS Socket.io 客户端。见源码获取日志和 `useDaemonLifecycle` 集成。 + +#### UserProvider、AIProvider、SkillProvider + +这些包装用户 profile 加载、AI/记忆客户端协调和技能目录/同步。它们位于 `PersistGate` **内部** 和路由器旁边或外部,如 `App.tsx` 所示。 + +### 最佳实践 + +1. **使用单例** - 永远不要创建多个服务实例 +2. **在 Redux 中存储会话** - 不用 localStorage +3. **卸载时清理** - 在 useEffect cleanup 中断开连接 +4. **优雅处理错误** - 瞬态失败时重试 +5. **通过正确通道传递 auth** - Socket auth 对象,而非 query string + +*** + +## Providers + +React context providers 管理服务生命周期并提供共享状态。 + +### Provider 链 + +providers 按特定顺序包装应用 (`app/src/App.tsx`): + +```tsx + + + + + + + + + + + + + + + + + +``` + +(`Router` 是 `react-router-dom` 的 `HashRouter`。) + +**顺序重要,因为:** + +1. Redux 在最外层用于 store 访问。 +2. `PersistGate` 在子组件依赖 auth 前重新水合持久化的 slice。 +3. `SocketProvider` 使用 store 中的 JWT。 +4. `AIProvider` / `SkillProvider` 依赖 socket 和 store-backed 功能。 +5. 路由器为所有路由提供导航。 + +### SocketProvider (`app/src/providers/SocketProvider.tsx`) + +管理实时连接:**web** 使用 JS Socket.io 客户端;**Tauri** 通过 `utils/tauriSocket.ts` 桥接到 Rust socket 并向 Redux 报告状态。 + +#### 职责 + +* `auth.token` 可用时连接;清除时断开 +* Tauri 中:安装监听器一次,连接 Rust socket,协调 daemon 生命周期 (`useDaemonLifecycle`) +* 更新 Redux socket slice / 连接状态 + +#### 实现 + +见 **`app/src/providers/SocketProvider.tsx`**。文件在 **`isTauri()`** 上分叉:web 模式直接使用 `socketService`;Tauri 设置 `tauriSocket` 监听器和 `connectRustSocket` / `disconnectRustSocket`。不要将下方的伪代码视为实时实现。 + +#### 用法 + +```typescript +import { useSocket } from '../providers/SocketProvider'; + +function MyComponent() { + const { socket, isConnected, emit, on, off } = useSocket(); + + useEffect(() => { + const handler = (data) => console.log('Received:', data); + on('event-name', handler); + return () => off('event-name', handler); + }, [on, off]); + + const sendMessage = () => { + emit('send-message', { text: 'Hello!' }); + }; + + return ( +
+ Status: {isConnected ? 'Connected' : 'Disconnected'} + +
+ ); +} +``` + +### AIProvider (`app/src/providers/AIProvider.tsx`) + +初始化 **memory**、**sessions**、**tool registry**(包括 memory + web-search 工具)、**entity manager**、**LLM / embedding providers** 和 **constitution** 加载。为子组件暴露 `useAI()`。重逻辑位于 `app/src/lib/ai/` 下。 + +### SkillProvider (`app/src/providers/SkillProvider.tsx`) + +挂载时(认证后),通过 Tauri 辅助函数 (`runtimeDiscoverSkills`) 从 **QuickJS** 技能引擎发现技能,将 manifest 同步到 Redux,监听技能相关的 Tauri 事件,并可以在开发中自动启动配置的技能。 + +### UserProvider (`providers/UserProvider.tsx`) + +最小用户 context provider(大多数用户状态在 Redux 中)。 + +#### 职责 + +* 兼容性用的遗留用户 context +* 可能弃用,改为 Redux + +#### 实现 + +```typescript +interface UserContextValue { + user: User | null; + loading: boolean; +} + +export function UserProvider({ children }) { + const user = useAppSelector((state) => state.user.profile); + const loading = useAppSelector((state) => state.user.loading); + + return ( + + {children} + + ); +} +``` + +#### 用法 + +```typescript +import { useUserContext } from '../providers/UserProvider'; + +function Header() { + const { user, loading } = useUserContext(); + + if (loading) return ; + if (!user) return null; + + return Welcome, {user.firstName}; +} +``` + +### Provider 模式 + +#### 基于 Effect 的生命周期 + +Providers 使用 `useEffect` 管理服务生命周期: + +```typescript +useEffect(() => { + // 挂载或依赖变更时设置 + service.connect(); + + // 卸载或依赖变更时清理 + return () => { + service.disconnect(); + }; +}, [dependencies]); +``` + +#### Redux 集成 + +Providers 从 Redux 读取并 dispatch: + +```typescript +// 读取状态 +const token = useAppSelector((state) => state.auth.token); + +// Dispatch actions +const dispatch = useAppDispatch(); +dispatch(setStatus({ userId, status: "connected" })); +``` + +#### 并行初始化 + +`SkillProvider` 和 `AIProvider` 可能在挂载时启动多个异步任务(技能发现、记忆初始化、constitution 加载)。优先阅读源码获取排序保证,而非假设到处都是并行 `Promise.all`。 + +#### 会话恢复 + +Providers 在挂载时恢复持久化状态: + +```typescript +useEffect(() => { + if (persistedSession) { + service.restoreSession(persistedSession); + } +}, [persistedSession]); +``` + +### Context vs Redux + +| 使用 Context 用于 | 使用 Redux 用于 | +| ---------------------------------- | ---------------------------------- | +| 服务实例 (socket、client) | 可序列化状态 (status、data) | +| 方法 (emit、on、off) | 持久化状态 (sessions、tokens) | +| 派生值 | 复杂状态逻辑 | + +示例: + +* `SocketContext` 提供 `socket` 实例和 `emit` 方法 +* Redux 存储 `socketStatus` 和 `socketId` + +### 测试 Providers + +#### 测试用的 Mock Provider + +```typescript +// test-utils.tsx +const mockSocketContext: SocketContextValue = { + socket: null, + isConnected: true, + emit: jest.fn(), + on: jest.fn(), + off: jest.fn() +}; + +export function TestProviders({ children }) { + return ( + + + {children} + + + ); +} +``` + +#### 测试 Provider Effects + +```typescript +test('SocketProvider 在 token 可用时连接', () => { + const store = createTestStore({ auth: { token: 'test-token' } }); + + render( + + + + + + ); + + expect(socketService.connect).toHaveBeenCalledWith('test-token'); +}); +``` + +*** + +## Human Mascot 表面 + +Human 页面 (`app/src/features/human/HumanPage.tsx`) 在对话侧边栏旁渲染主 +`YellowMascot`。mascot face 仍然来自 `useHumanMascot`,它订阅聊天生命周期事件以获取 thinking、 +speaking、acknowledgement 和 error 状态。 + +子智能体委托由 `SubMascotLayer` 可视化。它不引入新的 socket 协议。相反,它读取已选或活跃 thread 的 +`chatRuntime.toolTimelineByThread` 条目,`ChatRuntimeProvider` 已经从 +`subagent_spawned`、`subagent_completed`、`subagent_failed`、 +`subagent_iteration_start`、`subagent_tool_call` 和 `subagent_tool_result` 构建了这些条目。 + +生命周期映射: + +| Runtime timeline 状态 | Sub-mascot 状态 | +| ---------------------- | ---------------- | +| `running` | 带 thinking face 和短活动气泡的小型彩色 mascot | +| `success` | 相同 mascot 解析为 happy face 和完成气泡 | +| `error` | 相同 mascot 解析为 concerned face 和失败气泡 | + +活动气泡文本有意保持紧凑:当前子工具调用、子迭代、委托提示摘录或最终状态。Thread timeline 仍然是权威的详细视图;sub-mascot 只是主 mascot 周围可一瞥的编排层。 + +*** + +## 页面与路由 + +应用使用 HashRouter 配合受保护和公共路由守卫。 + +### 路由结构 + +在 **`app/src/AppRoutes.tsx`** (HashRouter) 中定义。近似映射: + +``` +/ → Welcome (公共包装器) +/onboarding → Onboarding (auth,onboarding 未完成) +/mnemonic → Mnemonic / 加密设置 (auth) +/home → Home (auth + onboarding + 加密密钥) +/intelligence → Intelligence (auth) +/skills → Skills (auth) +/conversations → Conversations (auth) +/invites → Invites (auth) +/agents → Agents (auth) +/settings/* → Settings (auth) +* → DefaultRedirect +``` + +`AppRoutes` 中**没有**顶级 `/login` 路由;认证流程通过 welcome/onboarding 和后端重定向处理。 + +### 路由配置 (`AppRoutes.tsx`) + +```typescript +export function AppRoutes() { + return ( + <> + + {/* 公共路由 - 已认证时重定向 */} + }> + } /> + } /> + + + {/* 受保护路由 - 需要认证 */} + }> + } /> + + + {/* 受保护 + 已 onboard 路由 */} + }> + } /> + + + {/* Fallback 重定向 */} + } /> + + + {/* 设置模态覆盖层 - 在路由之上渲染 */} + + + ); +} +``` + +### 路由守卫 + +#### PublicRoute (`components/PublicRoute.tsx`) + +将已认证用户从公共页面重定向走。 + +```typescript +export function PublicRoute() { + const token = useAppSelector((state) => state.auth.token); + const isOnboarded = useAppSelector((state) => + selectIsOnboarded(state, userId), + ); + + if (token) { + // 已认证 - 重定向到适当页面 + return ; + } + + return ; +} +``` + +#### ProtectedRoute (`components/ProtectedRoute.tsx`) + +强制执行认证和可选的 onboarding 状态。 + +```typescript +interface ProtectedRouteProps { + requireOnboarded?: boolean; +} + +export function ProtectedRoute({ requireOnboarded = false }) { + const token = useAppSelector((state) => state.auth.token); + const isOnboarded = useAppSelector((state) => + selectIsOnboarded(state, userId), + ); + + if (!token) { + return ; + } + + if (requireOnboarded && !isOnboarded) { + return ; + } + + return ; +} +``` + +#### DefaultRedirect (`components/DefaultRedirect.tsx`) + +基于 auth 状态的 fallback 路由。 + +```typescript +export function DefaultRedirect() { + const token = useAppSelector((state) => state.auth.token); + const isOnboarded = useAppSelector((state) => + selectIsOnboarded(state, userId), + ); + + if (!token) { + return ; + } + + if (!isOnboarded) { + return ; + } + + return ; +} +``` + +### 页面 + +#### Welcome 页面 (`pages/Welcome.tsx`) + +未认证用户的落地页。 + +**特性:** + +* 应用介绍和品牌 +* 登录/注册 CTA +* 公共路由(已认证时重定向) + +#### Login 页面 (`pages/Login.tsx`) + +认证页面。 + +**特性:** + +* Telegram OAuth 按钮 +* 在浏览器中打开 `/auth/telegram?platform=desktop` +* 处理深度链接回调 + +```typescript +export function Login() { + const handleTelegramLogin = () => { + // 在系统浏览器中打开 Telegram OAuth + openUrl(`${BACKEND_URL}/auth/telegram?platform=desktop`); + }; + + return ( +
+ +
+ ); +} +``` + +#### Home 页面 (`pages/Home.tsx`) + +认证后的主仪表板。 + +**特性:** + +* 受保护路由(需要 auth + onboarded) +* 连接状态指示器 +* 导航到设置模态 +* 未来:聊天列表、消息等 + +```typescript +export function Home() { + const navigate = useNavigate(); + const user = useAppSelector((state) => state.user.profile); + const telegramStatus = useAppSelector((state) => + selectTelegramConnectionStatus(state, user?.id), + ); + + return ( +
+
+

Welcome, {user?.firstName}

+ +
+ + + + + {/* 主内容 */} +
+ ); +} +``` + +### Onboarding 流程 (`pages/onboarding/`) + +多步 onboarding 流程。 + +#### 结构 + +```text +pages/onboarding/ +├── Onboarding.tsx # 流程控制器 +└── steps/ + ├── GetStartedStep.tsx # Welcome + ├── PrivacyStep.tsx # 隐私政策 + ├── AnalyticsStep.tsx # Analytics 选择加入 + ├── ConnectStep.tsx # Telegram 连接 + └── FeaturesStep.tsx # 特性概览 +``` + +#### Onboarding 控制器 (`Onboarding.tsx`) + +```typescript +const STEPS = [ + { id: "get-started", component: GetStartedStep }, + { id: "privacy", component: PrivacyStep }, + { id: "analytics", component: AnalyticsStep }, + { id: "connect", component: ConnectStep }, + { id: "features", component: FeaturesStep }, +]; + +export function Onboarding() { + const [currentStep, setCurrentStep] = useState(0); + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + + const handleNext = () => { + if (currentStep < STEPS.length - 1) { + setCurrentStep(currentStep + 1); + } else { + // 完成 onboarding + dispatch(setOnboarded({ userId, isOnboarded: true })); + navigate("/home"); + } + }; + + const handleBack = () => { + if (currentStep > 0) { + setCurrentStep(currentStep - 1); + } + }; + + const StepComponent = STEPS[currentStep].component; + + return ( +
+ + +
+ ); +} +``` + +#### Step 组件 + +每个 step 接收 `onNext` 和 `onBack` 回调: + +```typescript +interface StepProps { + onNext: () => void; + onBack: () => void; +} + +export function ConnectStep({ onNext, onBack }: StepProps) { + const [showModal, setShowModal] = useState(false); + const telegramStatus = useAppSelector(/* ... */); + + return ( +
+

Connect Your Accounts

+ + {connectOptions.map((option) => ( + option.id === "telegram" && setShowModal(true)} + /> + ))} + + setShowModal(false)} + /> + +
+ + +
+
+ ); +} +``` + +### 设置模态路由 + +设置模态使用基于 URL 的路由覆盖现有内容。 + +#### 模态检测 + +```typescript +// 在 SettingsModal.tsx 中 +const location = useLocation(); +const isOpen = location.pathname.startsWith("/settings"); +``` + +#### 子路由 + +```text +/settings → SettingsHome (主菜单) +/settings/connections → ConnectionsPanel +/settings/messaging → MessagingPanel (未来) +/settings/privacy → PrivacyPanel (未来) +/settings/profile → ProfilePanel (未来) +/settings/advanced → AdvancedPanel (未来) +/settings/billing → BillingPanel (未来) +``` + +#### 导航 + +```typescript +import { useSettingsNavigation } from "./hooks/useSettingsNavigation"; + +function SettingsHome() { + const { navigateTo, closeModal } = useSettingsNavigation(); + + return ( +
+ navigateTo("connections")} + /> + +
+ ); +} +``` + +### HashRouter vs BrowserRouter + +应用使用 HashRouter 以兼容桌面: + +```typescript +// App.tsx +import { HashRouter } from "react-router-dom"; + +// URL 看起来像这样:app://localhost/#/home +// 而不是:app://localhost/home +``` + +**为什么用 HashRouter:** + +1. Tauri 深度链接与基于 hash 的 URL 配合工作 +2. 不需要服务器配置 +3. 与 file:// 协议配合工作 +4. 防止直接 URL 访问时的 404 + +### 深度链接处理 + +深度链接在路由前处理: + +```typescript +// main.tsx +import("./utils/desktopDeepLinkListener").then((m) => { + m.setupDesktopDeepLinkListener().catch(console.error); +}); +``` + +监听器拦截 `openhuman://auth?token=...` 并: + +1. 通过 Rust 命令交换 token +2. 在 Redux 中存储会话 +3. 导航到 `/onboarding` 或 `/home` + +### 导航模式 + +#### 程序化导航 + +```typescript +import { useNavigate } from "react-router-dom"; + +const navigate = useNavigate(); + +// 导航到路由 +navigate("/home"); + +// 替换历史条目 +navigate("/login", { replace: true }); + +// 返回 +navigate(-1); +``` + +#### Link 组件 + +```typescript +import { Link } from "react-router-dom"; + +Settings; +``` + +#### 状态传递 + +```typescript +// 向路由传递状态 +navigate("/details", { state: { itemId: 123 } }); + +// 接收状态 +const location = useLocation(); +const { itemId } = location.state; +``` + +*** + +## 组件 + +按功能组织的可复用 React 组件。 + +### 组件结构 + +```text +components/ +├── Route Guards +│ ├── ProtectedRoute.tsx +│ ├── PublicRoute.tsx +│ └── DefaultRedirect.tsx +│ +├── Authentication +│ └── TelegramLoginButton.tsx +│ +├── Connection Status +│ ├── ConnectionIndicator.tsx +│ ├── TelegramConnectionIndicator.tsx +│ ├── TelegramConnectionModal.tsx +│ └── GmailConnectionIndicator.tsx +│ +├── Onboarding +│ ├── ProgressIndicator.tsx +│ └── LottieAnimation.tsx +│ +├── Settings Modal (16 files) +│ ├── SettingsModal.tsx +│ ├── SettingsLayout.tsx +│ ├── SettingsHome.tsx +│ ├── panels/ +│ ├── components/ +│ └── hooks/ +│ +└── Development + └── DesignSystemShowcase.tsx +``` + +### 路由守卫组件 + +#### ProtectedRoute + +需要认证和可选的 onboarding。 + +```typescript +interface ProtectedRouteProps { + requireOnboarded?: boolean; +} + +// 在 AppRoutes.tsx 中的用法 +}> + } /> + + +}> + } /> + +``` + +#### PublicRoute + +将已认证用户重定向走。 + +```typescript +// 在 AppRoutes.tsx 中的用法 +}> + } /> + } /> + +``` + +#### DefaultRedirect + +基于 auth 状态的 fallback。 + +```typescript +// 重定向到: +// - "/" 如果未认证 +// - "/onboarding" 如果已认证但未 onboard +// - "/home" 如果已认证且已 onboard +``` + +### 认证组件 + +#### TelegramLoginButton + +Telegram 的 OAuth 登录按钮。 + +```typescript +interface TelegramLoginButtonProps { + onClick: () => void; + disabled?: boolean; +} + +// 用法 + openUrl(`${BACKEND_URL}/auth/telegram?platform=desktop`)} +/> +``` + +### 连接状态组件 + +#### ConnectionIndicator + +通用连接状态徽章。 + +```typescript +interface ConnectionIndicatorProps { + status: 'connected' | 'connecting' | 'disconnected' | 'error'; + label?: string; +} + + +``` + +#### TelegramConnectionIndicator + +Telegram 特定的状态显示。 + +```typescript +interface TelegramConnectionIndicatorProps { + status: 'connected' | 'connecting' | 'disconnected' | 'error'; +} + +// 配合 Redux 状态使用 +const telegramStatus = useAppSelector((state) => + selectTelegramConnectionStatus(state, userId) +); + + +``` + +#### TelegramConnectionModal + +设置 Telegram 连接的模态。 + +```typescript +interface TelegramConnectionModalProps { + isOpen: boolean; + onClose: () => void; +} + +// 在 onboarding/settings 中的用法 +const [showModal, setShowModal] = useState(false); + + setShowModal(false)} +/> +``` + +**特性:** + +* QR 码登录流程 +* 手机号登录流程 +* 连接状态显示 +* 错误处理 + +#### GmailConnectionIndicator + +Gmail 状态徽章(未来集成)。 + +```typescript + +``` + +### Onboarding 组件 + +#### ProgressIndicator + +通过 onboarding step 的视觉进度。 + +```typescript +interface ProgressIndicatorProps { + current: number; + total: number; +} + + +``` + +#### LottieAnimation + +Onboarding 的 Lottie 动画播放器。 + +```typescript +interface LottieAnimationProps { + animationData: object; + loop?: boolean; + autoplay?: boolean; + className?: string; +} + +import welcomeAnimation from '../assets/animations/welcome.json'; + + +``` + +### 设置模态系统 + +带基于 URL 路由的完整模态系统。 + +#### 文件结构 + +```text +components/settings/ +├── SettingsModal.tsx # 基于路由的容器 +├── SettingsLayout.tsx # Portal + 背景包装器 +├── SettingsHome.tsx # 带 profile 的主菜单 +├── panels/ +│ ├── ConnectionsPanel.tsx # 连接管理 +│ ├── MessagingPanel.tsx # (未来) +│ ├── PrivacyPanel.tsx # (未来) +│ ├── ProfilePanel.tsx # (未来) +│ ├── AdvancedPanel.tsx # (未来) +│ └── BillingPanel.tsx # (未来) +├── components/ +│ ├── SettingsHeader.tsx # 用户 profile 部分 +│ ├── SettingsMenuItem.tsx # 菜单项组件 +│ ├── SettingsBackButton.tsx # 返回导航 +│ └── SettingsPanelLayout.tsx# Panel 包装器 +└── hooks/ + ├── useSettingsNavigation.ts # URL 路由 + └── useSettingsAnimation.ts # 动画状态 +``` + +#### SettingsModal + +基于 URL 渲染的主容器。 + +```typescript +export function SettingsModal() { + const location = useLocation(); + const isOpen = location.pathname.startsWith('/settings'); + + if (!isOpen) return null; + + return ( + + {/* 路由到适当的 panel */} + {location.pathname === '/settings' && } + {location.pathname === '/settings/connections' && } + {/* ... 更多 panels */} + + ); +} +``` + +#### SettingsLayout + +基于 Portal 的模态包装器。 + +```typescript +export function SettingsLayout({ children }) { + const { closeModal } = useSettingsNavigation(); + + return createPortal( +
+ {/* 背景 */} +
+ + {/* 模态 */} +
+
+ {children} +
+
+
, + document.body + ); +} +``` + +#### SettingsHome + +带用户 profile 的主菜单。 + +```typescript +export function SettingsHome() { + const { navigateTo, closeModal } = useSettingsNavigation(); + const user = useAppSelector((state) => state.user.profile); + + const menuItems = [ + { id: 'connections', label: 'Connections', icon: LinkIcon }, + { id: 'messaging', label: 'Messaging', icon: MessageIcon }, + { id: 'privacy', label: 'Privacy', icon: ShieldIcon }, + // ... 更多项 + ]; + + return ( +
+ + + {menuItems.map((item) => ( + navigateTo(item.id)} + /> + ))} +
+ ); +} +``` + +#### ConnectionsPanel + +连接管理界面。 + +```typescript +export function ConnectionsPanel() { + const { navigateBack } = useSettingsNavigation(); + const [telegramModalOpen, setTelegramModalOpen] = useState(false); + + const telegramStatus = useAppSelector((state) => + selectTelegramConnectionStatus(state, userId) + ); + + // 复用 onboarding 中的 connectOptions + const connections = connectOptions.map((opt) => ({ + ...opt, + status: opt.id === 'telegram' ? telegramStatus : 'coming-soon' + })); + + return ( + + {connections.map((conn) => ( + conn.id === 'telegram' && setTelegramModalOpen(true)} + /> + ))} + + setTelegramModalOpen(false)} + /> + + ); +} +``` + +#### 设置 Hooks + +**useSettingsNavigation** + +设置模态的基于 URL 导航。 + +```typescript +interface UseSettingsNavigationReturn { + currentRoute: string; + navigateTo: (panel: string) => void; + navigateBack: () => void; + closeModal: () => void; +} + +const { navigateTo, navigateBack, closeModal } = useSettingsNavigation(); + +// 导航到 panel +navigateTo('connections'); // → /settings/connections + +// 返回 +navigateBack(); // → /settings + +// 关闭模态 +closeModal(); // → 之前的非设置路由 +``` + +**useSettingsAnimation** + +设置模态的动画状态管理。 + +```typescript +interface UseSettingsAnimationReturn { + isEntering: boolean; + isExiting: boolean; + animationClass: string; +} + +const { animationClass } = useSettingsAnimation(); + +
{/* Content */}
+``` + +#### 设置组件 + +**SettingsHeader** + +设置顶部的用户 profile 部分。 + +```typescript +interface SettingsHeaderProps { + user: User | null; + onClose: () => void; +} + + +``` + +**SettingsMenuItem** + +带图标和 chevron 的单个菜单项。 + +```typescript +interface SettingsMenuItemProps { + label: string; + icon: React.ComponentType; + onClick: () => void; + badge?: string; + disabled?: boolean; +} + + navigateTo('connections')} + badge="2" +/> +``` + +**SettingsBackButton** + +返回导航按钮。 + +```typescript +interface SettingsBackButtonProps { + onClick: () => void; +} + + +``` + +**SettingsPanelLayout** + +设置 panel 的包装器。 + +```typescript +interface SettingsPanelLayoutProps { + title: string; + onBack: () => void; + children: React.ReactNode; +} + + + {/* Panel content */} + +``` + +### 组件模式 + +#### 复用连接选项 + +`connectOptions` 数组在 onboarding 和 settings 之间共享: + +```typescript +// 在 ConnectStep.tsx 中定义,在其他地方导入 +export const connectOptions = [ + { + id: 'telegram', + label: 'Telegram', + icon: TelegramIcon, + description: 'Connect your Telegram account', + }, + { + id: 'gmail', + label: 'Gmail', + icon: GmailIcon, + description: 'Connect your Gmail account', + comingSoon: true, + }, +]; +``` + +#### 通过 Portal 的模态 + +设置模态使用 `createPortal` 在组件树外部渲染: + +```typescript +return createPortal( +
+ {/* 模态内容 */} +
, + document.body +); +``` + +#### 受控 vs 非受控 + +连接模态是受控组件: + +```typescript +// 父级控制 open 状态 +const [isOpen, setIsOpen] = useState(false); + + setIsOpen(false)} +/> +``` + +*** + +## Hook 与工具 + +自定义 React hook 和工具函数。 + +### 自定义 Hooks + +#### useSocket (`hooks/useSocket.ts`) + +从任何组件访问 Socket.io 功能。 + +```typescript +interface UseSocketReturn { + socket: Socket | null; + isConnected: boolean; + emit: (event: string, data: unknown) => void; + on: (event: string, handler: Function) => void; + off: (event: string, handler: Function) => void; + once: (event: string, handler: Function) => void; +} + +function useSocket(): UseSocketReturn; +``` + +**用法:** + +```typescript +import { useSocket } from "../hooks/useSocket"; + +function ChatInput() { + const { emit, isConnected } = useSocket(); + + const sendMessage = (text: string) => { + if (isConnected) { + emit("chat:message", { text }); + } + }; + + return ( + e.key === "Enter" && sendMessage(e.target.value)} + /> + ); +} +``` + +**配合事件监听器:** + +```typescript +function Notifications() { + const { on, off } = useSocket(); + const [notifications, setNotifications] = useState([]); + + useEffect(() => { + const handler = (notification) => { + setNotifications((prev) => [...prev, notification]); + }; + + on("notification", handler); + return () => off("notification", handler); + }, [on, off]); + + return ; +} +``` + +#### useUser (`hooks/useUser.ts`) + +访问用户 profile 数据和加载状态。 + +```typescript +interface UseUserReturn { + user: User | null; + loading: boolean; + error: string | null; + refetch: () => Promise; +} + +function useUser(): UseUserReturn; +``` + +**用法:** + +```typescript +import { useUser } from "../hooks/useUser"; + +function ProfileHeader() { + const { user, loading, error, refetch } = useUser(); + + if (loading) return ; + if (error) return ; + if (!user) return null; + + return ( +
+ + + {user.firstName} {user.lastName} + +
+ ); +} +``` + +#### 设置模态 Hooks + +**useSettingsNavigation (`components/settings/hooks/useSettingsNavigation.ts`)** + +设置模态的基于 URL 导航。 + +```typescript +interface UseSettingsNavigationReturn { + currentRoute: string; // 当前设置路径 + navigateTo: (panel: string) => void; // 导航到 panel + navigateBack: () => void; // 返回一级 + closeModal: () => void; // 完全关闭设置 +} + +function useSettingsNavigation(): UseSettingsNavigationReturn; +``` + +**用法:** + +```typescript +import { useSettingsNavigation } from "./hooks/useSettingsNavigation"; + +function SettingsMenu() { + const { navigateTo, closeModal } = useSettingsNavigation(); + + return ( + + ); +} +``` + +**useSettingsAnimation (`components/settings/hooks/useSettingsAnimation.ts`)** + +设置模态的动画状态管理。 + +```typescript +interface UseSettingsAnimationReturn { + isEntering: boolean; // 模态正在动画进入 + isExiting: boolean; // 模态正在动画退出 + animationClass: string; // 当前状态的 CSS 类 +} + +function useSettingsAnimation(): UseSettingsAnimationReturn; +``` + +**用法:** + +```typescript +import { useSettingsAnimation } from "./hooks/useSettingsAnimation"; + +function SettingsModal() { + const { animationClass, isExiting } = useSettingsAnimation(); + + return
{/* Content */}
; +} +``` + +### 工具 + +#### 配置 (`utils/config.ts`) + +构建时环境变量访问。这些常量只携带烘焙到 bundle 中的值,对于应用实际通信的**运行时** URL,见 `services/backendUrl` 和下方的 `hooks/useBackendUrl`。 + +```typescript +// 仅构建时 fallback(在 Tauri 外使用)。 +export const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'https://api.example.com'; + +// 调试模式 +export const DEBUG = import.meta.env.VITE_DEBUG === 'true'; +``` + +**用法(仅构建时、feature flag、调试开关、…):** + +```typescript +import { DEBUG } from '../utils/config'; + +if (DEBUG) { + console.log('debug enabled'); +} +``` + +> **不要**直接导入 `BACKEND_URL` 来发起 API 调用。在运行时解析 URL,以便核心 sidecar 的 `api_url`(通过登录屏幕上的 `openhuman.config_resolve_api_url` 设置)生效: +> +> ```typescript +> // React 组件 +> import { useBackendUrl } from '../hooks/useBackendUrl'; +> const backendUrl = useBackendUrl(); +> +> // 非 React 代码 +> import { getBackendUrl } from '../services/backendUrl'; +> const backendUrl = await getBackendUrl(); +> ``` + +#### 深度链接 (`utils/deeplink.ts`) + +为认证交接构建深度链接 URL。 + +```typescript +// 构建 auth 深度链接 +function buildAuthDeepLink(token: string): string; + +// 解析深度链接 URL +function parseDeepLink(url: string): { path: string; params: URLSearchParams }; +``` + +**用法:** + +```typescript +import { buildAuthDeepLink } from '../utils/deeplink'; + +// 为浏览器重定向构建 URL +const deepLink = buildAuthDeepLink(loginToken); +// → "openhuman://auth?token=abc123" + +// 在 Web 前端 auth 后: +window.location.href = deepLink; +``` + +#### 桌面深度链接监听器 (`utils/desktopDeepLinkListener.ts`) + +在桌面应用中处理传入的深度链接。 + +```typescript +// 设置深度链接事件监听器 +async function setupDesktopDeepLinkListener(): Promise; +``` + +**在 main.tsx 中调用:** + +```typescript +// 懒加载以确保 Tauri IPC 就绪 +import('./utils/desktopDeepLinkListener').then(m => { + m.setupDesktopDeepLinkListener().catch(console.error); +}); +``` + +**它做什么:** + +1. 监听来自 Tauri 深度链接插件的 `onOpenUrl` 事件 +2. 解析 `openhuman://auth?token=...` URL +3. 调用 Rust `exchange_token` 命令(绕过 CORS) +4. 在 Redux 中存储会话 +5. 导航到 `/onboarding` 或 `/home` + +**循环预防:** + +```typescript +// 导航前设置 flag 以防止重新处理 +localStorage.setItem('deepLinkHandled', 'true'); +window.location.replace('/'); + +// 下次加载时,清除 flag +if (localStorage.getItem('deepLinkHandled') === 'true') { + localStorage.removeItem('deepLinkHandled'); + return; // 不再处理 +} +``` + +#### URL 打开器 (`utils/openUrl.ts`) + +跨平台 URL 打开。 + +```typescript +// 在系统浏览器中打开 URL +async function openUrl(url: string): Promise; +``` + +**用法:** + +```typescript +import { openUrl } from '../utils/openUrl'; + +// 在系统浏览器中打开(非应用内 WebView) +await openUrl('https://telegram.org/auth'); +``` + +**实现:** + +```typescript +export async function openUrl(url: string): Promise { + try { + // 先尝试 Tauri opener 插件 + const { open } = await import('@tauri-apps/plugin-opener'); + await open(url); + } catch { + // Fallback 到浏览器 API + window.open(url, '_blank'); + } +} +``` + +### Polyfills (`polyfills.ts`) + +浏览器环境的 Node.js polyfills。 + +`telegram` npm 包需要 Node.js API。这些被 polyfill: + +```typescript +// polyfills.ts +import { Buffer } from 'buffer'; +import process from 'process'; +import util from 'util'; + +window.Buffer = Buffer; +window.process = process; +window.util = util; +``` + +**在应用入口导入:** + +```typescript +// main.tsx +import './polyfills'; + +// ... 应用的其余部分 +``` + +**Vite 配置:** + +```typescript +// vite.config.ts +export default defineConfig({ + resolve: { alias: { buffer: 'buffer', process: 'process/browser', util: 'util' } }, + define: { 'process.env': {}, global: 'globalThis' }, +}); +``` + +### 类型 + +#### API 类型 (`types/api.ts`) + +```typescript +// API 响应包装器 +interface ApiResponse { + success: boolean; + data?: T; + error?: string; +} + +// API 错误 +interface ApiError { + code: string; + message: string; + details?: unknown; +} + +// User 接口 +interface User { + id: string; + firstName: string; + lastName?: string; + username?: string; + email?: string; + avatar?: string; + telegramId?: string; + subscription?: SubscriptionInfo; + usage?: UsageInfo; + createdAt: string; + updatedAt: string; +} +``` + +#### Onboarding 类型 (`types/onboarding.ts`) + +```typescript +// Onboarding step 定义 +interface OnboardingStep { + id: string; + title: string; + component: React.ComponentType; +} + +// Step 组件 props +interface StepProps { + onNext: () => void; + onBack: () => void; +} + +// 连接选项 +interface ConnectionOption { + id: string; + label: string; + icon: React.ComponentType; + description: string; + comingSoon?: boolean; +} +``` + +### 静态数据 + +#### 国家 (`data/countries.ts`) + +手机号输入的国家列表。 + +```typescript +interface Country { + code: string; // "US" + name: string; // "United States" + dialCode: string; // "+1" + flag: string; // "🇺🇸" +} + +export const countries: Country[]; +``` + +**用法:** + +```typescript +import { countries } from "../data/countries"; + +function PhoneInput() { + const [country, setCountry] = useState(countries[0]); + + return ( +
+ + +
+ ); +} +``` + +### 最佳实践 + +#### Hook 依赖 + +始终在 useEffect 中包含依赖: + +```typescript +// 好 +useEffect(() => { + on('event', handler); + return () => off('event', handler); +}, [on, off, handler]); + +// 坏 - 缺失依赖 +useEffect(() => { + on('event', handler); + return () => off('event', handler); +}, []); +``` + +#### 清理函数 + +始终清理订阅: + +```typescript +useEffect(() => { + const subscription = subscribe(); + return () => subscription.unsubscribe(); +}, []); +``` + +#### 错误边界 + +将工具调用包装在 try-catch 中: + +```typescript +try { + await openUrl(url); +} catch (error) { + console.error('Failed to open URL:', error); + // Fallback 行为 +} +``` + +#### 类型安全 + +对 API 调用使用 TypeScript 泛型: + +```typescript +const user = await apiClient.get('/users/me'); +// user 被类型化为 User +``` + +*** diff --git a/gitbooks/developing/architecture/tauri-shell.zh-CN.md b/gitbooks/developing/architecture/tauri-shell.zh-CN.md new file mode 100644 index 000000000..113322805 --- /dev/null +++ b/gitbooks/developing/architecture/tauri-shell.zh-CN.md @@ -0,0 +1,209 @@ +--- +description: 桌面宿主 (`app/src-tauri/`) —— Tauri v2 + WebView、IPC、sidecar 生命周期、核心桥接。 +icon: desktop +--- + +# Tauri Shell (`app/src-tauri/`) + +OpenHuman 的桌面宿主:Tauri v2 + WebView、IPC 命令、窗口管理,以及桥接到 `openhuman-core` Rust sidecar(核心 JSON-RPC)。它**不会**重复完整的领域栈;那部分存在于仓库根目录的 Rust crate 中(`openhuman_core`、`src/main.rs`)。 + +## 职责 + +1. **Web UI**。从 `app/dist` 加载 Vite 构建(或开发服务器,端口 1420)。 +2. **IPC**。暴露一小套明确的 Tauri 命令(见 [Commands](#tauri-ipc-commands-app-src-tauri))。 +3. **核心生命周期**。确保 `openhuman-core` 二进制文件正在运行(子进程和/或服务)并通过 `core_rpc_relay` 代理 JSON-RPC。 +4. **磁盘上的 AI 提示**。从资源 / 开发 cwd 解析捆绑的 `src/openhuman/agent/prompts`,用于 `ai_get_config` / `write_ai_config_file`。 +5. **窗口 + 托盘**。桌面窗口行为和系统托盘(见 `lib.rs`)。 + +## 构建 sidecar + +`app/package.json` 的 `core:stage` 运行 `scripts/stage-core-sidecar.mjs`,后者在仓库根目录运行 `cargo build --bin openhuman-core` 并将二进制文件复制到 `app/src-tauri/binaries/`,供 Tauri `externalBin` 使用。 + +## 卡死进程恢复 + +正常应用退出从 `RunEvent::ExitRequested` 运行 teardown:CEF 关闭前先关闭子 webview,触发嵌入式核心的 cancellation token,最终进程扫描在短暂的宽限期后向直接子进程发送 `SIGTERM`,然后升级使用 `SIGKILL` 处理顽固进程。扫描摘要记录为 `[app] sweep: term=N kill=M total=K`;任何非零 `kill` 计数都是警告,意味着子进程忽略了优雅关闭。 + +在 macOS 上,硬退出(强制退出、`SIGKILL`、渲染器崩溃)可能跳过正常的 teardown。下一次启动在 CEF 缓存 preflight 之前运行启动恢复:它列出可执行路径属于正在启动的 `.app/Contents` 的 OpenHuman 进程,跳过当前进程,发送 `SIGTERM`,短暂等待,然后对仍然匹配相同 pid+command 的顽固进程发送 `SIGKILL`。日志使用 `[startup-recovery]` 前缀。 + +当设置了 `OPENHUMAN_CORE_REUSE_EXISTING=1` 时(以便手动 CLI-core 复用仍然有效),以及当 CEF `SingletonLock` 被实时进程持有时(以便正常的 second-instance 路径可以在不杀死已运行应用的情况下失败),启动恢复跳过。Tauri 命令 `process_diagnostics_list_owned` 返回当前拥有的进程列表;macOS 实现是 bundle 作用域的,Linux/Windows 目前返回空。 + + +## Tauri Shell 架构 (`app/src-tauri/`) + +### 概述 + +**`app/src-tauri`** crate(Rust 包 **`OpenHuman`**,二进制文件 **`OpenHuman`**)是一个**仅限桌面**的宿主。它嵌入 React UI,注册插件(深度链接、打开器、OS、通知、自动启动、更新器),管理主窗口和托盘,并**中继 JSON-RPC** 到单独构建的 **`openhuman-core`** 二进制文件。 + +非桌面目标在编译时失败(`lib.rs` 中的 `compile_error!`)。 + +### 目录布局(实际) + +```text +app/src-tauri/src/ +├── lib.rs # `run()`、托盘/菜单动作、插件、`generate_handler!`、核心启动 +├── main.rs # 二进制入口 +├── core_process.rs # CoreProcessHandle、生成/监控 openhuman sidecar +├── core_rpc.rs # 核心 JSON-RPC 的 HTTP 客户端 +├── commands/ +│ ├── mod.rs # 重新导出 +│ ├── core_relay.rs # `core_rpc_relay`、服务管理的核心引导 +│ ├── openhuman.rs # Daemon 宿主配置、systemd 风格服务辅助函数 +│ └── window.rs # 显示/隐藏/最小化/关闭窗口 +└── utils/ + ├── mod.rs + └── dev_paths.rs # 解析捆绑的 AI 提示路径 +``` + +此树中**没有** `src-tauri/src/services/session_service.rs`;会话语义在 Web 层 + 后端 + 核心中按适用情况处理。 + +### 数据流:UI → 核心 + +```text +React (invoke) + → core_rpc_relay { method, params, serviceManaged? } + → core_rpc::call HTTP POST 到 OPENHUMAN_CORE_RPC_URL + → openhuman 二进制文件 (src/bin/openhuman.rs → core_server) +``` + +`core_process.rs` 中的 `CoreProcessHandle` 启动或等待 sidecar;`commands/core_relay.rs` 可选地在 relay 之前确保**服务管理**的核心正在运行。 + +### 窗口和托盘行为 + +- 壳层在启动时创建托盘图标,并将动作连接到打开主窗口或退出。 +- 在 daemon 模式(`daemon` / `--daemon`)下,主窗口在启动时隐藏,可以从托盘动作重新打开。 +- 在 macOS 上,`RunEvent::Reopen` 也会恢复并聚焦主窗口。 +- Windows 和 Linux 使用相同的托盘动作(`Open OpenHuman`、`Quit`),某些 Linux 设置上有桌面环境特定的托盘渲染差异。 + +### 捆绑资源 + +`tauri.conf.json` 捆绑 **`../../skills/skills`** 和 **`../../src/openhuman/agent/prompts`**,使技能和提示 markdown 随应用一起发布。 + +### 相关 + +- IPC 表面:见下方的 [Commands](#tauri-ipc-commands-app-src-tauri) 部分 +- HTTP 桥接:见下方的 [Core bridge & helpers](#core-bridge-helpers-app-src-tauri) 部分 +- Rust 领域(实现):仓库根目录 `src/openhuman/`、`src/core_server/` + + +## Tauri IPC 命令 (`app/src-tauri`) {#tauri-ipc-commands-app-src-tauri} + +所有命令都在 **`app/src-tauri/src/lib.rs`** 中的 `tauri::generate_handler![...]` 内注册(桌面构建)。下方名称是 **Rust** 命令名称(在 JS 中通过 serde 应用 camelCase)。 + +### Demo / 诊断 + +| 命令 | 用途 | +| ------- | ------------------------------------------ | +| `greet` | Demo 字符串(生产中可安全移除) | + +### AI 配置(捆绑提示) + +| 命令 | 用途 | +| ---------------------- | -------------------------------------------------------------------------------------------- | +| `ai_get_config` | 从捆绑或开发 `src/openhuman/agent/prompts` 下解析的 `SOUL.md` / `TOOLS.md` 构建 `AIPreview` | +| `ai_refresh_config` | 与 `ai_get_config` 相同的读取路径(刷新 hook) | +| `write_ai_config_file` | 在仓库 `src/openhuman/agent/prompts` 下写入单个 `.md`(开发 / 安全文件名检查) | + +### 核心 JSON-RPC 中继 + +| 命令 | 用途 | +| ---------------- | -------------------------------------------------------------------------------------------------------------- | +| `core_rpc_relay` | Body: `{ method, params?, serviceManaged? }` → 转发到本地 **`openhuman-core`** HTTP JSON-RPC (`core_rpc.rs`) | + +从前端使用 **`app/src/services/coreRpcClient.ts`** (`callCoreRpc`)。 + +### 窗口管理 + +来自 **`commands/window.rs`**(名称可能略有不同;见 `lib.rs`): + +| 命令 | 用途 | +| ------------------- | ----------------- | +| `show_window` | 显示主窗口 | +| `hide_window` | 隐藏主窗口 | +| `toggle_window` | 切换可见性 | +| `is_window_visible` | 查询可见性 | +| `minimize_window` | 最小化 | +| `maximize_window` | 最大化 | +| `close_window` | 关闭 | +| `set_window_title` | 设置标题字符串 | + +### OpenHuman daemon / 服务辅助函数 + +来自 **`commands/openhuman.rs`**(见源码获取精确 payload): + +| 命令 | 用途 | +| ---------------------------------- | ---------------------------------------------- | +| `openhuman_get_daemon_host_config` | 读取 daemon 宿主偏好设置(例如托盘) | +| `openhuman_set_daemon_host_config` | 持久化 daemon 宿主偏好设置 | +| `openhuman_service_install` | 安装后台服务(平台特定) | +| `openhuman_service_start` | 启动服务 | +| `openhuman_service_stop` | 停止服务 | +| `openhuman_service_status` | 查询状态 | +| `openhuman_service_uninstall` | 卸载服务 | + +### 屏幕共享选择器(CEF / macOS) + +来自 **`screen_capture/mod.rs`**。支持 `webview_accounts/runtime.js` 中的页面内 `getDisplayMedia` shim。会话门控:shim 必须在成功枚举/缩略图捕获之前用实时用户手势打开会话。见 issue #713(选择器 UX)+ #812(会话门控)。 + +| 命令 | 用途 | +| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `screen_share_begin_session` | 从账户 webview 打开 30s 会话,在 `navigator.userActivation.isActive` 手势之后。返回 `{ token, sources }`。每个账户限速 10/分钟。 | +| `screen_share_thumbnail` | 将单个来源的缩略图捕获为 base64 PNG。需要 live token 和会话颁发的 `id`。仅 macOS;其他平台返回错误。 | +| `screen_share_finalize_session` | 关闭会话。由 shim 在 Share 或 Cancel 时调用;使用未知/过期 token 安全调用(no-op)。 | + +### 已移除 / 不存在 + +以下命令**不**存在于当前的 `generate_handler!` 列表中:`exchange_token`、`get_auth_state`、`socket_connect`、`start_telegram_login`。认证和 socket 在 **React** 应用和 **核心** 进程中处理,而非通过这些 IPC 名称。 + +### 示例:核心 RPC + +```typescript +import { invoke } from "@tauri-apps/api/core"; + +const result = await invoke("core_rpc_relay", { + request: { + method: "your.rpc.method", + params: { foo: "bar" }, + serviceManaged: false, + }, +}); +``` + +--- + +_见 `app/src-tauri/src/lib.rs` 获取权威列表。_ + + +## Core bridge & helpers (`app/src-tauri`) {#core-bridge-helpers-app-src-tauri} + +本文档替代了旧的 "SessionService / SocketService" 拆分。Tauri crate **不**嵌入重复的 Socket.io 服务器或 Telegram 客户端;相反,它专注于对 **`openhuman-core`** 二进制文件的**进程管理**和 **HTTP JSON-RPC**。 + +### `CoreProcessHandle` (`core_process.rs`) + +- 解析 **`openhuman-core`** 可执行文件(staging 在 `binaries/` 下或 `PATH` / 开发布局中)。 +- 启动或附加到核心进程并暴露其 RPC URL (`OPENHUMAN_CORE_RPC_URL`)。 +- 在 `lib.rs` 的应用设置期间使用 (`app.manage(core_handle)`)。 + +### `core_rpc` (`core_rpc.rs`) + +- 核心 JSON-RPC 表面的 HTTP 客户端(localhost)。 +- 由 **`core_rpc_relay`** 使用,以转发前端的 `method` + `params`。 + +### `commands/core_relay.rs` + +- **`core_rpc_relay`**。确保核心正在运行(进程内句柄或**服务管理**路径),然后调用 `core_rpc`。 +- **`ensure_service_managed_core_running`**。当 RPC 不可用时引导 systemd/launchd 风格服务(核心 CLI 内的平台特定行为)。 + +### `commands/openhuman.rs` + +- Daemon 宿主 JSON 配置(例如托盘可见性),位于应用数据目录下。 +- 为 **openhuman** 后台服务提供 install/start/stop/status/uninstall 辅助函数。 + +### `utils/dev_paths.rs` + +- 解析 AI preview 的开发和捆绑资源路径下的 **`src/openhuman/agent/prompts`**。 + +### `utils/tauriSocket.ts`(前端) + +不在 `src-tauri` 中,但与 shell **配对**:React 应用监听镜像 Rust 端客户端 socket 活动的 Tauri 事件。见 `app/src/utils/tauriSocket.ts` 和 [前端服务](frontend.zh-CN.md#services-layer) 章节。 + +--- diff --git a/gitbooks/developing/cef.zh-CN.md b/gitbooks/developing/cef.zh-CN.md new file mode 100644 index 000000000..3b69fd780 --- /dev/null +++ b/gitbooks/developing/cef.zh-CN.md @@ -0,0 +1,172 @@ +--- +description: >- + 为什么 OpenHuman 自带 Chromium 运行时,我们今天用它做什么,以及同样的 CDP 表面接下来能解锁什么。 +icon: chrome +--- + +# Chromium Embedded Framework + +OpenHuman 不运行在平台内置的 webview 上。它通过 `tauri-runtime` 的一个 fork 自带 **Chromium Embedded Framework (CEF) 运行时**,而这一个决策对产品几乎所有 "OpenHuman 知道你的工具里发生了什么" 的功能都是 load-bearing 的。 + +本页解释为什么 CEF 在 bundle 中,代码库今天用它做什么,以及同样的表面可以去哪里。 + +## 为什么用 CEF 而不是 stock webview + +Stock Tauri 使用每个平台的原生 webview。macOS 上的 WKWebView、Windows 上的 WebView2、Linux 上的 WebKitGTK。这些用于渲染 OpenHuman 应用本身都能正常工作。它们对我们的用例有一个致命的局限性:**没有一个暴露 Chrome DevTools Protocol (CDP)**。 + +CDP 是 load-bearing 的原语。OpenHuman 中每个 "观察 Slack / WhatsApp / Telegram / Discord / Meet 内部发生了什么" 的功能都通过 CDP 与这些嵌入应用对话,而非通过注入的 JavaScript。CDP 提供: + +* `Target.getTargets` 用于发现每个页面和服务 worker。 +* `IndexedDB.requestDatabaseNames` / `requestDatabase` / `requestData` 用于遍历第三方应用的本地存储。 +* `DOMSnapshot.captureSnapshot` 用于不会触发框架反应性的只读 DOM 检查。 +* `Runtime.evaluate` 用于短暂的一次性读取(单个固定的 JSON 序列化器,从来不是持久桥接)。 +* `Page.addScriptToEvaluateOnNewDocument` 用于极少数我们真正需要在页面 JS 运行前渲染器端 shim 的情况。 + +Stock webview 不能给我们任何这些。所以我们 vendor CEF。 + +Vendored 运行时位于 [`app/src-tauri/vendor/tauri-cef/`](https://github.com/tinyhumansai/openhuman/tree/main/app/src-tauri/vendor/tauri-cef)(从上游 `tauri-cef` 分支 fork 到 `tinyhumansai/tauri-cef:feat/cef-notification-intercept`,当前 CEF 146.4.1)。每个 Tauri crate 在 `app/src-tauri/Cargo.toml` 中通过 `[patch.crates-io]` 指向此 fork。Vendored `cargo-tauri` CLI 将 Chromium 正确捆绑到 `Contents/Frameworks/`;stock `@tauri-apps/cli` 会产生一个损坏的 bundle,在 `cef::library_loader::LibraryLoader::new` 中 panic。[`scripts/ensure-tauri-cli.sh`](../../scripts/ensure-tauri-cli.sh) 在 fork 比安装的二进制文件更新时重新安装 vendored CLI。 + +## CEF 今天用于什么 + +### 嵌入的第三方 webview + +每个作为托管 Web 应用运行的已连接提供商都有自己的子 CEF webview: + +* WhatsApp Web +* Telegram Web +* Slack +* Discord +* Google Meet +* LinkedIn +* Gmail +* Zoom +* browserscan + +每个账户的存储隔离到 `{app_local_data_dir}/webview_accounts/{id}/`。两个 Slack workspace,两个浏览器配置文件。代码:[`app/src-tauri/src/webview_accounts/mod.rs`](../../app/src-tauri/src/webview_accounts/mod.rs)。 + +### CDP 驱动的扫描器 + +每个提供商在 [`app/src-tauri/src/`](https://github.com/tinyhumansai/openhuman/tree/main/app/src-tauri/src) 中都有一个**扫描器模块**。每个扫描器持有到 CEF 的 `--remote-debugging-port=19222` 的长期 WebSocket,并按固定节奏 tick: + +| 扫描器 | 节奏 | 做什么 | +| ------------------ | ------------------------------- | -------------------------------------------------------------------- | +| `whatsapp_scanner` | 2s DOM tick + 30s 完整 IDB 遍历 | 读取消息存储、拉取媒体元数据 | +| `telegram_scanner` | 相同 | 额外加上 QR 登录 hand-off 到原生 Telegram Desktop | +| `slack_scanner` | 30s IDB 遍历 | 纯 IDB —— 无需 DOM 抓取 | +| `discord_scanner` | 定期 | 通过 CDP 的频道 + DM 状态 | +| `meet_scanner` | 定期 | 通话期间的实时字幕 + 参与者状态 | +| `imessage_scanner` | 定期 | **无 webview。** 在 macOS 上直接读取 `~/Library/Messages/chat.db` | + +每次扫描都会发出 `webview:event` payload,并直接向核心 RPC POST `openhuman.memory_doc_ingest`,因此无论 UI 窗口是否打开或后台运行,记忆都会增长。 + +### Google Meet mascot 摄像头 + +最炫的 CEF 技巧。Meet Agent 不只是"参加会议",它还**将自己广播为摄像头**。之所以能工作,是因为 CEF 允许我们: + +1. 在任何 Meet 代码运行前通过 `Page.addScriptToEvaluateOnNewDocument` 注入一个微小桥接 (`camera_bridge.js`)。 +2. 覆盖 `navigator.mediaDevices.getUserMedia`,使其从隐藏的 640×480 canvas 返回 `MediaStream`,而非真实摄像头。 +3. 在该 canvas 上渲染 mascot SVG,通过 Rust 经 CDP 驱动的 `window.__openhumanSetMood(...)` 交换情绪状态(idle、thinking、talking)。 + +还有一个构建时路径,将 mascot SVG 栅格化为 Y4M,并使用 CEF 的原生 `--use-file-for-fake-video-capture` flag,一个完全原生的 fake-camera 来源,完全不使用 JS。 + +代码:[`app/src-tauri/src/meet_video/`](https://github.com/tinyhumansai/openhuman/tree/main/app/src-tauri/src/meet_video)。 + +### 原生通知拦截 + +`feat/cef-notification-intercept` 上的 fork 为 `Notification.permission`、`Notification.requestPermission()` 和 `navigator.permissions.query({name: "notifications"})` 添加了渲染器端 shim。这些现在在每条运行时代码路径上都安装在真正的 `tauri-runtime-cef` 路径中,因此当 Slack 检查它是否可以显示通知时,答案与 CEF 的权限回调已经授予的内容一致。 + +这是 `docs/TAURI_CEF_FINDINGS_AND_CHANGES.md` 的大部分内容。这就是 Slack 在一次会话中不再五次询问相同权限的原因。 + +## "不注入新 JS" 规则 + +规则记录在 [`CLAUDE.md`](../../CLAUDE.md) 中:**迁移的提供商以零注入 JavaScript 加载**。所有抓取都通过扫描器侧的 CDP 原生进行。 + +这很重要,因为任何在第三方来源内部运行的宿主控制代码都是攻击面责任。Slack 内部的持久 JS 桥接离失效只有一个 Slack 更新之遥,离通过攻击者控制的 JS 泄露桥接只有一个错误之遥。从渲染器外部的 CDP 严格更好。 + +| 提供商 | 已迁移? | 启动时加载什么 | +| ----------- | ------------- | -------------------------------- | +| WhatsApp | ✅ | 零 JS | +| Telegram | ✅ | 零 JS | +| Slack | ✅ | 零 JS | +| Discord | ✅ | 零 JS | +| browserscan | ✅ | 零 JS | +| Gmail | grandfathered | 遗留 `runtime.js` 桥接 | +| LinkedIn | grandfathered | 遗留 `LINKEDIN_RECIPE_JS` | +| Google Meet | grandfathered | 摄像头 + 音频 + 字幕桥接 | + +遗留注入应该缩小,永远不要增长。新提供商直接走 CDP-only 路径。 + +## CEF 预热 + +一个隐藏的 CEF webview (`cef-prewarm`) 在应用启动时启动浏览器,因此当用户点击时第一个子 webview 立即生成。它在 `cef::shutdown()` 前被拆除以避免退出时的竞争。见 `app/src-tauri/src/lib.rs` 中 prewarm + 关闭生命周期附近的代码。 + +## Windows 启动诊断 + +CEF 在 onboarding UI 能够从渲染器故障中恢复之前初始化。如果 Windows 用户报告静默退出、永久的 "Connecting..." 转圈,或在第一个交互窗口出现前的 `tauri-runtime-cef` 断言,请在 issue 中询问这些细节: + +* Windows 版本和完整构建号,特别是 Insider 构建。 +* OpenHuman 版本和安装包类型(`.msi` 或 `.exe`)。 +* 重试前是否将 `%LOCALAPPDATA%\com.openhuman.app` 移到了一边。 +* `[startup]`、`[cef-profile]` 和 `[cef-startup]` 的启动日志行。 +* 任何命名 `tauri-runtime-cef/src/lib.rs` 的 panic 文本。 + +对于 Windows Insider 构建,还要确认相同的安装包是否在当前稳定版 Windows 发布上启动。这会将 profile/缓存问题与 CEF 启动中的 OS/运行时兼容性回归分开。 + +## Linux shell fallback(CEF 启动崩溃时) + +在某些 Linux 桌面上,特别是 NVIDIA 专有驱动设置下的 Wayland/XWayland,Tauri/CEF shell 可能在 React 应用变得可用之前的原生窗口配置期间失败。一个已知症状是 CEF 报告主浏览器上下文后的 X11 `BadWindow` 错误。 + +当核心本身健康时,你可以通过分别运行核心和前端来继续开发: + +```bash +cargo build --bin openhuman-core +./target/debug/openhuman-core run --port 7788 +``` + +在另一个终端: + +```bash +cd app +pnpm dev +``` + +在常规浏览器中打开 Vite URL,选择 **Advanced** / remote core 模式,将 RPC URL 设置为 `http://127.0.0.1:7788/rpc`,并使用核心写入的 bearer token。这会绕过原生专属功能,如托盘、自动更新和嵌入提供商 webview,但保持智能体、记忆、技能和 RPC 表面可用于调试。 + +## 插件审计 + +添加到 `app/src-tauri/src/lib.rs` 的任何新内容都必须审计 `js_init_script` 调用。`tauri-plugin-opener` 默认附带一个 init 脚本 (`init-iife.js`),添加了一个全局点击监听器;我们将其配置为 `.open_js_links_on_click(false)`,使其不在第三方 webview 内运行。`tauri-plugin-notification` 的 init 脚本同样从 vendored 副本中删除。 + +## 这里可以如何演进 + +CDP 表面是通用的。今天它为固定列表的提供商提供记忆摄入;同样的原语可以做更多。 + +### 浏览器自动化作为一等智能体工具 + +今天智能体有[原生工具](../features/native-tools/README.zh-CN.md)用于文件系统、git、网页搜索和网页获取。下一个明显的工具是**"驱动真实浏览器会话"**:登录用户已认证过的 SaaS,填写表单,抓取分页表格,下载导出。 + + plumbing 已经存在。`@openhuman/browser_task` 技能可以启动一个专用 CEF webview,通过 CDP 从核心驱动它,并将结果作为工具调用展示。用户现有的每账户配置文件意味着无需重新认证。 + +### Headless CEF 用于服务端回放 + +同样的扫描器模式(长期 WebSocket → IDB 遍历 + DOM snapshot)无需 UI 即可工作。核心 sidecar 中的 Headless CEF 可以按计划回放会话,适用于在云端托管核心并希望从不暴露干净 OAuth API 的来源自动获取的用户。 + +### 浏览器进程层的隐私 hook + +CEF 的 `CefRequestHandler` 已经允许我们拦截网络请求。从"拦截并记录"到"拦截并重写"只有一小步:广告拦截、跟踪器拦截、每个提供商的 DNS 固定、请求重写。隐私作为一等浏览器功能,而非每个来源内泄漏的 JS shim。 + +### CDP 驱动的测试框架 + +扫描器模式、生成 webview、遍历 IDB、snapshot DOM、评估一个短暂表达式,在结构上与 E2E 测试编排相同。我们可以将 `@openhuman/web_test` 作为公共技能发布:`connect_cef → snapshot → evaluate → assert`。用纯 Rust 针对任何 Web 应用编写的测试,无需 Selenium / Playwright 依赖。 + +### 渲染器 ↔ Rust 消息通道 + +今天每个 CDP `Runtime.evaluate` 都是 fire-and-forget。从渲染器到 Rust 的长期双向通道(Tauri 为主机应用做 IPC 的方式)将解锁流式用例:实时打字检测、实时选择/高亮跟踪、主动推送。设计它时不违反"第三方来源中不允许持久 JS 桥接"规则是有趣的约束。 + +### 多账户合并 + +每个连接账户都有自己的配置文件和自己的 IDB。CDP 可以 snapshot 一个账户的 IDB,与另一个账户的解密合并,并 upsert 到共享的记忆文档中,例如跨三个 workspace 的统一 Slack 记忆。 + +## 另请参阅 + +* [`docs/TAURI_CEF_FINDINGS_AND_CHANGES.md`](../../docs/TAURI_CEF_FINDINGS_AND_CHANGES.md)。通知权限深度解析。 +* [`CLAUDE.md`](../../CLAUDE.md)。权威的"不注入新 JS"规则。 diff --git a/gitbooks/developing/integrations/polymarket.zh-CN.md b/gitbooks/developing/integrations/polymarket.zh-CN.md new file mode 100644 index 000000000..39803f8a6 --- /dev/null +++ b/gitbooks/developing/integrations/polymarket.zh-CN.md @@ -0,0 +1,128 @@ +--- +lang: zh-CN +--- + +# Polymarket 集成(读取 + 交易) + +本文档描述 issue #1398 的 Polymarket 集成。 + +## 范围 + +`polymarket` 工具现在支持以下 API 上的市场浏览和交易工作流: + +- Gamma API (`https://gamma-api.polymarket.com`) +- CLOB API (`https://clob.polymarket.com`) + +支持的读取操作: + +- `list_markets` +- `get_market` +- `list_events` +- `get_orderbook` +- `get_price` +- `get_positions` +- `get_balance` +- `get_open_orders` +- `get_usdc_allowance` + +支持的写入操作: + +- `place_order` +- `cancel_order` + +## 架构 + +实现位于 `src/openhuman/tools/impl/network/polymarket.rs`,辅助模块包括: + +- `clob_auth.rs`:L1 凭据派生 + L2 HMAC 头 +- `polymarket_orders.rs`:EIP-712 订单类型数据签名 + +关键运行时行为: + +- Layer-2 API 凭据在首次认证调用时派生并缓存。 +- 派生凭据持久化到 `integrations.polymarket.derived_clob_credentials`(在 secret-store 迁移落地前使用明文配置 fallback)。 +- 下单前获取 `GET /nonce?user=` 以避免重放/nonce 不匹配。 +- USDC.e 授权通过 Polygon `eth_call` 对 ERC-20 `allowance(owner, spender)` 进行读取。 + +## 认证与签名流程 + +### L1 握手(一次性引导) + +- 使用 Polygon chain id `137` 签署 CLOB `ClobAuth` EIP-712 payload。 +- 调用 `POST /auth/api-key`;如需,fallback 到 `GET /auth/derive-api-key`。 +- 持久化返回的 `{ apiKey, secret, passphrase }` 以供 L2 使用。 + +### L2 认证请求 + +每个认证的 CLOB 请求签署: + +- `timestamp + method + request_path (+ POST 的 body)` + +Headers: + +- `POLY_ADDRESS` +- `POLY_SIGNATURE` +- `POLY_TIMESTAMP` +- `POLY_NONCE: 0` +- `POLY_API_KEY` +- `POLY_PASSPHRASE` + +### 订单签名 + +`place_order` 使用以下 domain 签署 EIP-712 订单: + +- name: `Polymarket CTF Exchange` +- version: `1` +- chain id: `137` +- verifying contract: `integrations.polymarket.clob_exchange_contract` + +## 权限 + +写入操作目前由显式的临时审批 flag 保护。 + +- `place_order` 和 `cancel_order` 需要 `approved=true`。 +- 如果省略或 `false`,工具返回: + - `Polymarket write requires explicit user approval. Re-invoke with arguments.approved = true after confirming with the user.` + +这是临时的,直到 #1339 的共享审批门禁集成进来。 + +## 配置 + +配置路径:`integrations.polymarket`。 + +字段: + +- `enabled`(默认 `false`) +- `gamma_base_url`(默认 `https://gamma-api.polymarket.com`) +- `clob_base_url`(默认 `https://clob.polymarket.com`) +- `timeout_secs`(默认 `15`) +- `eoa_address`(可选默认用户地址) +- `polygon_rpc_url`(默认 `https://polygon-rpc.com`) +- `usdc_contract`(默认 `0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174`) +- `clob_exchange_contract`(默认 `0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E`) +- `derived_clob_credentials`(可选缓存的 L2 凭据) + +## USDC Allowance 合约 + +`get_usdc_allowance` 仅报告授权状态;不改变链上状态。 + +- Token:Polygon 上的 USDC.e (`0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174`) +- Spender:Polymarket exchange (`0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E`) + +如果授权不足,必须单独执行审批(wallet 工具 / 显式用户审批流程)。 + +## 错误与重试行为 + +- 4xx 错误视为客户端错误,不重试。 +- 429 和 5xx 错误视为瞬态错误,最多重试 3 次。 +- 退避固定为每次重试间隔 500ms。 +- 超时表现为显式的 deadline 错误。 + +## 测试策略 + +单元测试位于 `src/openhuman/tools/impl/network/polymarket_tests.rs` 及辅助模块测试中。 + +- 现有读取路径和重试行为测试保持覆盖。 +- 新增认证读取操作、写入审批门禁和 Polygon 授权读取的覆盖。 +- `clob_auth.rs` 测试覆盖 HMAC/头 fixture 行为。 +- `polymarket_orders.rs` 测试覆盖 domain 和确定性签名 fixture 行为。 diff --git a/gitbooks/developing/mcp-server.zh-CN.md b/gitbooks/developing/mcp-server.zh-CN.md new file mode 100644 index 000000000..eafec726a --- /dev/null +++ b/gitbooks/developing/mcp-server.zh-CN.md @@ -0,0 +1,87 @@ +--- +description: 将 OpenHuman Core 作为只读 stdio Model Context Protocol 服务器运行。 +icon: plug +lang: zh-CN +--- + +# MCP 服务器 + +OpenHuman Core 可以作为可选的 stdio MCP 服务器运行,供 Claude Desktop、Cursor 或 Zed 等本地 MCP 客户端使用。 + +```bash +openhuman-core mcp +``` + +该命令不会启动 HTTP JSON-RPC 服务器。它从 stdin 读取换行分隔的 JSON-RPC 2.0 消息,并将 MCP 响应写入 stdout。日志输出到 stderr;添加 `--verbose` 以获得调试输出。 + +## 客户端来源 + +在 `initialize` 期间,MCP 服务器捕获 stdio 会话的 `params.clientInfo.name`。名称通过以下方式规范化:修剪首尾空白,转换为小写,将每个非 ASCII 字母数字字符序列替换为单个连字符,然后修剪首尾连字符。例如,`Claude Desktop` 变为 `claude-desktop`,`Cursor` 变为 `cursor`,`Windsurf` 变为 `windsurf`。 + +如果客户端省略了 `clientInfo.name`、发送空值,或发送一个规范化后结果为空的名称,会话会回退到裸的 `mcp` 来源标签。可写的 MCP 工具应使用此会话来源标签作为记忆来源,以便旧客户端保持现有的 `mcp` 行为,而可识别客户端可以作为 `mcp:` 写入。 + +## 工具 + +MCP 表面经过精心设计为只读,并通过现有的控制器注册表以及核心安全策略的读取门禁: + +| MCP 工具 | 背后的 RPC | 用途 | +| --- | --- | --- | +| `searxng_search`* | `openhuman.tools_searxng_search` | 搜索配置的自托管 SearXNG 实例。 | +| `memory.search` | `openhuman.memory_tree_search` | 对记忆树块进行关键词搜索。 | +| `memory.recall` | `openhuman.memory_tree_recall` | 对记忆树摘要/块进行语义召回。 | +| `tree.read_chunk` | `openhuman.memory_tree_get_chunk` | 读取搜索或召回返回的一个块。 | +| `tree.browse` | `openhuman.memory_tree_list_chunks` | 分页块列表,支持来源/实体/时间过滤。 | +| `tree.top_entities` | `openhuman.memory_tree_top_entities` | 引用最多的规范化实体,可选按类型过滤。 | +| `tree.list_sources` | `openhuman.memory_tree_list_sources` | 不同的摄入来源及其块计数和最后活动时间戳。 | + +* 仅在启用 SearXNG 时存在 `searxng_search`。 + +`searxng_search` 在启用 SearXNG 时加入 MCP 目录。它接受 `query`、可选的 `categories`(`web`、`news`、`images`)、可选的 `language`,以及可选的 `max_results`(1-50)。 +`memory.search` 和 `memory.recall` 接受 `query` 加可选的 `k`(默认 10,上限 50)。`tree.read_chunk` 接受 `chunk_id`。`tree.browse` 接受可选的 `source_kinds`、`source_ids`、`entity_ids`、`since_ms`、`until_ms`、`query`、`k` 和 `offset`。`tree.top_entities` 接受可选的 `kind` 和 `k`。`tree.list_sources` 接受可选的 `user_email_hint`。 + +在 `config.toml` 或通过环境变量启用 SearXNG: + +```toml +[searxng] +enabled = true +base_url = "http://localhost:8080" +max_results = 10 +default_language = "en" +timeout_seconds = 10 +``` + +```bash +OPENHUMAN_SEARXNG_ENABLED=true +OPENHUMAN_SEARXNG_BASE_URL=http://localhost:8080 +OPENHUMAN_SEARXNG_MAX_RESULTS=10 +OPENHUMAN_SEARXNG_DEFAULT_LANGUAGE=en +OPENHUMAN_SEARXNG_TIMEOUT_SECONDS=10 +``` + +## 工具注册表 + +HTTP JSON-RPC 服务器还暴露一个只读的全局工具注册表,供需要发现元数据而不打开 MCP stdio 会话的智能体和仪表板使用: + +| RPC 方法 | 用途 | +| --- | --- | +| `openhuman.tool_registry_list` | 列出 MCP stdio 工具和控制器支持的工具,包含稳定的 `tool_id`、路由、版本、输入/输出 schema、允许的智能体、标签、启用状态和健康状况。 | +| `openhuman.tool_registry_get` | 通过 `tool_id` 返回一个注册表条目,例如 `memory.search` 或 `tools.web_search`。 | + +注册表仅用于发现。它不改变工具分派或权限检查;MCP 调用仍通过 `tools/call`,控制器支持的工具仍通过其现有的 JSON-RPC 方法路由。 + +## 冒烟测试 + +```bash +printf '%s\n' \ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}' \ + '{"jsonrpc":"2.0","method":"notifications/initialized"}' \ + '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \ + | openhuman-core mcp +``` + +响应应包含来自 `initialize` 的 `capabilities.tools` 和来自 `tools/list` 的精选工具名称。成功的运行向 stdout 写入恰好两行紧凑的 JSON 响应;`notifications/initialized` 消息是通知,没有响应。 + +```json +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"openhuman-core","version":""},"instructions":"..."}} +{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"memory.search",...},{"name":"memory.recall",...},{"name":"tree.read_chunk",...},{"name":"tree.browse",...},{"name":"tree.top_entities",...},{"name":"tree.list_sources",...}]}} +``` diff --git a/gitbooks/developing/release-policy.zh-CN.md b/gitbooks/developing/release-policy.zh-CN.md new file mode 100644 index 000000000..23b5701fb --- /dev/null +++ b/gitbooks/developing/release-policy.zh-CN.md @@ -0,0 +1,81 @@ +--- +description: 发布节奏、版本策略、OAuth 与安装包规则。发布是如何运作的。 +icon: ship +lang: zh-CN +--- + +# 发布策略:最新桌面构建与 OAuth + +本 runbook 描述了我们如何避免用户在**过时的桌面安装包**上完成 **OAuth**(包括 **Gmail**),而规范流程始终要求**最新**发布版本。 + +## 分发 + +- [tinyhumansai/openhuman](https://github.com/tinyhumansai/openhuman/releases) 的 **GitHub Releases** 是桌面构建的主要来源。 +- **Tauri 更新器**端点(见 `scripts/prepareTauriConfig.js` 和发布工作流)应将用户指向当前发布产物。 +- **淘汰旧稳定版产物:** 当弃用一条发布线时,在 **GitHub Releases** 上移除或隐藏过时的安装包资源,将 **网站 / CDN** 下载链接更新为 **releases/latest**(或当前版本),刷新**更新器 manifest**(例如 Gist / `latest.json`)使其不再指向已弃用的构建,并抽查旧直接 URL 在适当位置是否被**重定向、返回 404 或 410**。验证方式:尝试从文档或书签中已知的旧资源 URL,确认它们不再提供主要安装路径。 + +## OAuth 最低应用版本 + +生产 Web 构建在**构建时**嵌入一个**最低支持的应用 semver**,使 OAuth 深度链接无法在已弃用的二进制文件上完成。每个安装包携带构建时设定的 floor;对于从不升级的用户,提高 floor 需要他们安装一个**新**的发布版本(或通过应用内更新)。可选的未来工作:仅通过**运行时** API 强制执行移动的最低版本,捆绑值仅作为 fallback。 + +| 变量 | 用途 | +| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | +| `VITE_MINIMUM_SUPPORTED_APP_VERSION` | 例如 `0.51.0` —— 桌面应用必须 **≥** 此版本才能完成 `openhuman://oauth/success`。 | +| `VITE_LATEST_APP_DOWNLOAD_URL` | 可选;默认为 `https://github.com/tinyhumansai/openhuman/releases/latest`。当门禁阻止 OAuth 时打开。 | + +将这些配置为 **GitHub Actions 变量**。它们必须同时存在于独立的 **`pnpm build`** 步骤和 **`.github/workflows/build-desktop.yml`** 中的 **`tauri-apps/tauri-action`** 步骤环境变量中(由 `release-production.yml` / `release-staging.yml` 调用的可重用矩阵)以及 `build-windows.yml`,以便嵌入已发布安装包的 Vite bundle 包含该门禁。本地开发时保持 `VITE_MINIMUM_SUPPORTED_APP_VERSION` **未设置**(门禁禁用)。 + +实现:`app/src/utils/oauthAppVersionGate.ts`、`app/src/utils/desktopDeepLinkListener.ts`。 + +## Gmail / Google Cloud OAuth + +- Google Cloud Console 中的 **Redirect URIs** 必须匹配**当前**后端 + 隧道回调路径。 +- 桌面 scheme(`openhuman://`)是稳定的;当 `VITE_MINIMUM_SUPPORTED_APP_VERSION` 设置时,**已安装的二进制文件**必须满足最低版本。 + +## 发布清单(避免回归) + +1. 按照现有版本工作流提升 `app/package.json` 和 `app/src-tauri/tauri.conf.json`(以及根目录 `Cargo.toml` / core)的版本。 +2. 当弃用对旧安装包的支持时,在该发布**之前**或**同时**将 **`VITE_MINIMUM_SUPPORTED_APP_VERSION`** 设置为新的 floor(仓库 Actions 变量 + 上述两个工作流步骤)。 +3. 从用户可见表面(GitHub Release 资源、网站、CDN、更新器 feed)移除、重定向或淘汰旧稳定版安装包和陈旧**更新器**条目。确认已弃用的资源无法从默认安装/更新流程中访问。 +4. 从 **releases/latest** 的全新安装上冒烟测试 **Gmail 连接**。 +5. 完成[手动冒烟清单](../../docs/RELEASE-MANUAL-SMOKE.md),然后将完成的签字块(逐字复制,每个已勾选项目保持勾选)粘贴到发布 PR 描述中,然后再打 tag。 + +## 工作流:staging vs. production + +两个一等 GitHub Actions 工作流,每个环境一个。按意图选择,而非切换 flag。 + +| 工作流 | 分支 | 提升 | 推送的 Tags | 并发组 | 使用场景 | +| ------------------------------------------------------- | --------- | ------- | -------------------------- | ----------------------- | --------------------------------------------------------------------- | +| [`release-staging.yml`](../../.github/workflows/release-staging.yml) | `main` | 仅 `patch` | `v-staging` | `release-staging` | 为 QA 切割 staging 构建。运行频繁;semver 移动范围窄。 | +| [`release-production.yml`](../../.github/workflows/release-production.yml) | `main` | `patch` / `minor` / `major`(仅在 `main_head` 上) | `v` | `release-production` | 提升已验证的 staging tag,或从 `main` HEAD 热修。 | + +两个流程使用的矩阵构建 / 签名 / Sentry-DIF / 产物上传流水线位于 [`.github/workflows/build-desktop.yml`](../../.github/workflows/build-desktop.yml) 中,作为 `workflow_call` 可重用工作流。上述两个顶层工作流拥有 ref 解析、版本提升、tagging 和发布/清理;构建本身是共享的。 + +### 切割 staging 构建 + +1. 通过 `workflow_dispatch` 从 `main` 运行 **Release (Staging)**。 +2. 工作流在 `main` 上提升 `patch`,commit `chore(staging): vX.Y.Z`,推送分支,并在该 commit 上创建不可变的 `vX.Y.Z-staging` tag。 +3. 构建矩阵从 **tag**(而非 main HEAD)运行,因此即使 `main` 已经前进,rerun 也会重建字节相同的内容。 +4. 失败时 staging tag 会被自动删除;`main` 上的提升 commit 保留,因此下一次切割从 `vX.Y.(Z+1)` 继续。 + +没有单独的 `staging` 分支,staging 切割和 production 提升都存在于 `main` 上。两者仅通过 tag 后缀(`-staging` vs 无)和创建工作流来区分。 + +### 提升为 production(默认流程) + +1. 通过 `workflow_dispatch` 以 `release_source = staging_tag`(默认)运行 **Release Production**。 +2. 留空 `staging_tag` 以提升最新的 `v*-staging`,或传入显式 tag(例如 `v1.2.4-staging`)以固定版本。 +3. 工作流去除 `-staging` 后缀,在同一 commit 上创建 `v`,并从该 tag 运行 production 构建矩阵。**不再提升版本**,产物复用 staging 已验证的内容。 + +### 从 `main` HEAD 热修 + +1. 通过 `workflow_dispatch` 以 `release_source = main_head` 和所需的 `release_type`(`patch` / `minor` / `major`)运行 **Release Production**。 +2. 工作流运行遗留的提升-and-tag 路径:在 `main` 上提升,commit `chore(release): vX.Y.Z`,推送,tag `vX.Y.Z`,构建。 +3. 仅当需要不经过 staging 的 production-only 修复时才使用此路径。 + +### Tag 策略与回滚 + +- **命名。** Staging tag 使用 SemVer 预发布后缀 `-staging`(`v1.2.4-staging`),因此它们在排序上位于匹配的 production tag *之前*。提升到 production 时逐字去除后缀;两个 tag 之间捆绑安装包中嵌入的版本是相同的。 +- **冲突。** 如果目标 tag 已存在于本地或 `origin` 上,两个工作流都会快速失败。通过删除陈旧 tag(仅限组织维护者)或跳过它来解决。 +- **回滚(production)。** 失败的构建矩阵会触发 `cleanup-failed-release`,删除草稿 GitHub Release 和 `v` tag。它从中提升的 staging tag 保持不变,修复后可以重新提升。 +- **回滚(staging)。** 失败的 staging 构建会删除 `v-staging` tag。`main` 上的提升 commit 保留;下一次 staging 切割从新的 patch 号继续,而不是重新使用它(我们接受 patch 号中的一个小"缺口",而不是与并发合并竞争)。 +- **谁可以删除 tag。** 与 `main` 相同的写入权限。工作流驱动的清理通过工作流的 token 使用 `actions/github-script` 运行删除(GitHub App token 仅由 `prepare-build` 用于提升 commit + tag 推送);手动删除(`git push --delete origin `)需要同等的维护者权限。