开发 Nemu 的第二天:从会说话,到受控地看懂钉钉

第一天,我为 Nemu 建好了 Context、Personal Memory 和 Private Knowledge。第二天的任务,是让这些本地能力第一次进入真实生成式对话,同时回答一个更难的问题:当 Windows UI Automation 看不懂钉钉聊天时,是否应该引入 Screenshot 和 Vision?
2026 年 8 月 27 日,Nemu 从 model_calls = 0 走到了第一条可验收的 Conversation Intelligence 闭环。
但这一天最重要的变化并不是"终于接上了 DeepSeek"。真正重要的是,我们没有让模型调用变成一条从 UI 直通公网的捷径:API Key、当前上下文、长期记忆、私人知识和 Citation 都各自经过独立边界;下午面对 UIA 的现实失败,也没有把"持续截图"当成默认解法。
这一天最终形成了两条主线:

上午:让模型会说话之前,先决定什么不能发出去
第一天结束时,Nemu 已经有三类很有价值、也很敏感的数据:
- 当前 Windows Context;
- 关于用户的 Personal Memory;
- 用户授权的 Obsidian / PDF / DOCX / TXT Knowledge。
最简单的模型接入方式,是把它们拼成 Prompt,然后调用一个 HTTP API。但这也是最危险的方式:业务层会逐渐知道 Provider 细节,API Key 可能流入 Renderer 或日志,本地完整 Context 可能在不知情时被上传,Memory 和 Knowledge 也可能在 Prompt 中混成一团。
所以 Stage 7 没有从"写一个 DeepSeek Client"开始,而是拆成了五条可以分别验收的安全链路。
1. Provider-neutral ModelGateway
Conversation 业务层不直接调用 DeepSeek HTTP Contract。真实路径是:

ModelGateway 统一处理 timeout、cancel、retry、rate limit、异常去敏和 structured output validation。DeepSeek 只是 Adapter,业务 Service 不持有 Provider Secret,也不依赖某一家模型的数据结构。
这意味着以后替换 Provider 时,不需要重写 Conversation Assistant;更重要的是,所有云端调用仍然必须经过同一个 Policy 入口。
2. API Key 只能单向进入安全存储
Renderer 可以提交 API Key,但永远不能把它读回来。
Windows 上的凭证路径是:
text
Renderer 一次性提交
↓ trusted IPC
Electron Main
↓ safeStorage / DPAPI
Encrypted credential file
真实调用时,Main 只进行一次性解密,再通过带认证的 loopback bridge 把临时 Credential 交给受管 Python Core。明文 Key 不进入:
- Renderer Snapshot;
- model-settings JSON;
- Runtime 状态;
- Prompt;
ModelRequest;- ordinary log。
这里也没有夸大 Windows safeStorage 的安全性:它主要隔离其他 Windows 用户,并不能抵御同一登录用户权限下的恶意进程。
3. 配置了模型,不等于允许数据外发
Stage 7 的 Cloud Gate 要同时满足:
text
Model enabled
AND Tutor enabled
AND Credential configured
AND privileged Core available
AND Cloud Egress enabled
AND CONVERSATION_TEXT explicitly allowed
Conversation、Current Context、Personal Memory 和 Knowledge Chunk 不是一个总开关。
| 数据类别 | 未授权时的行为 |
|---|---|
CONVERSATION_TEXT |
不发起本次生成 |
CURRENT_CONTEXT_SUMMARY |
不读取、不上传 Context projection |
PERSONAL_MEMORY |
本次不执行 Memory retrieval |
KNOWLEDGE_CHUNK |
本次不执行 Knowledge retrieval |
CREDENTIAL |
永远不进入普通模型请求 |
这条边界修复了一个非常现实的隐私旁路:早期 CurrentContext.current_task 可能直接来自 UIA 可见文本。最终 cloud-facing projection 不再发送 active_window、current_task 或 visible_text_excerpt,也不复用旧的任意 dict 作为 Prompt。
4. Memory 和 Knowledge 在 Prompt 中仍然是两种东西
TutorContextBuilder 不允许模型自由读取数据库,而是构建有界、分区的上下文:
xml
<current_context>...</current_context>
<conversation>...</conversation>
<personal_memory>...</personal_memory>
<knowledge>...</knowledge>
<uncertainties>...</uncertainties>
<policy>...</policy>
Personal Memory 检索也不能使用 scope=None,因为那会跨所有关系范围搜索。没有明确关系 scope 时只使用 global;有明确 scope 时才读取"该 scope + global",避免把另一个客户或朋友的长期记忆混入当前回复。
5. 模型不能伪造本地 Citation
模型只看到本次上下文里的短引用键:
text
K1 / K2 ... 代表 Knowledge
M1 / M2 ... 代表 Personal Memory
返回结构固定为:
json
{
"suggested_text": "...",
"citation_keys": ["K1"],
"memory_refs": ["M1"]
}
模型返回后,Nemu 会再次检查 K#/M# 是否属于本次 exact Tutor Package,再在本地映射回相对 File、Heading、Chunk、Page 或 Line。invented、stale 或 duplicate 引用都会 fail closed。
只有以下三层全部通过,结果才允许标记为 GENERATED:
text
Gateway SUCCESS
+ Structured Schema PASS
+ Local K#/M# Validation PASS
DENIED / FALLBACK / ERROR 不会伪造一段"看起来像模型生成"的文本来掩盖失败。
第一条真实闭环:建议、编辑、反馈,但不自动发送
Stage 7 的桌面链路最终变成:

