dsh-lost-and-found 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +218 -0
- package/client.js +407 -0
- package/config.mjs +189 -0
- package/cordis.patch.yml +5 -0
- package/core/classify.mjs +102 -0
- package/core/format.mjs +40 -0
- package/core/run-scan.mjs +211 -0
- package/core/scan.mjs +208 -0
- package/core/schedule.mjs +71 -0
- package/core/search.mjs +217 -0
- package/db.mjs +336 -0
- package/index.mjs +577 -0
- package/package.json +82 -0
- package/screenshots.json +6 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 wangzhanchao883
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# 文件快速寻回 · dsh-lost-and-found
|
|
2
|
+
|
|
3
|
+
[](https://github.com/wangzhanchao883/dsh-lost-and-found/actions/workflows/test.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](https://www.npmjs.com/package/dsh-lost-and-found)
|
|
6
|
+
|
|
7
|
+
> 忘了文件放哪,问一句就能找回来,再也不用翻遍硬盘。
|
|
8
|
+
|
|
9
|
+
忘了文件放哪,问一句就能找回来,再也不用翻遍硬盘。这款 DSH 插件把你指定的文件夹变成一座随问随答的本地文献库。支持多目录只读扫描,缓存与程序目录整棵跳过,几万个文件十几秒扫完;结果统一登记进本地 SQLite 索引,随时可从零重建。名字、类型、大小、出现时间、位置、来源自动入库,图片内容交给大模型看过之后写回描述。找文件采用两段式:先按时间、类型、目录、来源硬过滤,再按文件名、标签、路径、摘要、图片描述分层加权,多关键词取交,命中的是哪一层直接标给你看。索引里没有的当场实时兜底找一遍,但只现场比对、绝不写库,插件只读,绝不修改、移动或删除你的文件。比每次让大模型硬翻目录更省更可控,比你自己的记忆更靠得住,几万文件规模也照样秒回。
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
| 设置页:扫描概况与一键扫描 | 监视的文件夹(可逐个设内容深度) | 高级选项 |
|
|
14
|
+
| --- | --- | --- |
|
|
15
|
+
|  |  |  |
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## English summary
|
|
20
|
+
|
|
21
|
+
`dsh-lost-and-found` answers *"where did I put that file?"* from a **local SQLite
|
|
22
|
+
index** instead of walking your disk on every question. You pick the folders to
|
|
23
|
+
watch; the plugin records what appeared — name, type, size, appeared-at, location
|
|
24
|
+
and origin — and prunes caches, VCS and program folders at the directory level,
|
|
25
|
+
so a scan of tens of thousands of files finishes in seconds.
|
|
26
|
+
|
|
27
|
+
Search is two-stage: SQL hard filters (time / type / folder / origin) first, then
|
|
28
|
+
multi-keyword AND matching scored by *where* the hit landed (name > tags > path >
|
|
29
|
+
summary > image description > excerpt), and the result tells you which layer hit.
|
|
30
|
+
When the index has no hit, the watched folders are scanned live as a fallback —
|
|
31
|
+
compared only, never written back.
|
|
32
|
+
|
|
33
|
+
**Every watched folder keeps its own incremental anchor.** A folder added later is
|
|
34
|
+
backfilled in full on its first scan; after that it is incremental, and a folder
|
|
35
|
+
whose scan was interrupted keeps its anchor so the next run finishes the job.
|
|
36
|
+
|
|
37
|
+
**Read-only promise:** the plugin never modifies, moves, renames or deletes your
|
|
38
|
+
files, never writes NTFS alternate data streams, never generates thumbnails, never
|
|
39
|
+
touches timestamps, and never uploads anything — the whole index is one SQLite
|
|
40
|
+
file on your machine.
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
dsh plugin --profile web add dsh-lost-and-found
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Requires DSH with the web profile and Node.js `^22` or `>=24`. Windows-focused
|
|
47
|
+
(uses `birthtime` on NTFS), but nothing here is Windows-only by design.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## 它做什么
|
|
52
|
+
|
|
53
|
+
- 按你设定的间隔(每天 / 每 3 天 / 每周)扫描你指定的文件夹;打开 DSH 时会检查是否到期并补扫
|
|
54
|
+
- 记录文件名、类型、大小、**出现时间**、位置、来源,写进本地 SQLite 索引
|
|
55
|
+
- **每个目录各有自己的增量锚点**:新加入的目录第一次扫描会把它的历史文件一并收进来,之后转为增量
|
|
56
|
+
- 支持按**关键词**、时间范围、文件类型、目录范围、来源组合查找,并按「命中在哪一层」加权排序
|
|
57
|
+
- 索引里没有的,会对扫描目录做一次**实时兜底扫描**,尽力找回(只读,不写库)
|
|
58
|
+
- 图片内容可以交给模型看过之后写回描述,之后按画面内容也能搜到
|
|
59
|
+
- 提示你**扫描范围之外**哪些文件夹最近出现了新文件(只提示,不自动收录)
|
|
60
|
+
- 记忆库可随时从零重建:索引是派生数据,不是唯一真相
|
|
61
|
+
|
|
62
|
+
## 它不做什么
|
|
63
|
+
|
|
64
|
+
- **绝不修改、移动、重命名或删除你的任何文件**
|
|
65
|
+
- 不会去看你没有指定的文件夹
|
|
66
|
+
- 不写 NTFS 附加数据流(ADS)、不生成缩略图、不改任何时间戳
|
|
67
|
+
- 不联网上传任何内容:所有记录只存在你本机的一个 SQLite 文件里
|
|
68
|
+
- 不做文件同步、不做备份、不做重复文件清理(那些是别的工具的职责)
|
|
69
|
+
|
|
70
|
+
## 怎么用
|
|
71
|
+
|
|
72
|
+
不用记命令。直接说人话:
|
|
73
|
+
|
|
74
|
+
- 「我上个月收到的那份报价单在哪?」
|
|
75
|
+
- 「我让你写的那份关于 XX 的报告存哪了?」
|
|
76
|
+
- 「下载的那个安装包放哪了,好像这周下的」
|
|
77
|
+
- 「我记得有张截图,上面有转账金额 1280」
|
|
78
|
+
|
|
79
|
+
也可以直接调用工具:
|
|
80
|
+
|
|
81
|
+
| 工具 | 作用 |
|
|
82
|
+
| --- | --- |
|
|
83
|
+
| `file_find` | 找文件(核心):关键词 + 时间/类型/目录/来源过滤,索引没命中时自动兜底 |
|
|
84
|
+
| `file_index_scan` | 立刻扫描一次(`wait=true` 等它跑完;`full=true` 强制忽略锚点全量重扫) |
|
|
85
|
+
| `file_index_status` | 概况:上次扫描时间、库内数量、扫描目录、待处理项、库位置 |
|
|
86
|
+
| `file_index_open` | 在资源管理器里定位某个文件 |
|
|
87
|
+
| `file_index_forget` | 从记忆库里删掉某条记录(**不动文件**) |
|
|
88
|
+
| `file_index_verify` | 手动检查一批记录是否还在原处 |
|
|
89
|
+
| `file_index_pending_images` / `file_index_set_image_desc` | 图片内容的「待看清单 / 写回描述」 |
|
|
90
|
+
|
|
91
|
+
## 环境要求
|
|
92
|
+
|
|
93
|
+
- DSH,使用 `web` profile
|
|
94
|
+
- Node.js `^22` 或 `>=24`(用到内置 `node:sqlite`)
|
|
95
|
+
- 无需 API key、无需联网;扫描与检索全部在本机完成
|
|
96
|
+
|
|
97
|
+
## 安装
|
|
98
|
+
|
|
99
|
+
从 npm:
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
dsh plugin --profile web add dsh-lost-and-found
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
直接从仓库:
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
dsh plugin --profile web add https://github.com/wangzhanchao883/dsh-lost-and-found
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
开发期把工作副本链进 profile(改完**必须重启 DSH**,插件源码不热更新):
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
dsh plugin --profile web add link:/path/to/dsh-lost-and-found
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## 配置
|
|
118
|
+
|
|
119
|
+
设置页里有:**扫描概况与「立即扫描」按钮**、**扫描目录(可增删,每个目录可单独设内容深度)**、**记忆库存放位置**、**扫描间隔**、**内容读取说明**与高级选项。
|
|
120
|
+
|
|
121
|
+
| 配置项 | 默认 | 说明 |
|
|
122
|
+
| --- | --- | --- |
|
|
123
|
+
| `enabled` | `true` | 关闭后不再自动扫描,也不再提供找文件工具 |
|
|
124
|
+
| `roots` | `[]` | 要监视的目录,每个目录带自己的 `policy` |
|
|
125
|
+
| `dbPath` | 空 | 索引库位置;留空用 `%LOCALAPPDATA%\dsh-lost-and-found\index.db` |
|
|
126
|
+
| `intervalDays` | `1` | 自动扫描间隔;`0` = 只手动 |
|
|
127
|
+
| `firstScanMode` | `full` | 一个目录**第一次**被扫时收多久:`full` 全部历史 / `window` 最近 `firstRunWindowDays` 天 / `none` 只收今后新增 |
|
|
128
|
+
| `firstRunWindowDays` | `7` | 仅当 `firstScanMode = window` 时生效 |
|
|
129
|
+
| `maxFileMB` | `50` | 超过此体积只记基本信息,不读内容 |
|
|
130
|
+
| `imageQuotaPerRun` | `20` | 每次看图配额 |
|
|
131
|
+
| `patrolEnabled` | `true` | 是否提示白名单外有新文件的目录 |
|
|
132
|
+
| `backupKeep` | `7` | 每日备份保留份数,`0` = 不备份 |
|
|
133
|
+
| `extraExcludeDirs` / `extraExcludePatterns` | `[]` | 追加的排除目录名 / 路径片段(`re:` 前缀表示正则) |
|
|
134
|
+
|
|
135
|
+
## 扫描目录的内容深度(每个目录可不同)
|
|
136
|
+
|
|
137
|
+
- **完整**:记基本信息,并允许把图片交给模型看过之后写回描述(默认)
|
|
138
|
+
- **只摘要**:记基本信息;不把图片交给模型
|
|
139
|
+
- **只记基本信息**:只记名字、类型、位置、时间(适合含证件/合同等敏感内容的目录)
|
|
140
|
+
|
|
141
|
+
> 下拉框里的档位名写的是「完整(正文+摘要+看图)」,其中**正文与摘要抽取尚未启用**,见下方路线图;当前真正生效的是「图片是否看图」。
|
|
142
|
+
|
|
143
|
+
## 架构速览
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
用户提问
|
|
147
|
+
│
|
|
148
|
+
├─ file_find ──► ① SQL 硬过滤(时间/类型/目录/来源/状态/噪音)
|
|
149
|
+
│ ② 多关键词 AND 匹配(name/path/tags/summary/image_desc/excerpt)
|
|
150
|
+
│ ③ 命中位置加权打分 + 近 7/30 天加成
|
|
151
|
+
│ ④ 逐个 stat,标注「还在 / 已不在此处」
|
|
152
|
+
│ └─ 索引无命中 ──► 对扫描目录做一次只读实时兜底扫描
|
|
153
|
+
│
|
|
154
|
+
└─ 扫描(独立子进程,PID 锁)
|
|
155
|
+
├─ 逐目录取自己的锚点(从未扫过 → 按 firstScanMode 回填)
|
|
156
|
+
├─ 目录级剪枝 + 跳过临时/缓存件
|
|
157
|
+
├─ 逐目录回报 completed
|
|
158
|
+
└─ 只给「真正走完」的目录推进锚点;完成后写库、轮转备份
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## 目录结构
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
index.mjs host 入口:设置命名空间、工具注册、派发扫描
|
|
165
|
+
client.js web 客户端:设置页那一段
|
|
166
|
+
config.mjs 默认值、剪枝规则、运行快照
|
|
167
|
+
db.mjs SQLite 表结构、迁移与查询
|
|
168
|
+
core/scan.mjs 遍历器(剪枝、逐目录窗口、逐目录完成状态)
|
|
169
|
+
core/schedule.mjs 锚点与排期(computeSinceMs / rootSinceMs / isDue)
|
|
170
|
+
core/search.mjs 查询构造、打分、实时兜底扫描
|
|
171
|
+
core/run-scan.mjs 扫描子进程入口(独立进程 + PID 锁)
|
|
172
|
+
core/classify.mjs 类别、来源、噪音判定
|
|
173
|
+
core/format.mjs 体积与时间的可读化
|
|
174
|
+
tools/selftest.mjs 离线自测(不依赖 DSH)
|
|
175
|
+
docs/ 设计与路线图文档(不进 npm 包)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## 一些实现上的取舍(写给愿意深看的人)
|
|
179
|
+
|
|
180
|
+
- **不判断「作者是谁」**:Windows 无法可靠回答「这个文件是谁创建的」(NTFS Owner 只反映系统账户)。所以本插件索引的是**「最近出现在你地盘上的新文件」**——对「我找不见文件了」这个真实需求,这个口径更好用。
|
|
181
|
+
- **目录级剪枝**:`node_modules`、`.git`、各种缓存、程序目录命中的**整棵子树都不下钻**,而不是逐文件过滤。这让几万个文件的扫描只要十几秒。
|
|
182
|
+
- **不做全文检索索引**:FTS5 的可用性取决于 Node 版本(实测系统 node v22.14.0 报 `no such module: fts5`,而 QClaw 自带、DSH 运行时用的 v22.22.3 是带 FTS5 的)。为了在两种环境下都能跑,检索统一走「SQL 硬过滤 + 多关键词 LIKE 取交 + 命中位置加权」。中文无需分词,子串匹配天然可用,几万行的查询在百毫秒级;将来检测到 FTS5 可用时可作为加速路径启用。
|
|
183
|
+
- **扫描跑在独立子进程**:遍历是重 IO,放在 DSH 主进程里会拖慢界面。子进程只做 IO,写完库就退出,并用 PID 锁保证同一时间只有一个实例。
|
|
184
|
+
- **索引是可重建的派生数据**:库丢了不要紧,重新扫描即可。查询永远带实时兜底,绝不把索引当作唯一真相。
|
|
185
|
+
- **每个扫描目录有自己的增量锚点**:锚点存在 `roots` 表里(`last_scan_ms` / `first_scan_done`),不是全局共用一个。这是必须的——全局锚点会让「后加入的目录」只看到锚点之后 6 小时内的文件,它更早的历史文件永久漏收(实测某个目录 570 个文件只进了 13 个)。只有**真正走完**的目录才推进锚点,扫描被中断或目录不可读时保留原锚点,下次继续补。
|
|
186
|
+
- **新目录首次扫描默认回填全部历史**:由 `firstScanMode` 控制;老库升级后 `first_scan_done` 为 0,下一次扫描会自动逐目录补一次,然后转为增量。
|
|
187
|
+
|
|
188
|
+
## 已知限制与路线图
|
|
189
|
+
|
|
190
|
+
**已知限制**
|
|
191
|
+
|
|
192
|
+
- 索引只覆盖你配置的目录;目录之外的文件只能靠实时兜底扫描按名字/时间找到,搜不到内容。
|
|
193
|
+
- **正文与摘要抽取尚未启用**:`summary` / `excerpt` 字段目前不会被写入,所以「按文档内容找」还不成立(图片描述是走通的)。设置页的档位名与本节描述以此为准。
|
|
194
|
+
- 一个目录一天最多真扫一次(`intervalDays`);刚下载的文件可能还没进索引——此时用兜底扫描或 `file_index_scan`。
|
|
195
|
+
- 文件「出现时间」取 `birthtime` 与 `mtime` 的较大值;被网盘/同步工具改写过的 `mtime` 会让时间维度失真。
|
|
196
|
+
- 暂不支持:视频/音频内容理解、压缩包内文件、全文检索服务。
|
|
197
|
+
|
|
198
|
+
**路线图**
|
|
199
|
+
|
|
200
|
+
1. **内容索引 P0(零模型成本)**:本地抽取文本/Office/PDF 正文,图片元数据与描述入库,检索同时搜文件名与内容,并标注「命中文件名 / 命中内容」。
|
|
201
|
+
2. **图片内容 P1**:按价值排序的看图队列,批量看图写回描述;名字与内容明显不符的单独出报告(**只给建议名,绝不改名**)。
|
|
202
|
+
3. **语义检索 P2**:本地 BM25 / 字符 n-gram,解决「记不清词只记得大概意思」。
|
|
203
|
+
|
|
204
|
+
设计细节见 [`docs/内容索引与文件名脱钩-方案.md`](https://github.com/wangzhanchao883/dsh-lost-and-found/blob/main/docs/%E5%86%85%E5%AE%B9%E7%B4%A2%E5%BC%95%E4%B8%8E%E6%96%87%E4%BB%B6%E5%90%8D%E8%84%B1%E9%92%A9-%E6%96%B9%E6%A1%88.md)。
|
|
205
|
+
|
|
206
|
+
## 开发与自测
|
|
207
|
+
|
|
208
|
+
没有任何依赖需要安装:插件只用 Node 内置模块(`node:sqlite` / `node:fs` / `node:zlib`)。
|
|
209
|
+
|
|
210
|
+
```sh
|
|
211
|
+
npm test # 全量语法检查 + 离线自测(不依赖 DSH、不联网、不需要 API key)
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
`tools/selftest.mjs` 覆盖了 v0.1.1 修掉的那几类问题:新目录首扫全量回填、二次扫描幂等、中断不推进锚点、以及「文件名与内容脱钩」时的内容命中。CI 在 Node 22.x / 24.x 上跑同一套,且**不执行 `npm install`**,完全离线可复现。
|
|
215
|
+
|
|
216
|
+
## 许可
|
|
217
|
+
|
|
218
|
+
MIT — 见 [LICENSE](LICENSE)。
|
package/client.js
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/* dsh-lost-and-found client bundle — Web 设置页。
|
|
2
|
+
* 经典脚本形式注册到 window.__ModuleLoader__;工厂内用 require 取 React,不用 JSX。
|
|
3
|
+
* 本页只负责「配置」;扫描结果与找文件都在对话里由模型驱动完成。 */
|
|
4
|
+
window.__ModuleLoader__.load({
|
|
5
|
+
id: "dsh-lost-and-found",
|
|
6
|
+
factory: (require) => {
|
|
7
|
+
var module = { exports: {} };
|
|
8
|
+
var exports = module.exports;
|
|
9
|
+
|
|
10
|
+
const React = require("react");
|
|
11
|
+
const h = React.createElement;
|
|
12
|
+
const { useEffect, useRef, useState } = React;
|
|
13
|
+
|
|
14
|
+
const NS = "settings.lostAndFound";
|
|
15
|
+
const SETTINGS_NAMESPACE = "dsh-lost-and-found";
|
|
16
|
+
// 打包版本标记:显示在按钮下方状态行里,用来一眼确认浏览器跑的是哪一版前端代码。
|
|
17
|
+
const BUILD_TAG = "v5-perroot-anchor";
|
|
18
|
+
|
|
19
|
+
const zh = {
|
|
20
|
+
nav: "文件快速寻回",
|
|
21
|
+
loading: "正在读取配置…",
|
|
22
|
+
unavailable: "配置面板不可用(设置服务未挂载)。可直接编辑配置文件,或用对话里的 file_index_scan 工具。",
|
|
23
|
+
saved: "已保存",
|
|
24
|
+
error: "保存失败:",
|
|
25
|
+
intro: "这是什么?\n帮你记住电脑里都放过哪些文件。以后忘了东西存哪了,直接问我一句就能找回来。\n\n它会做什么?\n定期看看你指定的几个文件夹,记下文件叫什么、是什么类型、在哪里、什么时候出现的;新加入的文件夹第一次会把已有的历史文件也收进来。图片还能让模型看一眼、记下内容。所有记录只存在你电脑上的一个小文件里,不会上传到任何地方。\n\n它不会做什么?\n不会修改、移动或删除你的任何文件。不会去看你没有指定的文件夹。\n\n怎么用?\n不用记任何命令。以后直接跟我说「我上个月收到的那份报价单在哪」,我去帮你查。",
|
|
26
|
+
overview: "扫描概况",
|
|
27
|
+
scanNow: "立即扫描一次",
|
|
28
|
+
scanHint: "每个文件夹按各自的进度增量收录;新加入的文件夹第一次会把它已有的历史文件一并收进来。通常几秒到几十秒,结果会出现在对话里。",
|
|
29
|
+
statusBtn: "查看概况",
|
|
30
|
+
scanning: "扫描中…",
|
|
31
|
+
scanStarted: ">>> 已发起扫描,请到当前对话查看结果。",
|
|
32
|
+
done: ">>> 已完成,结果同时写入了对话:",
|
|
33
|
+
noSession: ">>> 还没有对话,请先在对话里新建一个会话。",
|
|
34
|
+
cmdMissing: ">>> 命令未加载(插件可能未启用)。",
|
|
35
|
+
runFail: "发起失败:",
|
|
36
|
+
rootsTitle: "让它看哪些文件夹",
|
|
37
|
+
rootsHint: "只扫描这里列出的文件夹(以及它们的子文件夹)。系统目录、缓存、程序目录会自动跳过。",
|
|
38
|
+
rootPath: "文件夹路径",
|
|
39
|
+
rootPolicy: "记录深度",
|
|
40
|
+
rootAdd: "添加一个文件夹",
|
|
41
|
+
rootRemove: "删除",
|
|
42
|
+
rootEmpty: "还没添加任何文件夹。以后想找文件,先把它常存东西的地方加进来。",
|
|
43
|
+
policyFull: "完整(正文+摘要+看图)",
|
|
44
|
+
policySummary: "只摘要(不留正文原文)",
|
|
45
|
+
policyMeta: "只记基本信息",
|
|
46
|
+
storageTitle: "记录存放在哪",
|
|
47
|
+
dbPath: "记忆库文件位置",
|
|
48
|
+
dbPathHint: "留空使用默认位置(在系统盘的 AppData\\Local\\dsh-lost-and-found\\index.db)。想放到别的盘就填完整路径,例如 D:\\file-index\\index.db。",
|
|
49
|
+
freqTitle: "多久看一次",
|
|
50
|
+
interval: "自动扫描间隔",
|
|
51
|
+
freqHint: "打开 DSH 时会检查:距离上次扫描超过这个间隔,就自动补扫一次。",
|
|
52
|
+
freq1: "每天(推荐)",
|
|
53
|
+
freq3: "每 3 天",
|
|
54
|
+
freq7: "每周",
|
|
55
|
+
freq0: "关闭自动扫描(只手动扫)",
|
|
56
|
+
contentTitle: "内容读取",
|
|
57
|
+
contentNotice: "图片内容按批次交给模型看过之后写回描述,之后就能按画面内容搜索。文档正文抽取还在路线图里(见仓库 docs/ 的规划),当前尚未启用。设为「只记基本信息」的文件夹不会看图。",
|
|
58
|
+
advanced: "高级选项",
|
|
59
|
+
maxFileMB: "超过这个体积就不读内容(MB)",
|
|
60
|
+
maxFileMBHint: "大文件只记名字、类型、位置和时间,避免卡顿。",
|
|
61
|
+
imageQuota: "每次看图张数上限",
|
|
62
|
+
imageQuotaHint: "看图片内容是分批进行的,避免一次看太多。",
|
|
63
|
+
patrol: "提示白名单外的新文件",
|
|
64
|
+
patrolHint: "每天提示一次:哪些没被列入的文件夹最近出现了新文件(只提示,不自动收录)。",
|
|
65
|
+
backupKeep: "每日备份保留份数",
|
|
66
|
+
backupKeepHint: "索引库每天自动备份一份,出错时可以回退。0 = 不备份。",
|
|
67
|
+
enabled: "启用文件快速寻回",
|
|
68
|
+
enabledHint: "关闭后不再自动扫描,也不再提供找文件的工具。",
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const en = {
|
|
72
|
+
nav: "File Recall",
|
|
73
|
+
loading: "Reading configuration…",
|
|
74
|
+
unavailable: "Configuration panel unavailable. Use the file_index_scan tool in chat instead.",
|
|
75
|
+
saved: "Saved",
|
|
76
|
+
error: "Save failed: ",
|
|
77
|
+
intro: "What is this?\nIt remembers which files have appeared on your computer, so when you forget where you put something you can just ask.\n\nWhat it does\nIt periodically looks at the folders you choose and records what files are called, what type they are, where they live and when they appeared; a folder added later gets its existing files backfilled on the first scan. Images can be reviewed and described too. Everything is stored in one local file on your machine and is never uploaded.\n\nWhat it never does\nIt never modifies, moves or deletes your files. It never looks at folders you did not choose.\n\nHow to use\nNo commands to memorise. Just ask: \"where is that quotation I received last month?\"",
|
|
78
|
+
overview: "Overview",
|
|
79
|
+
scanNow: "Scan now",
|
|
80
|
+
scanHint: "Each folder is collected on its own progress: a folder added later gets its existing files backfilled on the first scan. Takes seconds to a minute; results appear in the conversation.",
|
|
81
|
+
statusBtn: "View overview",
|
|
82
|
+
scanning: "Scanning…",
|
|
83
|
+
scanStarted: ">>> Scan started, check the current conversation.",
|
|
84
|
+
done: ">>> Done — the result was also written into the conversation:",
|
|
85
|
+
noSession: ">>> No conversation yet. Create one in chat first.",
|
|
86
|
+
cmdMissing: ">>> Command not loaded (plugin may be disabled).",
|
|
87
|
+
runFail: "Failed to start: ",
|
|
88
|
+
rootsTitle: "Folders to watch",
|
|
89
|
+
rootsHint: "Only the folders listed here (and their subfolders) are watched. System, cache and program folders are skipped automatically.",
|
|
90
|
+
rootPath: "Folder path",
|
|
91
|
+
rootPolicy: "Detail level",
|
|
92
|
+
rootAdd: "Add a folder",
|
|
93
|
+
rootRemove: "Remove",
|
|
94
|
+
rootEmpty: "No folders yet. Add the places where you usually save things.",
|
|
95
|
+
policyFull: "Full (text + summary + images)",
|
|
96
|
+
policySummary: "Summary only (no raw text)",
|
|
97
|
+
policyMeta: "Metadata only",
|
|
98
|
+
storageTitle: "Where records are stored",
|
|
99
|
+
dbPath: "Database file",
|
|
100
|
+
dbPathHint: "Leave empty for the default location under AppData\\Local\\dsh-lost-and-found\\. Or give a full path such as D:\\file-index\\index.db.",
|
|
101
|
+
freqTitle: "How often to look",
|
|
102
|
+
interval: "Automatic scan interval",
|
|
103
|
+
freqHint: "When you open DSH it checks whether this interval has passed, and catches up if needed.",
|
|
104
|
+
freq1: "Daily (recommended)",
|
|
105
|
+
freq3: "Every 3 days",
|
|
106
|
+
freq7: "Weekly",
|
|
107
|
+
freq0: "Off (manual only)",
|
|
108
|
+
contentTitle: "Reading contents",
|
|
109
|
+
contentNotice: "Image contents are reviewed by the model in batches and written back as descriptions, so pictures become searchable by what they show. Extracting document text is still on the roadmap (see docs/ in the repo) and is not enabled yet. Folders set to metadata-only never have images reviewed.",
|
|
110
|
+
advanced: "Advanced",
|
|
111
|
+
maxFileMB: "Skip contents above this size (MB)",
|
|
112
|
+
maxFileMBHint: "Large files keep only name, type, location and time.",
|
|
113
|
+
imageQuota: "Images per batch",
|
|
114
|
+
imageQuotaHint: "Image contents are reviewed in batches.",
|
|
115
|
+
patrol: "Suggest new folders",
|
|
116
|
+
patrolHint: "Once a day, point out folders outside the list that received new files (suggestion only).",
|
|
117
|
+
backupKeep: "Daily backups to keep",
|
|
118
|
+
backupKeepHint: "One backup per day; 0 disables backups.",
|
|
119
|
+
enabled: "Enable File Recall",
|
|
120
|
+
enabledHint: "When off, no automatic scanning and no find tools.",
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const STYLES = [
|
|
124
|
+
".laf-config{max-width:660px;display:flex;flex-direction:column;gap:16px;color:var(--dsw-alias-label-primary)}",
|
|
125
|
+
".laf-group{border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:14px;background:var(--dsw-alias-bg-layer-2)}",
|
|
126
|
+
".laf-group h3{margin:0 0 10px;font-size:13px;font-weight:600}",
|
|
127
|
+
".laf-intro{font-size:13px;line-height:1.8;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-left:4px solid var(--dsw-alias-state-business-primary);border-radius:8px;padding:12px 14px;white-space:pre-wrap}",
|
|
128
|
+
".laf-field{display:flex;flex-direction:column;gap:4px;margin-bottom:10px}",
|
|
129
|
+
".laf-field label{font-size:12px;font-weight:500}",
|
|
130
|
+
".laf-field input[type=text],.laf-field input[type=number],.laf-field select{height:30px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border-radius:6px;padding:0 8px;font:inherit;font-size:13px;box-sizing:border-box;width:100%}",
|
|
131
|
+
".laf-hint{font-size:11px;line-height:1.6;color:var(--dsw-alias-label-tertiary)}",
|
|
132
|
+
".laf-switch{display:flex;align-items:center;gap:8px;margin-bottom:6px}",
|
|
133
|
+
".laf-switch input{accent-color:var(--dsw-alias-state-business-primary)}",
|
|
134
|
+
".laf-status{font-size:12px;color:var(--dsw-alias-label-tertiary);min-height:16px;white-space:pre-wrap}",
|
|
135
|
+
".laf-note{font-size:12px;line-height:1.7;color:var(--dsw-alias-label-secondary);white-space:pre-wrap}",
|
|
136
|
+
".laf-btn{height:32px;padding:0 16px;border:1px solid var(--dsw-alias-state-business-primary);background:var(--dsw-alias-state-business-primary);color:#fff;border-radius:6px;font:inherit;font-size:13px;cursor:pointer}",
|
|
137
|
+
".laf-btn:disabled{opacity:.6;cursor:not-allowed}",
|
|
138
|
+
".laf-btn2{height:26px;padding:0 10px;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);border-radius:6px;font:inherit;font-size:12px;cursor:pointer}",
|
|
139
|
+
".laf-row{display:flex;gap:8px;align-items:flex-end;margin-bottom:8px}",
|
|
140
|
+
".laf-rootpath{flex:1 1 auto}",
|
|
141
|
+
".laf-rootpolicy{flex:0 0 190px}",
|
|
142
|
+
".laf-actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap}",
|
|
143
|
+
].join("");
|
|
144
|
+
|
|
145
|
+
function Field({ label, hint, children }) {
|
|
146
|
+
return h("div", { className: "laf-field" }, h("label", null, label), children, hint ? h("div", { className: "laf-hint" }, hint) : null);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 文本输入:本地即时反馈,失焦/回车才提交 */
|
|
150
|
+
function TextField({ value, onCommit, placeholder }) {
|
|
151
|
+
const [v, setV] = useState(value || "");
|
|
152
|
+
const last = useRef(value || "");
|
|
153
|
+
useEffect(() => { if ((value || "") !== last.current) { last.current = value || ""; setV(value || ""); } }, [value]);
|
|
154
|
+
const commit = () => { if (v !== last.current) { last.current = v; onCommit(v); } };
|
|
155
|
+
return h("input", {
|
|
156
|
+
type: "text", value: v, placeholder: placeholder || "",
|
|
157
|
+
onChange: (e) => setV(e.target.value),
|
|
158
|
+
onBlur: commit,
|
|
159
|
+
onKeyDown: (e) => { if (e.key === "Enter") commit(); },
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function RootsEditor({ roots, onCommit, t }) {
|
|
164
|
+
const [list, setList] = useState(() => (Array.isArray(roots) ? roots.map((r) => ({ ...r })) : []));
|
|
165
|
+
const commit = (next) => { setList(next); onCommit(next.filter((r) => String(r.path || "").trim())); };
|
|
166
|
+
return h("div", null, [
|
|
167
|
+
...list.map((r, i) => h("div", { className: "laf-row", key: `r${i}` }, [
|
|
168
|
+
h("div", { className: "laf-rootpath" }, h(Field, {
|
|
169
|
+
label: i === 0 ? t("rootPath") : "",
|
|
170
|
+
children: h(TextField, {
|
|
171
|
+
value: r.path,
|
|
172
|
+
placeholder: "D:\\某个文件夹",
|
|
173
|
+
onCommit: (v) => commit(list.map((x, j) => (j === i ? { ...x, path: v } : x))),
|
|
174
|
+
}),
|
|
175
|
+
})),
|
|
176
|
+
h("div", { className: "laf-rootpolicy" }, h(Field, {
|
|
177
|
+
label: i === 0 ? t("rootPolicy") : "",
|
|
178
|
+
children: h("select", {
|
|
179
|
+
value: r.policy || "full",
|
|
180
|
+
onChange: (e) => commit(list.map((x, j) => (j === i ? { ...x, policy: e.target.value } : x))),
|
|
181
|
+
}, [
|
|
182
|
+
h("option", { value: "full", key: "full" }, t("policyFull")),
|
|
183
|
+
h("option", { value: "summary", key: "summary" }, t("policySummary")),
|
|
184
|
+
h("option", { value: "meta", key: "meta" }, t("policyMeta")),
|
|
185
|
+
]),
|
|
186
|
+
})),
|
|
187
|
+
h("button", {
|
|
188
|
+
className: "laf-btn2",
|
|
189
|
+
onClick: () => commit(list.filter((_, j) => j !== i)),
|
|
190
|
+
}, t("rootRemove")),
|
|
191
|
+
])),
|
|
192
|
+
list.length === 0 ? h("div", { className: "laf-hint" }, t("rootEmpty")) : null,
|
|
193
|
+
h("button", {
|
|
194
|
+
className: "laf-btn2",
|
|
195
|
+
onClick: () => setList([...list, { path: "", policy: "full" }]),
|
|
196
|
+
}, t("rootAdd")),
|
|
197
|
+
]);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function ConfigSection({ scope, t, runCommand }) {
|
|
201
|
+
const [snap, setSnap] = useState(() => scope.getSnapshot());
|
|
202
|
+
const [status, setStatus] = useState("");
|
|
203
|
+
const [busy, setBusy] = useState("");
|
|
204
|
+
const timer = useRef(null);
|
|
205
|
+
useEffect(() => scope.subscribe(() => setSnap(scope.getSnapshot())), [scope]);
|
|
206
|
+
useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
|
|
207
|
+
|
|
208
|
+
// 成功提示 2.5 秒后消失;失败/诊断信息常驻,方便用户把它读给排查的人(别一闪而过)。
|
|
209
|
+
const flash = (msg, hold) => {
|
|
210
|
+
setStatus(msg);
|
|
211
|
+
if (timer.current) clearTimeout(timer.current);
|
|
212
|
+
if (!hold) timer.current = setTimeout(() => setStatus(""), 2500);
|
|
213
|
+
};
|
|
214
|
+
const save = (field, v) => {
|
|
215
|
+
Promise.resolve(scope.set(field, v))
|
|
216
|
+
.then(() => flash(t("saved")))
|
|
217
|
+
.catch((err) => flash(`${t("error")}${err && err.message ? err.message : String(err)}`, true));
|
|
218
|
+
};
|
|
219
|
+
const run = (cmd, mark) => {
|
|
220
|
+
if (busy) return;
|
|
221
|
+
setBusy(mark);
|
|
222
|
+
Promise.resolve(runCommand(cmd, t))
|
|
223
|
+
.then((msg) => flash(msg || "", msg !== t("scanStarted")))
|
|
224
|
+
.catch((err) => flash(`${t("runFail")}${err && err.message ? err.message : String(err)}`, true))
|
|
225
|
+
.finally(() => setBusy(""));
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
if (snap.status === "loading") return h("p", { className: "laf-status" }, t("loading"));
|
|
229
|
+
if (snap.status === "unavailable") return h("p", { className: "laf-status" }, t("unavailable"));
|
|
230
|
+
const v = snap.value || {};
|
|
231
|
+
|
|
232
|
+
return h("div", { className: "laf-config" }, [
|
|
233
|
+
h("div", { className: "laf-intro" }, t("intro")),
|
|
234
|
+
|
|
235
|
+
h("div", { className: "laf-group" }, [
|
|
236
|
+
h("h3", null, t("overview")),
|
|
237
|
+
h("div", { className: "laf-actions" }, [
|
|
238
|
+
h("button", { className: "laf-btn", disabled: !!busy, onClick: () => run("/lostfound_scan", t("scanning")) },
|
|
239
|
+
busy ? t("scanning") : t("scanNow")),
|
|
240
|
+
h("button", { className: "laf-btn2", disabled: !!busy, onClick: () => run("/lostfound_status", t("statusBtn")) }, t("statusBtn")),
|
|
241
|
+
]),
|
|
242
|
+
h("div", { className: "laf-hint", style: { marginTop: "6px" } }, t("scanHint")),
|
|
243
|
+
h("div", { className: "laf-status" }, status),
|
|
244
|
+
]),
|
|
245
|
+
|
|
246
|
+
h("div", { className: "laf-group" }, [
|
|
247
|
+
h("h3", null, t("rootsTitle")),
|
|
248
|
+
h("div", { className: "laf-hint", style: { marginBottom: "10px" } }, t("rootsHint")),
|
|
249
|
+
h(RootsEditor, { roots: v.roots || [], t, onCommit: (next) => save("roots", next) }),
|
|
250
|
+
]),
|
|
251
|
+
|
|
252
|
+
h("div", { className: "laf-group" }, [
|
|
253
|
+
h("h3", null, t("storageTitle")),
|
|
254
|
+
Field({
|
|
255
|
+
label: t("dbPath"),
|
|
256
|
+
hint: t("dbPathHint"),
|
|
257
|
+
children: h(TextField, { value: v.dbPath || "", placeholder: "", onCommit: (val) => save("dbPath", val) }),
|
|
258
|
+
}),
|
|
259
|
+
]),
|
|
260
|
+
|
|
261
|
+
h("div", { className: "laf-group" }, [
|
|
262
|
+
h("h3", null, t("freqTitle")),
|
|
263
|
+
Field({
|
|
264
|
+
label: t("interval"),
|
|
265
|
+
hint: t("freqHint"),
|
|
266
|
+
children: h("select", {
|
|
267
|
+
value: String(v.intervalDays ?? 1),
|
|
268
|
+
onChange: (e) => save("intervalDays", Number(e.target.value)),
|
|
269
|
+
}, [
|
|
270
|
+
h("option", { value: "1", key: "1" }, t("freq1")),
|
|
271
|
+
h("option", { value: "3", key: "3" }, t("freq3")),
|
|
272
|
+
h("option", { value: "7", key: "7" }, t("freq7")),
|
|
273
|
+
h("option", { value: "0", key: "0" }, t("freq0")),
|
|
274
|
+
]),
|
|
275
|
+
}),
|
|
276
|
+
]),
|
|
277
|
+
|
|
278
|
+
h("div", { className: "laf-group" }, [
|
|
279
|
+
h("h3", null, t("contentTitle")),
|
|
280
|
+
h("div", { className: "laf-note" }, t("contentNotice")),
|
|
281
|
+
]),
|
|
282
|
+
|
|
283
|
+
h("div", { className: "laf-group" }, [
|
|
284
|
+
h("h3", null, t("advanced")),
|
|
285
|
+
h("div", { className: "laf-switch" }, [
|
|
286
|
+
h("input", { type: "checkbox", id: "laf-enabled", checked: v.enabled !== false, onChange: (e) => save("enabled", e.target.checked) }),
|
|
287
|
+
h("label", { htmlFor: "laf-enabled" }, t("enabled")),
|
|
288
|
+
]),
|
|
289
|
+
h("div", { className: "laf-hint", style: { marginBottom: "10px" } }, t("enabledHint")),
|
|
290
|
+
h("div", { className: "laf-switch" }, [
|
|
291
|
+
h("input", { type: "checkbox", id: "laf-patrol", checked: v.patrolEnabled !== false, onChange: (e) => save("patrolEnabled", e.target.checked) }),
|
|
292
|
+
h("label", { htmlFor: "laf-patrol" }, t("patrol")),
|
|
293
|
+
]),
|
|
294
|
+
h("div", { className: "laf-hint", style: { marginBottom: "10px" } }, t("patrolHint")),
|
|
295
|
+
Field({
|
|
296
|
+
label: t("maxFileMB"),
|
|
297
|
+
hint: t("maxFileMBHint"),
|
|
298
|
+
children: h(TextField, { value: String(v.maxFileMB ?? 50), onCommit: (val) => save("maxFileMB", Number(val) || 0) }),
|
|
299
|
+
}),
|
|
300
|
+
Field({
|
|
301
|
+
label: t("imageQuota"),
|
|
302
|
+
hint: t("imageQuotaHint"),
|
|
303
|
+
children: h(TextField, { value: String(v.imageQuotaPerRun ?? 20), onCommit: (val) => save("imageQuotaPerRun", Number(val) || 0) }),
|
|
304
|
+
}),
|
|
305
|
+
Field({
|
|
306
|
+
label: t("backupKeep"),
|
|
307
|
+
hint: t("backupKeepHint"),
|
|
308
|
+
children: h(TextField, { value: String(v.backupKeep ?? 7), onCommit: (val) => save("backupKeep", Number(val) || 0) }),
|
|
309
|
+
}),
|
|
310
|
+
]),
|
|
311
|
+
]);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function apply(ctx) {
|
|
315
|
+
ctx.effect(() => {
|
|
316
|
+
const tag = document.createElement("style");
|
|
317
|
+
tag.setAttribute("data-plugin", "dsh-lost-and-found");
|
|
318
|
+
tag.textContent = STYLES;
|
|
319
|
+
document.head.appendChild(tag);
|
|
320
|
+
return () => { if (tag.parentNode) tag.parentNode.removeChild(tag); };
|
|
321
|
+
}, "dsh-lost-and-found: styles");
|
|
322
|
+
|
|
323
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-lost-and-found: dictionaries");
|
|
324
|
+
|
|
325
|
+
const t = ctx.locale.bind(NS);
|
|
326
|
+
const scope = ctx.settingsScope.bind({ namespace: SETTINGS_NAMESPACE });
|
|
327
|
+
// 客户端远程命名空间挂在 `remote.<namespace>` 服务上(DSH 0.1.5-rc.1 起;
|
|
328
|
+
// 旧的 connection.api.* 已不存在,写它会报 reading 'sessions')。
|
|
329
|
+
// 触发斜杠命令必须走 remote.commands.execute:往会话里塞一条 "/cmd" 文本
|
|
330
|
+
// 只会当成普通消息发给模型——斜杠命令的拦截在输入框那层,不在 host。
|
|
331
|
+
const remote = ctx.get("remote");
|
|
332
|
+
const remoteCommands = ctx.get("remote.commands") || (remote && remote.commands);
|
|
333
|
+
const sessions = ctx.get("sessions");
|
|
334
|
+
|
|
335
|
+
// 选"要发到哪个对话":优先侧栏当前选中的会话;current 缺失(停在设置页、
|
|
336
|
+
// 或选中的只是一张空白草稿)时,退回"最近更新过的非空白会话",
|
|
337
|
+
// 绝不把命令发进空白草稿——那正是 2026-09-14 结果"不见了"的原因。
|
|
338
|
+
const pickTarget = () => {
|
|
339
|
+
const s = (sessions.list.getSnapshot && sessions.list.getSnapshot()) || {};
|
|
340
|
+
const byId = s.byId || {};
|
|
341
|
+
const ids = Array.isArray(s.ids) ? s.ids : [];
|
|
342
|
+
const row = (id) => byId[id] || {};
|
|
343
|
+
if (s.current && !row(s.current).blank) {
|
|
344
|
+
return { id: s.current, title: row(s.current).displayTitle || String(s.current).slice(0, 8) };
|
|
345
|
+
}
|
|
346
|
+
const rows = ids
|
|
347
|
+
.map((id) => ({ id, ...row(id) }))
|
|
348
|
+
.filter((r) => r.id && !r.blank && r.origin !== "subagent")
|
|
349
|
+
.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
350
|
+
if (rows[0]) return { id: rows[0].id, title: rows[0].displayTitle || String(rows[0].id).slice(0, 8) };
|
|
351
|
+
if (s.current) return { id: s.current, title: row(s.current).displayTitle || String(s.current).slice(0, 8) };
|
|
352
|
+
if (ids.length) return { id: ids[ids.length - 1], title: row(ids[ids.length - 1]).displayTitle || String(ids[ids.length - 1]).slice(0, 8) };
|
|
353
|
+
return { id: void 0, title: void 0 };
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
// 按钮 -> 让 host 执行一条斜杠命令,并等它跑完:命令结果由 host 写进会话
|
|
357
|
+
// (command/run + command/done),execute 的返回值里也带着同一份结果文本,
|
|
358
|
+
// 所以这里直接把报告显示在设置页——不管目标会话有没有被切到别的窗口,
|
|
359
|
+
// 用户都能当场看到结果。增量扫描约 5 秒,等待是可接受的。
|
|
360
|
+
const WAIT_MS = 120000;
|
|
361
|
+
const runCommand = async (cmd, tr) => {
|
|
362
|
+
try {
|
|
363
|
+
if (!remoteCommands || typeof remoteCommands.execute !== "function") return `${tr("runFail")}remote.commands 不可用(${BUILD_TAG})`;
|
|
364
|
+
if (!sessions || !sessions.list) return `${tr("runFail")}sessions 不可用(${BUILD_TAG})`;
|
|
365
|
+
const target = pickTarget();
|
|
366
|
+
if (!target.id) return `${tr("noSession")}(${BUILD_TAG})`;
|
|
367
|
+
try { if (sessions.open) sessions.open(target.id); } catch { /* 已在前台 */ }
|
|
368
|
+
const where = `目标对话:${target.title}`;
|
|
369
|
+
const pending = Symbol("pending");
|
|
370
|
+
const outcome = await Promise.race([
|
|
371
|
+
Promise.resolve(remoteCommands.execute(target.id, cmd, [])).catch((e) => ({
|
|
372
|
+
ok: false,
|
|
373
|
+
error: { message: e && e.message ? e.message : String(e) },
|
|
374
|
+
})),
|
|
375
|
+
new Promise((resolve) => setTimeout(() => resolve(pending), WAIT_MS)),
|
|
376
|
+
]);
|
|
377
|
+
if (outcome === pending) return `${tr("scanStarted")}(${where}|${BUILD_TAG})`;
|
|
378
|
+
if (outcome && outcome.ok === false) {
|
|
379
|
+
return `${tr("runFail")}${outcome.error && outcome.error.message ? outcome.error.message : "未知错误"}(${where}|${BUILD_TAG})`;
|
|
380
|
+
}
|
|
381
|
+
const text = outcome && outcome.value && outcome.value.result ? outcome.value.result.text : void 0;
|
|
382
|
+
if (text) return `${tr("done")}(${where}|${BUILD_TAG})\n${text}`;
|
|
383
|
+
return `${tr("scanStarted")}(${where}|${BUILD_TAG})`;
|
|
384
|
+
} catch (e) {
|
|
385
|
+
return `${tr("runFail")}${e && e.message ? e.message : String(e)}(${BUILD_TAG})`;
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const injected = () => ({ scope, runCommand });
|
|
390
|
+
ctx.slots.inject("settings.section", () => ctx.slots.register({
|
|
391
|
+
name: "settings.section",
|
|
392
|
+
id: "dsh-lost-and-found",
|
|
393
|
+
order: 215,
|
|
394
|
+
label: () => t("nav"),
|
|
395
|
+
locale: NS,
|
|
396
|
+
inject: injected,
|
|
397
|
+
}, ConfigSection));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
module.exports = {
|
|
401
|
+
name: "dsh-lost-and-found",
|
|
402
|
+
inject: ["slots", "locale", "settingsScope", "remote", "remote.commands", "sessions"],
|
|
403
|
+
apply,
|
|
404
|
+
};
|
|
405
|
+
return module.exports;
|
|
406
|
+
},
|
|
407
|
+
});
|