diff --git a/.gitignore b/.gitignore index 9a9f924ca..876553714 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,8 @@ pnpm-debug.log* # Brainstorm assets (not tracked in source control) .superpowers/ .claude/ + +# Local code-analysis caches +.codegraph/ +.understand-anything/ +.remember/ diff --git a/README.md b/README.md index 4d1059842..88eddcfb4 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ - **Vector Semantic Search** — optional embedding-based retrieval via LanceDB, supports any OpenAI-compatible endpoint - **Persistent Ingest Queue** — serial processing with crash recovery, cancel, retry, and progress visualization - **Folder Import** — recursive folder import preserving directory structure, folder context as LLM classification hint +- **Finance Source Naming** — Finance template normalizes imported meeting notes to `yyyymmdd-ticker-name-title` using a `stock_basic.csv` lookup ([docs](docs/finance-source-naming.md)) - **Source Folder Auto-Watch** — detects external changes in `raw/sources/` and keeps ingest/delete cleanup in sync - **Deep Research** — LLM-optimized search topics, multi-query web search via Tavily, SerpApi, or SearXNG, auto-ingest results into wiki - **Async Review System** — LLM flags items for human judgment, predefined actions, pre-generated search queries @@ -383,10 +384,12 @@ Download from [Releases](https://github.com/nashsu/llm_wiki/releases): ### Build from Source ```bash -# Prerequisites: Node.js 20+, Rust 1.70+ +# Prerequisites: Node.js 20+, Rust 1.70+, protobuf (macOS: brew install protobuf / Windows: choco install protoc) +# If Rust was just installed via rustup, open a new terminal (or run `source ~/.cargo/env`) first git clone https://github.com/nashsu/llm_wiki.git cd llm_wiki npm install +npm --prefix mcp-server ci && npm run mcp:build # Build the bundled MCP server (required by the Tauri bundle) npm run tauri dev # Development npm run tauri build # Production build ``` diff --git a/README_CN.md b/README_CN.md index abf9cbffb..0c41b0b39 100644 --- a/README_CN.md +++ b/README_CN.md @@ -39,6 +39,7 @@ - **向量语义搜索** — 可选的 embedding 检索,基于 LanceDB,支持任意 OpenAI 兼容端点 - **持久化摄入队列** — 串行处理,崩溃恢复,取消/重试,进度可视化 - **文件夹导入** — 递归导入保留目录结构,文件夹路径作为 LLM 分类上下文 +- **金融来源命名规范化** — 金融研究模板导入纪要时自动规范为 `yyyymmdd-代码-简称-标题`,基于 `stock_basic.csv` 匹配个股([使用指南](docs/finance-source-naming.md)) - **Source 文件夹自动监听** — 检测 `raw/sources/` 的外部变更,并同步触发摄入或删除清理 - **深度研究** — LLM 智能生成搜索主题,通过 Tavily、SerpApi 或 SearXNG 进行多查询网络搜索,研究结果自动摄入 Wiki - **异步审核系统** — LLM 在摄入时标记需人工判断的项,预定义操作,预生成搜索查询 @@ -383,10 +384,12 @@ LLM Wiki 是一个跨平台桌面应用,能将你的文档自动转化为有 ### 从源码构建 ```bash -# 前置条件:Node.js 20+, Rust 1.70+ +# 前置条件:Node.js 20+, Rust 1.70+, protobuf(macOS: brew install protobuf / Windows: choco install protoc) +# 若刚通过 rustup 安装 Rust,请先重开终端(或执行 `source ~/.cargo/env`)使 cargo 生效 git clone https://github.com/nashsu/llm_wiki.git cd llm_wiki npm install +npm --prefix mcp-server ci && npm run mcp:build # 构建内置 MCP server(Tauri 打包资源依赖) npm run tauri dev # 开发模式 npm run tauri build # 生产构建 ``` diff --git a/README_JA.md b/README_JA.md index 0c8bc0317..62c9efb60 100644 --- a/README_JA.md +++ b/README_JA.md @@ -39,6 +39,7 @@ - **ベクトル意味検索** — LanceDB ベースの任意 OpenAI 互換エンドポイントによる埋め込み検索(オプション) - **永続インジェストキュー** — 直列処理、クラッシュ復旧、キャンセル、リトライ、進捗の可視化に対応 - **フォルダインポート** — ディレクトリ構造を保持した再帰インポート。フォルダパスを LLM の分類ヒントとして利用 +- **金融ソース命名の正規化** — 金融リサーチテンプレートでは、インポートした会議メモを `stock_basic.csv` の照合により `yyyymmdd-銘柄コード-銘柄名-タイトル` 形式へ自動正規化([ドキュメント](docs/finance-source-naming.md)) - **ソースフォルダの自動監視** — `raw/sources/` の外部変更を検出し、インジェスト・削除クリーンアップを同期 - **Deep Research** — LLM が検索トピックを最適化生成し、Tavily / SerpApi / SearXNG によるマルチクエリ Web 検索結果を自動で Wiki 化 - **非同期レビューシステム** — LLM が人間の判断を要する項目を作成し、定義済みアクションと事前生成された検索クエリを付与 @@ -392,10 +393,12 @@ LLM Wiki は、手元の文書を整理された相互リンク付きの知識 ### ソースからビルド ```bash -# 前提条件: Node.js 20+, Rust 1.70+ +# 前提条件: Node.js 20+, Rust 1.70+, protobuf(macOS: brew install protobuf / Windows: choco install protoc) +# rustup で Rust をインストールした直後は、新しいターミナルを開く(または `source ~/.cargo/env` を実行) git clone https://github.com/nashsu/llm_wiki.git cd llm_wiki npm install +npm --prefix mcp-server ci && npm run mcp:build # 同梱 MCP server のビルド(Tauri バンドルに必須) npm run tauri dev # 開発モード npm run tauri build # 本番ビルド ``` diff --git a/README_KO.md b/README_KO.md index 7d062342b..c11fac732 100644 --- a/README_KO.md +++ b/README_KO.md @@ -39,6 +39,7 @@ - **벡터 의미 검색** — LanceDB 기반의 선택적 임베딩 검색으로, OpenAI 호환 엔드포인트를 지원합니다 - **영속 인제스트 큐** — 직렬 처리, 충돌 복구, 취소, 재시도, 진행 상황 시각화를 지원합니다 - **폴더 가져오기** — 디렉터리 구조를 유지하며 재귀적으로 가져오고, 폴더 컨텍스트를 LLM 분류 힌트로 사용합니다 +- **금융 소스 이름 정규화** — 금융 리서치 템플릿은 가져온 미팅 노트를 `stock_basic.csv` 조회를 통해 `yyyymmdd-종목코드-종목명-제목` 형식으로 자동 정규화합니다 ([문서](docs/finance-source-naming.md)) - **소스 폴더 자동 감시** — `raw/sources/`의 외부 변경을 감지하고 인제스트/삭제 정리 흐름과 동기화합니다 - **Deep Research** — LLM에 최적화된 검색 주제와 Tavily, SerpApi, SearXNG 기반 다중 쿼리 웹 검색을 사용하고, 결과를 자동으로 Wiki에 인제스트합니다 - **비동기 리뷰 시스템** — LLM이 사람의 판단이 필요한 항목을 표시하고, 사전 정의된 작업과 미리 생성된 검색 쿼리를 제공합니다 @@ -392,10 +393,12 @@ LLM Wiki는 문서를 자동으로 정리되고 서로 연결된 지식 베이 ### 소스에서 빌드 ```bash -# Prerequisites: Node.js 20+, Rust 1.70+ +# Prerequisites: Node.js 20+, Rust 1.70+, protobuf (macOS: brew install protobuf / Windows: choco install protoc) +# rustup으로 Rust를 방금 설치했다면 새 터미널을 열거나 `source ~/.cargo/env`를 먼저 실행하세요 git clone https://github.com/nashsu/llm_wiki.git cd llm_wiki npm install +npm --prefix mcp-server ci && npm run mcp:build # 번들 MCP server 빌드 (Tauri 번들 필수 리소스) npm run tauri dev # Development npm run tauri build # Production build ``` diff --git a/docs/finance-source-naming.md b/docs/finance-source-naming.md new file mode 100644 index 000000000..e954531eb --- /dev/null +++ b/docs/finance-source-naming.md @@ -0,0 +1,134 @@ +# 金融来源文件名规范化使用指南 + +选择 **金融研究(Finance Research)** 模板的项目可以在导入纪要时自动把文件名规范化为: + +``` +yyyymmdd-<股票代码|NA>-<简称>-<标题>.<扩展名> +``` + +例如 `悦安电感材料专家交流260701.docx` 导入后变成 +`20260701-688786.SH-悦安新材-电感材料专家交流.docx`。日期与标的从此进入来源身份, +摘要页标题、检索结果、LLM 上下文里都能看到,deep-research 与矛盾判定也就有了时间锚点。 + +## 快速开始 + +1. 新建项目时选择 **金融研究** 模板,保持"导入来源时规范化文件名"复选框勾选(默认开启) +2. 把 `stock_basic.csv`(见下节)放到两处**任一**位置: + + **推荐:应用数据目录(全局,放一次所有金融项目共享)** + + | 系统 | 路径 | + |------|------| + | macOS | `~/Library/Application Support/com.llmwiki.app/` | + | Windows | `%APPDATA%\com.llmwiki.app\` | + | Linux | `~/.local/share/com.llmwiki.app/` | + + **或:项目根目录(仅该项目生效,且会覆盖全局同名表)** + + ``` + 我的金融库/ + ├── stock_basic.csv ← 项目级覆盖放这里 + ├── purpose.md + ├── raw/ + └── wiki/ + ``` + +3. 正常拖拽/导入纪要文件即可,改名自动完成 + +两处都没有表也能用:只做日期规范化,个股段一律为 `NA`。 + +> ⚠️ **数据时效**:成分股随上市/退市/改名持续变化,应用不会自动更新这两张表—— +> 建议定期(如每季度)重新从 tushare 导出覆盖全局目录中的文件。 + +## stock_basic.csv 的结构 + +按**表头**识别列,列顺序无所谓,多余的列会被忽略(pandas `to_csv` 未加 +`index=False` 时产生的首列无名索引也没关系)。必需与可选列: + +| 列名 | 必需 | 说明 | 示例 | +|------|------|------|------| +| `ts_code` | ✅ | 带交易所后缀的代码,将写入文件名 | `688786.SH` | +| `name` | ✅ | 股票简称,用于匹配文件名中的标的 | `悦安新材` | +| `cnspell` | 可选 | 拼音缩写(当前保留字段) | `yaxc` | + +最小可用示例: + +```csv +ts_code,name,cnspell +688786.SH,悦安新材,yaxc +002738.SZ,中矿资源,zkzy +600519.SH,贵州茅台,gzmt +``` + +用 [tushare](https://tushare.pro) 一次导出全部 A 股: + +```python +import tushare as ts + +pro = ts.pro_api("你的token") +df = pro.stock_basic(exchange="", list_status="L", + fields="ts_code,symbol,name,area,industry,cnspell,market") +df.to_csv("stock_basic.csv", index=False) +``` + +### 港股:hk_basic.csv(可选) + +港股不在 `stock_basic` 表内。把 tushare `hk_basic` 导出为**单独的** +`hk_basic.csv`,同样放在项目根目录即可(不要与 A 股表合并成一个文件—— +两表列顺序不同且拼音列名不同,`hk_basic` 是 `cn_spell`,两种列名都能识别): + +```python +df = pro.hk_basic(list_status="L", + fields="ts_code,name,fullname,enname,cn_spell,market") +df.to_csv("hk_basic.csv", index=False) +``` + +A+H 两地上市的公司(如中芯国际)在两张表中同名,匹配时**A 股代码优先**; +若某篇纪要明确谈的是港股主体,可在导入后按审计表手工核对。 +美股暂无约定文件,相关纪要落 `NA` 格式。 + +## 命名规则细节 + +**日期提取**(按优先级,均校验月/日合法性): + +1. 8 位 `yyyymmdd`(如 `20260630`),任意位置 +2. 6 位 `yymmdd`(如 `260607` → `20260607`),年份限 2020–2039 窗口—— + 所以裸股票代码(如 `600519`)不会被误判成日期 +3. 4 位 `mmdd`(如 `0701`),**仅当位于文件名主干结尾**——避免 `1206` 这类型号/规格被误判 +4. 都提取不到 → 使用文件的修改时间,并在审计表里标注 `dateSource: "fallback"` + +只有被实际采用的那段日期会从标题中移除,`2028年产能`、`1600V` 这类数字原样保留。 + +**个股匹配**:先找简称全称,找不到再按前缀递减(最短两个字,`悦安` 能命中 `悦安新材`), +取最长的唯一命中。同一强度命中多只个股(歧义)或零命中时**不猜**,落 `NA` 格式。 + +## 改名审计与排错 + +每次实际发生的改名都会追加记录到 `<项目>/.llm-wiki/rename-map.json`: + +```json +{ + "original": "悦安电感材料专家交流260701.docx", + "renamed": "20260701-688786.SH-悦安新材-电感材料专家交流.docx", + "date": "20260701", + "dateSource": "filename", + "tsCode": "688786.SH", + "stockName": "悦安新材", + "matchedBy": "name-prefix", + "importedAt": 1782971234567 +} +``` + +- `renamed` 是**实际落盘**的文件名(重名时追加 `-导入当日日期` 后缀,仍冲突则再加 `-2`、`-3` 序号) +- `dateSource: "fallback"` 表示文件名里提不出日期,用了文件修改时间——值得人工复核 +- `matchedBy: null` 且你预期应命中个股时,检查 CSV 里该股的 `name` 与文件名写法是否对得上 +- 该文件仅供排错,应用运行不依赖它,删除不影响功能 + +## 范围与限制 + +- **文件导入**与**文件夹导入**均改名(文件夹导入只规范叶子文件名,目录结构原样保留); + 定时导入、网页剪藏暂保留原名 +- 已有项目想启用:在项目下手动创建 `.llm-wiki/source-naming.json`,内容为 + `{"mode": "finance"}`,重启应用后生效 +- **不要**对已被摄取过的存量文件在文件系统里手工改名——来源文件名是全库引用的身份主键, + 改名会打断既有 wiki 页面的溯源链 diff --git a/package-lock.json b/package-lock.json index b62c3b8fe..0520635f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -101,7 +101,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -884,7 +883,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", @@ -961,7 +959,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", "license": "MIT", - "peer": true, "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } @@ -983,7 +980,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.41.0.tgz", "integrity": "sha512-6H/qadXsVuDY219Yljhohglve8xf4B8xJkVOEWfA5uiYKiTFppjqsvsfR5iPA0RbvRBoOyTZpbLIxe9+0UR8xA==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", @@ -1135,10 +1131,31 @@ "@noble/ciphers": "^1.0.0" } }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "license": "MIT", "optional": true, "dependencies": { @@ -1591,7 +1608,6 @@ "resolved": "https://registry.npmjs.org/@milkdown/core/-/core-7.20.0.tgz", "integrity": "sha512-X9LaUcIR4Y2oiY2J5tslavlPVOwIB3X8/9z1bOeBjlIPtr+urbkY7YEX86EeLV9LyRQ3+t+jXaLMCIjW1wsV6w==", "license": "MIT", - "peer": true, "dependencies": { "@milkdown/ctx": "7.20.0", "@milkdown/exception": "7.20.0", @@ -1631,7 +1647,6 @@ "resolved": "https://registry.npmjs.org/@milkdown/ctx/-/ctx-7.20.0.tgz", "integrity": "sha512-LUK4xdsUngY2xCCvPTyHPifjAknJ5rE6VBjgsP+LySIUKeFUrhqzo/zz2vaOODKzm3DBMIhpZAoW3MAqxoMGIQ==", "license": "MIT", - "peer": true, "dependencies": { "@milkdown/exception": "7.20.0" } @@ -1898,7 +1913,6 @@ "resolved": "https://registry.npmjs.org/@milkdown/prose/-/prose-7.20.0.tgz", "integrity": "sha512-Qe6jmKcXsjOfpk8duDFdkLCEo5044L8HSyKVn7ewAe7XJJPUM6bPQaP130UAznq75/+TiKxFCzurcrBO3LzNRg==", "license": "MIT", - "peer": true, "dependencies": { "@milkdown/exception": "7.20.0", "prosemirror-changeset": "^2.3.1", @@ -1947,7 +1961,6 @@ "resolved": "https://registry.npmjs.org/@milkdown/transformer/-/transformer-7.20.0.tgz", "integrity": "sha512-h7KGFr1o5AYwc+hEfnA3Dldo4jRrYOB/7KExaqelcjUz++KYI/9LXMOsV7CpgjtLI3Xtf2IIRTZND1+p2nsOaw==", "license": "MIT", - "peer": true, "dependencies": { "@milkdown/exception": "7.20.0", "@milkdown/prose": "7.20.0", @@ -2067,7 +2080,6 @@ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "license": "MIT", - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -3336,7 +3348,6 @@ "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -3346,7 +3357,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3869,7 +3879,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -4042,7 +4051,6 @@ "version": "12.0.0", "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz", "integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==", - "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "12.0.0", "@chevrotain/gast": "12.0.0", @@ -4423,7 +4431,6 @@ "version": "3.33.3", "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.3.tgz", "integrity": "sha512-Gej7U+OKR+LZ8kvX7rb2HhCYJ0IhvEFsnkud4SB1PR+BUY/TsSO0dmOW59WEVLu51b1Rm+gQRKoz4bLYxGSZ2g==", - "peer": true, "engines": { "node": ">=0.10" } @@ -4809,7 +4816,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", - "peer": true, "engines": { "node": ">=12" } @@ -5356,7 +5362,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -5780,7 +5785,6 @@ "resolved": "https://registry.npmjs.org/graphology/-/graphology-0.26.0.tgz", "integrity": "sha512-8SSImzgUUYC89Z042s+0r/vMibY7GX/Emz4LDO5e7jYXhuoWfHISPFJYjpRLUSJGq6UQ6xlenvX1p/hJdfXuXg==", "license": "MIT", - "peer": true, "dependencies": { "events": "^3.3.0" }, @@ -6061,7 +6065,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.10.tgz", "integrity": "sha512-mx/p18PLy5og9ufies2GOSUqep98Td9q4i/EF6X7yJgAiIopxqdfIO3jbqsi3jRgTgw88jMDEzVKi+V2EF+27w==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -6146,7 +6149,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.29.2" }, @@ -8798,7 +8800,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", - "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -8832,7 +8833,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -8866,7 +8866,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz", "integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -8992,7 +8991,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -9002,7 +9000,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9663,7 +9660,6 @@ "resolved": "https://registry.npmjs.org/sigma/-/sigma-3.0.2.tgz", "integrity": "sha512-/BUbeOwPGruiBOm0YQQ6ZMcLIZ6tf/W+Jcm7dxZyAX0tK3WP9/sq7/NAWBxPIxVahdGjCJoGwej0Gdrv0DxlQQ==", "license": "MIT", - "peer": true, "dependencies": { "events": "^3.3.0", "graphology-utils": "^2.5.2" @@ -10126,7 +10122,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10423,7 +10418,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -10643,7 +10637,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.32", "@vue/compiler-sfc": "3.5.32", @@ -10906,7 +10899,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/plans/finance-source-naming.md b/plans/finance-source-naming.md new file mode 100644 index 000000000..22419e0d5 --- /dev/null +++ b/plans/finance-source-naming.md @@ -0,0 +1,30 @@ +# 金融模板与来源文件名规范化预处理 + +## 目标 + +面向金融纪要类知识库:来源文件名统一为 `yyyymmdd--<简称|主体>-<标题>.`, +使时间与标的锚点进入来源身份,全链路(摘要页/检索/LLM 上下文/deep-research)可见。 + +## 组成 + +1. **金融研究模板**(`templates.ts` 新增 `finance`) + - 个股实体页 frontmatter 必带 `ts_code` / `industry` + - schema 写明来源命名规范与时效性矛盾处理规则(同标的冲突优先最新纪要) +2. **命名引擎**(`src/lib/finance-naming.ts`,纯函数 + IO 包装) + - 日期提取:`yyyymmdd` → `yymmdd` → `mmdd`(缺年份按参考年)三级;带月/日合法性 + 校验(避免把 6 位股票代码误判为日期);全部失败回退文件修改日期并标注 + - 个股匹配:读项目根 `stock_basic.csv`(固定约定位置;缺失则仅做日期规范化), + name 包含匹配取最长命中;多只个股同时命中视为歧义 → 落 `NA` 行业格式,不猜 + - 无匹配格式:`yyyymmdd-NA-<清理后的原文件名主干>.` +3. **导入钩子**(`source-lifecycle.ts:importSourceFiles`) + - 项目启用预处理时(`.llm-mwiki/source-naming.json`,建项目复选框写入), + 导入前重命名,成功后追加审计映射 `.llm-wiki/rename-map.json` + (`original/renamed/date/dateSource/tsCode/matchedBy/importedAt`, + 仅供出错排查,运行时无消费方) +4. **建项目对话框**:选金融模板时显示"导入时规范化文件名"复选框(默认勾选) + +## v1 范围外(记录备查) + +- 定时导入 / 剪藏路径暂不重命名(文件夹导入已支持:仅改叶子名、保留目录结构) +- 港股/美股基础表(可后续以同格式 CSV 追加) +- 向量库结构化元数据列(等真实检索需求) diff --git a/src/components/layout/preview-panel.tsx b/src/components/layout/preview-panel.tsx index fa9317ebd..016470f80 100644 --- a/src/components/layout/preview-panel.tsx +++ b/src/components/layout/preview-panel.tsx @@ -1,5 +1,5 @@ import { useEffect, useCallback, useRef } from "react" -import { X } from "lucide-react" +import { ArrowLeft, X } from "lucide-react" import { useWikiStore } from "@/stores/wiki-store" import { readFile, writeFile } from "@/commands/fs" import { getFileCategory, isBinary, isExtractedTextPreviewFile } from "@/lib/file-types" @@ -12,6 +12,7 @@ export function PreviewPanel() { const fileContent = useWikiStore((s) => s.fileContent) const previewContentPath = useWikiStore((s) => s.previewContentPath) const externalPreview = useWikiStore((s) => s.externalPreview) + const previewHistory = useWikiStore((s) => s.previewHistory) const setFileContent = useWikiStore((s) => s.setFileContent) const setSelectedFile = useWikiStore((s) => s.setSelectedFile) const saveTimerRef = useRef | null>(null) @@ -107,9 +108,20 @@ export function PreviewPanel() { return (
- - {fileName} - +
+ {previewHistory.length > 0 && ( + + )} + + {fileName} + +
+ {selectedTemplate === "finance" && ( +
+ +

+ {t("project.financeNamingHint")} +

+
+ )}
diff --git a/src/components/review/review-view.tsx b/src/components/review/review-view.tsx index ded61ce4f..56f593ef9 100644 --- a/src/components/review/review-view.tsx +++ b/src/components/review/review-view.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from "react" +import { useCallback, useEffect, useMemo, useState } from "react" import { queueResearch } from "@/lib/deep-research" import { AlertTriangle, @@ -16,11 +16,17 @@ import { Button } from "@/components/ui/button" import { useReviewStore, type ReviewItem } from "@/stores/review-store" import { useWikiStore } from "@/stores/wiki-store" import { writeFile, readFile, deleteFile } from "@/commands/fs" -import { normalizePath } from "@/lib/path-utils" +import { getFileName, normalizePath } from "@/lib/path-utils" import { refreshProjectFileTree } from "@/lib/project-file-tree-refresh" import { hasConfiguredDeepResearchSources } from "@/lib/web-search" import { makeQueryFileName } from "@/lib/wiki-filename" -import { createReviewPageDrafts } from "@/lib/review-create-page" +import { + buildReviewPageContent, + collectReviewProvenance, + collectReviewSourceIdentities, + createReviewPageDrafts, + type ReviewProvenance, +} from "@/lib/review-create-page" import { cleanAssistantContentForWikiSave, titleFromCleanAssistantContent } from "@/lib/chat-save-to-wiki" import { useTranslation } from "react-i18next" @@ -207,12 +213,12 @@ export function ReviewView() { date: string }> = [] + // 溯源链:自身来源与受影响页面来源的并集,写入新页 sources + const sourceIdentities = await collectReviewSourceIdentities(pp, item, readFile) for (const draft of drafts) { const { date, fileName } = makeQueryFileName(draft.title) const filePath = `${pp}/wiki/${draft.dir}/${fileName}` - const frontmatter = `---\ntype: ${draft.pageType}\ntitle: "${draft.title.replace(/"/g, '\\"')}"\ncreated: ${date}\ntags: []\nrelated: []\n---\n\n` - const body = `# ${draft.title}\n\n${item.description}\n` - const pageContent = frontmatter + body + const pageContent = buildReviewPageContent(draft, item, date, sourceIdentities) await writeFile(filePath, pageContent) created.push({ title: draft.title, dir: draft.dir, fileName, filePath, pageContent, pageType: draft.pageType, date }) } @@ -306,6 +312,8 @@ export function ReviewView() { setSelectedReviewIds(new Set()) }, [dismissItem, selectedPendingIds]) + const projectPath = project ? normalizePath(project.path) : "" + return (
@@ -385,6 +393,7 @@ export function ReviewView() { void onDismiss: (id: string) => void selected: boolean @@ -429,6 +441,18 @@ function ReviewCard({ const { t } = useTranslation() const config = typeConfig[item.type] const Icon = config.icon + // 溯源诊断(来源并集 + 断链原因),异步解析后展示 + const [provenance, setProvenance] = useState(null) + useEffect(() => { + if (!projectPath) return + let cancelled = false + void collectReviewProvenance(projectPath, item, readFile).then((result) => { + if (!cancelled) setProvenance(result) + }) + return () => { + cancelled = true + } + }, [projectPath, item]) return (
0 && (
- Pages: {item.affectedPages.join(", ")} + {t("review.pages")}: {item.affectedPages.join(", ")}
)} + {provenance && (provenance.sourceIdentities.length > 0 || provenance.wikiSourcePage) ? ( +
+ {t("review.source")}:{" "} + {provenance.sourceIdentities.map((identity, index) => ( + + {index > 0 && ", "} + + + ))} + {provenance.wikiSourcePage && ( + + {provenance.sourceIdentities.length > 0 && ", "} + + + )} +
+ ) : provenance && (item.sourcePath || (item.affectedPages?.length ?? 0) > 0) ? ( +
+ {t("review.untraceable")}:{" "} + {[ + provenance.missingPages.length > 0 + ? t("review.untraceableMissing", { count: provenance.missingPages.length }) + : null, + provenance.pagesWithoutSources.length > 0 + ? t("review.untraceableNoSources", { count: provenance.pagesWithoutSources.length }) + : null, + ].filter(Boolean).join("; ") || t("review.untraceableNone")} +
+ ) : null} + {!item.resolved ? (
{(item.type === "suggestion" || item.type === "missing-page") && ( diff --git a/src/i18n/en.json b/src/i18n/en.json index e07d3878b..9956a7e1a 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -69,7 +69,9 @@ "browse": "Select Parent Directory", "aiOutputLanguage": "AI Output Language", "aiOutputLanguageHint": "All AI-generated content (wiki pages, chat replies, research output) will use this language. You can change it later in Settings → Output.", - "pickLanguage": "Pick a language…" + "pickLanguage": "Pick a language…", + "financeNaming": "Normalize imported source filenames (yyyymmdd-code-name-title)", + "financeNamingHint": "Reads stock_basic.csv / hk_basic.csv from the project root or the app data directory. Listings change over time — refresh the tables yourself periodically." }, "templates": { "research": { @@ -91,6 +93,10 @@ "general": { "name": "General", "description": "Minimal setup — a blank slate for any purpose" + }, + "finance": { + "name": "Finance Research", + "description": "Market research on meeting notes — time-stamped sources, ticker-anchored entities" } }, "sources": { @@ -233,7 +239,14 @@ "markSelectedResolved": "Mark selected resolved", "dismissSelected": "Dismiss selected", "allClear": "All clear — nothing to review", - "resolvedDivider": "— Resolved —" + "resolvedDivider": "— Resolved —", + "pages": "Pages", + "source": "Source", + "researchReport": "web research", + "untraceable": "Source untraceable", + "untraceableMissing": "{{count}} referenced page(s) missing on disk (likely stale/invented references)", + "untraceableNoSources": "{{count}} referenced page(s) carry no source record", + "untraceableNone": "this item carries no source info" }, "settings": { "title": "Settings", diff --git a/src/i18n/zh.json b/src/i18n/zh.json index c5dc0ee5d..b7c610f76 100644 --- a/src/i18n/zh.json +++ b/src/i18n/zh.json @@ -69,7 +69,9 @@ "browse": "选择父目录", "aiOutputLanguage": "AI 输出语言", "aiOutputLanguageHint": "所有 AI 生成的内容(Wiki 页面、聊天回复、Research 结果)都将使用此语言。您可以在设置 → 输出 中稍后更改。", - "pickLanguage": "选择语言..." + "pickLanguage": "选择语言...", + "financeNaming": "导入来源时规范化文件名(yyyymmdd-代码-简称-标题)", + "financeNamingHint": "读取项目根或应用数据目录中的 stock_basic.csv / hk_basic.csv。成分股随上市/退市持续变化,表中数据可能已过旧,建议定期自行更新。" }, "templates": { "research": { @@ -91,6 +93,10 @@ "general": { "name": "通用", "description": "最小化初始结构,适合从空白开始" + }, + "finance": { + "name": "金融研究", + "description": "基于调研纪要的市场研究——来源带时间戳,实体锚定股票代码" } }, "sources": { @@ -233,7 +239,14 @@ "markSelectedResolved": "标记为已解决", "dismissSelected": "忽略选中项", "allClear": "一切正常,无需审阅", - "resolvedDivider": "— 已解决 —" + "resolvedDivider": "— 已解决 —", + "pages": "页面", + "source": "来源", + "researchReport": "网络研究报告", + "untraceable": "来源不可追溯", + "untraceableMissing": "引用的 {{count}} 个页面不存在(可能为旧审阅项的臆造引用)", + "untraceableNoSources": "{{count}} 个引用页面未记录来源", + "untraceableNone": "该审阅项未携带来源信息" }, "settings": { "title": "设置", diff --git a/src/lib/affected-pages-resolver.test.ts b/src/lib/affected-pages-resolver.test.ts new file mode 100644 index 000000000..5096a3d89 --- /dev/null +++ b/src/lib/affected-pages-resolver.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest" +import { + buildPageResolutionIndex, + resolveAffectedPages, +} from "./affected-pages-resolver" + +const index = buildPageResolutionIndex([ + { relativePath: "wiki/concepts/非晶纳米晶材料.md", title: "非晶/纳米晶材料" }, + { relativePath: "wiki/findings/悦安新材非晶粉体即将进入小批量量产.md", title: "悦安新材非晶粉体即将进入小批量量产,AI场景方案成头部客户主流选择" }, + { relativePath: "wiki/entities/openai.md", title: "OpenAI" }, + { relativePath: "wiki/sources/wei-2022-cot.md", title: "Source: wei-2022-cot.pdf" }, +]) + +describe("resolveAffectedPages", () => { + it("keeps exact relative paths verbatim", () => { + const { resolved, dropped } = resolveAffectedPages( + ["wiki/concepts/非晶纳米晶材料.md"], + index, + ) + expect(resolved).toEqual(["wiki/concepts/非晶纳米晶材料.md"]) + expect(dropped).toEqual([]) + }) + + it("resolves bare stems and missing wiki/ prefix to the canonical path", () => { + const { resolved } = resolveAffectedPages( + ["openai", "concepts/非晶纳米晶材料.md"], + index, + ) + expect(resolved).toEqual([ + "wiki/entities/openai.md", + "wiki/concepts/非晶纳米晶材料.md", + ]) + }) + + it("resolves frontmatter titles (case/width-insensitive) to the canonical path", () => { + const { resolved } = resolveAffectedPages( + ["非晶/纳米晶材料", "OPENAI"], + index, + ) + expect(resolved).toEqual([ + "wiki/concepts/非晶纳米晶材料.md", + "wiki/entities/openai.md", + ]) + }) + + it("unwraps wikilink-shaped references before resolving", () => { + const { resolved } = resolveAffectedPages(["[[openai|OpenAI 公司]]"], index) + expect(resolved).toEqual(["wiki/entities/openai.md"]) + }) + + it("matches across NFC/NFD unicode normal forms", () => { + const nfdIndex = buildPageResolutionIndex([ + { relativePath: `wiki/entities/café.md`.normalize("NFD"), title: "café" }, + ]) + const { resolved } = resolveAffectedPages(["wiki/entities/café.md".normalize("NFC")], nfdIndex) + expect(resolved.length).toBe(1) + }) + + it("drops unresolvable references and reports them", () => { + const { resolved, dropped } = resolveAffectedPages( + ["wiki/concepts/amorphous-nanocrystalline-material.md", "openai"], + index, + ) + expect(resolved).toEqual(["wiki/entities/openai.md"]) + expect(dropped).toEqual(["wiki/concepts/amorphous-nanocrystalline-material.md"]) + }) + + it("dedupes references that resolve to the same page", () => { + const { resolved } = resolveAffectedPages( + ["openai", "wiki/entities/openai.md", "OpenAI"], + index, + ) + expect(resolved).toEqual(["wiki/entities/openai.md"]) + }) +}) diff --git a/src/lib/affected-pages-resolver.ts b/src/lib/affected-pages-resolver.ts new file mode 100644 index 000000000..268d8ee89 --- /dev/null +++ b/src/lib/affected-pages-resolver.ts @@ -0,0 +1,181 @@ +/** + * 审阅项 PAGES 引用的确定性解析器。 + * + * LLM 在 REVIEW 块的 PAGES 行给出的页面引用不可靠:可能是确切路径、 + * 裸 slug、frontmatter 标题,甚至是对中文文件名的英文转写(臆造)。 + * 本模块在写入 review-store 之前把每个引用解析为真实存在的 wiki + * 相对路径;无法解析的引用被丢弃并上报为 ingest 警告——保证持久化的 + * affectedPages 永远指向真实页面,下游(来源并集/related/级联删除) + * 不再消费脏引用。 + */ +import { listDirectory, readFile } from "@/commands/fs" +import { parseFrontmatter } from "@/lib/frontmatter" +import { getFileStem, getRelativePath, normalizePath } from "@/lib/path-utils" +import { unwrapWikilink } from "@/lib/wiki-page-resolver" +import type { FileNode } from "@/types/wiki" + +export interface PageIndexEntry { + /** 规范的 wiki 相对路径,如 `wiki/concepts/foo.md` */ + relativePath: string + /** frontmatter title;缺失时为 null */ + title: string | null +} + +export interface PageResolutionIndex { + byPath: Map + byStem: Map + byTitle: Map +} + +/** + * 模糊匹配键:NFKC 统一全角/半角与兼容字形(同时覆盖 NFC/NFD 差异), + * 再小写。仅用于解析查找,不改变存储值本身。 + */ +function pageKey(value: string): string { + return value.normalize("NFKC").toLowerCase().trim() +} + +/** + * 由页面清单构建解析索引(纯函数,供测试直接使用)。 + * + * :param entries: 页面清单(相对路径 + frontmatter 标题) + * :returns: 路径/文件名主干/标题三层查找索引 + */ +export function buildPageResolutionIndex(entries: PageIndexEntry[]): PageResolutionIndex { + const byPath = new Map() + const byStem = new Map() + const byTitle = new Map() + for (const entry of entries) { + const canonical = entry.relativePath + byPath.set(pageKey(canonical), canonical) + // 先入者优先:重名 stem/title 保留第一个,歧义引用宁可解析到稳定目标 + const stemKey = pageKey(getFileStem(canonical)) + if (!byStem.has(stemKey)) byStem.set(stemKey, canonical) + if (entry.title) { + const titleKey = pageKey(entry.title) + if (!byTitle.has(titleKey)) byTitle.set(titleKey, canonical) + } + } + return { byPath, byStem, byTitle } +} + +/** + * 把 PAGES 引用列表解析为真实存在的页面路径。 + * + * 解析顺序:剥 wikilink → 精确路径(含补 wiki/ 前缀、补 .md 后缀的 + * 变体)→ 文件名主干 → frontmatter 标题;全部落空则计入 dropped。 + * + * :param refs: LLM 给出的原始引用列表 + * :param index: buildPageResolutionIndex 产出的索引 + * :returns: resolved(去重的规范路径,保持原顺序)与 dropped(无法解析的原始引用) + */ +export function resolveAffectedPages( + refs: string[], + index: PageResolutionIndex, +): { resolved: string[]; dropped: string[] } { + const resolved: string[] = [] + const dropped: string[] = [] + const seen = new Set() + + for (const raw of refs) { + const candidate = unwrapWikilink(raw.trim()).slug.trim() + if (candidate.length === 0) continue + + const target = lookupCandidate(candidate, index) + if (target === null) { + dropped.push(raw) + continue + } + if (!seen.has(target)) { + seen.add(target) + resolved.push(target) + } + } + + return { resolved, dropped } +} + +function lookupCandidate(candidate: string, index: PageResolutionIndex): string | null { + const normalized = candidate.replace(/^\.\//, "") + const withMd = normalized.endsWith(".md") ? normalized : `${normalized}.md` + // 按需补 wiki/ 前缀与 .md 后缀,Set 去重避免无意义的重复/嵌套键查询 + const pathVariants = new Set([normalized, withMd]) + if (!normalized.startsWith("wiki/")) { + pathVariants.add(`wiki/${normalized}`) + pathVariants.add(`wiki/${withMd}`) + } + for (const variant of pathVariants) { + const hit = index.byPath.get(pageKey(variant)) + if (hit) return hit + } + const stemHit = index.byStem.get(pageKey(getFileStem(normalized))) + if (stemHit) return stemHit + const titleHit = index.byTitle.get(pageKey(normalized.replace(/\.md$/i, ""))) + if (titleHit) return titleHit + return null +} + +/** 递归收集 wiki 树下全部 .md 文件节点(大小写不敏感,供本模块与 sweep-reviews 共用)。 */ +export function flattenMdFiles(nodes: FileNode[]): FileNode[] { + const files: FileNode[] = [] + for (const node of nodes) { + if (node.is_dir && node.children) files.push(...flattenMdFiles(node.children)) + else if (!node.is_dir && node.name.toLowerCase().endsWith(".md")) files.push(node) + } + return files +} + +export interface PageResolver { + /** 解析一批 PAGES 引用;未命中时才惰性读盘补建标题索引(每实例最多一次)。 */ + resolve: (refs: string[]) => Promise<{ resolved: string[]; dropped: string[] }> +} + +/** + * 创建页面引用解析器(IO 包装,一次 ingest 建一个实例供全部审阅项共用)。 + * + * 两阶段策略:先用一次 listDirectory 建路径/主干索引(零逐页读取), + * 大多数引用在此命中;仅当存在未解析引用时才逐页读 frontmatter 标题 + * (经 parseFrontmatter 宽容解析)补建完整索引,且每实例只做一次。 + * + * :param projectPath: 项目根路径 + * :returns: PageResolver;wiki 目录缺失时全部引用计入 dropped + */ +export async function createPageResolver(projectPath: string): Promise { + const pp = normalizePath(projectPath) + let files: FileNode[] = [] + try { + files = flattenMdFiles(await listDirectory(`${pp}/wiki`)) + } catch { + // wiki 目录尚不存在:空索引,全部引用 dropped + } + const relativeOf = (file: FileNode) => { + const relative = getRelativePath(file.path, pp) + return relative.startsWith("wiki/") ? relative : `wiki/${file.name}` + } + + let index = buildPageResolutionIndex(files.map((f) => ({ relativePath: relativeOf(f), title: null }))) + let titlesLoaded = false + + return { + async resolve(refs) { + let result = resolveAffectedPages(refs, index) + if (result.dropped.length > 0 && !titlesLoaded && files.length > 0) { + titlesLoaded = true + const entries: PageIndexEntry[] = [] + for (const file of files) { + let title: string | null = null + try { + const parsed = parseFrontmatter(await readFile(file.path)).frontmatter + title = typeof parsed?.title === "string" ? parsed.title : null + } catch { + // 单页不可读不影响整体索引 + } + entries.push({ relativePath: relativeOf(file), title }) + } + index = buildPageResolutionIndex(entries) + result = resolveAffectedPages(refs, index) + } + return result + }, + } +} diff --git a/src/lib/deep-research.ts b/src/lib/deep-research.ts index 4d6359076..35cb17917 100644 --- a/src/lib/deep-research.ts +++ b/src/lib/deep-research.ts @@ -232,11 +232,18 @@ async function executeResearch( // no index yet } + const today = currentWikiDate() const systemPrompt = [ "You are a research assistant. Synthesize the collected research sources into a comprehensive wiki page.", "", buildLanguageDirective(topic), "", + "## Time Sensitivity (CRITICAL)", + `- Today's date is **${today}**. This knowledge base tracks time-sensitive information (e.g. market research); treat recency as a first-class quality signal.`, + "- When a source snippet reveals its publication date, carry it inline next to the claim, e.g. (2026-06).", + "- When sources conflict, prefer the most recent one and say so.", + "- Explicitly flag data points that are older than ~6 months or undated as potentially stale (数据时效存疑) instead of presenting them as current facts.", + "", "## Cross-referencing (IMPORTANT)", "- The wiki already has existing pages listed in the Wiki Index below.", "- When your synthesis mentions an entity or concept that exists in the wiki, ALWAYS use [[wikilink]] syntax to link to it.", @@ -315,7 +322,7 @@ async function executeResearch( "", cleanedSynthesis, "", - "## References", + `## References (retrieved ${date})`, "", references, "", diff --git a/src/lib/finance-naming.test.ts b/src/lib/finance-naming.test.ts new file mode 100644 index 000000000..3091de3b5 --- /dev/null +++ b/src/lib/finance-naming.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, it } from "vitest" +import { + buildFinanceFileName, + extractSourceDate, + matchStock, + mergeStockRecords, + parseStockBasicCsv, + type StockRecord, +} from "./finance-naming" + +const stocks: StockRecord[] = [ + { tsCode: "688786.SH", name: "悦安新材", cnspell: "yaxc" }, + { tsCode: "002738.SZ", name: "中矿资源", cnspell: "zkzy" }, + { tsCode: "300080.SZ", name: "易成新能", cnspell: "ycxn" }, +] + +const FALLBACK = new Date("2026-07-02T00:00:00Z") + +describe("extractSourceDate", () => { + it("prefers full yyyymmdd dates", () => { + expect(extractSourceDate("20260630-稀美资源-小范围交流(附0401参观调研纪要)", FALLBACK)) + .toEqual({ date: "20260630", source: "filename", matchedText: "20260630" }) + }) + + it("expands yymmdd with the century", () => { + expect(extractSourceDate("260607-久谦论坛-调研周报", FALLBACK)) + .toEqual({ date: "20260607", source: "filename", matchedText: "260607" }) + }) + + it("expands trailing mmdd with the reference year", () => { + expect(extractSourceDate("悦安电感材料专家交流0701", FALLBACK)) + .toEqual({ date: "20260701", source: "filename", matchedText: "0701" }) + }) + + it("does not mistake a 6-digit stock code for a date (invalid month)", () => { + // 688786 → 月份 87 非法,不是日期 + expect(extractSourceDate("688786调研摘录", FALLBACK)) + .toEqual({ date: "20260702", source: "fallback" }) + }) + + it("does not mistake a bare ticker like 600519 for a yymmdd date (year window)", () => { + // 60/05/19 月日合法,但年份 2060 超出 20-39 合理窗口 → 拒绝 + expect(extractSourceDate("600519调研纪要", FALLBACK)) + .toEqual({ date: "20260702", source: "fallback" }) + }) + + it("does not treat a leading 4-digit model number as mmdd (end-anchored only)", () => { + // 1206 是封装尺寸而非日期;mmdd 仅在主干结尾时才认 + expect(extractSourceDate("1206阻容专家交流", FALLBACK)) + .toEqual({ date: "20260702", source: "fallback" }) + }) + + it("falls back to the reference date when nothing parses", () => { + expect(extractSourceDate("汉钟精机学习笔记", FALLBACK)) + .toEqual({ date: "20260702", source: "fallback" }) + }) +}) + +describe("parseStockBasicCsv", () => { + it("reads ts_code/name/cnspell columns by header", () => { + const csv = [ + "ts_code,symbol,name,area,industry,cnspell,market", + "688786.SH,688786,悦安新材,福建,小金属,yaxc,科创板", + '002738.SZ,002738,"中矿资源",江西,小金属,zkzy,主板', + ].join("\n") + + expect(parseStockBasicCsv(csv)).toEqual([ + { tsCode: "688786.SH", name: "悦安新材", cnspell: "yaxc" }, + { tsCode: "002738.SZ", name: "中矿资源", cnspell: "zkzy" }, + ]) + }) + + it("returns empty list for missing required headers", () => { + expect(parseStockBasicCsv("foo,bar\n1,2")).toEqual([]) + }) + + it("tolerates a leading unnamed index column (pandas to_csv without index=False)", () => { + const csv = [ + ",ts_code,symbol,name,area,industry,cnspell,market", + "0,688786.SH,688786,悦安新材,福建,小金属,yaxc,科创板", + ].join("\n") + + expect(parseStockBasicCsv(csv)).toEqual([ + { tsCode: "688786.SH", name: "悦安新材", cnspell: "yaxc" }, + ]) + }) + + it("strips a UTF-8 BOM so the first header still matches (Excel 'CSV UTF-8')", () => { + const csv = "\uFEFFts_code,name,cnspell\n688786.SH,悦安新材,yaxc" + + expect(parseStockBasicCsv(csv)).toEqual([ + { tsCode: "688786.SH", name: "悦安新材", cnspell: "yaxc" }, + ]) + }) + + it("accepts hk_basic's cn_spell header (with underscore) for the spell column", () => { + const csv = [ + "ts_code,name,fullname,enname,cn_spell,market", + "00700.HK,腾讯控股,腾讯控股有限公司,TENCENT,txkg,主板", + ].join("\n") + + expect(parseStockBasicCsv(csv)).toEqual([ + { tsCode: "00700.HK", name: "腾讯控股", cnspell: "txkg" }, + ]) + }) +}) + +describe("mergeStockRecords", () => { + it("dedupes by name with earlier tables taking priority (A股优先于港股)", () => { + const aShare: StockRecord[] = [{ tsCode: "688981.SH", name: "中芯国际", cnspell: "zxgj" }] + const hk: StockRecord[] = [ + { tsCode: "00981.HK", name: "中芯国际", cnspell: "zxgj" }, + { tsCode: "00700.HK", name: "腾讯控股", cnspell: "txkg" }, + ] + + expect(mergeStockRecords(aShare, hk)).toEqual([ + { tsCode: "688981.SH", name: "中芯国际", cnspell: "zxgj" }, + { tsCode: "00700.HK", name: "腾讯控股", cnspell: "txkg" }, + ]) + }) +}) + +describe("matchStock", () => { + it("matches by full name containment", () => { + expect(matchStock("悦安新材专家交流", stocks)?.stock.tsCode).toBe("688786.SH") + }) + + it("matches abbreviated prefix forms (悦安 → 悦安新材)", () => { + const match = matchStock("悦安电感材料专家交流", stocks) + expect(match?.stock.tsCode).toBe("688786.SH") + expect(match?.matchedBy).toBe("name-prefix") + }) + + it("returns null when multiple stocks match at the same strength (ambiguous)", () => { + expect(matchStock("中矿资源与悦安新材对比纪要", stocks)).toBeNull() + }) + + it("returns null when nothing matches", () => { + expect(matchStock("久谦论坛-调研周报", stocks)).toBeNull() + }) + + it("resolves A/B-suffix base-name hit over an HK sibling's prefix hit (京东方A vs 京东方精电)", () => { + // A+H 前缀撞车:两者都以「京东方」(长度 3)命中;「京东方A」的基名 + // (去掉市场类别后缀 A)恰为「京东方」,应视为完整名命中并胜出 + const ahStocks: StockRecord[] = [ + { tsCode: "000725.SZ", name: "京东方A", cnspell: "jdfa" }, + { tsCode: "00710.HK", name: "京东方精电", cnspell: "jdfjd" }, + ] + const match = matchStock("京东方投资者交流日纪要", ahStocks) + expect(match?.stock.tsCode).toBe("000725.SZ") + expect(match?.matchedBy).toBe("name") + }) + + it("treats a full-width A suffix the same way (万科A vs 万科企业)", () => { + const ahStocks: StockRecord[] = [ + { tsCode: "000002.SZ", name: "万科A", cnspell: "wka" }, + { tsCode: "02202.HK", name: "万科企业", cnspell: "wkqy" }, + ] + expect(matchStock("万科年度业绩会纪要", ahStocks)?.stock.tsCode).toBe("000002.SZ") + }) + + it("still ambiguous when tied prefix hits have no base-name winner", () => { + const tied: StockRecord[] = [ + { tsCode: "300059.SZ", name: "东方财富", cnspell: "dfcf" }, + { tsCode: "600958.SH", name: "东方证券", cnspell: "dfzq" }, + ] + expect(matchStock("东方专家电话会", tied)).toBeNull() + }) + + it("matches *ST stocks by base name (marker prefix stripped)", () => { + const st: StockRecord[] = [{ tsCode: "002447.SZ", name: "*ST春兴", cnspell: "stcx" }] + const match = matchStock("春兴精工债务进展交流", st) + expect(match?.stock.tsCode).toBe("002447.SZ") + expect(match?.matchedBy).toBe("name") // 基名「春兴」整名命中 + expect(match?.matchedText).toBe("春兴") + }) + + it("matches the literal marker-bearing name when the filename carries it", () => { + const st: StockRecord[] = [{ tsCode: "000078.SZ", name: "ST海王", cnspell: "sthw" }] + const match = matchStock("ST海王重整进展纪要", st) + expect(match?.stock.tsCode).toBe("000078.SZ") + expect(match?.matchedBy).toBe("name") + expect(match?.matchedText).toBe("ST海王") + }) + + it("matches case-insensitively and returns the original substring for stripping", () => { + const st: StockRecord[] = [{ tsCode: "002602.SZ", name: "ST华通", cnspell: "stht" }] + const match = matchStock("st华通游戏业务专家会", st) + expect(match?.stock.tsCode).toBe("002602.SZ") + expect(match?.matchedText).toBe("st华通") // 原文切片,供构名精确剥除 + }) + + it("strips hyphenated market tags (-U/-W/-SW) before matching", () => { + const tagged: StockRecord[] = [ + { tsCode: "688256.SH", name: "寒武纪-U", cnspell: "hwj" }, + { tsCode: "09618.HK", name: "京东集团-SW", cnspell: "jdjt" }, + ] + expect(matchStock("寒武纪三季报交流", tagged)?.stock.tsCode).toBe("688256.SH") + expect(matchStock("京东集团物流业务纪要", tagged)?.stock.tsCode).toBe("09618.HK") + }) + + it("only strips enum market tags: a non-tag uppercase suffix stays in the base name", () => { + // -AI 不在枚举集(U/W/B/S/UW/WD/SW)内,属股名本体不剥除: + // 与同前缀个股平局时不再被误判为"基名整名命中"而胜出,维持歧义 + const stocks: StockRecord[] = [ + { tsCode: "800001.XX", name: "小米-AI", cnspell: "xmai" }, + { tsCode: "800002.XX", name: "小米通讯", cnspell: "xmtx" }, + ] + expect(matchStock("小米近况交流", stocks)).toBeNull() + }) + + it("strips the 未股改 S prefix (S佳通 → 佳通)", () => { + const s: StockRecord[] = [{ tsCode: "600182.SH", name: "S佳通", cnspell: "sjt" }] + expect(matchStock("佳通轮胎渠道调研", s)?.stock.tsCode).toBe("600182.SH") + }) + + it("does not strip a leading S from Latin-lettered names (SOHO中国)", () => { + const soho: StockRecord[] = [{ tsCode: "00410.HK", name: "SOHO中国", cnspell: "soho" }] + expect(matchStock("SOHO中国租金交流", soho)?.stock.tsCode).toBe("00410.HK") + expect(matchStock("OHO中国纪要", soho)).toBeNull() // S 不能被误剥 + }) +}) + +describe("buildFinanceFileName", () => { + it("builds date-code-name-title for a matched stock", () => { + const { fileName, record } = buildFinanceFileName( + "悦安电感材料专家交流260701.docx", + stocks, + FALLBACK, + ) + expect(fileName).toBe("20260701-688786.SH-悦安新材-电感材料专家交流.docx") + expect(record.tsCode).toBe("688786.SH") + expect(record.matchedBy).toBe("name-prefix") + expect(record.dateSource).toBe("filename") + }) + + it("resolves the reported 京东方 A+H collision end-to-end (regression)", () => { + // 用户报告:此文件曾因 京东方A/京东方精电 前缀撞车落到 NA + const ahStocks: StockRecord[] = [ + { tsCode: "000725.SZ", name: "京东方A", cnspell: "jdfa" }, + { tsCode: "00710.HK", name: "京东方精电", cnspell: "jdfjd" }, + ] + const { fileName, record } = buildFinanceFileName( + "京东方投资者交流日纪要20260702.docx", + ahStocks, + FALLBACK, + ) + expect(fileName).toBe("20260702-000725.SZ-京东方A-投资者交流日纪要.docx") + expect(record.tsCode).toBe("000725.SZ") + expect(record.matchedBy).toBe("name") + }) + + it("builds date-NA-stem for unmatched industry documents", () => { + const { fileName, record } = buildFinanceFileName( + "260607-久谦论坛-调研周报.md", + stocks, + FALLBACK, + ) + expect(fileName).toBe("20260607-NA-久谦论坛-调研周报.md") + expect(record.tsCode).toBeNull() + }) + + it("keeps already-normalized names stable (idempotent)", () => { + const first = buildFinanceFileName("悦安电感材料专家交流260701.docx", stocks, FALLBACK) + const second = buildFinanceFileName(first.fileName, stocks, FALLBACK) + expect(second.fileName).toBe(first.fileName) + }) + + it("uses a placeholder title when nothing is left after cleaning", () => { + const { fileName } = buildFinanceFileName("悦安新材0701.docx", stocks, FALLBACK) + expect(fileName).toBe("20260701-688786.SH-悦安新材-纪要.docx") + }) + + it("preserves meaningful digits in the title (only the adopted date is stripped)", () => { + const { fileName } = buildFinanceFileName("悦安2028年产能规划260701.docx", stocks, FALLBACK) + expect(fileName).toBe("20260701-688786.SH-悦安新材-2028年产能规划.docx") + }) + + it("keeps a bare ticker in the title instead of misreading it as a date", () => { + const { fileName, record } = buildFinanceFileName("600519调研纪要.docx", stocks, FALLBACK) + expect(fileName).toBe("20260702-NA-600519调研纪要.docx") + expect(record.dateSource).toBe("fallback") + }) +}) diff --git a/src/lib/finance-naming.ts b/src/lib/finance-naming.ts new file mode 100644 index 000000000..5b890513d --- /dev/null +++ b/src/lib/finance-naming.ts @@ -0,0 +1,424 @@ +/** + * 金融来源文件名规范化引擎。 + * + * 目标格式:`yyyymmdd--<简称|主体>-<标题>.`—— + * 让时间与标的锚点进入来源身份,全链路(摘要页/检索/LLM 上下文)可见。 + * 纯逻辑与 IO 分离:解析/匹配/构名为纯函数,磁盘读写在文件末尾的包装函数。 + */ +import { appDataDir } from "@tauri-apps/api/path" +import { readFile, writeFile, createDirectory, getFileModifiedTime } from "@/commands/fs" +import { normalizePath } from "@/lib/path-utils" + +export interface StockRecord { + tsCode: string + name: string + cnspell: string +} + +export interface RenameRecord { + original: string + renamed: string + date: string + dateSource: "filename" | "fallback" + tsCode: string | null + stockName: string | null + matchedBy: "name" | "name-prefix" | null + importedAt: number +} + +/** 校验 mm/dd 是否为合法月日。 */ +function isValidMonthDay(mm: number, dd: number): boolean { + return mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31 +} + +/** yymmdd 补世纪时可信的两位年份窗口(2020-2039),窗口外视为非日期(如裸代码 600519)。 */ +const YY_MIN = 20 +const YY_MAX = 39 + +/** + * 从文件名中提取日期,统一为 yyyymmdd。 + * + * 提取优先级:yyyymmdd → yymmdd(补世纪,年份限 20-39 窗口)→ + * mmdd(补参考年份,仅认主干结尾位置,避免把型号/规格误判)。 + * 每级都做月/日合法性校验;全部落空时回退参考日期(通常为文件 + * 修改时间)。matchedText 返回被采用的原文片段,供构名时精确剥除, + * 不误伤标题中的其他数字。 + * + * :param fileName: 原始文件名(不含扩展名为佳) + * :param fallback: 回退参考日期 + * :returns: { date: yyyymmdd, source: 来源, matchedText?: 采用的原文片段 } + */ +export function extractSourceDate( + fileName: string, + fallback: Date, +): { date: string; source: "filename" | "fallback"; matchedText?: string } { + for (const match of fileName.matchAll(/(20\d{2})(\d{2})(\d{2})/g)) { + if (isValidMonthDay(Number(match[2]), Number(match[3]))) { + return { date: match[0], source: "filename", matchedText: match[0] } + } + } + for (const match of fileName.matchAll(/(?= YY_MIN && yy <= YY_MAX && isValidMonthDay(Number(match[2]), Number(match[3]))) { + return { date: `20${match[0]}`, source: "filename", matchedText: match[0] } + } + } + const referenceYear = fallback.toISOString().slice(0, 4) + const trailing = fileName.match(/(? f.trim()) +} + +/** + * 解析 tushare stock_basic 导出的 CSV,按表头定位 ts_code/name/cnspell 列。 + * + * :param csv: CSV 全文 + * :returns: 个股记录列表;缺少必需表头时返回空列表 + */ +export function parseStockBasicCsv(csv: string): StockRecord[] { + // Excel「CSV UTF-8」会带 BOM,不剥除会让首列头变成 "\uFEFFts_code" 而整表失配 + const lines = csv.replace(/^\uFEFF/, "").split(/\r?\n/).filter((l) => l.trim().length > 0) + if (lines.length < 2) return [] + const headers = parseCsvLine(lines[0]).map((h) => h.toLowerCase()) + const tsCodeIdx = headers.indexOf("ts_code") + const nameIdx = headers.indexOf("name") + // A 股表列名为 cnspell,港股表(hk_basic)为 cn_spell,两者都认 + const cnspellIdx = headers.indexOf("cnspell") >= 0 + ? headers.indexOf("cnspell") + : headers.indexOf("cn_spell") + if (tsCodeIdx < 0 || nameIdx < 0) return [] + + const records: StockRecord[] = [] + for (const line of lines.slice(1)) { + const fields = parseCsvLine(line) + const tsCode = fields[tsCodeIdx] ?? "" + const name = fields[nameIdx] ?? "" + if (!tsCode || !name) continue + records.push({ tsCode, name, cnspell: cnspellIdx >= 0 ? (fields[cnspellIdx] ?? "") : "" }) + } + return records +} + +export interface StockMatch { + stock: StockRecord + /** 文件名中实际命中的文本(全称或其前缀),供构名时剔除 */ + matchedText: string + matchedBy: "name" | "name-prefix" +} + +/** + * 匹配用归一化:全角 ASCII 区(!-~)折半角、小写字母折大写。 + * + * 逐 UTF-16 单元 1:1 变换,长度与索引严格保持——归一化文本上的命中 + * 区间可直接切回原文(matchedText 需要原文切片供构名精确剥除)。 + */ +function normalizeForMatch(text: string): string { + let out = "" + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i) + let ch = text[i] + if (code >= 0xff01 && code <= 0xff5e) ch = String.fromCharCode(code - 0xfee0) + if (ch >= "a" && ch <= "z") ch = ch.toUpperCase() + out += ch + } + return out +} + +/** + * 连字符市场标记的枚举集(tushare A股/港股命名): + * -U 未盈利、-W 同股不同权、-B 生科、-S 第二上市、-UW/-WD/-SW 组合。 + * 仅认枚举集,-AI/-ESG 类纯大写缩写属于股名本体,不作标记剥除; + * 长标记在前保证正确择优。 + */ +const HYPHEN_MARKET_TAG = "(?:UW|WD|SW|U|W|B|S)" + +/** + * 剥除股票名中的市场标记,得到主体基名(输入须已归一化)。 + * + * 前缀:ST/*ST/SST/S*ST(风险警示)、S(未股改,后随非拉丁字母, + * 避免误伤 SOHO 中国类英文名);后缀:连字符标记(HYPHEN_MARKET_TAG + * 枚举集)、裸 A/B(深市老 A/B 股类别)。 + */ +function stripStockMarkers(name: string): string { + return name + .replace(/^S?\*?ST/, "") + .replace(/^S(?![A-Z])/, "") + .replace(new RegExp(`-${HYPHEN_MARKET_TAG}$`), "") + .replace(/[AB]$/, "") +} + +/** + * 在文本中匹配个股:全名(含标记)包含优先,其次基名前缀递减匹配(最短 2 字)。 + * + * 归一化(全角折半角、大小写不敏感)后比较;基名 = 全称剥除市场标记 + * (ST/*ST/S 前缀、-U/-W 类连字符后缀、裸 A/B 后缀)——文件名里 + * "京东方"即指京东方A、"春兴"即指*ST春兴。纪要文件名常用简称缩写 + * (如"悦安"指悦安新材),故按命中长度分级取最长;基名整名命中视为 + * 完整名命中,同长度平局时完整名命中优先(解决 A+H 前缀撞车,如 + * 京东方A vs 京东方精电);仍多解视为歧义,返回 null + * (宁可落行业格式也不猜)。 + * + * :param subject: 待匹配文本(通常为去掉日期与扩展名的文件名) + * :param stocks: 个股基础表 + * :returns: 唯一最长命中,歧义或零命中为 null + */ +export function matchStock(subject: string, stocks: StockRecord[]): StockMatch | null { + const normSubject = normalizeForMatch(subject) + let bestLength = 0 + let winners: StockMatch[] = [] + + // 在归一化文本上找 needle,命中则按原文切片登记;同长度并列保留待平局裁决 + function consider(stock: StockRecord, needle: string, matchedBy: "name" | "name-prefix"): boolean { + const idx = normSubject.indexOf(needle) + if (idx < 0) return false + const match: StockMatch = { + stock, + matchedText: subject.slice(idx, idx + needle.length), + matchedBy, + } + if (needle.length > bestLength) { + bestLength = needle.length + winners = [match] + } else if (needle.length === bestLength) { + winners.push(match) + } + return true + } + + for (const stock of stocks) { + const normName = normalizeForMatch(stock.name) + if (normName.length < 2) continue + // 1) 全名(含标记)直接包含——最强命中,如文件名里写明 "ST海王" + if (consider(stock, normName, "name")) continue + // 2) 基名前缀阶梯,从整基名往短试 + const base = stripStockMarkers(normName) + if (base.length < 2) continue + for (let length = base.length; length >= 2; length--) { + if (consider(stock, base.slice(0, length), length === base.length ? "name" : "name-prefix")) break + } + } + + if (winners.length === 1) return winners[0] + // 同长度平局:完整名命中(全名或基名整名)唯一时胜出,否则维持歧义 + const fullNameWinners = winners.filter((w) => w.matchedBy === "name") + return fullNameWinners.length === 1 ? fullNameWinners[0] : null +} + +/** 清理分段分隔符残渣(连续/首尾的 -、_、空白、全半角括号内空壳)。 */ +function cleanSegment(value: string): string { + return value + .replace(/[((]\s*[))]/g, "") + .replace(/[\s_]+/g, "-") + .replace(/-{2,}/g, "-") + .replace(/^[-]+|[-]+$/g, "") +} + +/** + * 构造规范化文件名并产出审计记录。 + * + * 匹配成功:`yyyymmdd--<简称>-<标题>.`(标题为原名去日期 + * 去简称后的残余,空则用"纪要"占位);未匹配:`yyyymmdd-NA-<主干>.`。 + * 对已符合格式的文件名幂等(重复处理不再变化)。 + * + * :param originalName: 原始文件名(含扩展名) + * :param stocks: 个股基础表(可为空列表) + * :param fallbackDate: 日期回退参考(通常为文件修改时间) + * :returns: { fileName: 新文件名, record: 审计记录 } + */ +export function buildFinanceFileName( + originalName: string, + stocks: StockRecord[], + fallbackDate: Date, +): { fileName: string; record: RenameRecord } { + const extMatch = originalName.match(/\.[^./\\]+$/) + const ext = extMatch ? extMatch[0] : "" + const stem = ext ? originalName.slice(0, -ext.length) : originalName + + // 幂等:重复处理已规范化的文件名时,先剥掉 ts_code 形状的段 + // (避免其中 6 位数字被日期提取误判),再仅剥"实际采用"的日期片段—— + // 标题中的其他数字(年份/规格/型号)原样保留 + const codeStripped = stem.replace(/\d{6}\.[A-Z]{2}/g, "") + const { date, source: dateSource, matchedText } = extractSourceDate(codeStripped, fallbackDate) + const dateStripped = matchedText ? codeStripped.replace(matchedText, "") : codeStripped + + const match = matchStock(dateStripped, stocks) + let fileName: string + if (match) { + // matchedText 即文本中实际命中的片段(全称或其前缀),删它即可 + const title = cleanSegment( + dateStripped.replace(match.matchedText, "").replace(/^-?NA-?/, ""), + ) || "纪要" + fileName = `${date}-${match.stock.tsCode}-${match.stock.name}-${title}${ext}` + } else { + const cleaned = cleanSegment(dateStripped.replace(/^-?NA-?/, "")) || "纪要" + fileName = `${date}-NA-${cleaned}${ext}` + } + + return { + fileName, + record: { + original: originalName, + renamed: fileName, + date, + dateSource, + tsCode: match?.stock.tsCode ?? null, + stockName: match?.stock.name ?? null, + matchedBy: match?.matchedBy ?? null, + importedAt: Date.now(), + }, + } +} + +// ── IO 包装 ──────────────────────────────────────────────────────── + +const NAMING_CONFIG_FILE = ".llm-wiki/source-naming.json" +const RENAME_MAP_FILE = ".llm-wiki/rename-map.json" +/** 项目根下 A 股基础表的约定位置。 */ +export const STOCK_BASIC_FILE = "stock_basic.csv" +/** 项目根下港股基础表的约定位置(可选,tushare hk_basic 导出)。 */ +export const HK_BASIC_FILE = "hk_basic.csv" + +/** + * 合并多张个股表并按简称去重,靠前的表优先。 + * + * A+H 两地上市公司在两张表中同名,若不去重会造成匹配歧义 + * (永远落 NA);按加载顺序保留首个记录即"A 股代码优先"。 + * + * :param tables: 个股表列表(按优先级排列) + * :returns: 去重后的合并表 + */ +export function mergeStockRecords(...tables: StockRecord[][]): StockRecord[] { + const merged: StockRecord[] = [] + const seenNames = new Set() + for (const table of tables) { + for (const record of table) { + if (seenNames.has(record.name)) continue + seenNames.add(record.name) + merged.push(record) + } + } + return merged +} + +/** 项目是否启用了金融来源命名规范化。 */ +export async function isFinanceNamingEnabled(projectPath: string): Promise { + try { + const raw = await readFile(`${normalizePath(projectPath)}/${NAMING_CONFIG_FILE}`) + return (JSON.parse(raw) as { mode?: string }).mode === "finance" + } catch { + return false + } +} + +/** 为项目启用金融来源命名规范化(建项目复选框调用)。 */ +export async function enableFinanceNaming(projectPath: string): Promise { + const pp = normalizePath(projectPath) + await createDirectory(`${pp}/.llm-wiki`) + await writeFile(`${pp}/${NAMING_CONFIG_FILE}`, JSON.stringify({ mode: "finance" }, null, 2)) +} + +/** 全局个股表目录(应用数据目录);非 Tauri 环境(如单测)返回 null。 */ +async function globalStockTableDir(): Promise { + try { + return normalizePath(await appDataDir()) + } catch { + return null + } +} + +/** + * 读取个股基础表:stock_basic.csv(A 股)+ 可选 hk_basic.csv(港股)。 + * + * 每个文件按"项目根 → 应用数据目录"顺序取首个可读的——全局放一份 + * 即可供所有金融项目共享(更新一处全部生效),项目根同名文件可覆盖。 + * 合并按简称去重、A 股优先,使 A+H 两地上市公司稳定解析到 A 股代码。 + * + * :param projectPath: 项目根路径 + * :returns: 合并去重后的个股表;两处均缺失时为空(仅做日期规范化) + */ +export async function loadStockBasic(projectPath: string): Promise { + const pp = normalizePath(projectPath) + const globalDir = await globalStockTableDir() + const readTable = async (fileName: string): Promise => { + for (const dir of [pp, globalDir]) { + if (!dir) continue + let content: string + try { + content = await readFile(`${dir}/${fileName}`) + } catch { + // 该位置缺失/不可读,尝试下一个 + continue + } + const records = parseStockBasicCsv(content) + if (records.length > 0) return records + // 文件存在却解析不出记录(表头缺失/编码异常):告警并继续试下一位置, + // 避免一张坏表静默压制另一处的好表、导入全部退化为 NA 却无迹可循 + console.warn(`[FinanceNaming] ${dir}/${fileName} 可读但未解析出任何个股记录(表头或编码异常?)`) + } + return [] + } + return mergeStockRecords(await readTable(STOCK_BASIC_FILE), await readTable(HK_BASIC_FILE)) +} + +/** 追加改名审计记录到 .llm-wiki/rename-map.json(仅供排错,无运行时消费方)。 */ +export async function appendRenameMap( + projectPath: string, + records: RenameRecord[], +): Promise { + if (records.length === 0) return + const pp = normalizePath(projectPath) + const mapPath = `${pp}/${RENAME_MAP_FILE}` + let existing: RenameRecord[] = [] + let raw: string | null = null + try { + raw = await readFile(mapPath) + } catch { + // 文件不存在:首次写入 + } + if (raw !== null) { + try { + const parsed = JSON.parse(raw) as unknown + if (Array.isArray(parsed)) existing = parsed as RenameRecord[] + } catch { + // 既有文件损坏:无法合并历史,告警后仅写入本次记录(该文件仅供排错) + console.warn(`[FinanceNaming] ${mapPath} 内容损坏,历史审计记录无法合并,将被本次记录覆盖`) + } + } + await createDirectory(`${pp}/.llm-wiki`) + await writeFile(mapPath, JSON.stringify([...existing, ...records], null, 2)) +} + +/** 取文件修改时间作为日期回退参考;读取失败回退当前时间。 */ +export async function fileDateFallback(sourcePath: string): Promise { + try { + const mtime = await getFileModifiedTime(sourcePath) + return new Date(mtime) + } catch { + return new Date() + } +} diff --git a/src/lib/ingest.ts b/src/lib/ingest.ts index 7fd5fc3bd..3a73a5e7e 100644 --- a/src/lib/ingest.ts +++ b/src/lib/ingest.ts @@ -22,6 +22,7 @@ import { sourceSummarySlugCandidatesFromIdentity, sourceSummarySlugFromIdentity, } from "@/lib/source-identity" +import { createPageResolver } from "@/lib/affected-pages-resolver" import { parseSources, writeSources } from "@/lib/sources-merge" import { checkIngestCache, saveIngestCache } from "@/lib/ingest-cache" import { sanitizeIngestedFileContent } from "@/lib/ingest-sanitize" @@ -1267,7 +1268,22 @@ async function autoIngestImpl( ...parseReviewBlocks(reviewSuggestionOutput, sp), ] if (reviewItems.length > 0) { + // PAGES 引用确定性校验:解析为真实存在的页面路径(含标题/主干模糊 + // 匹配),臆造引用在入库前丢弃并记入警告,杜绝下游消费脏引用 + const pageResolver = await createPageResolver(pp) + const pageWarnings: string[] = [] + for (const reviewItem of reviewItems) { + if (!reviewItem.affectedPages || reviewItem.affectedPages.length === 0) continue + const { resolved, dropped } = await pageResolver.resolve(reviewItem.affectedPages) + reviewItem.affectedPages = resolved.length > 0 ? resolved : undefined + for (const ref of dropped) { + pageWarnings.push( + `Review "${reviewItem.title}": dropped unresolvable PAGES entry "${ref}"`, + ) + } + } useReviewStore.getState().addItems(reviewItems) + await appendIngestWarningLog(pp, sourceIdentity, pageWarnings) } // ── Step 5: Save to cache ─────────────────────────────────── @@ -2122,6 +2138,7 @@ export function buildGenerationPrompt( "Description of what needs the user's attention.", "OPTIONS: Create Page | Skip", "PAGES: wiki/page1.md, wiki/page2.md", + "PAGES entries MUST be exact relative paths of wiki pages that exist in the CURRENT index above (copy the path verbatim, including non-ASCII filenames). NEVER translate, transliterate, or invent slugs.", "SEARCH: query 1 | query 2 | query 3", "---END REVIEW---", "```", @@ -2184,6 +2201,7 @@ function buildReviewSuggestionPrompt( "Concise description of the gap and why it matters.", "OPTIONS: Create Page | Skip", "PAGES: wiki/page1.md, wiki/page2.md", + "PAGES entries MUST be exact relative paths of wiki pages that exist in the CURRENT index above (copy the path verbatim, including non-ASCII filenames). NEVER translate, transliterate, or invent slugs.", "SEARCH: query 1 | query 2 | query 3", "---END REVIEW---", "```", diff --git a/src/lib/optimize-research-topic.ts b/src/lib/optimize-research-topic.ts index 09feb437d..4d1f8bf04 100644 --- a/src/lib/optimize-research-topic.ts +++ b/src/lib/optimize-research-topic.ts @@ -37,6 +37,7 @@ export async function optimizeResearchTopic( "Generate a research topic and search queries that are specific to this wiki's domain and purpose.", "The topic should precisely describe what information would fill this knowledge gap.", "The search queries should be optimized for web search engines — keyword-rich, specific, not generic.", + `Today's date is ${new Date().toISOString().slice(0, 10)}. For time-sensitive topics (markets, prices, capacity, guidance, supply/demand), include the current year or a recent-period qualifier in the queries so search engines surface fresh results instead of stale ones.`, "", "## Output Format (STRICT — follow exactly, no other text)", "Respond with EXACTLY 4 lines, no more:", diff --git a/src/lib/reset-project-state.ts b/src/lib/reset-project-state.ts index 75b0b876a..7393f45af 100644 --- a/src/lib/reset-project-state.ts +++ b/src/lib/reset-project-state.ts @@ -13,9 +13,13 @@ import { useReviewStore } from "@/stores/review-store" import { useLintStore } from "@/stores/lint-store" import { useActivityStore } from "@/stores/activity-store" import { useResearchStore } from "@/stores/research-store" +import { useWikiStore } from "@/stores/wiki-store" export async function resetProjectState(): Promise { // Zustand stores — clear all per-project data (synchronous) + // 预览导航历史存的是上一项目的绝对路径,切项目必须清空, + // 否则新项目里点 Back 会打开旧项目的文件 + useWikiStore.setState({ previewHistory: [] }) useChatStore.setState({ conversations: [], messages: [], diff --git a/src/lib/review-create-page.test.ts b/src/lib/review-create-page.test.ts index 88f7c4828..7420e0a39 100644 --- a/src/lib/review-create-page.test.ts +++ b/src/lib/review-create-page.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest" import type { ReviewItem } from "@/stores/review-store" -import { createReviewPageDrafts } from "./review-create-page" +import { + buildReviewPageContent, + collectReviewProvenance, + collectReviewSourceIdentities, + createReviewPageDrafts, +} from "./review-create-page" function review(overrides: Partial): ReviewItem { return { @@ -48,3 +53,163 @@ describe("createReviewPageDrafts", () => { }) }) +describe("buildReviewPageContent", () => { + const draft = { title: "鲁巴亚矿区复产时间矛盾", pageType: "query" as const, dir: "queries" } + + it("writes sources from the review item's source identity", () => { + const content = buildReviewPageContent( + draft, + review({ type: "contradiction", title: "鲁巴亚矿区复产时间矛盾", description: "两处复产时间相差一年。" }), + "2026-07-02", + ["20260630-稀美资源-小范围交流.docx"], + ) + + expect(content).toContain('sources: ["20260630-稀美资源-小范围交流.docx"]') + expect(content).toContain("type: query") + expect(content).toContain('title: "鲁巴亚矿区复产时间矛盾"') + expect(content).toContain("# 鲁巴亚矿区复产时间矛盾\n\n两处复产时间相差一年。") + }) + + it("writes multiple deduped source identities so cross-source contradictions stay verifiable", () => { + const content = buildReviewPageContent( + draft, + review({}), + "2026-07-02", + ["a-纪要.docx", "b-周报.md", "a-纪要.docx"], + ) + + expect(content).toContain('sources: ["a-纪要.docx", "b-周报.md"]') + }) + + it("fills related with slugs derived from affectedPages", () => { + const content = buildReviewPageContent( + draft, + review({ + affectedPages: ["wiki/sources/20260630-稀美资源-小范围交流-updated.md", "wiki/entities/稀美资源.md"], + }), + "2026-07-02", + [], + ) + + expect(content).toContain('related: ["20260630-稀美资源-小范围交流-updated", "稀美资源"]') + }) + + it("dedupes related slugs and unwraps wikilink-shaped entries", () => { + const content = buildReviewPageContent( + draft, + review({ + affectedPages: ["wiki/entities/foo.md", "wiki/sources/foo.md", "[[rubin-dram-reduction]]"], + }), + "2026-07-02", + [], + ) + + expect(content).toContain('related: ["foo", "rubin-dram-reduction"]') + }) + + it("omits sources line and keeps empty related when nothing is known", () => { + const content = buildReviewPageContent(draft, review({}), "2026-07-02", []) + + expect(content).not.toContain("sources:") + expect(content).toContain("related: []") + }) + + it("escapes double quotes in the title", () => { + const content = buildReviewPageContent( + { ...draft, title: 'He said "no"' }, + review({}), + "2026-07-02", + [], + ) + + expect(content).toContain('title: "He said \\"no\\""') + }) + + it("collectReviewSourceIdentities unions own source with affected pages' sources", async () => { + const pages: Record = { + "/p/wiki/concepts/rubin-dram-reduction.md": + '---\ntype: concept\ntitle: "x"\nsources: ["260601-另一篇纪要.docx"]\ntags: []\nrelated: []\n---\n\n# x\n', + } + const readFile = async (path: string) => { + const content = pages[path] + if (content === undefined) throw new Error("not found") + return content + } + + const ids = await collectReviewSourceIdentities( + "/p", + review({ + sourcePath: "/p/raw/sources/260607-久谦论坛-调研周报.md", + affectedPages: ["wiki/concepts/rubin-dram-reduction.md", "wiki/gone/missing.md"], + }), + readFile, + ) + + expect(ids).toEqual(["260607-久谦论坛-调研周报.md", "260601-另一篇纪要.docx"]) + }) + + it("collectReviewSourceIdentities drops non-raw sourcePath instead of faking an identity", async () => { + const ids = await collectReviewSourceIdentities( + "/p", + review({ sourcePath: "/p/wiki/queries/research-x.md" }), + async () => { throw new Error("not found") }, + ) + + expect(ids).toEqual([]) + }) + + it("collectReviewProvenance reports the wiki research page as a web-derived source", async () => { + const provenance = await collectReviewProvenance( + "/p", + review({ sourcePath: "/p/wiki/queries/research-金刚石散热.md" }), + async () => { throw new Error("not found") }, + ) + + expect(provenance.sourceIdentities).toEqual([]) + expect(provenance.wikiSourcePage).toBe("wiki/queries/research-金刚石散热.md") + }) + + it("collectReviewProvenance classifies missing pages and pages without sources", async () => { + const pages: Record = { + "/p/wiki/concepts/exists-no-sources.md": + '---\ntype: concept\ntitle: "x"\ntags: []\nrelated: []\n---\n\n# x\n', + "/p/wiki/concepts/exists-with-sources.md": + '---\ntype: concept\ntitle: "y"\nsources: ["a.docx"]\ntags: []\nrelated: []\n---\n\n# y\n', + } + const readFile = async (path: string) => { + const content = pages[path] + if (content === undefined) throw new Error("not found") + return content + } + + const provenance = await collectReviewProvenance( + "/p", + review({ + affectedPages: [ + "wiki/concepts/exists-with-sources.md", + "wiki/concepts/exists-no-sources.md", + "wiki/queries/invented-slug.md", + ], + }), + readFile, + ) + + expect(provenance.sourceIdentities).toEqual(["a.docx"]) + expect(provenance.pagesWithoutSources).toEqual(["wiki/concepts/exists-no-sources.md"]) + expect(provenance.missingPages).toEqual(["wiki/queries/invented-slug.md"]) + expect(provenance.wikiSourcePage).toBeNull() + }) + + it("escapes backslashes so YAML double-quoted scalars stay valid", () => { + const content = buildReviewPageContent( + { ...draft, title: "C:\\temp 报告\\" }, + review({}), + "2026-07-02", + ["dir\\file.docx"], + ) + + expect(content).toContain('title: "C:\\\\temp 报告\\\\"') + expect(content).toContain('sources: ["dir\\\\file.docx"]') + }) +}) + diff --git a/src/lib/review-create-page.ts b/src/lib/review-create-page.ts index 1ccbf6287..2afa2fdc7 100644 --- a/src/lib/review-create-page.ts +++ b/src/lib/review-create-page.ts @@ -1,3 +1,7 @@ +import { getFileStem } from "@/lib/path-utils" +import { rawSourceIdentityOrNull } from "@/lib/source-identity" +import { parseSources } from "@/lib/sources-merge" +import { unwrapWikilink } from "@/lib/wiki-page-resolver" import type { ReviewItem } from "@/stores/review-store" export type ReviewPageType = "entity" | "concept" | "comparison" | "synthesis" | "query" @@ -82,6 +86,131 @@ function dirForPageType(pageType: ReviewPageType): string { } } +/** 把字符串转为带双引号的 YAML 标量(先转义反斜杠再转义双引号,保证标量合法闭合)。 */ +const yamlStr = (value: string) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` + +/** + * 收集审阅项的全部来源身份并集。 + * + * 组成:审阅项自身来源(仅当 sourcePath 位于 raw/sources 下) + * + 各受影响页面 frontmatter sources 中记录的来源。 + * 用于建页写入 sources 与审阅卡片的来源展示,使跨来源矛盾可直接核对全部原始文档。 + * + * :param projectPath: 项目根路径(已规范化) + * :param item: 审阅项 + * :param readFile: 文件读取函数(注入以保持纯逻辑可测) + * :returns: 去重后的来源身份列表(自身来源在前) + */ +export async function collectReviewSourceIdentities( + projectPath: string, + item: ReviewItem, + readFile: (path: string) => Promise, +): Promise { + return (await collectReviewProvenance(projectPath, item, readFile)).sourceIdentities +} + +/** 审阅项溯源诊断结果:来源并集 + 不可追溯时的原因分类。 */ +export interface ReviewProvenance { + /** raw/sources 来源身份并集(自身来源在前) */ + sourceIdentities: string[] + /** sourcePath 指向 wiki 页时(deep-research 链路)的相对路径,证据为其 References 网络文献 */ + wikiSourcePage: string | null + /** 引用了但读取失败(磁盘上不存在)的页面 */ + missingPages: string[] + /** 存在但 frontmatter 未记录 sources 的页面 */ + pagesWithoutSources: string[] +} + +/** + * 收集审阅项的完整溯源信息:来源并集 + 断链原因分类。 + * + * 与 collectReviewSourceIdentities 相同的并集逻辑,额外区分三类 + * "不可追溯"原因(wiki 研究页来源 / 引用页面不存在 / 页面无来源记录), + * 供审阅卡片向用户解释来源为何为空。 + * + * :param projectPath: 项目根路径(已规范化) + * :param item: 审阅项 + * :param readFile: 文件读取函数(注入以保持纯逻辑可测) + * :returns: 溯源诊断结果 + */ +export async function collectReviewProvenance( + projectPath: string, + item: ReviewItem, + readFile: (path: string) => Promise, +): Promise { + const identities: string[] = [] + const missingPages: string[] = [] + const pagesWithoutSources: string[] = [] + + const own = item.sourcePath ? rawSourceIdentityOrNull(projectPath, item.sourcePath) : null + if (own) identities.push(own) + + // sourcePath 在项目内但不在 raw/sources 下 → deep-research 摄取的 wiki 研究页 + let wikiSourcePage: string | null = null + if (item.sourcePath && !own) { + const sp = item.sourcePath.replace(/\\/g, "/") + const marker = "/wiki/" + const markerIndex = sp.indexOf(marker) + if (markerIndex >= 0) wikiSourcePage = `wiki/${sp.slice(markerIndex + marker.length)}` + } + + for (const page of item.affectedPages ?? []) { + try { + const sources = parseSources(await readFile(`${projectPath}/${page}`)) + if (sources.length === 0) pagesWithoutSources.push(page) + identities.push(...sources) + } catch { + missingPages.push(page) + } + } + + return { + sourceIdentities: Array.from(new Set(identities)), + wikiSourcePage, + missingPages, + pagesWithoutSources, + } +} + +/** + * 由审阅项构造新建 wiki 页面的完整内容(frontmatter + 正文)。 + * + * 溯源链关键:sourceIdentities(原始源文件相对 raw/sources 的身份, + * 含审阅项自身来源与受影响页面的来源并集)写入 sources 字段, + * affectedPages 转为裸 slug 写入 related, + * 与 ingest 直接生成页面的 frontmatter 约定保持一致。 + * + * :param draft: 页面草稿(标题/类型/目录) + * :param item: 审阅项(提供正文描述与 affectedPages) + * :param date: 页面 created 日期(YYYY-MM-DD) + * :param sourceIdentities: 原始源文件身份列表;为空时省略 sources 行 + * :returns: 可直接写盘的页面全文 + */ +export function buildReviewPageContent( + draft: ReviewPageDraft, + item: ReviewItem, + date: string, + sourceIdentities: string[], +): string { + const sources = Array.from(new Set(sourceIdentities)) + const relatedSlugs = Array.from(new Set( + (item.affectedPages ?? []) + .map((page) => getFileStem(unwrapWikilink(page).slug)) + .filter((slug) => slug.length > 0), + )) + + const lines = [ + "---", + `type: ${draft.pageType}`, + `title: ${yamlStr(draft.title)}`, + `created: ${date}`, + ] + if (sources.length > 0) lines.push(`sources: [${sources.map(yamlStr).join(", ")}]`) + lines.push("tags: []", `related: [${relatedSlugs.map(yamlStr).join(", ")}]`, "---", "") + + return `${lines.join("\n")}\n# ${draft.title}\n\n${item.description}\n` +} + export function createReviewPageDrafts(item: ReviewItem, action: string): ReviewPageDraft[] { const text = `${item.title}\n${item.description}` const pageType = detectPageType(action, item.type, text) diff --git a/src/lib/source-delete-decision.test.ts b/src/lib/source-delete-decision.test.ts index 1971e57a3..e469ee978 100644 --- a/src/lib/source-delete-decision.test.ts +++ b/src/lib/source-delete-decision.test.ts @@ -120,4 +120,19 @@ describe("decidePageFate — the full lifecycle of a shared page", () => { d = decidePageFate(sources, "b.md") expect(d.action).toBe("delete") }) + + it("matches sources across NFC/NFD unicode normal forms", () => { + // macOS 文件系统常以 NFD 存储文件名,frontmatter 可能写入 NFC 形式 + const nfd = "café-report.docx".normalize("NFD") + const nfc = "café-report.docx".normalize("NFC") + + const d = decidePageFate([nfd], nfc) + expect(d.action).toBe("delete") + }) + + it("does not equate fullwidth and halfwidth punctuation (distinct files on disk)", () => { + // NFKC 会把全角括号折叠成半角——那会误删;身份比较必须用 NFC + const d = decidePageFate(["报告(附录).docx"], "报告(附录).docx") + expect(d.action).toBe("skip") + }) }) diff --git a/src/lib/source-delete-decision.ts b/src/lib/source-delete-decision.ts index 6b858aab9..f72ad9c31 100644 --- a/src/lib/source-delete-decision.ts +++ b/src/lib/source-delete-decision.ts @@ -8,6 +8,8 @@ * substring match). */ +import { sourceIdentityKey as identityKey } from "@/lib/source-identity" + export type DeleteDecision = /** Keep the page on disk; rewrite its sources to the returned list. */ | { action: "keep"; updatedSources: string[] } @@ -30,14 +32,15 @@ export type DeleteDecision = * filtered list so caller can rewrite the frontmatter. * - If it IS and it's the ONLY source → delete. */ + export function decidePageFate( frontmatterSources: readonly string[], deletingSource: string, ): DeleteDecision { - const targetLower = deletingSource.toLowerCase() + const targetKey = identityKey(deletingSource) const inList = frontmatterSources.some( - (s) => s.toLowerCase() === targetLower, + (s) => identityKey(s) === targetKey, ) if (!inList) { return { @@ -47,7 +50,7 @@ export function decidePageFate( } const survivors = frontmatterSources.filter( - (s) => s.toLowerCase() !== targetLower, + (s) => identityKey(s) !== targetKey, ) if (survivors.length > 0) { diff --git a/src/lib/source-identity.test.ts b/src/lib/source-identity.test.ts index 51b5764e2..f3335fee7 100644 --- a/src/lib/source-identity.test.ts +++ b/src/lib/source-identity.test.ts @@ -1,13 +1,37 @@ import { describe, expect, it } from "vitest" import { legacySourceSummarySlugFromIdentity, + rawSourceIdentityOrNull, sourceIdentityForPath, + sourceIdentityKey, sourceReferenceIdentity, sourceSummarySlugCandidatesFromIdentity, sourceSummarySlugFromIdentity, } from "./source-identity" describe("source identity helpers", () => { + it("rawSourceIdentityOrNull returns the identity for paths under raw/sources", () => { + expect( + rawSourceIdentityOrNull("/tmp/project", "/tmp/project/raw/sources/a-纪要.docx"), + ).toBe("a-纪要.docx") + }) + + it("rawSourceIdentityOrNull returns null for paths outside raw/sources", () => { + expect( + rawSourceIdentityOrNull("/tmp/project", "/tmp/project/wiki/queries/research-x.md"), + ).toBeNull() + }) + + it("sourceIdentityKey equates NFC/NFD forms case-insensitively", () => { + expect(sourceIdentityKey("café-Report.docx".normalize("NFD"))) + .toBe(sourceIdentityKey("café-report.docx".normalize("NFC"))) + }) + + it("sourceIdentityKey keeps fullwidth and halfwidth punctuation distinct", () => { + // 全角/半角括号在磁盘上是不同文件,身份键不得折叠(NFKC 会误删) + expect(sourceIdentityKey("报告(附录).docx")).not.toBe(sourceIdentityKey("报告(附录).docx")) + }) + it("keeps raw/sources relative folder context as the source identity", () => { expect( sourceIdentityForPath("/tmp/project", "/tmp/project/raw/sources/project-a/config.yaml"), diff --git a/src/lib/source-identity.ts b/src/lib/source-identity.ts index e1e943acc..13a32977d 100644 --- a/src/lib/source-identity.ts +++ b/src/lib/source-identity.ts @@ -5,7 +5,27 @@ const RAW_SOURCES_MARKER = "/raw/sources/" const MAX_SOURCE_SUMMARY_SLUG_LENGTH = 120 const FALLBACK_SOURCE_PART = "source" -export function sourceIdentityForPath(projectPath: string, sourcePath: string): string { +/** + * 来源身份比较键:NFC 统一 Unicode 组合形式后小写。 + * + * macOS 文件系统以 NFD 存储文件名而 frontmatter 可能记录 NFC 形式, + * 跨平台同步后裸 toLowerCase 比较会失配。刻意不用 NFKC——全角/半角 + * 括号等兼容字符在磁盘上是不同文件,折叠会导致删除级联误删。 + * + * :param value: 任意来源身份/文件名 + * :returns: 可安全用于相等比较的规范化键 + */ +export function sourceIdentityKey(value: string): string { + return value.normalize("NFC").toLowerCase() +} + +/** + * 仅当路径确实位于 raw/sources 下时返回来源身份,否则返回 null。 + * + * 与 sourceIdentityForPath 的区别:不做裸文件名回退——wiki 页等 + * 非原始来源路径不应被当作来源身份写入 frontmatter sources。 + */ +export function rawSourceIdentityOrNull(projectPath: string, sourcePath: string): string | null { const pp = normalizePath(projectPath).replace(/\/+$/, "") const sp = normalizePath(sourcePath) const projectRawSourcesPrefix = `${pp}/${RAW_SOURCES_PREFIX}` @@ -20,7 +40,13 @@ export function sourceIdentityForPath(projectPath: string, sourcePath: string): if (markerIndex >= 0) { return sp.slice(markerIndex + RAW_SOURCES_MARKER.length) } - return getFileName(sp) + return null +} + +export function sourceIdentityForPath(projectPath: string, sourcePath: string): string { + return ( + rawSourceIdentityOrNull(projectPath, sourcePath) ?? getFileName(normalizePath(sourcePath)) + ) } export function sourceReferenceIdentity(sourceReference: string): string { diff --git a/src/lib/source-lifecycle.test.ts b/src/lib/source-lifecycle.test.ts index 072aa5565..9583b99dd 100644 --- a/src/lib/source-lifecycle.test.ts +++ b/src/lib/source-lifecycle.test.ts @@ -9,6 +9,10 @@ const mocks = vi.hoisted(() => ({ listDirectory: vi.fn(), preprocessFile: vi.fn(), enqueueBatch: vi.fn(), + isFinanceNamingEnabled: vi.fn(), + loadStockBasic: vi.fn(), + fileDateFallback: vi.fn(), + appendRenameMap: vi.fn(), })) vi.mock("@/commands/fs", async () => { @@ -29,6 +33,17 @@ vi.mock("@/lib/ingest-queue", () => ({ enqueueBatch: mocks.enqueueBatch, })) +vi.mock("@/lib/finance-naming", async () => { + const actual = await vi.importActual("@/lib/finance-naming") + return { + ...actual, + isFinanceNamingEnabled: mocks.isFinanceNamingEnabled, + loadStockBasic: mocks.loadStockBasic, + fileDateFallback: mocks.fileDateFallback, + appendRenameMap: mocks.appendRenameMap, + } +}) + import { enqueueSourceIngest, folderContextForSourcePath, @@ -47,6 +62,80 @@ beforeEach(() => { mocks.listDirectory.mockResolvedValue([]) mocks.preprocessFile.mockResolvedValue("") mocks.enqueueBatch.mockResolvedValue(["task"]) + mocks.isFinanceNamingEnabled.mockResolvedValue(false) + mocks.loadStockBasic.mockResolvedValue([]) + mocks.fileDateFallback.mockResolvedValue(new Date("2026-07-02T00:00:00Z")) + mocks.appendRenameMap.mockResolvedValue(undefined) +}) + +describe("importSourceFolder finance naming", () => { + it("renames leaf filenames while preserving folder structure when finance naming is on", async () => { + mocks.isFinanceNamingEnabled.mockResolvedValue(true) + mocks.loadStockBasic.mockResolvedValue([ + { tsCode: "688786.SH", name: "悦安新材", cnspell: "yaxc" }, + ]) + mocks.listDirectory.mockResolvedValue([ + { + name: "sub", + path: "/external/notes/sub", + is_dir: true, + children: [ + { name: "悦安电感材料专家交流260701.docx", path: "/external/notes/sub/悦安电感材料专家交流260701.docx", is_dir: false }, + ], + }, + ]) + + const copied = await importSourceFolder( + { id: "p1", name: "Project", path: "/project" }, + "/external/notes", + { provider: "openai", apiKey: "key", model: "model" } as never, + { + enabled: true, + autoIngest: true, + includeExtensions: ["docx"], + excludeExtensions: [], + excludeDirs: [], + excludeGlobs: [], + maxFileSizeMb: 100, + }, + ) + + // 目录结构保留(sub/),叶子文件名被规范化 + expect(copied).toEqual([ + "/project/raw/sources/notes/sub/20260701-688786.SH-悦安新材-电感材料专家交流.docx", + ]) + expect(mocks.appendRenameMap).toHaveBeenCalledWith("/project", [ + expect.objectContaining({ + original: "悦安电感材料专家交流260701.docx", + renamed: "20260701-688786.SH-悦安新材-电感材料专家交流.docx", + tsCode: "688786.SH", + }), + ]) + }) + + it("keeps original names when finance naming is off", async () => { + mocks.listDirectory.mockResolvedValue([ + { name: "悦安交流260701.docx", path: "/external/notes/悦安交流260701.docx", is_dir: false }, + ]) + + const copied = await importSourceFolder( + { id: "p1", name: "Project", path: "/project" }, + "/external/notes", + { provider: "openai", apiKey: "key", model: "model" } as never, + { + enabled: true, + autoIngest: true, + includeExtensions: ["docx"], + excludeExtensions: [], + excludeDirs: [], + excludeGlobs: [], + maxFileSizeMb: 100, + }, + ) + + expect(copied).toEqual(["/project/raw/sources/notes/悦安交流260701.docx"]) + expect(mocks.appendRenameMap).toHaveBeenCalledWith("/project", []) + }) }) describe("source-lifecycle path helpers", () => { diff --git a/src/lib/source-lifecycle.ts b/src/lib/source-lifecycle.ts index 55e1ceaba..f6c557894 100644 --- a/src/lib/source-lifecycle.ts +++ b/src/lib/source-lifecycle.ts @@ -16,6 +16,7 @@ import { hasUsableLlm } from "@/lib/has-usable-llm" import { getFileName, getFileStem, getRelativePath, normalizePath } from "@/lib/path-utils" import { sourceIdentityForPath, + sourceIdentityKey, sourceReferenceIdentity, } from "@/lib/source-identity" import { @@ -24,6 +25,14 @@ import { writeFrontmatterArray, writeSources, } from "@/lib/sources-merge" +import { + appendRenameMap, + buildFinanceFileName, + fileDateFallback, + isFinanceNamingEnabled, + loadStockBasic, + type RenameRecord, +} from "@/lib/finance-naming" import { removeFromIngestCache } from "@/lib/ingest-cache" import { removePageEmbedding } from "@/lib/embedding" import { @@ -167,8 +176,13 @@ export async function importSourceFiles( const cfg = normalizeSourceWatchConfig(sourceWatchConfig) const maxBytes = cfg.maxFileSizeMb * 1024 * 1024 + // 金融命名规范化:项目启用时导入前重命名(yyyymmdd-代码-简称-标题), + // 开关与个股表每批只读一次 + const financeNaming = await isFinanceNamingEnabled(pp) + const stockTable = financeNaming ? await loadStockBasic(pp) : [] + const renameRecords: RenameRecord[] = [] + for (const sourcePath of sourcePaths) { - const originalName = getFileName(sourcePath) || "unknown" if (isSensitiveConfigSourceFile(sourcePath)) { continue } @@ -182,16 +196,34 @@ export async function importSourceFiles( } if (!allowed) continue + // 改名在全部过滤检查之后计算,审计记录只在复制成功后追加, + // 且以实际落盘文件名为准(getUniqueDestPath 碰撞时会加后缀) + let originalName = getFileName(sourcePath) || "unknown" + let financeRecord: RenameRecord | null = null + if (financeNaming) { + const { fileName, record } = buildFinanceFileName( + originalName, + stockTable, + await fileDateFallback(sourcePath), + ) + if (fileName !== originalName) financeRecord = record + originalName = fileName + } + const destPath = await getUniqueDestPath(`${pp}/raw/sources`, originalName) try { await copyFile(sourcePath, destPath) importedPaths.push(destPath) + if (financeRecord) { + renameRecords.push({ ...financeRecord, renamed: getFileName(destPath) }) + } preprocessFile(destPath).catch(() => {}) } catch (err) { console.error(`Failed to import ${originalName}:`, err) } } + await appendRenameMap(pp, renameRecords) await enqueueSourceIngest(project, importedPaths, llmConfig) return importedPaths @@ -219,9 +251,13 @@ export async function importSourceFolder( // keys / tool config do not enter ingest. const sourceFiles = flattenFiles(await listDirectory(selectedFolder, true)) + // 金融命名规范化:启用时仅规范"叶子文件名",目录结构原样保留 + const financeNaming = await isFinanceNamingEnabled(pp) + const stockTable = financeNaming ? await loadStockBasic(pp) : [] + const renameRecords: RenameRecord[] = [] + for (const file of sourceFiles) { const relativeSourcePath = getRelativePath(file.path, sourceRoot) - const destPath = `${destDir}/${relativeSourcePath}` const relPath = `raw/sources/${folderName}/${relativeSourcePath}` if (isSensitiveConfigSourceFile(file.path)) { continue @@ -235,13 +271,44 @@ export async function importSourceFolder( } } if (!allowed) continue + + let destRelativePath = relativeSourcePath + let financeRecord: RenameRecord | null = null + if (financeNaming) { + const baseName = getFileName(relativeSourcePath) || relativeSourcePath + const { fileName, record } = buildFinanceFileName( + baseName, + stockTable, + await fileDateFallback(file.path), + ) + if (fileName !== baseName) { + financeRecord = record + const dirPart = relativeSourcePath.slice(0, relativeSourcePath.length - baseName.length) + destRelativePath = `${dirPart}${fileName}` + } + } + + let destPath = `${destDir}/${destRelativePath}` const parent = parentPath(destPath) if (parent) await createDirectory(parent) - await copyFile(file.path, destPath) - allowedFiles.push(destPath) - preprocessFile(destPath).catch(() => {}) + // 仅在发生改名时做落盘去重:不同原名可能规范化成同名(同日同标的同标题) + if (financeRecord && parent) { + destPath = await getUniqueDestPath(parent, getFileName(destPath) || destRelativePath) + } + try { + await copyFile(file.path, destPath) + allowedFiles.push(destPath) + if (financeRecord) { + renameRecords.push({ ...financeRecord, renamed: getFileName(destPath) }) + } + preprocessFile(destPath).catch(() => {}) + } catch (err) { + console.error(`Failed to import ${relativeSourcePath}:`, err) + } } + await appendRenameMap(pp, renameRecords) + const naturallyOrderedFiles = [...allowedFiles].sort((a, b) => naturalCompare(getRelativePath(a, destDir), getRelativePath(b, destDir)), ) @@ -289,9 +356,10 @@ export async function deleteSourceFiles( return { deletedWikiPaths: [], rewrittenSourcePages: 0, skippedPages: 0 } } - const deletingNames = new Set(sourceInfos.map((info) => info.fileName.toLowerCase())) + // NFC 身份键:macOS 磁盘名(NFD)与 frontmatter 记录(可能 NFC)跨平台失配防护 + const deletingNames = new Set(sourceInfos.map((info) => sourceIdentityKey(info.fileName))) const deletingIdentities = new Set( - sourceInfos.map((info) => sourceReferenceIdentity(info.identity).toLowerCase()), + sourceInfos.map((info) => sourceIdentityKey(sourceReferenceIdentity(info.identity))), ) if (!options.fileAlreadyDeleted) { @@ -530,7 +598,7 @@ function sourceNameMatchesAny( deletingNames: Set, ): boolean { const normalized = normalizePath(source) - const identity = sourceReferenceIdentity(normalized).toLowerCase() + const identity = sourceIdentityKey(sourceReferenceIdentity(normalized)) if (deletingIdentities.has(identity)) return true // Legacy wiki pages stored only basenames in `sources`. Keep that fallback @@ -538,8 +606,7 @@ function sourceNameMatchesAny( // `project-b/config.yaml` match a different deleted `config.yaml`. if (normalized.includes("/")) return false - const normalizedSource = normalized.toLowerCase() - return deletingNames.has(normalizedSource) + return deletingNames.has(sourceIdentityKey(normalized)) } function withRootContext(context: string, rootContext?: string): string { diff --git a/src/lib/sweep-reviews.ts b/src/lib/sweep-reviews.ts index 2a0506dfe..1e02902d1 100644 --- a/src/lib/sweep-reviews.ts +++ b/src/lib/sweep-reviews.ts @@ -15,9 +15,9 @@ import { useReviewStore, type ReviewItem } from "@/stores/review-store" import { useActivityStore } from "@/stores/activity-store" import { useWikiStore } from "@/stores/wiki-store" import { streamChat } from "@/lib/llm-client" -import type { FileNode } from "@/types/wiki" import { normalizePath } from "@/lib/path-utils" import { normalizeReviewTitle } from "@/lib/review-utils" +import { flattenMdFiles } from "@/lib/affected-pages-resolver" import { hasUsableLlm } from "@/lib/has-usable-llm" // ── Types ───────────────────────────────────────────────────────────────── @@ -35,17 +35,6 @@ interface WikiIndex { // ── Helpers ──────────────────────────────────────────────────────────────── -function flattenMdFiles(nodes: FileNode[]): FileNode[] { - const files: FileNode[] = [] - for (const node of nodes) { - if (node.is_dir && node.children) { - files.push(...flattenMdFiles(node.children)) - } else if (!node.is_dir && node.name.endsWith(".md")) { - files.push(node) - } - } - return files -} /** Build an index of wiki pages: id (filename without .md) + title → normalized */ async function buildWikiIndex(projectPath: string): Promise { diff --git a/src/lib/templates.ts b/src/lib/templates.ts index a16d58e95..65a841022 100644 --- a/src/lib/templates.ts +++ b/src/lib/templates.ts @@ -637,11 +637,104 @@ ${BASE_CONTRADICTION} `, } +const financeTemplate: WikiTemplate = { + id: "finance", + name: "Finance Research", + description: "Market research on meeting notes — time-stamped sources, ticker-anchored entities", + icon: "📈", + extraDirs: ["wiki/findings", "wiki/thesis"], + schema: `# Wiki Schema — Finance Market Research + +## Page Types + +| Type | Directory | Purpose | +|------|-----------|---------| +${BASE_SCHEMA_TYPES} +| thesis | wiki/thesis/ | Working investment thesis and its evolution over time | +| finding | wiki/findings/ | Individual data points or claims from meeting notes | + +## Naming Conventions + +${BASE_NAMING} +- Theses: hypothesis as slug (e.g., \`tantalum-supply-tightening.md\`) +- Findings: descriptive slug with the subject (e.g., \`yuean-powder-mass-production-2026.md\`) + +## Source File Naming (IMPORTANT) + +Raw source files follow \`yyyymmdd---.<ext>\` +(e.g., \`20260701-688786.SH-悦安新材-电感材料专家交流.docx\`). The leading +date is the MEETING/PUBLICATION date of the note — treat it as the +authoritative timestamp for every claim extracted from that source. + +## Frontmatter + +${BASE_FRONTMATTER} + +Stock entity pages MUST also include: +\`\`\`yaml +ts_code: "688786.SH" # exchange ticker; omit only if the entity is not listed +industry: "小金属" # sector/industry classification +\`\`\` + +Finding pages also include: +\`\`\`yaml +source: "[[source-slug]]" +as_of: YYYY-MM-DD # the date the data point was stated (from the source's filename date) +\`\`\` + +## Index Format + +${BASE_INDEX_FORMAT} + +## Log Format + +${BASE_LOG_FORMAT} + +## Cross-referencing Rules + +${BASE_CROSSREF} + +## Contradiction Handling (time-aware) + +${BASE_CONTRADICTION} + +Additionally, because market data is time-sensitive: +- Always compare the SOURCE DATES of conflicting claims (from the yyyymmdd filename prefix). +- Newer sources supersede older ones for point-in-time facts (prices, capacity, guidance); note the supersession instead of treating it as an open contradiction. +- Only treat same-period conflicts as true contradictions worth a query page. +- Never present a dated claim without its as-of date.`, + purpose: `# Wiki Purpose + +## Goal + +Build a time-aware knowledge base from financial meeting notes (调研纪要/交流纪要/专家访谈), +tracking companies, industry chains, and the evolution of supply/demand and pricing views. + +## Key Questions + +> e.g. Which companies' fundamentals are inflecting? Where do sources disagree on timing? + +- TBD + +## Scope + +- In scope: meeting notes, expert calls, research weeklies imported into raw/sources +- Out of scope: TBD + +## Evolving Thesis + +> Updated as evidence accumulates; every thesis links its supporting findings with dates. + +- TBD +`, +} + export const templates: WikiTemplate[] = [ researchTemplate, readingTemplate, personalTemplate, businessTemplate, + financeTemplate, generalTemplate, ] diff --git a/src/stores/wiki-store.test.ts b/src/stores/wiki-store.test.ts index 7a621d5a0..bcb850480 100644 --- a/src/stores/wiki-store.test.ts +++ b/src/stores/wiki-store.test.ts @@ -10,9 +10,59 @@ describe("wiki preview store actions", () => { fileContent: "", previewContentPath: null, externalPreview: null, + previewHistory: [], }) }) + it("pushes the previous file onto previewHistory when opening another file", () => { + useWikiStore.getState().openPathInPreview("/p/wiki/a.md") + useWikiStore.getState().openPathInPreview("/p/wiki/b.md") + useWikiStore.getState().openFileInPreview("/p/wiki/c.md", "content") + + expect(useWikiStore.getState().previewHistory).toEqual(["/p/wiki/a.md", "/p/wiki/b.md"]) + expect(useWikiStore.getState().selectedFile).toBe("/p/wiki/c.md") + }) + + it("goBackInPreview returns to the previous file and pops the history", () => { + useWikiStore.getState().openPathInPreview("/p/wiki/a.md") + useWikiStore.getState().openPathInPreview("/p/wiki/b.md") + + useWikiStore.getState().goBackInPreview() + + expect(useWikiStore.getState().selectedFile).toBe("/p/wiki/a.md") + expect(useWikiStore.getState().previewHistory).toEqual([]) + // 历史为空时再后退是安全的空操作 + useWikiStore.getState().goBackInPreview() + expect(useWikiStore.getState().selectedFile).toBe("/p/wiki/a.md") + }) + + it("does not push duplicate history entries when re-opening the same file", () => { + useWikiStore.getState().openPathInPreview("/p/wiki/a.md") + useWikiStore.getState().openPathInPreview("/p/wiki/a.md") + + expect(useWikiStore.getState().previewHistory).toEqual([]) + }) + + it("closing the preview (select null) does not push history", () => { + useWikiStore.getState().openPathInPreview("/p/wiki/a.md") + useWikiStore.getState().setSelectedFile(null) + + expect(useWikiStore.getState().previewHistory).toEqual([]) + // 关闭后再打开别的文件,不应出现指向已关闭文件的 Back + useWikiStore.getState().openPathInPreview("/p/wiki/b.md") + expect(useWikiStore.getState().previewHistory).toEqual([]) + }) + + it("does not push an external-preview path (back cannot restore its state)", () => { + useWikiStore.getState().openFileInPreview("/p/external-ref", "snippet") + useWikiStore.setState({ + externalPreview: { path: "/p/external-ref", url: "https://x", title: "t", source: "web", snippet: "s" }, + }) + useWikiStore.getState().openPathInPreview("/p/wiki/b.md") + + expect(useWikiStore.getState().previewHistory).toEqual([]) + }) + it("keeps the project path index in sync when setting the file tree", () => { useWikiStore.getState().setFileTree([ { diff --git a/src/stores/wiki-store.ts b/src/stores/wiki-store.ts index d474751b1..265be823d 100644 --- a/src/stores/wiki-store.ts +++ b/src/stores/wiki-store.ts @@ -350,6 +350,8 @@ interface WikiState { fileContent: string previewContentPath: string | null externalPreview: ExternalPreview | null + /** 预览导航历史(后退栈),随文件切换自动入栈,供预览面板的返回按钮使用。 */ + previewHistory: string[] /** * One-shot scroll target for the markdown preview. When the user * clicks an image in search results and chooses "jump to source", @@ -391,6 +393,8 @@ interface WikiState { setFileContent: (content: string) => void openPathInPreview: (path: string) => void openFileInPreview: (path: string, content: string) => void + /** 后退到上一个预览文件;历史为空时是安全的空操作。 */ + goBackInPreview: () => void setExternalPreview: (preview: ExternalPreview | null) => void setPendingScrollImageSrc: (src: string | null) => void setActiveView: (view: WikiState["activeView"]) => void @@ -412,6 +416,25 @@ interface WikiState { bumpDataVersion: () => void } +const PREVIEW_HISTORY_LIMIT = 20 + +/** + * 切换预览文件时把上一个文件压入历史栈。 + * + * 不入栈的情形:同文件重开、关闭预览(nextFile 为 null——否则关闭再 + * 打开别的文件后 Back 会回到用户已主动关闭的文件)、上一个是外部引用 + * 预览(历史只存路径,externalPreview 状态无法随 Back 还原)。 + */ +function pushPreviewHistory( + state: Pick<WikiState, "selectedFile" | "previewHistory" | "externalPreview">, + nextFile: string | null, +): string[] { + const previous = state.selectedFile + if (!previous || nextFile === null || previous === nextFile) return state.previewHistory + if (state.externalPreview?.path === previous) return state.previewHistory + return [...state.previewHistory, previous].slice(-PREVIEW_HISTORY_LIMIT) +} + export const useWikiStore = create<WikiState>((set) => ({ project: null, fileTree: [], @@ -420,6 +443,7 @@ export const useWikiStore = create<WikiState>((set) => ({ fileContent: "", previewContentPath: null, externalPreview: null, + previewHistory: [], pendingScrollImageSrc: null, activeView: "wiki", llmConfig: { @@ -449,17 +473,41 @@ export const useWikiStore = create<WikiState>((set) => ({ setProjectPathIndexFromTree: (tree) => set({ projectPathIndex: buildProjectPathIndexFromTree(tree) }), setSelectedFile: (selectedFile) => - set({ selectedFile, previewContentPath: null, externalPreview: null }), + set((state) => ({ + selectedFile, + previewContentPath: null, + externalPreview: null, + previewHistory: pushPreviewHistory(state, selectedFile), + })), setFileContent: (fileContent) => set({ fileContent }), openPathInPreview: (selectedFile) => - set({ selectedFile, previewContentPath: null, externalPreview: null, activeView: "wiki" }), + set((state) => ({ + selectedFile, + previewContentPath: null, + externalPreview: null, + activeView: "wiki", + previewHistory: pushPreviewHistory(state, selectedFile), + })), openFileInPreview: (selectedFile, fileContent) => - set({ + set((state) => ({ selectedFile, fileContent, previewContentPath: selectedFile, externalPreview: null, activeView: "wiki", + previewHistory: pushPreviewHistory(state, selectedFile), + })), + goBackInPreview: () => + set((state) => { + const previous = state.previewHistory[state.previewHistory.length - 1] + if (previous === undefined) return {} + // 后退本身不入栈,否则会在两个文件间来回循环 + return { + selectedFile: previous, + previewContentPath: null, + externalPreview: null, + previewHistory: state.previewHistory.slice(0, -1), + } }), setExternalPreview: (externalPreview) => set({ externalPreview }), setPendingScrollImageSrc: (pendingScrollImageSrc) => set({ pendingScrollImageSrc }),