UI 中不存在 Send、Click 或 Execute 按钮。用户只能审阅、编辑、复制或反馈建议。
一次成功生成会创建 30 分钟有效、内存保存的 feedback_ref。普通反馈只记录 outcome、bounded edit summary,以及使用的 Memory/Knowledge 数量,不保存原始生成文本和最终编辑全文。
"将这次编辑作为本地学习证据"默认关闭。只有用户明确打开,并且结果是 EDITED_THEN_USED 时,才允许写入 USER_CORRECTION Evidence;它仍要经过 MemoryPolicy,而且一次编辑不会自动升级成长期 Style 或 Preference。
这条规则看起来保守,却很关键:模型输出被用户改过一次,不代表系统已经理解了用户长期偏好。
Stage 7 的验收证据
最终 Windows E2E 使用本机 loopback Mock DeepSeek Endpoint,不访问真实公网,但其余路径全部走生产链路:Renderer、Main、一次性 DPAPI Credential、privileged Core bridge、真实本地 synthetic Knowledge Vault、Memory retrieval、DataEgressPolicy、ModelGateway、Citation mapping 和 Feedback。
最终记录:
| 项目 | 结果 |
|---|---|
| 运行时代码基线 | e318fa58562c914cf63083cd91e01b5a8dfe8158 |
| Windows CI | 33028191564 / Run #160 / SUCCESS |
| Final Artifact | 9629244063 |
| Artifact SHA-256 | efb041ba2177bfd0519fba32d009db1066a9149dfa777773cac81252f349a574 |
Artifact 人工复核确认:Memory 1/1、Knowledge 1/1、Citation 返回相对路径 项目/知识.md,Credential 和绝对 Vault 路径没有进入 Renderer,也不存在自动发送控件。
到上午 09:00,Stage 7 正式归档为 PASS。
下午:真实钉钉让"UIA-first"遇到了边界
Stage 8 的第一版需求仍然坚持 trust-first:用户主动请求、UIA-first、先预览再生成、缺关键事实时只问一个最高价值问题,不自动 Screenshot/OCR/Vision。
我们为钉钉设计了严格的 UIA Semantic Proof:不能因为 UIA 能读到一些 text_items[],就宣称已经识别出消息气泡、发送者和顺序。
Proof 要证明:
text
message node
→ sender / direction semantics
→ chronological order
→ stable local turn reference
→ group target reference
真实 Windows 证据给出的答案并不理想:测试版本的钉钉没有通过 UIA 暴露足够可靠的聊天正文语义。generic text collection 无法支撑产品级 Direct/Group Conversation Contract。
这是第二天最有价值的一次"失败"。它阻止了我们把"能抓到文字"包装成"能理解对话"。
没有偷偷降级,而是做一次有边界的 Vision Spike
按照原 Contract,UIA 不足时应该 STOP/BLOCKED,不能静默升级 Screenshot、OCR 或 VLM。
之后,在 Product Owner 明确批准下,我们做了一次独立、一次性的 DingTalk HWND Screenshot + DeepSeek Vision Spike。它不是生产 PerceptionSource,也不改变已有 Policy。
Spike 的边界包括:
- 只允许当前前台、唯一匹配的钉钉顶层窗口;
- 使用 HWND 单窗口捕获,不截取整个桌面;
- 私有 PNG 与模型原始响应必须写在仓库外;
- Key 只从 Electron DPAPI Credential Store 解密;
- 必须再次显式确认图片 Cloud Egress;
- 不提供后台、周期性、静默截图或自动重试;
- 不执行 Send、Click、键盘输入或剪贴板监控。
实验返回 HTTP 200,模型提取出可见文本,但人工复核也发现了一处轻微 OCR 增字错误;而且当前 viewport 只包含 self 消息,因此没有证明 Direct 双向方向、回复关系或 Group 语义。
这让结论保持在正确的层级:
Vision 可行性得到证明,但 DingTalk 仍然不能因此标记为
SUPPORTED。
产品方向调整:从"每次点击读取"到三态助手
仅仅把"复制聊天文字"替换成"每次点击让模型看一次",产品价值仍然有限。经过新的产品决策,DingTalk Direct V1 获准增加一个受限的可选自动理解模式,但默认仍是发现后询问。
最终三态为:
text
关闭
发现后询问(推荐 / 默认)
自动理解(高级 Opt-in,仅单聊 Beta)

