From 4dbd9a978b6062cfed1cb1b9c7816728d7145adb Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Fri, 5 Jun 2026 23:00:53 +0800 Subject: [PATCH] Improve local evidence search and AI capabilities --- ...6-document-evidence-retrieval-kernel-v1.md | 1 + ...te-public-capability-plugin-registry-v1.md | 413 ++++++++++++ ...ference-resource-ingestion-job-index-v1.md | 540 ++++++++++++++++ package.json | 3 +- rust/crates/core-protocol/src/evidence.rs | 6 + rust/crates/core-protocol/src/tool.rs | 8 +- .../document-editor-adapter-runtime.js | 16 +- .../browser/document-resource-tab-runtime.js | 200 +++++- .../sidebar-page-ai-markdown-runtime.js | 28 + .../sidebar-page-ai-profile-runtime.js | 11 +- .../browser/sidebar-page-ai-render-runtime.js | 370 ++++++++--- .../browser/sidebar-page-ai-runtime.js | 158 ++++- .../sidebar-page-ai-session-runtime.js | 100 ++- .../browser/sidebar-page-ai-skill-runtime.js | 57 +- .../browser/sidebar-page-ai-target-runtime.js | 19 +- .../browser/sidebar-page-settings-runtime.js | 43 +- .../sidebar-tree-live-apply-runtime.js | 4 +- .../mnote-web/browser/sidebar-tree-runtime.js | 133 +++- .../mnote-web/src/hermes_tools/index.rs | 229 +++++++ .../mnote-web/src/hermes_tools/manifest.rs | 250 ++++++-- rust/crates/mnote-web/src/hermes_tools/mod.rs | 1 + .../mnote-web/src/hermes_tools/skill.rs | 143 ++++- rust/crates/mnote-web/src/routes/evidence.rs | 298 ++++++++- .../mnote-web/src/routes/hermes_client.rs | 546 ++++++++++++++++ .../mnote-web/src/routes/hermes_tools.rs | 202 +++++- .../src/routes/local_folder_source.rs | 204 +++++- .../src/routes/local_search_index.rs | 591 +++++++++++++++++- rust/crates/mnote-web/src/routes/mod.rs | 15 +- rust/crates/mnote-web/src/routes/search.rs | 302 +++++++++ rust/crates/mnote-web/src/routes/tree.rs | 11 + rust/crates/mnote-web/src/routes/web_shell.rs | 331 +++++++++- rust/crates/mnote-web/src/ssr/pages/layout.rs | 54 +- rust/crates/mnote-web/src/ssr/styles.rs | 402 ++++++++++-- .../src/tree_shell/filetree_renderer.rs | 15 +- .../src/tree_shell/picker_renderer.rs | 2 + scripts/prod-build-start.js | 449 +++++++++++++ scripts/reasonix-acp-wrapper.mjs | 65 +- .../task-hermes-page-ai-agent-skill-smoke.js | 7 + scripts/task-hermes-page-ai-queue-smoke.js | 7 + ...k-hermes-page-ai-runtime-controls-smoke.js | 7 + scripts/task-hermes-page-ai-smoke.js | 107 +++- ...-rust-web-wolai-visual-regression-smoke.js | 2 +- scripts/task490-runtime-surfaces-smoke.js | 7 + ...02-page-ai-agent-selector-context-smoke.js | 186 ++++-- ...k504-page-ai-history-agent-filter-smoke.js | 97 +++ ...sk520-page-ai-raw-resource-target-smoke.js | 7 + ...ai-onlyoffice-real-target-session-smoke.js | 7 + ...5-page-ai-mindmap-resource-target-smoke.js | 7 + ...document-evidence-liteparse-agent-smoke.js | 37 +- ...-local-search-result-open-locator-smoke.js | 195 ++++++ skills/mnote-document-evidence/SKILL.md | 16 - skills/mnote-local-index/SKILL.md | 33 + 52 files changed, 6415 insertions(+), 527 deletions(-) create mode 100644 design/07-ai/process/7-47-mnote-public-capability-plugin-registry-v1.md create mode 100644 design/07-ai/process/7-48-paperless-ngx-reference-resource-ingestion-job-index-v1.md create mode 100644 rust/crates/mnote-web/src/hermes_tools/index.rs create mode 100644 scripts/prod-build-start.js create mode 100644 scripts/task529-local-search-result-open-locator-smoke.js delete mode 100644 skills/mnote-document-evidence/SKILL.md create mode 100644 skills/mnote-local-index/SKILL.md diff --git a/design/07-ai/process/7-46-document-evidence-retrieval-kernel-v1.md b/design/07-ai/process/7-46-document-evidence-retrieval-kernel-v1.md index 4f332f16..2996238a 100644 --- a/design/07-ai/process/7-46-document-evidence-retrieval-kernel-v1.md +++ b/design/07-ai/process/7-46-document-evidence-retrieval-kernel-v1.md @@ -896,6 +896,7 @@ LiteParseProvider + MinerUProvider - Hermes tool manifest 已暴露 `mnote.evidence.search/read/open`,运行时 dispatch 到 evidence route helper。 - 2026-06-04 复核补强:`scripts/reasonix-acp-wrapper.mjs` 已注册 `mnote_evidence_search/read/open` 三个 Reasonix ACP 只读工具,并把它们转发到 Rust `mnote.evidence.search/read/open`;wrapper selftest 覆盖 evidence payload 继承 `rootUri` 与 evidence search 只读属性。 - 2026-06-04 真实服务验证:登录测试账号后,`/api/hermes/client/tools?scope=mnote&profile=reasonix` 已返回 `mnote.evidence.search/read/open`;`/api/hermes/client/skills?runtime=mnote&agentId=reasonix` 已返回启用的 `mnote-document-evidence` skill。 +- 2026-06-04 索引 skill 合并:公开 skill 改为 `mnote-local-index`,合并 evidence 检索与索引管理说明;`mnote-document-evidence` 仅保留为 `mnote.skill.read` 兼容别名,不再作为公开 skill 摘要展示。新增 `mnote.index.status/refresh/update_settings`,Reasonix ACP 对应 `mnote_index_status/refresh/update_settings`。 - 2026-06-04 真 Reasonix ACP 验证:带 `agentId=reasonix`、`contextRefs=[current_page, folder]`、local-folder `rootUri` 发起 `/api/hermes/client/runs`,SSE 中出现 `tool.started/tool.completed`,工具为 `mnote_evidence_search`,返回 `quote="Printer test page"`、`page=1`、`bbox`、`sourceMapPath` 和 `mnote.agent_run_receipt.evidence.v1`。 - 2026-06-04 修正:Reasonix wrapper 原先用字符串包含 `"error"` 判断工具失败,导致 `error:null` 的成功结果在 UI/SSE 中被标记成 `tool.failed`;已改为解析 JSON,仅 `ok:false` 或非空 `error` 才标记失败。 - Skill 约束明确要求回答保留 quote、source 和 openAction,禁止直接读取 `.mnote/index`、OCR sidecar 或自行拼接 URL。 diff --git a/design/07-ai/process/7-47-mnote-public-capability-plugin-registry-v1.md b/design/07-ai/process/7-47-mnote-public-capability-plugin-registry-v1.md new file mode 100644 index 00000000..d8e0ea7b --- /dev/null +++ b/design/07-ai/process/7-47-mnote-public-capability-plugin-registry-v1.md @@ -0,0 +1,413 @@ +# 7-47 MNote 公共 capability/plugin 注册表设计 v1 + +> 状态:process +> +> 目标:把当前分裂的 MNote builtin skill、mnote tool manifest、Hermes plugin、Reasonix wrapper 和 Page AI UI 开关收口为同一个“AI 能力”模型。对用户来说 skill / plugin / tool 都是“授予 AI 的能力”,UI 不应暴露实现层分类;实现层再把一个能力映射到说明书、工具、runtime adapter 和权限策略。本轮只整理 MNote 公共能力;Reasonix / Hermes 自带的 skills/plugins 维持现状,不纳入统一注册表迁移范围。 + +## 0. 用户口径 + +用户不需要理解 skill、plugin、tool 的区别。Page AI 设置中统一展示为“AI 能力”: + +- `当前页读取` +- `本地索引与证据检索` +- `本地文件编辑` +- `思维导图` +- `ONLYOFFICE 实时编辑` +- `纯聊天` + +每个能力只有一个主开关。展开后可以显示该能力包含的工具、上下文要求和读写权限,但这些是高级详情,不是主概念。 + +实现层映射: + +- `skill`:能力说明书,告诉 agent 什么时候用、怎么用。 +- `tool`:能力的可执行函数,真正落到 Rust runtime / kernel。 +- `plugin / adapter`:把同一套工具暴露给 Hermes ACP、Reasonix ACP 或其它 agent runtime。 +- `policy`:决定该能力是否可见、是否启用、是否允许写入。 + +后续 UI 文案统一使用“能力”,不再把 MNote builtin skill、Hermes skill、Reasonix skill、mnote tool 作为并列用户入口。 + +## 1. 当前盘点 + +### 1.1 MNote builtin skills + +当前入口:`rust/crates/mnote-web/src/hermes_tools/skill.rs` + +现有内置 skill: + +- `mnote-current-page` +- `mnote-local-index` +- `mnote-local-file` +- `mnote-onlyoffice-live` +- `mnote-mindmap` +- `mnote-chat-only` + +特点: + +- skill 是 Rust 静态注册,正文来自 `skills/*/SKILL.md`。 +- `/api/hermes/client/skills?runtime=mnote&agentId=...` 通过 `mnote_builtin_skills_payload()` 输出到 UI。 +- 每个 skill 现在已经带 `toolNames` 和 `requiresContextRefs`,但这些只是弱引用,不是一个正式 capability/plugin 合同。 +- `mnote.skill.read` 能懒加载正文;旧 `mnote-document-evidence` 已作为兼容别名映射到 `mnote-local-index`。 + +### 1.2 MNote tools + +当前入口:`rust/crates/mnote-web/src/hermes_tools/manifest.rs` + +现有工具大类: + +- skill/context:`mnote.skill.read`、`mnote.context.*` +- evidence/index:`mnote.evidence.*`、`mnote.index.*` +- doc/block/page/artifact:`mnote.doc.*`、`mnote.block.*`、`mnote.page.*`、`mnote.artifact.*` +- mindmap:`mnote.mindmap.*` +- office/onlyoffice:`mnote.office.*`、`mnote.onlyoffice.*` + +执行入口: + +- `/api/hermes/tools/mnote/manifest` +- `/api/hermes/tools/mnote/call` +- `execute_mnote_tool_call()` 统一做认证、profile 禁用检查、workspace 校验、capabilityScope 校验、写入 guard、audit、idempotency。 + +问题: + +- manifest 只有 plugin 总描述和扁平 tools,没有“哪个 tool 属于哪个公共能力包”的结构。 +- `/api/hermes/client/tools?scope=mnote&profile=...` 只展示扁平工具列表。 +- tool 开关写入 Hermes profile 的 `mnote.tools.disabled`,skill 开关写入 MNote SQLite user preference,两个开关域不同步。 + +### 1.3 Hermes plugin + +当前本机存在 `/home/lix/.hermes/plugins/mnote/`,包括: + +- `plugin.yaml` +- `__init__.py` + +现状: + +- 这是 Hermes 用户插件,不在 mnote repo 内。 +- `plugin.yaml` 只列出旧批次工具:`mnote_page_get/save/update_title/update_options`、`mnote_doc_*`、`mnote_block_*`、artifact 等。 +- 没有 `mnote_evidence_*`、`mnote_index_*`、mindmap、onlyoffice live 等新工具。 +- `__init__.py` 手写 Python wrapper,把 Hermes tool call 转发到 `/api/hermes/tools/mnote/call`。 + +问题: + +- Hermes 用户插件和 Rust manifest 已经漂移。 +- 新增 Rust tool 后不会自动进入 Hermes plugin。 +- 如果继续维护该插件,必须由 Rust manifest 生成 plugin.yaml / Python adapter,不能手写。 + +### 1.4 Reasonix wrapper + +当前入口:`scripts/reasonix-acp-wrapper.mjs` + +现状: + +- 手写 Reasonix ACP 可见工具名,例如 `mnote_evidence_search`、`mnote_index_status`。 +- 手写 `REASONIX_TOOL_TO_MNOTE_TOOL` 映射到 Rust `mnote.*` 工具。 +- 手写每个工具的 parameters。 + +问题: + +- 与 Rust manifest 重复。 +- 与 Hermes plugin 重复。 +- 新增/改名工具必须同步改 wrapper,否则 agent 看得到 skill 也无法调用工具。 + +### 1.5 Page AI UI + +当前入口: + +- skills panel:`rust/crates/mnote-web/browser/sidebar-page-ai-skill-runtime.js` +- runtime/tools panel:`rust/crates/mnote-web/browser/sidebar-page-ai-render-runtime.js` +- data load/toggle:`rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js` + +现状: + +- Skills 页能展示 MNote builtin skills、Reasonix skills、Hermes profile skills。 +- Runtime/高级页展示扁平 `mnote tools` 列表。 +- MNote skill 开关调用 `/api/hermes/client/skills/toggle`,写 SQLite user preference。 +- Tool 开关调用 `/api/hermes/client/tools/toggle`,写 profile YAML `mnote.tools.disabled`。 + +问题: + +- 用户想管理的是“索引能力包”,不是单独 skill 或一堆扁平工具。 +- 现在 UI 上 skill 和 tool 分两个地方,不能表达“启用索引 skill,同时启用对应工具”。 +- 无法像公共 skill 一样展示 plugin/capability 包的工具内容、权限、启停状态。 + +## 2. 设计判断 + +当前应该引入 `MnoteAiCapability` / `MnoteCapabilityPack`,而不是继续把 skill、tool、plugin 分开维护。代码中可叫 capability pack;UI 中只叫“AI 能力”。 + +定义: + +```rust +pub struct MnoteCapabilityPack { + pub id: &'static str, + pub title: &'static str, + pub description: &'static str, + pub agent_ids: &'static [&'static str], + pub read_only: bool, + pub requires_context_refs: &'static [&'static str], + pub skill_id: &'static str, + pub skill_content: &'static str, + pub tool_names: &'static [&'static str], + pub ui_kind: &'static str, // capability | chat | compat + pub category: &'static str, // mnote, office, resource, chat + pub public: bool, +} +``` + +原则: + +- 一个公共能力包可以包含一个 skill 和多个 tools。 +- UI 主要展示 capability pack,不再让用户先理解 skill/tool/plugin 三套概念。 +- agent prompt 仍注入短 skill/capability 摘要,正文仍走 `mnote.skill.read` 懒加载。 +- tool manifest 仍是执行真相,但每个 tool 必须能反查所属 capability pack。 +- Reasonix wrapper / Hermes plugin 都从同一份 capability/tool manifest 生成或动态注册。 + +## 3. 目标 API + +### 3.1 新增 capability 列表 + +`GET /api/hermes/client/capabilities?runtime=mnote&agentId=reasonix&profile=...` + +返回: + +```json +{ + "ok": true, + "runtime": "mnote", + "categories": [ + { + "name": "mnote", + "title": "MNote", + "capabilities": [ + { + "id": "mnote-local-index", + "title": "MNote local index", + "description": "Search local documents with evidence locators and manage local index scopes.", + "enabled": true, + "toggleable": true, + "readOnly": false, + "skillId": "mnote-local-index", + "toolNames": [ + "mnote.evidence.search", + "mnote.evidence.read", + "mnote.evidence.open", + "mnote.index.status", + "mnote.index.refresh", + "mnote.index.update_settings" + ], + "tools": [ + { + "name": "mnote.index.update_settings", + "kind": "write", + "status": "available", + "enabled": true, + "requiresWritePermission": true + } + ], + "requiresContextRefs": ["folder"], + "configScope": "user_sqlite+profile_tool_policy" + } + ] + } + ] +} +``` + +### 3.2 新增 capability toggle + +`PUT /api/hermes/client/capabilities/toggle` + +入参: + +```json +{ + "runtime": "mnote", + "profile": "reasonix", + "id": "mnote-local-index", + "enabled": true +} +``` + +行为: + +- 写入 MNote SQLite user preference:`ai.agent.mnote_builtin.skill.{id}.enabled` +- 对包内 tools 批量更新 profile `mnote.tools.disabled` +- 保留单 tool 开关作为高级功能,但 UI 默认展示包开关。 + +### 3.3 manifest 扩展 + +`/api/hermes/tools/mnote/manifest` 增加: + +```json +{ + "capabilities": [ + { + "id": "mnote-local-index", + "skillId": "mnote-local-index", + "toolNames": ["mnote.evidence.search", "mnote.index.status"] + } + ], + "tools": [ + { + "name": "mnote.index.status", + "capabilityId": "mnote-local-index", + "capabilityScope": ["index.read", "evidence.read"] + } + ] +} +``` + +## 4. UI 设计 + +### 4.1 Skills 页改为 AI 能力页 + +在 Page AI 的 Skills 页中,用户看到的是统一“AI 能力”列表,不再分 skill / plugin / tool: + +- 行标题:`MNote local index` +- 副标题:`索引 / 证据检索 · 6 tools · 需要 folder` +- 状态 chip:`只读` / `可写` / `部分工具关闭` / `只读上下文不可写` +- 主开关:启用/关闭整个能力包 +- 展开项:列出 tools,显示 read/write、enabled、status + +不新增单独 landing/settings 页;沿用当前 Skills 页即可,避免再散一处入口。 + +页面标题建议从 `Skills` 改为 `能力`;内部 source filter 可以保留 `MNote / Hermes / Reasonix`,但展示为能力来源,不作为用户要理解的能力类型。 + +### 4.2 Runtime 高级页保留扁平工具 + +Runtime 页继续保留 `mnote tools`,但作为高级调试面: + +- 默认按 capability 分组,而不是纯扁平列表。 +- 单 tool 开关仍保留,用于排查或临时禁用某个写工具。 +- 若 capability 关闭,包内工具显示 `disabled_by_capability`。 + +### 4.3 索引面板与能力包关系 + +Sidebar 的“索引设置”仍是用户直接管理索引范围的产品 UI;Page AI 的 `mnote-local-index` capability 是 agent 能力开关。 + +两者职责不同: + +- 索引设置面板:用户手动新增/删除/刷新索引范围。 +- MNote local index capability:允许 agent 使用工具帮用户查看、新增、刷新、删除索引范围。 + +## 5. Runtime 适配 + +### 5.1 Reasonix + +短期: + +- 保留 `scripts/reasonix-acp-wrapper.mjs`。 +- 但 wrapper 启动时请求 `/api/hermes/tools/mnote/manifest`,按 manifest 自动注册工具。 +- 保留手写 fallback,避免 mnote-web 未启动时 wrapper 不能初始化。 + +中期: + +- 删除 `REASONIX_TOOL_TO_MNOTE_TOOL` 手写表。 +- 工具名转换统一由函数生成: + - `mnote.index.status` -> `mnote_index_status` + - `mnote.evidence.search` -> `mnote_evidence_search` + +### 5.2 Hermes ACP + +短期: + +- MNote 继续通过 `AcpMnoteToolContext` 把 `availableSkills` 和 capability policy 交给 Hermes ACP。 +- 如果 Hermes ACP 原生不能消费 Rust manifest 注册工具,则仍依赖 `/home/lix/.hermes/plugins/mnote`。 + +中期: + +- 用 Rust manifest 生成 `/home/lix/.hermes/plugins/mnote/plugin.yaml` 和 Python adapter。 +- 生成内容覆盖当前手写旧插件,确保 Hermes plugin tools 与 Rust manifest 一致。 + +长期: + +- Hermes ACP 若支持从 host 动态接收 tools,则不再需要本机 Python plugin,只保留 Rust manifest。 + +## 6. 迁移方案 + +### Phase A:只加 registry,不改 UI 行为 + +- 新增 `hermes_tools/capability.rs`。 +- 将当前 `SKILLS` 迁到 `CAPABILITY_PACKS`,`skill_summaries_for_agent()` 从 pack 派生。 +- `manifest()` 给每个 tool 增加 `capabilityId`,并输出 `capabilities`。 +- 保持 `/client/skills`、`/client/tools` 响应兼容。 + +验收: + +- `mnote-local-index` 在 `/client/skills?runtime=mnote` 可见。 +- `/api/hermes/tools/mnote/manifest` 可看到 `capabilities[]`。 +- 旧 `mnote.skill.read` 不破。 + +### Phase B:UI 展示 capability pack + +- 新增 `/api/hermes/client/capabilities`。 +- Skills 页对 `runtime=mnote` 优先消费 capabilities。 +- 能力包行展示 tools 数、read/write、contextRefs。 +- Runtime 页 tools 按 capability 分组。 + +验收: + +- Page AI Skills 页中 `MNote local index` 像公共 skill 一样可见。 +- 展开能看到 `mnote.evidence.*` 和 `mnote.index.*`。 +- 开关 capability 后,下一次 run 的 `skillPreferences.mnote` 同步变化。 + +### Phase C:能力包开关驱动工具开关 + +- 新增 `/api/hermes/client/capabilities/toggle`。 +- 开关包时同步 skill preference 和 profile tool disabled 列表。 +- `page_ai_capability_policy()` 过滤 disabled capability,且 `execute_mnote_tool_call()` 对 disabled tool 继续硬拒绝。 + +验收: + +- 关闭 `mnote-local-index` 后 agent 不再看到该 skill 摘要。 +- 关闭后直接调用 `mnote.index.status` 返回 `mnote_tool_disabled` 或 capability disabled。 +- 再打开后恢复。 + +### Phase D:生成 Reasonix/Hermes adapters + +- Reasonix wrapper 从 manifest 自动注册工具。 +- Hermes plugin 从 manifest 生成或在启动时同步。 +- 删除手写工具表漂移。 + +验收: + +- 新增 Rust tool 后,只改 Rust manifest/capability pack,Reasonix/Hermes UI 与 runtime 均自动出现。 +- `node scripts/reasonix-acp-wrapper.mjs` selftest 覆盖 manifest 动态注册。 +- `hermes plugins list` / Hermes tool list 显示与 Rust manifest 一致。 + +## 7. 对 mnote-local-index 的落地形态 + +`mnote-local-index` 是第一批公共能力包试点: + +- skill:`skills/mnote-local-index/SKILL.md` +- tools: + - `mnote.evidence.search` + - `mnote.evidence.read` + - `mnote.evidence.open` + - `mnote.index.status` + - `mnote.index.refresh` + - `mnote.index.update_settings` +- requiresContextRefs:`folder` +- readOnly:`false` +- write guard:只 `mnote.index.update_settings` 写设置;必须 `dryRun/idempotencyKey`;共享只读禁止写。 +- UI 文案:`索引 / 证据检索 · 可管理索引范围` + +## 8. 非目标 + +- 不把索引设置面板挪进 Page AI。 +- 不让 agent 直接读写 `.mnote/index` 文件。 +- 不让 Hermes plugin 直接写 Convex 或绕过 Rust runtime。 +- 不在前端手写工具 schema。 +- 不把 Reasonix wrapper 作为长期 tool registry 真相。 + +## 9. 风险 + +- Hermes Python plugin 当前在 `/home/lix/.hermes/plugins/mnote`,不在 repo,自动生成前仍会漂移。 +- tool 开关现在是 profile 级,skill 开关是用户级;能力包 toggle 需要明确优先级。 +- `mnote-local-file` 是 agent 原生文件能力指导,当前没有对应 MNote 写工具,放入 capability pack 时需要标记为 `native_agent_tools`,避免误以为 MNote 提供文件 patch tool。 +- `mnote-chat-only` 不是业务能力包,应保留为 agent mode skill,不参与 tools。 + +## 10. 建议执行顺序 + +1. 先做 Phase A:Rust `MnoteCapabilityPack` registry,完全兼容现有接口。 +2. 再做 Phase B:UI 展示 capability pack,保留旧 skills/tools fallback。 +3. 再做 Phase C:能力包 toggle 批量控制 skill + tools。 +4. 最后做 Phase D:Reasonix/Hermes adapter 从 manifest 自动生成。 diff --git a/design/07-ai/process/7-48-paperless-ngx-reference-resource-ingestion-job-index-v1.md b/design/07-ai/process/7-48-paperless-ngx-reference-resource-ingestion-job-index-v1.md new file mode 100644 index 00000000..2f776929 --- /dev/null +++ b/design/07-ai/process/7-48-paperless-ngx-reference-resource-ingestion-job-index-v1.md @@ -0,0 +1,540 @@ +# 7-48 [process] Paperless-ngx Reference: Resource Ingestion / Job Ledger / Evidence Index v1 + +> 创建时间:2026-06-05 +> +> 当前状态:`PROCESS` +> +> Owner:07-ai / 03-rust-web / control-plane / 01-tree-first-graph-kernel +> +> 参考项目:`/mnt/Data1T/mnote/reference-code/paperless-ngx` +> +> 参考版本:`f56f29111` +> +> CodeGraph:已单独建立,`712 files / 16,952 nodes / 35,407 edges` +> +> 上位依据: +> - `/mnt/Data1T/mnote/ARCHITECTURE.md` +> - `/mnt/Data1T/mnote/CURRENT_ARCHITECTURE.md` +> - `/mnt/Data1T/mnote/design/07-ai/process/7-46-document-evidence-retrieval-kernel-v1.md` +> - `/mnt/Data1T/mnote/design/03-rust-web/done/3-25-local-folder-mineru-ocr-sidecar-v1.md` +> - `/mnt/Data1T/mnote/design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md` + +## 1. 第一结论 + +Paperless-ngx 对 MNote 最有价值的不是 Django / Angular / Celery 技术栈,而是三套工程结构: + +1. `PaperlessTask`:所有后台任务有统一账本,能记录来源、状态、耗时、输入、结果和用户是否已确认。 +2. `consume_file` plugin pipeline:资源导入、预检、解析、OCR、存储、索引、通知按阶段推进,失败可以定位到阶段。 +3. `Tantivy search backend + sanity checker`:索引有 schema 版本、权限字段、锁、延迟补偿和可重建性;文件与派生物有一致性检查。 + +MNote 应把这些模式收口成自己的 `Resource Work Kernel`: + +```text +- local file / attachment / OCR source / parsed artifact + -> ResourceWorkJob control-plane ledger + -> ResourceIngestionPipeline stage runner + -> sidecar artifact + source-map + -> evidence.sqlite / local search projection + -> realtime job events + -> sanity check / recovery job +``` + +这不是要引入 paperless-ngx 的运行时。MNote 的数据真相仍然是 local Markdown、附件原文件、resource tree 和 control-plane;索引、OCR Markdown、parse Markdown、source-map 都是可删除重建的派生物。 + +## 2. Paperless-ngx 可借鉴点 + +### 2.1 统一后台任务账本 + +参考路径: + +- `src/documents/models.py`:`PaperlessTask` +- `src/documents/signals/handlers.py`:Celery task publish / prerun / postrun / failure handlers +- `src-ui/src/app/components/admin/tasks/tasks.component.ts` + +可借鉴点: + +- 任务不是只存在于内存事件或前端状态,而是落库。 +- 每个任务有 `task_type`、`trigger_source`、`status`、`date_created`、`date_started`、`date_done`、`duration_seconds`、`wait_time_seconds`、`input_data`、`result_data`、`acknowledged`。 +- 系统任务和用户触发任务统一展示,但保留来源差异。 + +MNote 映射: + +- 当前 `JobTicket`、OCR job、index refresh、agent run、OnlyOffice bridge 任务、recovery job 不应继续分散。 +- 新增 control-plane 表 `resource_work_jobs`,先覆盖 local OCR / evidence parse / index refresh,后续再纳入 agent run 和 recovery job。 + +### 2.2 阶段化资源导入管线 + +参考路径: + +- `src/documents/tasks.py`:`consume_file` +- `src/documents/plugins/base.py`:`ConsumeTaskPlugin` +- `src/documents/consumer.py`:解析、OCR、存储、索引、progress +- `src/documents/plugins/helpers.py`:`ProgressManager` + +可借鉴点: + +- 导入任务按插件链执行,每个 stage 有 `setup / run / cleanup`。 +- 阶段状态通过 websocket 通知。 +- 文件写入、数据库更新、索引更新分层处理,失败时能说明卡在预检、解析、写 sidecar 还是索引。 + +MNote 映射: + +- 不引入插件框架泛化;先定义窄的 `ResourceIngestionPipeline`。 +- stage 只覆盖当前真实需要:`preflight`、`parse_text`、`ocr`、`write_artifact`、`write_source_map`、`refresh_evidence_index`、`broadcast_done`。 +- 当前 `local_ocr` 里的 stage 字符串和 sidecar 写入逻辑可以作为第一批迁移对象。 + +### 2.3 索引生命周期和自愈 + +参考路径: + +- `src/documents/search/_backend.py` +- `src/documents/search/_schema.py` +- `src/documents/search/_query.py` +- `src/documents/tasks.py`:`index_document`、`remove_document_from_index` + +可借鉴点: + +- schema version sentinel 决定是否重建。 +- 写索引用 file lock 和 retry,锁耗尽后排延迟任务,而不是让前台操作失败。 +- 查询层有权限过滤、autocomplete、highlight、CJK bigram、simple search。 + +MNote 映射: + +- `.mnote/index/evidence.sqlite` 当前已存在,但需要更明确的 schema sentinel 和 rebuild reason。 +- `query_evidence_sqlite_results` 继续作为默认 evidence path;后续补充 autocomplete / CJK / highlight 时仍以 `EvidenceLocator` 为返回真相。 +- 索引写失败不能悄悄丢失,应写入 `resource_work_jobs` 的 retry-scheduled 状态。 + +### 2.4 权限过滤的实时事件 + +参考路径: + +- `src/paperless/consumers.py` +- `src/documents/plugins/helpers.py` + +可借鉴点: + +- websocket payload 带 owner / visible users / visible groups。 +- server-side websocket consumer 根据当前用户过滤。 + +MNote 映射: + +- local-only 阶段可以先只带 `workspaceId`、`actorId`、`rootUri`、`targetDocumentId`、`grantId`。 +- 一旦进入 share / team workspace,OCR / index / agent job event 不能只按广播频道粗暴推送。 +- `AiAccessScope` 和 share grants 应能映射成 job event 可见性字段。 + +### 2.5 Sanity checker + +参考路径: + +- `src/documents/sanity_checker.py` +- `src/documents/management/commands/document_sanity_checker.py` + +可借鉴点: + +- 独立检查原文件、派生文件、checksum、孤儿文件和空 OCR 内容。 +- 输出按 error / warning / info 分级,既能 CLI 显示,也能作为后台任务结果。 + +MNote 映射: + +- 新增 `workspace_sanity_check`,先检查 local-folder resource evidence: + - Markdown owner 是否存在。 + - 附件路径是否存在且在 allowed root 内。 + - `{pageStem}.ocr/` sidecar 是否能回到 owner Markdown。 + - `*.source-map.json` 是否能解析为 `mnote.resource_source_map.v1`。 + - `evidence.sqlite` 是否能由 sidecar 重建。 + - evidence locator 的 `openAction` 是否能落回 document / resource tab。 + +## 3. 目标架构 + +### 3.1 ResourceWorkJob + +新增 control-plane job ledger,不替代 domain event,也不替代 agent run receipt。 + +```rust +pub struct ResourceWorkJob { + pub job_id: String, + pub job_type: ResourceWorkJobType, + pub trigger_source: ResourceWorkTriggerSource, + pub status: ResourceWorkJobStatus, + pub stage: Option, + pub progress_current: Option, + pub progress_total: Option, + pub stage_label: Option, + pub workspace_id: String, + pub root_uri: String, + pub actor_id: Option, + pub target_document_id: Option, + pub source_root_relative_path: Option, + pub artifact_root_relative_path: Option, + pub source_map_root_relative_path: Option, + pub input_json: serde_json::Value, + pub result_json: Option, + pub error_code: Option, + pub error_message: Option, + pub created_at_ms: u128, + pub started_at_ms: Option, + pub finished_at_ms: Option, + pub acknowledged_at_ms: Option, +} +``` + +首批枚举: + +```text +job_type: +- local_ocr +- resource_parse +- evidence_index_refresh +- evidence_index_rebuild +- workspace_sanity_check + +trigger_source: +- web_ui +- api +- watcher +- agent_tool +- system +- recovery + +status: +- pending +- running +- succeeded +- failed +- retry_scheduled +- canceled +``` + +约束: + +- `job_id` 由 MNote 生成,不复用外部 provider task id。 +- MinerU task id、LiteParse request id、Reasonix run id 只进入 `input_json / result_json`。 +- `result_json` 必须能存 evidence id、artifact path、source-map path、locator count、index row count。 +- `stage / progress_current / progress_total / stage_label` 是前台任务中心的稳定合同;不能只塞在 provider 私有 payload 里。 +- job ledger 是 control-plane 事实,不是用户正文真相。 + +### 3.2 ResourceIngestionPipeline + +先实现窄接口,不做任意插件市场: + +```text +preflight + -> detect_resource_kind + -> choose_provider + -> parse_or_ocr + -> write_artifact + -> write_source_map + -> refresh_evidence_index + -> broadcast_job_event +``` + +Provider 策略: + +- 文本型 PDF / Office parse 优先走 `LiteParseProvider` 或当前轻量 parser。 +- 扫描件 / 图片走 `MinerUProvider`。 +- mock provider 仅用于 smoke,不得在真实功能报告中冒充成功链路。 + +阶段状态: + +```text +queued +preflight +parsing +ocr_uploading +ocr_processing +writing_artifact +writing_source_map +indexing +done +failed +retry_scheduled +stale +``` + +当前 `local_ocr.job.updated` 可兼容保留,但 payload 应逐步包含 `jobId / jobType / stage / workspaceId / rootUri / sourceRootRelativePath / targetDocumentId / artifactPath / sourceMapPath`。 + +### 3.3 Evidence Index Lifecycle + +当前 `.mnote/index/evidence.sqlite` 继续作为默认 evidence index。升级点: + +- 增加 `schema_version` 和 `build_settings` sentinel。 +- 每次 write / refresh 记录 `job_id`。 +- 索引 row 必须能通过 `source_map_root_relative_path` 回到 canonical artifact。 +- lock 失败进入 `retry_scheduled` job,不直接吞掉。 +- watcher 增量刷新失败时,不做前端轮询补偿;排 recovery job 并广播一次明确事件。 + +建议最小表: + +```sql +CREATE TABLE IF NOT EXISTS evidence_index_meta ( + key TEXT PRIMARY KEY, + value_json TEXT NOT NULL, + updated_at_ms INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS evidence_index_jobs ( + job_id TEXT PRIMARY KEY, + last_error_code TEXT, + last_error_message TEXT, + updated_at_ms INTEGER NOT NULL +); +``` + +如果 control-plane 已存 job,这里只保存 index-local rebuild metadata,不重复完整 job 账本。 + +### 3.4 Workspace Sanity Check + +新增只读检查,不自动删除、不自动修复。 + +检查项: + +| 级别 | 检查 | 说明 | +| --- | --- | --- | +| error | owner Markdown 不存在 | evidence locator 无法打开 | +| error | resource file 不存在 | 附件或 OCR source 丢失 | +| error | source-map JSON 无法解析 | 定位真相损坏 | +| error | evidence.sqlite schema 不匹配 | 需要 rebuild | +| warning | sidecar 孤儿文件 | 有 OCR/parse artifact 但找不到 owner link | +| warning | source hash / mtime stale | 可重建,但不阻断 | +| info | parse/OCR 内容为空 | 允许,但要可见 | + +输出既可作为 API: + +```text +POST /api/workspaces/sanity/check +GET /api/work/jobs/:jobId +``` + +也可作为 smoke / CLI helper 的 JSON 结果。 + +### 3.5 Unified Task Foreground UI + +统一后台任务必须有统一前台可见入口。否则用户仍然只能在 OCR 设置、索引设置、toast、局部状态灯之间猜系统是否还在运行。 + +当前已有的 `mnote-local-ocr-task-dock` 是迁移起点,不是长期终点。它应升级为全局 `Work Task Center`: + +```text +Topbar task button + -> badge: active / failed / needs attention count + -> drawer: current jobs + recent completed + failed + -> row: icon + title + stage label + progress bar + actions + -> detail: input/result/error + related document/resource + trace/run receipt +``` + +入口位置: + +- 顶栏保留一个任务中心图标,建议使用 `progress_activity` 或 `pending_actions`。 +- OCR 和索引设置按钮仍可保留,但它们不再各自承载任务列表。 +- 任务中心抽屉优先靠右打开,保持轻量;不做全屏管理台作为第一阶段。 + +任务行最小字段: + +| 字段 | 来源 | UI 用途 | +| --- | --- | --- | +| `jobId` | job ledger | 稳定 row key 和详情查询 | +| `jobType` | job ledger | 图标、分类、筛选 | +| `status` | job ledger | 颜色、分组、是否需要确认 | +| `stageLabel` | job event | 当前阶段文案 | +| `progressCurrent / progressTotal` | job event | 确定性进度条 | +| `createdAtMs / startedAtMs / finishedAtMs` | job ledger | 排队/耗时/最近完成 | +| `targetDocumentId` | job ledger | 打开 owner 文档 | +| `sourceRootRelativePath` | job ledger | 打开附件或定位资源 | +| `artifactRootRelativePath / sourceMapRootRelativePath` | result | 打开 OCR/parse/source-map | +| `errorCode / errorMessage` | job ledger | 失败摘要和复现证据 | + +进度规则: + +- 有 `progressCurrent / progressTotal` 时显示确定性进度条。 +- 没有总量但 status 为 running 时显示细条 indeterminate,不伪造百分比。 +- `queued / retry_scheduled` 显示排队态,不显示假进度。 +- `failed` 和 `retry_scheduled` 必须进入“需要处理”计数。 +- `succeeded` 默认保留在最近完成列表,可由用户清除/ack。 + +任务动作: + +| 状态 | 动作 | +| --- | --- | +| running | 打开目标、查看详情 | +| succeeded | 打开结果、打开目标、清除 | +| failed | 查看错误、重试、打开目标、复制错误摘要 | +| retry_scheduled | 查看重试原因、立即重试、取消重试 | +| stale | 重新生成、打开旧结果 | + +事件与数据流: + +```text +GET /api/work/jobs?scope=currentWorkspace&active=true +GET /api/work/jobs?scope=currentWorkspace&recent=true +GET /api/work/jobs/:jobId +POST /api/work/jobs/:jobId/ack +POST /api/work/jobs/:jobId/retry +SSE/WS event: resource_work.job.updated +``` + +前端运行时: + +- 新增 `browser/work-task-center-runtime.js`,由 `layout.rs` 统一注入。 +- 现有 `document-resource-tab-runtime.js` 中 OCR task dock 的 state/render 逻辑迁入该 runtime。 +- `mnote:local-ocr-job-updated` 作为兼容事件继续转发为 `resource_work.job.updated`,直到后端统一 payload 完成。 +- 前端只在启动时拉一次 active/recent snapshot;后续靠 WS/SSE 事件更新,不新增周期轮询。 + +视觉约束: + +- 顶栏只显示一个任务中心 badge,避免 OCR/索引/AI 各自占顶栏状态位。 +- 抽屉行要密集、可扫描,不能做大卡片堆叠。 +- 每行必须有可见进度或阶段文本,长路径截断但 title 保留完整路径 tooltip。 +- 移动端抽屉宽度占满可用宽度,任务行按钮折到第二行,避免文字溢出。 + +## 4. 不做什么 + +- 不把 paperless-ngx 的 Django model / Angular UI / Celery worker 引入 MNote。 +- 不把 OCR Markdown、parse Markdown 或 evidence.sqlite 变成正文真相。 +- 不新增前端轮询来弥补 job 状态;状态更新走现有 WS / SSE / watcher event。 +- 不把任务 UI 继续拆成 OCR 一套、索引一套、AI 一套;这些只能是任务中心里的分类或过滤。 +- 不让 workflow UI 先行。先做 event-triggered job 和少量内置动作,再考虑可视化配置。 +- 不在本轮实现向量 RAG。Paperless 的 FAISS append-only 方案只作为反例和参考,不作为 MNote 默认路径。 + +## 5. 实施分期 + +### Phase A:设计冻结和接口补齐 + +Owner:07-ai / 03-rust-web + +任务: + +- [ ] 在 `core-protocol` 增加 `ResourceWorkJob` / `ResourceWorkJobStatus` / `ResourceWorkTriggerSource` 合同。 +- [ ] 在 job 合同中加入 `stage / stageLabel / progressCurrent / progressTotal`,作为前台任务中心稳定字段。 +- [ ] 明确 `local_ocr.job.updated` 与新 `resource_work.job.updated` 的兼容关系。 +- [ ] 在 `7-46` 里引用本设计作为 job / index lifecycle 的执行补充。 + +验收: + +- [ ] Rust unit 覆盖 job status 序列化。 +- [ ] Rust unit 覆盖 progress 字段缺省、确定性进度和 indeterminate 语义。 +- [ ] 不改变现有 OCR smoke 行为。 + +### Phase B:OCR job ledger 收口 + +Owner:03-rust-web / control-plane + +任务: + +- [ ] 给 `local_ocr::create_job` 创建 control-plane job 记录。 +- [ ] 每次 stage advance 同步 job ledger。 +- [ ] `done / failed` 写入 `finished_at_ms / result_json / error_code`。 +- [ ] 增加 `GET /api/work/jobs/:jobId`。 +- [ ] 增加 `GET /api/work/jobs?active=true&recent=true`,供任务中心首屏 snapshot 使用。 + +验收: + +- [ ] 现有 OCR sidecar smoke 仍通过。 +- [ ] 新增 smoke 验证 OCR job 可查询、失败错误脱敏、done 后 result 包含 sidecar 和 source-map。 +- [ ] 新增 smoke 验证 active snapshot 包含运行中 OCR job,完成后转入 recent。 + +### Phase B2:统一任务中心 UI + +Owner:03-rust-web / 05-editor-mainline + +任务: + +- [ ] 新增 `browser/work-task-center-runtime.js`。 +- [ ] 将 `mnote-local-ocr-task-dock` 的状态聚合和 drawer 渲染迁移到 work task center。 +- [ ] 顶栏新增统一任务中心按钮和 badge,OCR 按钮回归 OCR 设置入口或合并进设置面板。 +- [ ] 支持 active / needs attention / recent 三个分组。 +- [ ] 任务行支持确定性 progress bar、indeterminate running bar、失败重试、打开目标、打开结果、ack。 +- [ ] 兼容接收 `mnote:local-ocr-job-updated`,并在后端统一事件上线后接 `resource_work.job.updated`。 + +验收: + +- [ ] 浏览器 smoke:触发 OCR 后顶栏 badge 从 0 变 1,抽屉显示阶段和进度。 +- [ ] 浏览器 smoke:OCR 完成后任务移入 recent,能打开 OCR sidecar。 +- [ ] 浏览器 smoke:mock 失败任务进入 needs attention,能查看错误和重试。 +- [ ] 截图验证桌面和移动端抽屉不溢出、不遮挡主编辑区关键内容。 + +### Phase C:Evidence index lifecycle + +Owner:07-ai / 03-rust-web + +任务: + +- [ ] 给 evidence.sqlite 增加 schema/settings sentinel。 +- [ ] index refresh 写入 job id 或 index-local job metadata。 +- [ ] lock / write / parse source-map 失败时进入 retry-scheduled 或 failed job。 +- [ ] `mnote.index.status` 返回 schema、last build、last job、pending retry。 +- [ ] index refresh / rebuild 通过任务中心显示阶段和结果摘要。 + +验收: + +- [ ] `task528-document-evidence-liteparse-agent-smoke.js` 继续通过。 +- [ ] 新增 index stale / rebuild smoke,验证删除 evidence.sqlite 后可通过 job 重建。 +- [ ] 浏览器 smoke 验证 index rebuild 任务出现在任务中心。 + +### Phase D:Workspace sanity check + +Owner:03-rust-web / 04-tree-domain + +任务: + +- [ ] 实现只读 `workspace_sanity_check` job。 +- [ ] 检查 owner Markdown、resource file、sidecar、source-map、evidence.sqlite。 +- [ ] 结果按 error / warning / info 分级。 +- [ ] 从任务中心和 index/OCR 设置 surface 都能启动检查;结果统一进入任务中心详情。 + +验收: + +- [ ] smoke 构造缺失 source-map、孤儿 sidecar、损坏 sqlite,能得到稳定 JSON。 +- [ ] 不删除、不移动任何用户文件。 +- [ ] 浏览器 smoke 验证 sanity check 运行时有任务行,完成后详情展示 error / warning / info 摘要。 + +### Phase E:内置 workflow actions + +Owner:01-tree-first-graph-kernel / 07-ai + +任务: + +- [ ] 定义内置事件:`resource.created`、`resource.changed`、`ocr.done`、`evidence.index.failed`。 +- [ ] 定义内置动作:`parse_resource`、`run_ocr`、`refresh_evidence_index`、`schedule_sanity_check`。 +- [ ] 先用静态配置或 settings 控制,不做复杂 UI。 + +验收: + +- [ ] 新增附件后能按 settings 自动排 parse/OCR/index job。 +- [ ] 失败不会循环重试;必须有 retry budget 和可见错误。 + +## 6. 设计验收基线 + +本设计完成后,MNote 应具备以下能力: + +- 用户能看到后台 OCR / parse / index / sanity job 的真实状态,而不是只看到散落的 toast 或局部状态灯。 +- 顶栏任务中心能显示 active / failed / recent 任务,且所有任务进度来自 job ledger 或 job event,不伪造百分比。 +- agent 调用 evidence 工具时,MNote 能把本次证据索引状态、evidence ids、source-map path 写入 run receipt。 +- 删除或损坏 evidence.sqlite 不会让系统静默退化;会显示 rebuild required 或排 rebuild job。 +- OCR sidecar、parse artifact、source-map 和 evidence locator 可以被 sanity check 证明互相可追溯。 +- 多人 / share 场景下,job event 有明确可见性字段,不把本地单用户广播模型固化成长期事实。 + +## 7. 参考代码索引 + +Paperless-ngx: + +- `src/documents/models.py`:`PaperlessTask`、workflow model、document version fields。 +- `src/documents/tasks.py`:`consume_file`、index deferred tasks、bulk update。 +- `src/documents/consumer.py`:resource consume main path。 +- `src/documents/plugins/base.py`:plugin lifecycle contract。 +- `src/documents/plugins/helpers.py`:progress websocket payload。 +- `src/documents/search/_backend.py`:Tantivy backend、lock retry、autocomplete、highlight。 +- `src/documents/search/_schema.py`:schema version sentinel。 +- `src/documents/search/_query.py`:permission filter、date rewrite、CJK/simple query。 +- `src/documents/sanity_checker.py`:archive consistency checker。 +- `src/paperless/consumers.py`:permission-aware websocket consumer。 + +MNote 当前落点: + +- `rust/crates/core-protocol/src/governance.rs` +- `rust/crates/core-protocol/src/evidence.rs` +- `rust/crates/core-protocol/src/tool.rs` +- `rust/crates/mnote-web/src/routes/local_ocr.rs` +- `rust/crates/mnote-web/src/routes/local_search_index.rs` +- `rust/crates/mnote-web/src/routes/evidence.rs` +- `rust/crates/mnote-web/src/routes/local_folder_events.rs` +- `rust/crates/mnote-web/src/routes/ws.rs` +- `scripts/task528-document-evidence-liteparse-agent-smoke.js` diff --git a/package.json b/package.json index ad2b04bf..533d45f6 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "scripts": { "dev": "node scripts/dev-hot.js", "desktop:hot": "node scripts/desktop-hot.js", - "dev:hot": "node scripts/dev-hot.js" + "dev:hot": "node scripts/dev-hot.js", + "prod:start": "node scripts/prod-build-start.js" }, "devDependencies": { "@playwright/test": "^1.56.1", diff --git a/rust/crates/core-protocol/src/evidence.rs b/rust/crates/core-protocol/src/evidence.rs index d83de16b..966c8f7f 100644 --- a/rust/crates/core-protocol/src/evidence.rs +++ b/rust/crates/core-protocol/src/evidence.rs @@ -234,6 +234,12 @@ pub struct EvidenceSearchResult { pub quote: String, pub score: f64, pub source: EvidenceLocator, + #[serde(skip_serializing_if = "Option::is_none")] + pub citation_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub citation_label: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub citation_markdown: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] diff --git a/rust/crates/core-protocol/src/tool.rs b/rust/crates/core-protocol/src/tool.rs index 9552b83d..cbc6490b 100644 --- a/rust/crates/core-protocol/src/tool.rs +++ b/rust/crates/core-protocol/src/tool.rs @@ -143,7 +143,8 @@ pub const DOCS_TOOL_READ: ToolSpec = ToolSpec { pub const EVIDENCE_TOOL_SEARCH: ToolSpec = ToolSpec { name: "mnote.evidence.search", display_name: "证据搜索", - description: "在工作区内搜索可回跳原文的证据块,返回 quote、locator 与 openAction。", + description: + "在工作区内搜索可回跳原文的证据块,返回 quote、locator、openAction 与 citationMarkdown。", toolset_id: "toolset.evidence_read", invocation_kind: InvocationKind::Query, effect: ToolEffect::Read, @@ -154,7 +155,8 @@ pub const EVIDENCE_TOOL_SEARCH: ToolSpec = ToolSpec { pub const EVIDENCE_TOOL_READ: ToolSpec = ToolSpec { name: "mnote.evidence.read", display_name: "证据读回", - description: "按 EvidenceLocator 读取原文证据及周边上下文,供 AI 回答引用。", + description: + "按 EvidenceLocator 读取原文证据及周边上下文,供 AI 回答引用,并返回可点击引用链接。", toolset_id: "toolset.evidence_read", invocation_kind: InvocationKind::Query, effect: ToolEffect::Read, @@ -165,7 +167,7 @@ pub const EVIDENCE_TOOL_READ: ToolSpec = ToolSpec { pub const EVIDENCE_TOOL_OPEN: ToolSpec = ToolSpec { name: "mnote.evidence.open", display_name: "证据打开", - description: "把 EvidenceLocator 归一化为 MNote 可执行的打开/定位动作。", + description: "把 EvidenceLocator 归一化为 MNote 可执行的打开/定位动作,并返回 citationUrl/citationMarkdown。", toolset_id: "toolset.evidence_read", invocation_kind: InvocationKind::Query, effect: ToolEffect::Read, diff --git a/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js b/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js index e30dc385..5785559b 100644 --- a/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js +++ b/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js @@ -267,7 +267,15 @@ import { root.setAttribute('data-mnote-evidence-open', 'true'); if (blockId) root.setAttribute('data-mnote-evidence-block-id', blockId); if (lineRange) root.setAttribute('data-mnote-evidence-line-range', lineRange); - const target = blockId ? root.querySelector(`[data-block-id="${cssSafe(blockId)}"]`) : null; + let target = blockId ? root.querySelector(`[data-block-id="${cssSafe(blockId)}"]`) : null; + if (!(target instanceof HTMLElement) && lineRange) { + const start = Number(String(lineRange).split('-')[0] || 0); + if (Number.isFinite(start) && start > 0) { + const blocks = Array.from(root.querySelectorAll('.ProseMirror [data-block-id]')) + .filter((node) => node instanceof HTMLElement); + target = blocks[Math.max(0, Math.min(blocks.length - 1, start - 1))] || null; + } + } if (target instanceof HTMLElement) { root.querySelectorAll('[data-mnote-evidence-text-highlight="true"]').forEach((node) => { if (node instanceof HTMLElement) node.removeAttribute('data-mnote-evidence-text-highlight'); @@ -302,6 +310,12 @@ import { title, fileName: title, openTarget: 'active-tab', + page: url.searchParams.get('page') || undefined, + bbox: url.searchParams.get('bbox') || undefined, + sourceMapPath: String(url.searchParams.get('sourceMapPath') || '').trim(), + blockId: String(url.searchParams.get('blockId') || '').trim(), + lineRange: url.searchParams.get('lineRange') || null, + charRange: url.searchParams.get('charRange') || null, }; }; diff --git a/rust/crates/mnote-web/browser/document-resource-tab-runtime.js b/rust/crates/mnote-web/browser/document-resource-tab-runtime.js index 835c4589..c520750e 100644 --- a/rust/crates/mnote-web/browser/document-resource-tab-runtime.js +++ b/rust/crates/mnote-web/browser/document-resource-tab-runtime.js @@ -418,6 +418,21 @@ export const createResourceTabRuntime = (dependencies = {}) => { } }; + const officeOpenModeForEntry = (entry) => { + if (!entry || normalizeResourceTabKind(entry) !== 'office') return ''; + const href = String(entry.passiveFrameSrc || entry.officeUrl || entry.href || '').trim() + || String(entry.panel?.querySelector?.('iframe.mnote-resource-tab-frame')?.getAttribute?.('src') || '').trim(); + if (href) { + try { + const url = new URL(href, window.location.origin); + if (url.pathname === '/office-preview' || url.pathname === '/office-n') return 'preview'; + if (url.pathname === '/onlyoffice' || url.pathname.startsWith('/office/')) return 'onlyoffice_live'; + } catch (_) {} + } + if (entry.onlyofficeSessionId || entry.bridgeSessionId) return 'onlyoffice_live'; + return 'preview'; + }; + const openEditorsSnapshotEntry = (entry, key, generatedAt = Date.now()) => { const active = entry?.tab instanceof HTMLElement ? entry.tab.getAttribute('aria-selected') === 'true' @@ -432,15 +447,23 @@ export const createResourceTabRuntime = (dependencies = {}) => { const kind = normalizeResourceTabKind(entry); const dirtyState = resourceTabCloseGuardReason(entry?.session); const officeBridgeDebug = kind === 'office' ? officeBridgeDebugForEntry(entry) : null; + const officeOpenMode = kind === 'office' ? officeOpenModeForEntry(entry) : ''; const onlyofficeSessionId = String( officeBridgeDebug?.bridgeSessionId || entry?.onlyofficeSessionId || entry?.bridgeSessionId || '', ).trim(); + const snapshotResourceKind = kind === 'office' + ? (officeOpenMode === 'onlyoffice_live' || onlyofficeSessionId ? 'only_office' : 'attachment') + : kind; + const workspacePathSeed = entry?.workspacePath && typeof entry.workspacePath === 'object' + ? { ...entry.workspacePath, resourceKind: snapshotResourceKind } + : entry?.workspacePath; return { objectIdentity, workspacePath: buildWorkspacePath({ + workspacePath: workspacePathSeed, workspaceId, sourceKind, rootUri, @@ -448,8 +471,7 @@ export const createResourceTabRuntime = (dependencies = {}) => { documentId, objectIdentity, assetId, - resourceKind: kind, - workspacePath: entry?.workspacePath, + resourceKind: snapshotResourceKind, }), paneRole: normalizePaneRole(entry?.paneRole), documentId, @@ -465,13 +487,14 @@ export const createResourceTabRuntime = (dependencies = {}) => { dirtyGuard: dirtyState, assetId, path: relativePath, + officeOpenMode, onlyofficeSessionId, bridgeSessionId: onlyofficeSessionId, bridgeSessionReady: Boolean(onlyofficeSessionId), bridgeDocumentId: String(officeBridgeDebug?.documentId || '').trim(), bridgeAssetId: String(officeBridgeDebug?.assetId || '').trim(), lastActiveAt: Number(entry?.session?.lastActiveAt || entry?.lastActiveAt || 0) || (active ? generatedAt : 0), - preview: false, + preview: officeOpenMode === 'preview', pinned: false, }; }; @@ -848,6 +871,7 @@ export const createResourceTabRuntime = (dependencies = {}) => { panel.setAttribute('data-mnote-object-identity', objectIdentity); panel.setAttribute('data-pane-role', paneRole); panel.setAttribute('data-resource-kind', kind); + panel.setAttribute('data-resource-path', String(input.path || '').trim()); panel.hidden = true; nodes.strip.append(tab); nodes.panelRoot.append(panel); @@ -989,6 +1013,16 @@ export const createResourceTabRuntime = (dependencies = {}) => { if (bbox) url.searchParams.set('bbox', bbox); if (locator.sourceMapPath) url.searchParams.set('sourceMapPath', String(locator.sourceMapPath)); if (locator.blockId) url.searchParams.set('blockId', String(locator.blockId)); + if (url.pathname === '/office-preview' && frame.contentWindow) { + frame.contentWindow.postMessage({ + type: 'mnote:office-evidence-locator', + page: locator.page, + bbox, + sourceMapPath: locator.sourceMapPath || '', + blockId: locator.blockId || '', + }, window.location.origin); + return; + } frame.src = url.toString(); } catch (_) {} }; @@ -1015,11 +1049,22 @@ export const createResourceTabRuntime = (dependencies = {}) => { }; const applyEvidenceLocatorToTextPanel = (entry, locator) => { - if (!(entry?.panel instanceof HTMLElement) || !locator?.blockId) return; + if (!(entry?.panel instanceof HTMLElement) || !locator) return; const root = entry.panel.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"]'); if (!(root instanceof HTMLElement)) return; - const selector = `[data-block-id="${cssSafe(locator.blockId)}"]`; - const target = root.querySelector(selector); + const selector = locator.blockId ? `[data-block-id="${cssSafe(locator.blockId)}"]` : ''; + let target = selector ? root.querySelector(selector) : null; + const query = String(locator?.openAction?.params?.query || locator?.query || '').trim(); + if (!(target instanceof HTMLElement) && query) { + target = Array.from(root.querySelectorAll('.ProseMirror [data-block-id]')) + .find((node) => node instanceof HTMLElement && String(node.textContent || '').includes(query)) || null; + } + const lineStart = Number(locator?.lineRange?.start ?? locator?.line_range?.start ?? 0); + if (!(target instanceof HTMLElement) && Number.isFinite(lineStart) && lineStart > 0) { + const blocks = Array.from(root.querySelectorAll('.ProseMirror [data-block-id]')) + .filter((node) => node instanceof HTMLElement); + target = blocks[Math.max(0, Math.min(blocks.length - 1, lineStart - 1))] || null; + } if (!(target instanceof HTMLElement)) return; root.querySelectorAll('[data-mnote-evidence-text-highlight="true"]').forEach((node) => { if (node instanceof HTMLElement) node.removeAttribute('data-mnote-evidence-text-highlight'); @@ -1364,6 +1409,7 @@ export const createResourceTabRuntime = (dependencies = {}) => { rootUri: '', jobsBySource: new Map(), drawerOpen: false, + taskFilter: 'active', eventSource: null, fileTreeRefreshKeys: new Set(), }; @@ -1467,13 +1513,36 @@ export const createResourceTabRuntime = (dependencies = {}) => { : status || '未知'; }; + const localOcrTaskCategory = (job) => { + const status = String(job?.status || '').trim(); + if (['failed', 'stale', 'retry_scheduled'].includes(status)) return 'attention'; + if (['done', 'succeeded', 'success'].includes(status)) return 'completed'; + return 'active'; + }; + + const localOcrTaskProgress = (job) => { + const current = Number(job?.progressCurrent ?? job?.currentProgress); + const total = Number(job?.progressTotal ?? job?.maxProgress); + if (Number.isFinite(current) && Number.isFinite(total) && total > 0) { + return Math.max(0, Math.min(100, Math.round((current / total) * 100))); + } + return null; + }; + + const localOcrTaskFilterLabel = (filter) => { + return filter === 'active' ? '进行中' + : filter === 'completed' ? '已完成' + : filter === 'attention' ? '需处理' + : '全部'; + }; + const ensureLocalOcrTaskDock = () => { let dock = document.querySelector('[data-testid="mnote-local-ocr-task-dock"]'); if (!(dock instanceof HTMLElement)) { dock = document.createElement('section'); dock.className = 'mnote-local-ocr-task-dock'; dock.setAttribute('data-testid', 'mnote-local-ocr-task-dock'); - dock.innerHTML = ''; + dock.innerHTML = ''; document.body.appendChild(dock); } let toggle = document.querySelector('[data-testid="mnote-local-ocr-task-toggle"]'); @@ -1509,6 +1578,20 @@ export const createResourceTabRuntime = (dependencies = {}) => { } return; } + const tabButton = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-tab]') : null; + if (tabButton instanceof HTMLElement) { + localOcrTaskState.taskFilter = tabButton.getAttribute('data-mnote-local-ocr-task-tab') || 'active'; + renderLocalOcrTaskDock(); + return; + } + const clearCompleted = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-clear-completed]') : null; + if (clearCompleted instanceof HTMLElement) { + Array.from(localOcrTaskState.jobsBySource.entries()).forEach(([key, job]) => { + if (localOcrTaskCategory(job) === 'completed') localOcrTaskState.jobsBySource.delete(key); + }); + renderLocalOcrTaskDock(); + return; + } const deleteButton = event.target instanceof Element ? event.target.closest('[data-mnote-local-ocr-task-delete]') : null; if (deleteButton instanceof HTMLElement) { const sourcePath = deleteButton.getAttribute('data-mnote-local-ocr-task-delete') || ''; @@ -1568,12 +1651,18 @@ export const createResourceTabRuntime = (dependencies = {}) => { const dock = ensureLocalOcrTaskDock(); const jobs = Array.from(localOcrTaskState.jobsBySource.values()) .sort((left, right) => Number(right.updatedAtMs || 0) - Number(left.updatedAtMs || 0)); - const runningCount = jobs.filter((job) => !['done', 'failed', 'stale'].includes(String(job?.status || ''))).length; - const toggle = document.querySelector('[data-testid="mnote-local-ocr-task-toggle"]'); - if (toggle instanceof HTMLButtonElement) { + const activeJobs = jobs.filter((job) => localOcrTaskCategory(job) === 'active'); + const attentionJobs = jobs.filter((job) => localOcrTaskCategory(job) === 'attention'); + const completedJobs = jobs.filter((job) => localOcrTaskCategory(job) === 'completed'); + const runningCount = activeJobs.length; + const taskToggles = Array.from(document.querySelectorAll('[data-testid="mnote-local-ocr-task-toggle"], [data-testid="mnote-floating-task-toggle"]')) + .filter((node) => node instanceof HTMLButtonElement); + taskToggles.forEach((toggle) => { + const opensSettings = toggle.getAttribute('data-mnote-action') === 'open-ocr-settings'; const label = runningCount > 0 ? `${runningCount} 个 OCR 处理中,打开 OCR 设置` : (jobs.length > 0 ? `${jobs.length} 个 OCR 任务,打开 OCR 设置` : 'OCR 设置'); - toggle.setAttribute('title', label); - toggle.setAttribute('aria-label', label); + const taskLabel = runningCount > 0 ? `${runningCount} 个后台任务正在运行` : (jobs.length > 0 ? `${jobs.length} 个后台任务` : '后台任务'); + toggle.setAttribute('title', opensSettings ? label : taskLabel); + toggle.setAttribute('aria-label', opensSettings ? label : taskLabel); toggle.setAttribute('aria-expanded', localOcrTaskState.drawerOpen ? 'true' : 'false'); toggle.classList.toggle('has-mnote-local-ocr-tasks', jobs.length > 0); const badge = toggle.querySelector('[data-mnote-local-ocr-task-count]'); @@ -1583,28 +1672,85 @@ export const createResourceTabRuntime = (dependencies = {}) => { } else { toggle.textContent = runningCount > 0 ? `OCR ${runningCount} 处理中` : `OCR ${jobs.length}`; } - } + }); const drawer = dock.querySelector('[data-testid="mnote-local-ocr-task-drawer"]'); if (drawer instanceof HTMLElement) drawer.hidden = !localOcrTaskState.drawerOpen; + const summary = dock.querySelector('[data-mnote-local-ocr-task-summary]'); + if (summary instanceof HTMLElement) { + summary.textContent = `${runningCount} 进行中 · ${attentionJobs.length} 需处理 · ${completedJobs.length} 已完成`; + } + const tabs = dock.querySelector('[data-mnote-local-ocr-task-tabs]'); + if (tabs instanceof HTMLElement) { + const tabItems = [ + ['active', '进行中', activeJobs.length], + ['attention', '需处理', attentionJobs.length], + ['completed', '已完成', completedJobs.length], + ['all', '全部', jobs.length], + ]; + tabs.replaceChildren(); + tabItems.forEach(([key, label, count]) => { + const button = document.createElement('button'); + button.type = 'button'; + button.setAttribute('data-mnote-local-ocr-task-tab', key); + button.setAttribute('aria-selected', localOcrTaskState.taskFilter === key ? 'true' : 'false'); + button.textContent = `${label} ${count}`; + tabs.appendChild(button); + }); + } + const filterLabel = dock.querySelector('[data-mnote-local-ocr-task-filter-label]'); + if (filterLabel instanceof HTMLElement) filterLabel.textContent = localOcrTaskFilterLabel(localOcrTaskState.taskFilter); + const clearCompleted = dock.querySelector('[data-mnote-local-ocr-task-clear-completed]'); + if (clearCompleted instanceof HTMLButtonElement) clearCompleted.disabled = completedJobs.length === 0; const list = dock.querySelector('[data-testid="mnote-local-ocr-task-list"]'); if (!(list instanceof HTMLElement)) return; list.replaceChildren(); - if (!jobs.length) { + const visibleJobs = jobs.filter((job) => { + return localOcrTaskState.taskFilter === 'all' || localOcrTaskCategory(job) === localOcrTaskState.taskFilter; + }); + if (!visibleJobs.length) { const empty = document.createElement('div'); empty.className = 'mnote-local-ocr-task-empty'; - empty.textContent = '暂无 OCR 任务'; + empty.textContent = jobs.length ? `暂无${localOcrTaskFilterLabel(localOcrTaskState.taskFilter)}任务` : '暂无后台任务'; list.appendChild(empty); return; } - jobs.forEach((job) => { + visibleJobs.forEach((job) => { const row = document.createElement('div'); row.className = 'mnote-local-ocr-task-row'; row.setAttribute('data-mnote-local-ocr-task-row', String(job.sourceRootRelativePath || '')); row.setAttribute('data-mnote-local-ocr-task-status', String(job.status || '')); + row.setAttribute('data-mnote-local-ocr-task-category', localOcrTaskCategory(job)); const title = String(job.sourceRootRelativePath || '').split('/').filter(Boolean).pop() || 'OCR'; - row.innerHTML = '
'; - row.querySelector('strong').textContent = title; - row.querySelector('span').textContent = statusTextForLocalOcrJob(job); + const category = localOcrTaskCategory(job); + const progress = localOcrTaskProgress(job); + row.innerHTML = '
'; + const titleNode = row.querySelector('strong'); + if (titleNode instanceof HTMLElement) { + titleNode.textContent = title; + titleNode.setAttribute('title', String(job.sourceRootRelativePath || title)); + } + const categoryNode = row.querySelector('em'); + if (categoryNode instanceof HTMLElement) { + categoryNode.textContent = category === 'active' ? '进行中' : category === 'attention' ? '需处理' : '已完成'; + } + const statusNode = row.querySelector('span'); + if (statusNode instanceof HTMLElement) statusNode.textContent = statusTextForLocalOcrJob(job); + const progressBar = row.querySelector('.mnote-local-ocr-task-progress'); + const progressValue = row.querySelector('.mnote-local-ocr-task-progress i'); + if (progressBar instanceof HTMLElement && progressValue instanceof HTMLElement) { + progressBar.hidden = category !== 'active' && progress === null; + progressBar.setAttribute('aria-valuemin', '0'); + progressBar.setAttribute('aria-valuemax', '100'); + if (progress === null) { + progressBar.setAttribute('data-progress-mode', 'indeterminate'); + progressBar.removeAttribute('aria-valuenow'); + progressValue.style.width = ''; + } else { + progressBar.setAttribute('data-progress-mode', 'determinate'); + progressBar.setAttribute('aria-valuenow', String(progress)); + progressValue.style.width = `${progress}%`; + } + } const open = row.querySelector('[data-mnote-local-ocr-task-open]'); if (open instanceof HTMLButtonElement) { open.setAttribute('data-mnote-local-ocr-task-open', String(job.sourceRootRelativePath || '')); @@ -2178,6 +2324,20 @@ export const createResourceTabRuntime = (dependencies = {}) => { return true; }; + const officePreviewBaseHref = (href) => { + const raw = String(href || '').trim(); + if (!raw) return ''; + try { + const url = new URL(raw, window.location.origin); + ['page', 'bbox', 'sourceMapPath', 'blockId', 'lineRange', 'charRange', 'mnoteResourceReload'].forEach((key) => { + url.searchParams.delete(key); + }); + return url.pathname + '?' + url.searchParams.toString(); + } catch (_) { + return raw; + } + }; + const refreshExistingOfficeResourceTab = (entry, input) => { if (!entry || entry.kind !== 'office') return false; const nextHref = String(input.officeUrl || input.href || '').trim(); @@ -2186,7 +2346,7 @@ export const createResourceTabRuntime = (dependencies = {}) => { const currentHref = frame instanceof HTMLIFrameElement ? String(frame.getAttribute('src') || frame.src || '').trim() : ''; - if (currentHref !== nextHref) void openPassiveResourceTab(entry, input); + if (officePreviewBaseHref(currentHref) !== officePreviewBaseHref(nextHref)) void openPassiveResourceTab(entry, input); return true; }; diff --git a/rust/crates/mnote-web/browser/sidebar-page-ai-markdown-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-ai-markdown-runtime.js index 15e275bf..9624f0fa 100644 --- a/rust/crates/mnote-web/browser/sidebar-page-ai-markdown-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-page-ai-markdown-runtime.js @@ -24,6 +24,12 @@ export function createSidebarPageAiMarkdownRuntime(context) { codeSpans.push('' + code + ''); return key; }); + html = html.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, function(match, label, href) { + var normalizedHref = normalizePageAiMarkdownHref(href); + if (!normalizedHref) return match; + var citationAttr = isMnoteCitationHref(normalizedHref) ? ' data-page-ai-citation-link="true"' : ''; + return '' + label + ''; + }); html = html.replace(/\*\*([^*\n]+)\*\*/g, '$1'); html = html.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1$2'); codeSpans.forEach(function(value, index) { @@ -32,6 +38,28 @@ export function createSidebarPageAiMarkdownRuntime(context) { return html; } + function normalizePageAiMarkdownHref(value) { + var href = String(value || '').trim() + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, "'"); + if (!href || /[\u0000-\u001f<>"']/.test(href)) return ''; + var lower = href.toLowerCase(); + if (lower.startsWith('javascript:') || lower.startsWith('data:') || lower.startsWith('vbscript:')) return ''; + if (href.startsWith('/') || href.startsWith('#')) return href; + if (/^https?:\/\//i.test(href) || /^mailto:/i.test(href)) return href; + return ''; + } + + function isMnoteCitationHref(href) { + try { + var url = new URL(href, window.location.origin); + return url.origin === window.location.origin && url.pathname.startsWith('/documents/'); + } catch (_error) { + return false; + } + } + function renderPageAiMarkdown(content) { var lines = String(content || '').replace(/\r\n?/g, '\n').split('\n'); var blocks = []; diff --git a/rust/crates/mnote-web/browser/sidebar-page-ai-profile-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-ai-profile-runtime.js index fc5527e6..48022b5f 100644 --- a/rust/crates/mnote-web/browser/sidebar-page-ai-profile-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-page-ai-profile-runtime.js @@ -154,7 +154,7 @@ export function createSidebarPageAiProfileRuntime(context) { function pageAiSessionAgentFilterOptions(rows) { var byValue = { all: '全部 agent' }; - pageAiNormalizeArray(rows).forEach(function(session) { + pageAiNormalizeArray(rows).filter(pageAiVisibleHistorySession).forEach(function(session) { var value = pageAiSessionAgentFilterValue(session); byValue[value] = pageAiSessionAgentLabel(session); }); @@ -163,9 +163,16 @@ export function createSidebarPageAiProfileRuntime(context) { }); } + function pageAiVisibleHistorySession(session) { + var source = String(session && session.source || '').trim(); + var status = String(session && session.status || '').trim(); + var messages = pageAiNormalizeArray(session && session.messages); + return !(source === 'draft' && status === 'draft' && messages.length === 0); + } + function pageAiFilteredHistoryRows(rows) { var filterValue = String(pageUiState.pageAiSessionAgentFilter || 'all').trim() || 'all'; - var normalized = pageAiNormalizeArray(rows); + var normalized = pageAiNormalizeArray(rows).filter(pageAiVisibleHistorySession); if (filterValue === 'all') return normalized; return normalized.filter(function(session) { return pageAiSessionAgentFilterValue(session) === filterValue; diff --git a/rust/crates/mnote-web/browser/sidebar-page-ai-render-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-ai-render-runtime.js index f03653e4..df2ec282 100644 --- a/rust/crates/mnote-web/browser/sidebar-page-ai-render-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-page-ai-render-runtime.js @@ -563,10 +563,13 @@ export function createSidebarPageAiRenderRuntime(context) { var skillSourceSelect = drawer.querySelector('[data-page-ai-skill-source-select]'); if (skillSourceSelect instanceof HTMLSelectElement) { var activeSkillSource = pageAiCurrentSkillSource(); - skillSourceSelect.innerHTML = pageAiSkillSourceOptions().map(function(option) { + var sourceOptions = pageAiSkillSourceOptions(); + skillSourceSelect.innerHTML = sourceOptions.map(function(option) { return ''; }).join(''); skillSourceSelect.value = activeSkillSource; + var sourceControl = skillSourceSelect.closest('[data-page-ai-skill-source-control]'); + if (sourceControl instanceof HTMLElement) sourceControl.hidden = sourceOptions.length <= 1; } var hermesBuiltinToggle = drawer.querySelector('[data-page-ai-hide-hermes-builtin]'); if (hermesBuiltinToggle instanceof HTMLInputElement) { @@ -580,7 +583,7 @@ export function createSidebarPageAiRenderRuntime(context) { if (skillList instanceof HTMLElement) { var skills = pageAiFilteredSkillEntries(); if (!skills.length) { - skillList.innerHTML = '
没有匹配的技能。
'; + skillList.innerHTML = '
没有匹配的能力。
'; } else { var sourceParts = pageAiSkillSourceParts(pageAiCurrentSkillSource()); var group = sourceParts.group; @@ -593,14 +596,33 @@ export function createSidebarPageAiRenderRuntime(context) { '' + escapeHtml(String(skills.length) + headingExtra) + '' + '' + (collapsed ? '' : skills.map(function(skill) { - var sourceText = pageAiSkillOriginLabel(skill) - + (skill.configScope ? ' · ' + skill.configScope : '') - + (skill.readOnly ? ' · 只读' : '') - + (skill.modified ? ' · modified' : ''); + var toolCount = Number(skill.toolCount || pageAiNormalizeArray(skill.tools).length || 0); + var disabledToolCount = Number(skill.disabledToolCount || 0); + var capabilityMeta = []; + if (toolCount > 0) capabilityMeta.push(String(toolCount) + ' 工具'); + if (disabledToolCount > 0) capabilityMeta.push(String(disabledToolCount) + ' 已关闭'); + if (pageAiNormalizeArray(skill.requiresContextRefs).length > 0) capabilityMeta.push('需要上下文'); + var categoryText = group === 'mnote' ? String(skill.categoryTitle || skill.category || '').trim() : ''; + var sourceMetaParts = []; + if (group === 'mnote') { + sourceMetaParts.push(categoryText || 'MNote'); + } else { + sourceMetaParts.push(pageAiSkillOriginLabel(skill)); + sourceMetaParts.push('只读查看'); + } + if (capabilityMeta.length) sourceMetaParts = sourceMetaParts.concat(capabilityMeta); + if (skill.readOnly && group === 'mnote') sourceMetaParts.push('只读'); + if (skill.modified) sourceMetaParts.push('modified'); + var sourceText = sourceMetaParts.filter(Boolean).join(' · '); var description = String(skill.description || '').trim(); var hasDescription = description && description !== '---' && description !== '无描述'; var displayName = String(skill.title || skill.name || skill.id || '').trim(); var disabled = skill.toggleable === false || skill.readOnly === true; + var switchControl = group === 'mnote' + ? '' + : '查看'; return '' + '
' + '
' + @@ -610,9 +632,7 @@ export function createSidebarPageAiRenderRuntime(context) { '
' + (hasDescription ? '
' + escapeHtml(description) + '
' : '') + '
' + - '' + + switchControl + ''; }).join('')) + ''; @@ -751,7 +771,7 @@ export function createSidebarPageAiRenderRuntime(context) { '
' + '' + '' + - '' + - '' + + '' + '' + '
' + '' + '
' + '' + '' + '
' + @@ -911,9 +931,9 @@ export function createSidebarPageAiRenderRuntime(context) { '' + '
' + '' + - '
' + + ' @@ -232,6 +232,8 @@ mod tests { include_str!("../../../browser/sidebar-page-tree-runtime.js"); const SIDEBAR_PAGE_AI_RUNTIME_JS: &str = include_str!("../../../browser/sidebar-page-ai-runtime.js"); + const SIDEBAR_PAGE_AI_MARKDOWN_RUNTIME_JS: &str = + include_str!("../../../browser/sidebar-page-ai-markdown-runtime.js"); const SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS: &str = include_str!("../../../browser/sidebar-page-ai-render-runtime.js"); const SIDEBAR_PAGE_AI_PERMISSION_RUNTIME_JS: &str = @@ -499,6 +501,9 @@ mod tests { assert!(html.contains(r#"data-icon="manage_search""#)); assert!(html.contains(r#"data-testid="mnote-local-ocr-task-toggle""#)); assert!(html.contains(r#"data-mnote-action="open-ocr-settings""#)); + assert!(html.contains(r#"data-testid="mnote-floating-task-toggle""#)); + assert!(html.contains(r#"data-mnote-action="toggle-ocr-tasks""#)); + assert!(html.contains(r#"data-icon="pending_actions""#)); } #[test] @@ -515,12 +520,30 @@ mod tests { "新增索引范围必须保留空白输入行,不能先过滤成默认 ." ); assert!( - SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("if (!values.length) values = [''];"), - "删除最后一个索引范围时 UI 应显示空行,不能强制回填 ." + SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-local-index-persisted=\"true\""), + "已保存索引范围必须锁定为不可直接修改" + ); + assert!( + SIDEBAR_PAGE_SETTINGS_RUNTIME_JS + .contains("return currentLocalIndexRangeValues(popover).map"), + "删除最后一个索引范围后保存应提交空数组,不能强制回填 ." + ); + assert!( + SIDEBAR_PAGE_SETTINGS_RUNTIME_JS + .contains("var savedIncludePaths = Array.isArray(settings.includePaths) ? settings.includePaths : [];"), + "索引面板默认不应把空配置回填成工作区根目录" + ); + assert!( + SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains( + "var includePaths = savedIncludePaths.length ? savedIncludePaths : indexedPaths;" + ), + "无用户配置但已有索引缓存时仍应展示旧索引范围,便于删除" ); assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS .contains("data-local-ocr-settings-action=\"run-active\"")); assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mnote:local-ocr-settings-action")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("[data-mnote-action=\"toggle-ocr-tasks\"]")); + assert!(SIDEBAR_TREE_RUNTIME_JS.contains("detail: { action: 'tasks' }")); assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-page-settings-tab=\"index\"")); assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("本地索引仅在本地工作区页面可用")); assert!( @@ -617,6 +640,11 @@ mod tests { assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("function pageAiLoadBackendSessions")); assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("/api/hermes/client/sessions?")); assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("params.set('source', 'acp')")); + assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains( + "var draftSessions = pageAiNormalizeArray(pageUiState.pageAiSessions).filter" + )); + assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS + .contains("pageAiDedupeSessions(backendSessions.concat(draftSessions))")); assert!( SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("function pageAiLoadBackendSessionDetail") ); @@ -625,6 +653,9 @@ mod tests { assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-delete")); assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-resume")); assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-search")); + assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("function pageAiRenderThoughtGroup")); + assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-thought-card")); + assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-collapse-card")); assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiUsageSummary")); assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("usage.updated")); assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("thought.delta")); @@ -664,9 +695,11 @@ mod tests { assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("function pageAiSessionStorageLabel")); assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("local_private")); assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("local_shared")); + assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("sqlite_control_plane")); assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("convex_acp_runtime_store")); assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("本地私有")); assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("共享会话")); + assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("账号会话")); assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("云端会话")); assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("sessionStorage:")); assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("permissionLevel:")); @@ -688,6 +721,10 @@ mod tests { assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiNormalizeAcpRuntimes(acpRuntimes)")); assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiAcpRuntimeSelect.value || 'reasonix'")); assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function handlePageAiClick")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiOpenCitationUrl")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("a[data-page-ai-citation-link=\"true\"]")); + assert!(SIDEBAR_PAGE_AI_MARKDOWN_RUNTIME_JS.contains("data-page-ai-citation-link=\"true\"")); + assert!(SIDEBAR_PAGE_AI_MARKDOWN_RUNTIME_JS.contains("target=\"_blank\"")); assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function handlePageAiChange")); assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function installPageAiDelegates")); assert!(SIDEBAR_TREE_RUNTIME_JS.contains("sidebarPageAi.installPageAiDelegates()")); @@ -722,7 +759,8 @@ mod tests { #[test] fn sidebar_workspace_source_switch_remembers_real_cloud_workspace_before_local_folder() { assert!( - !SIDEBAR_TREE_RUNTIME_JS.contains("rememberCloudWorkspaceId(resolveWorkspaceId(document.body))"), + !SIDEBAR_TREE_RUNTIME_JS + .contains("rememberCloudWorkspaceId(resolveWorkspaceId(document.body))"), "进入本地文件夹前不能用 document.body 推导 workspaceId;它会在无 workspaceId URL 时回退成 default" ); assert!( @@ -881,7 +919,8 @@ mod tests { assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("uploadFilesWithResolvedTarget")); assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("__mnoteLastEditorUploadRoot")); assert!( - LOCAL_UPLOAD_RUNTIME_JS.contains("var imageUrl = localOpenUrl || fallbackUrl || markdownHref;"), + LOCAL_UPLOAD_RUNTIME_JS + .contains("var imageUrl = localOpenUrl || fallbackUrl || markdownHref;"), "图片上传首屏必须使用本地 open URL 渲染,避免相对 href 在 /documents 下命中退役 Convex 兼容路径" ); assert!( @@ -1530,8 +1569,9 @@ mod tests { ); assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS .contains("fileTreeViewState.selectedRowIds.add(rowId)")); - assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS - .contains("row.setAttribute('data-selected', String(fileTreeViewState.selectedRowIds.has(rowId)))")); + assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains( + "row.setAttribute('data-selected', String(fileTreeViewState.selectedRowIds.has(rowId)))" + )); assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains( "row.setAttribute('data-focused', String(rowId === fileTreeViewState.focusedRowId))" )); diff --git a/rust/crates/mnote-web/src/ssr/styles.rs b/rust/crates/mnote-web/src/ssr/styles.rs index 0582e3c4..408b6792 100644 --- a/rust/crates/mnote-web/src/ssr/styles.rs +++ b/rust/crates/mnote-web/src/ssr/styles.rs @@ -1732,7 +1732,8 @@ body { justify-content: center; flex: 0 0 20px; color: #9A958F; - overflow: hidden; + overflow: visible; + position: relative; } .sidebar-tree .tree-kind-badge::before { @@ -1745,6 +1746,32 @@ body { mask: var(--mnote-filetree-icon-mask) center / contain no-repeat; } +.sidebar-tree .tree-row[data-index-status="indexed"] .tree-kind-badge::after, +.sidebar-tree .tree-row[data-index-status="failed"] .tree-kind-badge::after { + position: absolute; + left: -1px; + bottom: 1px; + width: 9px; + height: 9px; + border-radius: 2px; + color: #FFFFFF; + font-size: 8px; + line-height: 9px; + font-weight: 700; + text-align: center; + box-shadow: 0 0 0 1px #FFFFFF; +} + +.sidebar-tree .tree-row[data-index-status="indexed"] .tree-kind-badge::after { + content: "✓"; + background: #38B86E; +} + +.sidebar-tree .tree-row[data-index-status="failed"] .tree-kind-badge::after { + content: "x"; + background: #D94841; +} + .sidebar-tree .tree-kind-badge[data-kind="page"]::before { --mnote-filetree-icon-mask: var(--mnote-filetree-icon-file); } @@ -2783,9 +2810,10 @@ body { .mnote-local-ocr-task-dock { position: fixed; - right: 16px; - top: 48px; - z-index: 90; + top: 12px; + right: 12px; + bottom: 12px; + z-index: 89; color: #37352f; font-size: 13px; pointer-events: none; @@ -2813,30 +2841,61 @@ body { } .mnote-local-ocr-task-drawer { - width: min(360px, calc(100vw - 36px)); - max-height: min(420px, calc(100vh - 120px)); - overflow: auto; - border: 1px solid rgba(55, 53, 47, 0.14); - border-radius: 6px; - background: #FFF; - box-shadow: 0 14px 36px rgba(15, 23, 42, 0.16); + width: min(440px, calc(100vw - 24px)); + height: 100%; pointer-events: auto; } +.mnote-local-ocr-task-drawer[hidden] { + display: none !important; +} + +.mnote-local-ocr-task-panel { + height: 100%; + display: flex; + flex-direction: column; + gap: 12px; + padding: 14px; + border: 1px solid rgba(27, 28, 28, 0.08); + border-radius: 14px; + background: rgba(255, 255, 255, 0.98); + box-shadow: -18px 0 42px rgba(27, 28, 28, 0.14); + box-sizing: border-box; +} + .mnote-local-ocr-task-head { display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; gap: 12px; - padding: 10px 12px; - border-bottom: 1px solid rgba(55, 53, 47, 0.1); +} + +.mnote-local-ocr-task-head > div { + min-width: 0; +} + +.mnote-local-ocr-task-head strong { + display: block; + color: #1B1C1C; + font-size: 16px; + font-weight: 650; + line-height: 22px; +} + +.mnote-local-ocr-task-head span { + display: block; + margin-top: 2px; + color: #8B8782; + font-size: 12px; + line-height: 18px; } .mnote-local-ocr-task-close { - width: 24px; - height: 24px; + flex: 0 0 auto; + width: 28px; + height: 28px; border: 0; - border-radius: 4px; + border-radius: 6px; background: transparent; color: #787774; cursor: pointer; @@ -2847,55 +2906,208 @@ body { color: #37352f; } -.mnote-local-ocr-task-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 10px 12px; - border-bottom: 1px solid rgba(55, 53, 47, 0.08); +.mnote-local-ocr-task-tabs { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 4px; + padding: 3px; + border-radius: 8px; + background: #F4F3F2; } -.mnote-local-ocr-task-main { +.mnote-local-ocr-task-tabs button { min-width: 0; -} - -.mnote-local-ocr-task-main strong, -.mnote-local-ocr-task-main span { - display: block; + height: 28px; + border: 0; + border-radius: 6px; + background: transparent; + color: #5A5A5A; + font-size: 12px; + cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.mnote-local-ocr-task-main span, -.mnote-local-ocr-task-empty { +.mnote-local-ocr-task-tabs button[aria-selected="true"] { + background: #FFF; + color: #1B1C1C; + box-shadow: 0 1px 4px rgba(27, 28, 28, 0.08); +} + +.mnote-local-ocr-task-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.mnote-local-ocr-task-toolbar span { + color: #8B8782; + font-size: 12px; +} + +.mnote-local-ocr-task-toolbar button, +.mnote-local-ocr-task-actions button { + min-height: 28px; + border: 1px solid rgba(27, 28, 28, 0.08); + border-radius: 6px; + background: #FFF; + color: #1B1C1C; + font-size: 12px; + cursor: pointer; +} + +.mnote-local-ocr-task-toolbar button:disabled { + color: #AAA6A0; + cursor: default; +} + +.mnote-local-ocr-task-list { + min-height: 0; + overflow: auto; + display: flex; + flex-direction: column; + gap: 8px; +} + +.mnote-local-ocr-task-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 12px; + padding: 10px; + border: 1px solid rgba(27, 28, 28, 0.08); + border-radius: 8px; + background: #FFF; +} + +.mnote-local-ocr-task-main { + min-width: 0; + display: grid; + gap: 5px; +} + +.mnote-local-ocr-task-title-line { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.mnote-local-ocr-task-main strong { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #1B1C1C; + font-size: 13px; + font-weight: 600; + line-height: 18px; +} + +.mnote-local-ocr-task-main em { + flex: 0 0 auto; + padding: 1px 6px; + border-radius: 999px; + background: #F0EFED; + color: #8B8782; + font-size: 10px; + font-style: normal; + line-height: 15px; +} + +.mnote-local-ocr-task-row[data-mnote-local-ocr-task-category="attention"] .mnote-local-ocr-task-main em { + background: #FEE2E2; + color: #B3261E; +} + +.mnote-local-ocr-task-row[data-mnote-local-ocr-task-category="active"] .mnote-local-ocr-task-main em { + background: #DBEAFE; + color: #1D4ED8; +} + +.mnote-local-ocr-task-main span { + display: block; + min-width: 0; + overflow: hidden; color: #787774; + font-size: 12px; + line-height: 17px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mnote-local-ocr-task-progress { + position: relative; + height: 4px; + overflow: hidden; + border-radius: 999px; + background: #ECE9E4; +} + +.mnote-local-ocr-task-progress i { + display: block; + height: 100%; + border-radius: inherit; + background: #5B8DEF; +} + +.mnote-local-ocr-task-progress[data-progress-mode="indeterminate"] i { + width: 40%; + animation: mnote-task-progress-slide 1.15s ease-in-out infinite; +} + +@keyframes mnote-task-progress-slide { + 0% { + transform: translateX(-120%); + } + 100% { + transform: translateX(260%); + } } .mnote-local-ocr-task-empty { - padding: 12px; + padding: 24px 12px; + border: 1px dashed rgba(27, 28, 28, 0.12); + border-radius: 8px; + color: #787774; + text-align: center; } .mnote-local-ocr-task-actions { - display: flex; + display: grid; flex: 0 0 auto; gap: 6px; } .mnote-local-ocr-task-actions button { - height: 26px; - border: 1px solid rgba(55, 53, 47, 0.14); - border-radius: 4px; - background: #FFF; - color: #37352f; - cursor: pointer; + padding: 0 8px; } .mnote-local-ocr-task-actions button[data-mnote-local-ocr-task-delete] { color: #b3261e; } +@media (max-width: 720px) { + .mnote-local-ocr-task-dock { + left: 12px; + } + + .mnote-local-ocr-task-drawer { + width: 100%; + } + + .mnote-local-ocr-task-row { + grid-template-columns: 1fr; + } + + .mnote-local-ocr-task-actions { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + .mnote-resource-tab-frame, .mnote-resource-tab-image, .mnote-resource-tab-text-shell { @@ -3481,7 +3693,7 @@ body { cursor: pointer; } -.wolai-floating-button--help { +.wolai-floating-button--tasks { width: 40px; height: 40px; border: 1px solid rgba(27, 28, 28, 0.1); @@ -3767,6 +3979,12 @@ body { color: #A19D97; } +.wolai-page-settings-index-root-hint { + color: #8B8782; + font-size: 11px; + white-space: nowrap; +} + .wolai-page-settings-index-remove, .wolai-page-settings-index-add { border: 1px solid #D8D4CE; @@ -4597,13 +4815,14 @@ body { .wolai-page-ai-skills-toolbar { display: grid; - grid-template-columns: minmax(0, 1fr) minmax(124px, 160px) max-content; + grid-template-columns: minmax(0, 1fr); align-items: end; gap: 8px; } .wolai-page-ai-skill-filter-toggle { display: inline-flex; + grid-column: 1 / -1; min-height: 34px; align-items: center; gap: 6px; @@ -4670,10 +4889,10 @@ body { .wolai-page-ai-skill-row { display: flex; min-height: 38px; - align-items: center; + align-items: flex-start; justify-content: space-between; gap: 10px; - padding: 6px 8px; + padding: 8px; border: 0; border-radius: 6px; background: transparent; @@ -4687,20 +4906,21 @@ body { display: grid; gap: 2px; min-width: 0; + flex: 1 1 auto; } .wolai-page-ai-skill-main { - display: flex; + display: grid; min-width: 0; - align-items: center; - gap: 8px; + gap: 2px; } .wolai-page-ai-skill-name { min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + overflow: visible; + text-overflow: clip; + white-space: normal; + word-break: break-word; } .wolai-page-ai-skill-desc { @@ -4708,18 +4928,22 @@ body { color: #5A5A5A; font-size: 11px; line-height: 15px; - text-overflow: ellipsis; - white-space: nowrap; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + text-overflow: clip; + white-space: normal; } .wolai-page-ai-skill-source { - flex: 0 0 auto; - padding: 1px 5px; - border-radius: 999px; - background: #F0EFED; + padding: 0; + border-radius: 0; + background: transparent; color: #8B8782; font-size: 10px; line-height: 14px; + white-space: normal; + word-break: break-word; } .wolai-page-ai-skill-switch { @@ -4753,6 +4977,17 @@ body { transform: translateX(14px); } +.wolai-page-ai-skill-readonly-badge { + flex: 0 0 auto; + margin-top: 2px; + padding: 2px 6px; + border-radius: 999px; + background: #F0EFED; + color: #8B8782; + font-size: 10px; + line-height: 14px; +} + .wolai-page-ai-message { display: flex; flex-direction: column; @@ -4867,6 +5102,61 @@ button.wolai-page-ai-message-text { white-space: nowrap; } +.wolai-page-ai-tool-group-list { + display: grid; + gap: 8px; + margin-top: 8px; +} + +.wolai-page-ai-thought-group-list { + min-width: 0; + margin-top: 8px; + padding-left: 10px; + border-left: 2px solid rgba(27, 28, 28, 0.12); + color: #6F6B66; + font-size: 12px; + line-height: 18px; + white-space: pre-wrap; +} + +.wolai-page-ai-tool-item { + display: grid; + gap: 4px; + min-width: 0; + padding: 8px; + border: 1px solid rgba(27, 28, 28, 0.08); + border-radius: 6px; + background: rgba(255, 255, 255, 0.58); +} + +.wolai-page-ai-tool-item-head { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(92px, auto); + gap: 8px; + align-items: center; +} + +.wolai-page-ai-tool-item-head strong { + min-width: 0; + overflow: hidden; + color: #1B1C1C; + font-size: 12px; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wolai-page-ai-tool-item-head span { + min-width: 0; + overflow: hidden; + color: #8B8782; + font-size: 11px; + line-height: 16px; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + .wolai-page-ai-footer { position: relative; z-index: 20; diff --git a/rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs b/rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs index 5c45824f..c32d5696 100644 --- a/rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs +++ b/rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs @@ -16,6 +16,7 @@ pub struct FileTreeRenderRow { pub asset_id: Option, pub relative_path: Option, pub object_identity: Option, + pub index_status: Option, pub selected: bool, } @@ -90,8 +91,13 @@ fn render_filetree_row( _ => "", }; let owner_document_id = row.document_id.as_deref().unwrap_or_default(); + let index_status_attr = row + .index_status + .as_deref() + .map(|status| format!(r#" data-index-status="{}""#, escape_html(status))) + .unwrap_or_default(); html.push_str(&format!( - r#"
  • {toggle_html}
    {create_action_html}
    "#, + r#"
  • {toggle_html}
    {create_action_html}
    "#, node_id = escape_html(&row.node_id), aria_level = row.depth + 1, expanded_attr = if row.expandable && row.expanded { "true" } else { "false" }, @@ -104,6 +110,7 @@ fn render_filetree_row( asset_id = escape_html(row.asset_id.as_deref().unwrap_or_default()), relative_path = escape_html(row.relative_path.as_deref().unwrap_or_default()), object_identity = escape_html(row.object_identity.as_deref().unwrap_or_default()), + index_status_attr = index_status_attr, selected = row.selected, toggle_html = toggle_html, icon_kind = escape_html(&row.icon_kind), @@ -182,6 +189,7 @@ mod tests { object_identity: Some( r#"{"objectKind":"page","documentId":"page_root","blockId":null,"assetId":null}"#.into(), ), + index_status: None, selected: true, }, FileTreeRenderRow { @@ -200,6 +208,7 @@ mod tests { object_identity: Some( r#"{"objectKind":"mindmap","documentId":"page_root","blockId":null,"assetId":"mind_1"}"#.into(), ), + index_status: None, selected: false, }, ], @@ -241,6 +250,7 @@ mod tests { asset_id: None, relative_path: Some("docs".into()), object_identity: None, + index_status: None, selected: false, }, FileTreeRenderRow { @@ -257,6 +267,7 @@ mod tests { asset_id: None, relative_path: Some("docs/README.md".into()), object_identity: None, + index_status: None, selected: false, }, ], @@ -286,11 +297,13 @@ mod tests { asset_id: None, relative_path: Some("design/03-rust-web".into()), object_identity: None, + index_status: Some("indexed".into()), selected: false, }], }); assert!(html.contains(r#"data-local-relative-path="design/03-rust-web""#)); + assert!(html.contains(r#"data-index-status="indexed""#)); assert!(html.contains(r#"