上图是 8 月 27 日冻结的产品视觉与权限 UX,不是已通过 E2E 的上线截图。
默认:发现后询问
本地 Gate 可以检测"似乎出现了新回复",但图片不会自动上传。只有用户点击"理解一下",才进入 Vision 授权与调用流程。
在识别成功前,界面不能提前显示聊天摘要,更不能猜测内容。
高级:自动理解
用户必须单独授权"钉钉本地视觉感知"和"自动图片 Cloud Egress"。只有本地 Gate 证明以下条件成立,才允许自动调用 Vision:
- 钉钉是当前前台窗口;
- 当前是已授权的单聊;
- viewport 接近实时尾部;
- 内容相对 baseline 确实是新变化;
- 变化已经稳定;
- 更像对方新消息,而不是 resize、动画、输入、切换会话或手动翻历史;
- 当前候选没有处理过;
- 用户没有在之后高置信度地自行回复;
- 用户没有正在输入;
- grace period、cooldown、budget 和 Privacy Policy 全部通过。

Vision 只负责解释已被本地确定性 Gate 确认的新证据,绝不负责发现变化。
这形成了一种刻意不对称的架构:

高频检测留在本地、廉价且确定;低频 Cloud Vision 只解释已确认事件,并且只发送最小必要聊天区域,而不是联系人列表、输入框、无关历史或整个桌面。
Living Companion Portal:重新分配桌面窗口职责
原来的 400×700 透明置顶窗口承载了太多东西。第二天下午冻结的新方向把桌面体验拆成三个原生窗口:
text
OrbWindow
只承担粒子球、状态、拖动和入口
PortalWindow
只承担当前消息理解与回复建议
ControlCenterWindow
承担设置、隐私、模型、记忆、知识和活动记录

这是 Product Owner 选定方向的早期概念图。图中的"实时监控"二态开关随后在架构复审中被替换为"关闭 / 发现后询问 / 自动理解"三态;文字规格才是行为 Source of Truth。
这个设计不只是换皮。它解决了几个桌面产品的真实问题:
- 常驻窗口只保留轻量粒子球;
- Portal 关闭后不能留下透明但拦截鼠标的原生窗口;
- Control Center 不再 always-on-top;
- 拖动由 pointer gesture + Main 移动窗口完成,避免整球
app-region: drag与点击冲突; - 透明区域需要
BrowserWindow.setIgnoreMouseEvents实现真正点击穿透,不能只写 CSSpointer-events: none; - Privacy Pause 的优先级高于所有助手模式。
这一天留下的数字
以 2026-08-27 23:13 的日终 commit 8d718ce 为准:
| 指标 | 结果 |
|---|---|
| 当日进入 main 的提交 | 17 |
| 相对第一天 Stage 6 归档的文件变化 | 90 files |
| 代码与资产变化 | +16,882 / -51 lines |
| 日终已跟踪文件 | 190 |
| Python 测试文件 | 40 |
| Python 测试函数 | 279 |
写作时对该日终快照进行了 fresh verification:
- Python Core + Screenshot Spike tests:
302 passed / 1 skipped; - Desktop TypeScript typecheck:通过;
- Vite/Electron production build:通过;
- Vision Spike Node safety tests:
6 passed / 0 failed。
这里仍然要区分测试层级:deterministic tests、synthetic Windows E2E、受授权的一次性真实聊天 diagnostic proof 和最终真实产品 Beta Gate,不是同一件事。Vision Spike 成功也不等于自动理解已经达到 SUPPORTED。
第二天最重要的四个结论
1. 接入模型不是加一个 API,而是增加一条数据出境路径
Provider Adapter 只是最小的一部分。Credential 生命周期、DataEgressPolicy、Prompt projection、structured validation、Citation mapping 和 failure semantics 才决定这条路径是否可信。
2. 可解释性必须在模型调用前设计
如果 K#/M#、本地映射和 exact Tutor Package validation 没有先定义,模型生成之后再补 Citation,最后往往只剩一串无法验证的"参考了你的资料"。
3. 一次用户编辑不是长期人格
Feedback 可以成为 Evidence,但必须是 bounded、可见、可撤销的 Evidence。长期 Style 至少需要多条同方向证据和用户确认,而不是一次 edit 就自动写入画像。
4. 实验失败可以改变架构,但不能绕过授权
UIA 证据不足,确实推动了 Vision 方向;但正确流程是先 STOP、记录事实、单独批准 Spike、验证可行性、冻结新 Contract,再进入生产实现。不能因为"产品需要"就让 Screenshot 变成静默 fallback。
下一篇:从 Gate 设计走向真实的钉钉单聊候选执行器
8 月 28 日的最新 main 已推进到本地 monitor、scheduler、Living Companion 原生 shell、Consent UX 和经过校准的 DingTalk Direct Vision candidate executor。
下一篇会继续回答:如何判断一段画面变化真的是"对方发了新消息",如何处理 burst、用户正在输入、已经自行回复、切换会话和手动翻历史,以及怎样证明默认模式绝不会自动上传图片。