@yolk_vat-y/dsh-project-memory 0.5.13 → 0.5.14
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/CHANGELOG.md +126 -0
- package/README.md +5 -5
- package/README.zh-CN.md +5 -5
- package/client/client.js +120 -52
- package/client/client.js.map +1 -1
- package/package.json +8 -8
- package/src/audit.js +52 -3
- package/src/auto-inject.js +64 -16
- package/src/client/ShowTaskPanelNode.tsx +51 -0
- package/src/client/TaskPanel.tsx +8 -8
- package/src/client/client.ts +10 -31
- package/src/client/slash.ts +33 -17
- package/src/client/task-ui-store.ts +43 -0
- package/src/index.js +8 -3
- package/src/insight-store.js +2 -17
- package/src/readiness.js +26 -1
- package/src/store.js +8 -22
- package/src/tools/forget.js +1 -1
- package/src/tools/lesson-tools.js +1 -0
- package/src/tools/remember.js +5 -3
- package/src/tools/task-tools.js +8 -7
- package/src/tools/watch-repo.js +1 -1
- package/src/util/fs.js +33 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yolk_vat-y/dsh-project-memory",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.14",
|
|
4
4
|
"description": "Persistent project memory for dsh agents: index docs (PDF/Markdown/text) and code symbols into a searchable per-workspace store, recall them with cited sources, and keep experience entries (problems -> solutions) searchable on demand.",
|
|
5
5
|
"author": "00080000 <3388065969@qq.com>",
|
|
6
6
|
"type": "module",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"url": "https://github.com/00080000/dsh-project-memory.git"
|
|
20
20
|
},
|
|
21
21
|
"scripts": {
|
|
22
|
-
"test": "node test/run-test.mjs && node test/taskbridge.test.mjs && node test/insight-store.test.mjs && node test/reflection-pipeline.test.mjs && node test/auto-inject.test.mjs && node test/insight-actions.test.mjs && node test/host-contract.test.mjs && node test/llm-route.test.mjs && node test/doc-index.test.mjs && node test/client-hints.test.mjs && node test/recall.test.mjs && node test/readiness.test.mjs && node test/insight-derive.test.mjs && node test/readiness-eval.test.mjs && node test/ops.test.mjs && node test/injection-audit.test.mjs && node test/injection-budget.test.mjs && node test/injection-scenarios.test.mjs && node test/bugfix-0.5.7.test.mjs && node test/client-icons.test.mjs && node test/client-slash.test.mjs && node test/workflow-command.test.mjs && node test/client-session-id.test.mjs && node test/task-view.test.mjs && node test/root-guards.test.mjs && node test/store-gitignore.test.mjs && node test/store-cache.test.mjs && node test/enhancer.test.mjs",
|
|
22
|
+
"test": "node test/run-test.mjs && node test/taskbridge.test.mjs && node test/insight-store.test.mjs && node test/reflection-pipeline.test.mjs && node test/auto-inject.test.mjs && node test/insight-actions.test.mjs && node test/host-contract.test.mjs && node test/llm-route.test.mjs && node test/doc-index.test.mjs && node test/client-hints.test.mjs && node test/recall.test.mjs && node test/readiness.test.mjs && node test/insight-derive.test.mjs && node test/readiness-eval.test.mjs && node test/ops.test.mjs && node test/injection-audit.test.mjs && node test/injection-budget.test.mjs && node test/injection-scenarios.test.mjs && node test/bugfix-0.5.7.test.mjs && node test/client-icons.test.mjs && node test/client-slash.test.mjs && node test/client-toolview.test.mjs && node test/workflow-command.test.mjs && node test/client-session-id.test.mjs && node test/task-view.test.mjs && node test/root-guards.test.mjs && node test/store-gitignore.test.mjs && node test/atomic-write.test.mjs && node test/store-cache.test.mjs && node test/enhancer.test.mjs",
|
|
23
23
|
"eval:injection": "node test/injection-scenarios.test.mjs",
|
|
24
24
|
"typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.client.json",
|
|
25
25
|
"selfcheck:triggers": "node test/injection-scenarios.test.mjs --selfcheck",
|
|
@@ -50,12 +50,12 @@
|
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@deepseek-ai/cordis": "^4.0.4",
|
|
53
|
-
"@deepseek-ai/dsh-client-locale": "0.2.0-rc.
|
|
54
|
-
"@deepseek-ai/dsh-client-store": "0.2.0-rc.
|
|
55
|
-
"@deepseek-ai/dsh-client-ui-primitives": "0.2.0-rc.
|
|
56
|
-
"@deepseek-ai/dsh-client-ui-slots": "0.2.0-rc.
|
|
57
|
-
"@deepseek-ai/dsh-llm": "0.2.0-rc.
|
|
58
|
-
"@deepseek-ai/dsh-tools": "0.2.0-rc.
|
|
53
|
+
"@deepseek-ai/dsh-client-locale": "0.2.0-rc.2",
|
|
54
|
+
"@deepseek-ai/dsh-client-store": "0.2.0-rc.2",
|
|
55
|
+
"@deepseek-ai/dsh-client-ui-primitives": "0.2.0-rc.2",
|
|
56
|
+
"@deepseek-ai/dsh-client-ui-slots": "0.2.0-rc.2",
|
|
57
|
+
"@deepseek-ai/dsh-llm": "0.2.0-rc.2",
|
|
58
|
+
"@deepseek-ai/dsh-tools": "0.2.0-rc.2",
|
|
59
59
|
"@deepseek-ai/schemastery": "^3.18.2",
|
|
60
60
|
"@tsdown/css": "0.22.14",
|
|
61
61
|
"@types/react": "^18.3.31",
|
package/src/audit.js
CHANGED
|
@@ -19,7 +19,21 @@ const DEFAULT_MAX_BYTES = 256 * 1024
|
|
|
19
19
|
// "本该零注入"的步)完全不落痕,于是日志回答不了"换个阈值/换个判据会怎样"。影子记录每步都写,
|
|
20
20
|
// 带全部候选的特征与判据结果,用来 (a) 离线重放任一阈值、(b) 攒"特征 → 该不该注入"的训练样本。
|
|
21
21
|
export const SHADOW_FILE = 'admission-shadow.jsonl'
|
|
22
|
-
|
|
22
|
+
// 512KB 而不是 2MB:轮转只保留 `.1` 一代,所以单项目日志的硬上限是
|
|
23
|
+
// `2 × shadowMaxBytes + 2 × auditMaxBytes`,之前是 4.6MB(在小项目上比 store 本身还大,
|
|
24
|
+
// 实测本插件自己的 store 里 3.1M/4.2M 是日志)。512KB 下上限降到 ≈1.5MB,
|
|
25
|
+
// 而按每行 ~1.5KB 算仍有 ~680 步的历史窗口 —— 远超"离线重放最近的历史"所需。
|
|
26
|
+
const DEFAULT_SHADOW_MAX_BYTES = 512 * 1024
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 一行影子记录最多保留多少个候选。
|
|
30
|
+
*
|
|
31
|
+
* 影子日志 **90.6% 的体积是 `candidates`**(实测 210 行 / 965KB,候选占 875KB),而真实 store
|
|
32
|
+
* 单步就能产生 40~60 条(中位 42)。候选按分数降序排列,`decision === 'cand'` 必然排在最前
|
|
33
|
+
* (相对阈值就是分数比),所以「**全部 cand + 按排名补足**」既保住离线重放最需要的那批,
|
|
34
|
+
* 又砍掉远处必然被 coverage/relative 拦掉的尾巴。截断量记在 `candidatesTotal`,不静默。
|
|
35
|
+
*/
|
|
36
|
+
const SHADOW_MAX_CANDIDATES = 16
|
|
23
37
|
|
|
24
38
|
/** 审计配置:默认开(观测是这个插件唯一的仪表盘),显式 `auditLog: false` 才关。 */
|
|
25
39
|
export function cfgAudit(config) {
|
|
@@ -52,11 +66,34 @@ export function shadowFileFor(memoryDir) {
|
|
|
52
66
|
* - **每步都写**(含零注入的静默步与对照组),所以"没注入"也留下了可复盘的事实;
|
|
53
67
|
* - 带**全部候选**的判据特征(cov/matched/support/relative)与场景(query/ops/writes),
|
|
54
68
|
* 所以能离线回答"阈值换成 0.40 会注入什么"、并攒出"特征 → 该不该注入"的样本。
|
|
55
|
-
* @param {object} input { sessionId, root, step, query, ops, writes, reasons, candidates, silence }
|
|
69
|
+
* @param {object} input { sessionId, root, step, query, scoreQuery, ops, writes, reasons, dropped, candidates, silence }
|
|
56
70
|
*/
|
|
57
71
|
export function shadowRecordFrom(input) {
|
|
58
72
|
const reasons = Array.isArray(input.reasons) ? input.reasons : []
|
|
59
73
|
const candidates = Array.isArray(input.candidates) ? input.candidates : []
|
|
74
|
+
const dropped = Array.isArray(input.dropped) ? input.dropped : []
|
|
75
|
+
// `support` / `terms` 是**查询级**量(语料里有几个词能表示这条查询),同一个 pre-step 内
|
|
76
|
+
// 逐条恒定(实测 139/139 步)。把它们从每条候选提到行级:单候选省 ~25 B,整行省 ~23%。
|
|
77
|
+
const first = candidates[0]
|
|
78
|
+
const slim = candidates.map((c) => ({
|
|
79
|
+
id: c.id,
|
|
80
|
+
rel: c.rel,
|
|
81
|
+
coverage: c.coverage,
|
|
82
|
+
matched: c.matched,
|
|
83
|
+
// `decision` 是门槛层结论,`outcome` 是预算层结论,两者正交(见 auto-inject.js)。
|
|
84
|
+
decision: c.decision,
|
|
85
|
+
outcome: c.outcome,
|
|
86
|
+
}))
|
|
87
|
+
// 候选**只有提示通道**(trigger 的结果在 `injected` / `dropped` 里),所以不再逐条记 channel。
|
|
88
|
+
const bounded = slim.length <= SHADOW_MAX_CANDIDATES ? slim : (() => {
|
|
89
|
+
const keep = new Set()
|
|
90
|
+
for (const c of slim) if (c.decision === 'cand') keep.add(c.id)
|
|
91
|
+
for (const c of slim) {
|
|
92
|
+
if (keep.size >= SHADOW_MAX_CANDIDATES) break
|
|
93
|
+
keep.add(c.id)
|
|
94
|
+
}
|
|
95
|
+
return slim.filter((c) => keep.has(c.id))
|
|
96
|
+
})()
|
|
60
97
|
return {
|
|
61
98
|
at: new Date().toISOString(),
|
|
62
99
|
session: input.sessionId || null,
|
|
@@ -64,10 +101,22 @@ export function shadowRecordFrom(input) {
|
|
|
64
101
|
step: typeof input.step === 'number' ? input.step : null,
|
|
65
102
|
// 人类消息截断保存:没有它就判不了"这次注入该不该"——标签要能回放到当时的场景。
|
|
66
103
|
query: typeof input.query === 'string' ? input.query.slice(0, 300) : '',
|
|
104
|
+
// **实际用于评分**的查询文本(intent + 写目标的拼合)。它和 `query` 不是一回事:
|
|
105
|
+
// 写目标驱动的那些步 `query` 是空的、候选却非空。少了这一栏,判据无法离线重放。
|
|
106
|
+
scoreQuery: typeof input.scoreQuery === 'string' ? input.scoreQuery.slice(0, 300) : '',
|
|
67
107
|
ops: (Array.isArray(input.ops) ? input.ops : []).slice(0, 12),
|
|
68
108
|
writes: (Array.isArray(input.writes) ? input.writes : []).slice(0, 12),
|
|
69
109
|
injected: reasons.map((r) => ({ id: r.id, channel: r.channel, why: r.why })),
|
|
70
|
-
|
|
110
|
+
// 查询级支持度:语料能表示查询里多少个词(`terms` 是查询词数)—— 通道级沉默判据用的就是它。
|
|
111
|
+
support: typeof first?.support === 'number' ? first.support : null,
|
|
112
|
+
terms: typeof first?.terms === 'number' ? first.terms : null,
|
|
113
|
+
// candidates 的 `decision` 只说明"过没过门槛",进不进上下文看 `outcome`
|
|
114
|
+
// (injected / budget / quota / idle / gate / unscheduled)。两者正交。
|
|
115
|
+
candidates: bounded,
|
|
116
|
+
// 截断前的候选总数:不静默,读日志的人能一眼看出这一步被裁掉了多少。
|
|
117
|
+
candidatesTotal: candidates.length,
|
|
118
|
+
// 触发通道的丢弃不在 candidates 里(候选只收提示通道),单列;有界,避免长候选池撑爆一行。
|
|
119
|
+
dropped: dropped.slice(0, 40),
|
|
71
120
|
silence: input.silence || null,
|
|
72
121
|
}
|
|
73
122
|
}
|
package/src/auto-inject.js
CHANGED
|
@@ -17,12 +17,22 @@ import { cfgInsight, GlobalStore, defaultGlobalFile, recordHit } from './insight
|
|
|
17
17
|
import { projectTags } from './project-profile.js'
|
|
18
18
|
import { rankEntriesMergedScored } from './util/search.js'
|
|
19
19
|
import { insightToEntry, insightScoringText } from './recall.js'
|
|
20
|
-
import { buildReadinessContext, hintQueryText,
|
|
20
|
+
import { buildReadinessContext, hintQueryText, corpusTermSets, idfCoveragePrepared, matchTrigger, normalizeTrigger, relativeHits } from './readiness.js'
|
|
21
21
|
import { activityFromCalls } from './ops.js'
|
|
22
22
|
import { appendInjectionAudit, appendShadowAudit, auditRecordFrom, cfgAudit, cfgShadow, shadowRecordFrom } from './audit.js'
|
|
23
23
|
|
|
24
24
|
export const INJECT_MARK = '[Memory Inject]'
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* 注入块抬头里的插件名。
|
|
28
|
+
*
|
|
29
|
+
* 消息的 `source.kind`(= {@link SOURCE_KIND})只有宿主看得到:请求序列化只取 `role` 与
|
|
30
|
+
* `content`(provider 侧对 `.source` 零引用),所以模型从元数据里读不到"这是谁注入的"。
|
|
31
|
+
* 抬头是**唯一**能让模型(和 GUI 里的用户)看见生产者的地方。抬头不是行为契约:
|
|
32
|
+
* 客户端不解析它,判据也不读它。
|
|
33
|
+
*/
|
|
34
|
+
export const PLUGIN_NAME = 'dsh-project-memory'
|
|
35
|
+
|
|
26
36
|
/**
|
|
27
37
|
* 本插件在 `message.source` 上声明的生产者 kind。
|
|
28
38
|
*
|
|
@@ -97,9 +107,14 @@ export function cfgEngine(config) {
|
|
|
97
107
|
// 条目通道的步间隔:两次"条目注入"之间至少隔这么多步(常驻任务卡不受限——它是状态快照,
|
|
98
108
|
// 内容变了就该更新)。这是"不频繁"的主要旋钮。
|
|
99
109
|
gateCooldownSteps: typeof c.gateCooldownSteps === 'number' && c.gateCooldownSteps >= 0 ? c.gateCooldownSteps : 2,
|
|
100
|
-
// 每会话条目注入的上限(条数 /
|
|
101
|
-
|
|
102
|
-
|
|
110
|
+
// 每会话条目注入的上限(条数 / 字符):这是**保险丝**,不是节流阀。
|
|
111
|
+
// 节流由单轮 maxTokens 与 gateCooldownSteps 负责;这两个数的唯一职责是兜住病态的
|
|
112
|
+
// runaway,所以默认值必须远高于任何真实会话的用量。旧默认 12 条 / 4000 字符 ≈ 11 条,
|
|
113
|
+
// 实测真实日志里 11/81 个会话打满,打满之后是**永久**静默(实测 1234 步失明),
|
|
114
|
+
// 而"会话后段"恰是关键时刻概率最高的地方。实测最长会话用 35 条 / 8640 字符,故取
|
|
115
|
+
// 60 / 24000(≈1.7× 与 ≈2.8× 余量)。想复现旧行为显式配小值即可。
|
|
116
|
+
maxItemsPerSession: typeof c.maxItemsPerSession === 'number' && c.maxItemsPerSession >= 0 ? c.maxItemsPerSession : 60,
|
|
117
|
+
maxItemCharsPerSession: typeof c.maxItemCharsPerSession === 'number' && c.maxItemCharsPerSession >= 0 ? c.maxItemCharsPerSession : 24000,
|
|
103
118
|
// 提示通道的**绝对**覆盖底线(IDF 加权覆盖率):相对阈值分不出"有信号"和"矮子里拔将军",
|
|
104
119
|
// 只有归一在 [0,1]、有真零点才谈得上下限。0.30 时的实测反例:真实 store(43 条同源洞察)上,
|
|
105
120
|
// 对照组场景「改 pptx 时间戳」以 cov 0.32~0.35 注入了 3 条无关条目——语料同源时共享词多、
|
|
@@ -259,7 +274,10 @@ function scoreHints({ cands, query, humanText, hintQuery, cfg, dropped }) {
|
|
|
259
274
|
// 现有两类记录都只覆盖"过得了相对阈值的那部分",恰恰缺了"因为没到阈值而从未被考虑"的候选,
|
|
260
275
|
// 而那正是离线重放阈值时需要的那批。这里只做加法,不动任何现有判据。
|
|
261
276
|
const candidates = []
|
|
262
|
-
|
|
277
|
+
// 真正用于评分的查询文本。影子记录必须留下它:`query`(人类消息原文)与它不是一回事,
|
|
278
|
+
// 写目标驱动的那些步 `query` 为空、候选却非空。少了它,判据无法离线重放。
|
|
279
|
+
const q = hintQueryText(hintQuery)
|
|
280
|
+
if (!cands.length) return { hints, candidates, scoreQuery: q }
|
|
263
281
|
if (typeof cfg.relevanceMin === 'number') {
|
|
264
282
|
// 兼容:显式 relevanceMin → 旧的绝对 overlap 判据(老 profile 行为不变)
|
|
265
283
|
for (const it of cands) {
|
|
@@ -267,10 +285,9 @@ function scoreHints({ cands, query, humanText, hintQuery, cfg, dropped }) {
|
|
|
267
285
|
if (score >= cfg.relevanceMin) hints.push({ it, score, why: `overlap:${score.toFixed(3)}` })
|
|
268
286
|
}
|
|
269
287
|
hints.sort((a, b) => b.score - a.score)
|
|
270
|
-
return { hints, candidates } // 旧判据路径不产出影子候选(已废弃,无重放价值)
|
|
288
|
+
return { hints, candidates, scoreQuery: q } // 旧判据路径不产出影子候选(已废弃,无重放价值)
|
|
271
289
|
}
|
|
272
|
-
|
|
273
|
-
if (!q) return { hints, candidates }
|
|
290
|
+
if (!q) return { hints, candidates, scoreQuery: q }
|
|
274
291
|
const byId = new Map(cands.map((it) => [it.id, it]))
|
|
275
292
|
// 覆盖率语料必须与 BM25 排序同源(insightScoringText):否则"只在 fix/solution 里有匹配"
|
|
276
293
|
// 的查询词 df=0 → supportRatio=0 → 整条提示通道沉默,而排序明明给了它高分。
|
|
@@ -278,7 +295,10 @@ function scoreHints({ cands, query, humanText, hintQuery, cfg, dropped }) {
|
|
|
278
295
|
// 查询与被测覆盖率的文本用**同一个**过滤后的查询:否则缩写(wsl/npm)会在 BM25 里被剔除、
|
|
279
296
|
// 却仍在覆盖率里计分,"至少两个共同词"就被它们凑够了。
|
|
280
297
|
const scored = rankEntriesMergedScored(cands.map(insightToEntry), [q], cands.length)
|
|
281
|
-
|
|
298
|
+
// 语料词集合建**一次**再逐条算覆盖率。逐条调用 `idfCoverage` 会让它每次重建整个语料,
|
|
299
|
+
// 即 O(C²) 次 tokenize(实测真实 store 单步 147ms → 1.3ms,109×,结果逐字段一致)。
|
|
300
|
+
const corpusSets = corpusTermSets(corpus)
|
|
301
|
+
const coverage = new Map(cands.map((it, i) => [it.id, idfCoveragePrepared(q, corpus[i], corpusSets)]))
|
|
282
302
|
const qStats = coverage.get(cands[0].id) || { supported: 0, terms: 0 }
|
|
283
303
|
const supportRatio = qStats.terms > 0 ? qStats.supported / qStats.terms : 0
|
|
284
304
|
// 通道级沉默:查询里绝大多数词在语料里根本没有对应 → "覆盖率 1.00"只是假象。
|
|
@@ -334,7 +354,7 @@ function scoreHints({ cands, query, humanText, hintQuery, cfg, dropped }) {
|
|
|
334
354
|
})
|
|
335
355
|
}
|
|
336
356
|
}
|
|
337
|
-
return { hints, candidates }
|
|
357
|
+
return { hints, candidates, scoreQuery: q }
|
|
338
358
|
}
|
|
339
359
|
|
|
340
360
|
/**
|
|
@@ -378,8 +398,11 @@ export function buildInjection(opts) {
|
|
|
378
398
|
}
|
|
379
399
|
// procedure 不进提示通道:它要过 trigger.scope(见 scoreHints 注释)。
|
|
380
400
|
const consumed = new Set(triggered.map((t) => t.it.id))
|
|
381
|
-
|
|
382
|
-
|
|
401
|
+
// 限流命中时**照旧评分**(只算不注入,`place()` 仍按 skipItems 跳过):影子记录要能回答
|
|
402
|
+
// "这一步如果不限流会注入什么"——那正是限流期间最需要回答的问题。此前这里直接置空,
|
|
403
|
+
// 实测 1420 个静默步一个候选都没留下。
|
|
404
|
+
const hintCands = cands.filter((it) => !consumed.has(it.id) && it.kind !== 'procedure')
|
|
405
|
+
const { hints, candidates, scoreQuery } = scoreHints({
|
|
383
406
|
cands: hintCands,
|
|
384
407
|
query,
|
|
385
408
|
humanText: ctx.humanText,
|
|
@@ -408,7 +431,9 @@ export function buildInjection(opts) {
|
|
|
408
431
|
const remaining = Math.min(stepBudget - used, itemBudget - itemUsed)
|
|
409
432
|
const body = fitBody(insightBody(it), remaining, minChars)
|
|
410
433
|
if (body === null) {
|
|
411
|
-
|
|
434
|
+
// 带出数字:旧的 `budget` 没有量纲,"还剩 38 字符、这条最少要 120" 与"根本没预算"
|
|
435
|
+
// 在日志里长得一样 —— 配额逼近上限时那条永久静默的细缝因此完全不可诊断。
|
|
436
|
+
dropped.push({ id: it.id, channel, reason: 'budget', remaining, need: minChars })
|
|
412
437
|
continue
|
|
413
438
|
}
|
|
414
439
|
used += body.length + 1
|
|
@@ -425,12 +450,33 @@ export function buildInjection(opts) {
|
|
|
425
450
|
place('hint', hints, MIN_BODY_CHARS.hint, () => 'hint')
|
|
426
451
|
}
|
|
427
452
|
|
|
453
|
+
// 预算层结局写回候选。`decision` 是**门槛层**的结论(support/coverage/thin/relative/cand),
|
|
454
|
+
// `outcome` 是**预算层**的结论——两者正交。"过了全部门槛却没注入"必须能归因,
|
|
455
|
+
// 否则日志只能证明门槛做了什么,证明不了预算拿走了什么。
|
|
456
|
+
{
|
|
457
|
+
const outcomeById = new Map()
|
|
458
|
+
for (const r of reasons) outcomeById.set(`${r.channel}\u0000${r.id}`, 'injected')
|
|
459
|
+
for (const d of dropped) {
|
|
460
|
+
const key = `${d.channel}\u0000${d.id}`
|
|
461
|
+
if (!outcomeById.has(key)) outcomeById.set(key, d.reason)
|
|
462
|
+
}
|
|
463
|
+
for (const c of candidates) {
|
|
464
|
+
c.outcome = skipItems ? 'idle'
|
|
465
|
+
: c.decision !== 'cand' ? 'gate'
|
|
466
|
+
: outcomeById.get(`hint\u0000${c.id}`) || 'unscheduled'
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
428
470
|
const total = [entry, ...parts].filter(Boolean)
|
|
429
|
-
|
|
471
|
+
// 早退也必须带上 candidates/scoreQuery:空块不等于"没有候选"(无任务卡 + 全部被预算挤掉
|
|
472
|
+
// 就是这种形状),此前这里把两者一起丢了。
|
|
473
|
+
if (!total.length) {
|
|
474
|
+
return { text: '', entry, labels, reasons, dropped, candidates, scoreQuery, itemChars: itemUsed }
|
|
475
|
+
}
|
|
430
476
|
// 整块只受单轮预算(maxTokens)约束;会话条目额度已在 place() 里单独扣过。
|
|
431
477
|
const clamp = (t) => (t.length > stepBudget ? `${t.slice(0, stepBudget)}\n…(截断)` : t)
|
|
432
478
|
const dedupeText = clamp([entryStable, ...parts].filter(Boolean).join('\n'))
|
|
433
|
-
return { text: clamp(total.join('\n')), entry, entryStable, labels, reasons, dropped, candidates, dedupeText, itemChars: itemUsed }
|
|
479
|
+
return { text: clamp(total.join('\n')), entry, entryStable, labels, reasons, dropped, candidates, scoreQuery, dedupeText, itemChars: itemUsed }
|
|
434
480
|
}
|
|
435
481
|
|
|
436
482
|
// 每会话状态的会话数上限:六张表共用(旧实现把同一个 200 在五处各写一遍)。
|
|
@@ -618,6 +664,8 @@ async function injectForStep({ payload, decision, config, cfg, audit, shadow, se
|
|
|
618
664
|
writes: readiness.targets,
|
|
619
665
|
reasons: built.reasons,
|
|
620
666
|
candidates: built.candidates,
|
|
667
|
+
scoreQuery: built.scoreQuery,
|
|
668
|
+
dropped: built.dropped,
|
|
621
669
|
silence: silence.reason,
|
|
622
670
|
}), shadow, root)
|
|
623
671
|
|
|
@@ -737,7 +785,7 @@ function recordDropped({ sessions, sessionId, dropped, budgetLog }) {
|
|
|
737
785
|
/** 追加的注入消息:必须是带 source 的完整消息——裸 {role,content} 会让宿主读 message.source 时崩。 */
|
|
738
786
|
function injectionMessage(text) {
|
|
739
787
|
return createUserMessage({
|
|
740
|
-
content: [{ type: 'text', text: `\n\n${INJECT_MARK} auto-context\n${text}` }],
|
|
788
|
+
content: [{ type: 'text', text: `\n\n${INJECT_MARK} ${PLUGIN_NAME} · auto-context\n${text}` }],
|
|
741
789
|
// 这一块是「同一生产者后续快照会取代的当前状态」,不是一次性通知。
|
|
742
790
|
// 宿主 ContextFormed 是判别联合:snapshot 必须带 sections(notice 才需要 summary)。
|
|
743
791
|
// kind 必须是生产者自有的(v4 没有 'plugin' 兜底 kind),见 SOURCE_KIND 的注释。
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `show_task_panel` 工具调用的会话内视图 —— 它的作用就是**把面板打开**。
|
|
3
|
+
*
|
|
4
|
+
* 为什么这件事必须在这里做:面板的显示状态(closed / minimized)是**浏览器侧**的
|
|
5
|
+
* (`task-ui-store.ts`),宿主进程碰不到它。宿主侧原本写的是
|
|
6
|
+
* `exec.ctx.events.emit('dsh:task-panel:show')`,而宿主契约里根本没有 `ctx`:
|
|
7
|
+
*
|
|
8
|
+
* packages/core/tools/src/index.ts:418
|
|
9
|
+
* export interface ToolRunContext extends ToolExecution {
|
|
10
|
+
* deferContext(context: UserMessage): void
|
|
11
|
+
* concludeTurn(): void
|
|
12
|
+
* }
|
|
13
|
+
*
|
|
14
|
+
* 于是那个工具每次都只返回「无法获取上下文」,面板从来不会打开 —— 插件里唯一的
|
|
15
|
+
* 宿主命令之外,这是第二个"写了但没生效"的面(详见 CHANGELOG 0.5.14)。
|
|
16
|
+
*
|
|
17
|
+
* 正确的位置是宿主给出的扩展点:`ui-tool` 注册了 `conversation.chat.node`(key `tool-call`),
|
|
18
|
+
* 其下有个按**工具名**分发的子槽 `tool.call.toolview`(契约原文:*Any name is allowed,
|
|
19
|
+
* including tools registered by your package. Register with `key: '<tool name>'`*)。
|
|
20
|
+
* 在这里认领 `show_task_panel` 这个 key,工具结果一渲染就把面板打开。
|
|
21
|
+
*/
|
|
22
|
+
import { useEffect, useRef } from 'react'
|
|
23
|
+
import { taskUIStore } from './task-ui-store.ts'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 只在**现场执行**时打开面板。
|
|
27
|
+
*
|
|
28
|
+
* 历史节点在刷新 / 切换会话后挂载时会**直接**处于 `result` 阶段;现场调用则会先经过
|
|
29
|
+
* `preparing` / `start`。这与 `/tasks` 命令节点用的判据同款(见 `TaskCommandNode.tsx`
|
|
30
|
+
* 的 `live` ref):刷新后只展示、不再开面板,否则每次打开会话都会弹一次面板。
|
|
31
|
+
*/
|
|
32
|
+
export function ShowTaskPanelNode(props: any) {
|
|
33
|
+
const phase = props?.phase
|
|
34
|
+
const live = useRef(phase !== 'result')
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (phase !== 'result' || !live.current) return
|
|
37
|
+
live.current = false
|
|
38
|
+
taskUIStore.actions.open()
|
|
39
|
+
}, [phase])
|
|
40
|
+
// 不渲染任何行:认领 key 会**替换**通用工具行,而这次调用的全部意义就是开面板,
|
|
41
|
+
// 留一行"打开面板"的噪音不如让面板自己说话。
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 把 `show_task_panel` 的视图注册进宿主的 `tool.call.toolview` 槽。 */
|
|
46
|
+
export function registerShowTaskPanelView(slots: any): void {
|
|
47
|
+
slots.inject('tool.call.toolview', () => slots.register(
|
|
48
|
+
{ name: 'tool.call.toolview', key: 'show_task_panel' },
|
|
49
|
+
ShowTaskPanelNode,
|
|
50
|
+
))
|
|
51
|
+
}
|
package/src/client/TaskPanel.tsx
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from './icons.ts'
|
|
15
15
|
import { createTranslate, zh, en } from './locales.ts'
|
|
16
16
|
import { useTaskData, useTaskDataActions, taskDataStore, parseTaskPayloadText, type TaskStep } from './task-data-store.ts'
|
|
17
|
-
import { useTaskUI, useTaskUIActions } from './task-ui-store.ts'
|
|
17
|
+
import { useTaskUI, useTaskUIActions, PANEL_VIEWS } from './task-ui-store.ts'
|
|
18
18
|
import { MiniBar, TaskCard } from './TaskComponents.tsx'
|
|
19
19
|
import { MemoryView, type InsightScope } from './MemoryView.tsx'
|
|
20
20
|
import { pickSessionId } from './session-id.js'
|
|
@@ -23,8 +23,8 @@ import css from './TaskPanel.module.css'
|
|
|
23
23
|
const NS = 'dsh-project-memory'
|
|
24
24
|
|
|
25
25
|
const STATUS_CYCLE = ['pending', 'in_progress', 'completed'] as const
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
// 视图列表的唯一事实来源在 UI store(`/` 菜单那三行也读它);这里不再抄一份。
|
|
27
|
+
const VIEW_CYCLE = PANEL_VIEWS
|
|
28
28
|
|
|
29
29
|
function getT(ctx: any) {
|
|
30
30
|
const locale = ctx?.locale?.getSnapshot?.()?.active === 'zh' ? zh : en
|
|
@@ -77,19 +77,19 @@ function TaskPanelView({ ctx }: { ctx: any }) {
|
|
|
77
77
|
const [syncError, setSyncError] = useState<string | null>(null)
|
|
78
78
|
// 提示信息开关落在 UI store(持久化):关掉后面板内所有悬停 title 气泡都不再弹出。
|
|
79
79
|
const showHints = ui.showHints
|
|
80
|
-
const
|
|
80
|
+
const dataActions = useTaskDataActions()
|
|
81
|
+
const uiActions = useTaskUIActions()
|
|
82
|
+
// 视图页同样落在 UI store:`/` 菜单的三行是"打开某一页",它们必须能从这个组件外面切页。
|
|
83
|
+
const view = ui.view
|
|
81
84
|
const cycleView = () => {
|
|
82
85
|
const i = VIEW_CYCLE.indexOf(view)
|
|
83
|
-
setView(VIEW_CYCLE[(i + 1) % VIEW_CYCLE.length])
|
|
86
|
+
uiActions.setView(VIEW_CYCLE[(i + 1) % VIEW_CYCLE.length])
|
|
84
87
|
}
|
|
85
88
|
const viewTitle =
|
|
86
89
|
view === 'task' ? t('panel.title')
|
|
87
90
|
: view === 'global' ? t('view.global')
|
|
88
91
|
: t('view.project')
|
|
89
92
|
|
|
90
|
-
const dataActions = useTaskDataActions()
|
|
91
|
-
const uiActions = useTaskUIActions()
|
|
92
|
-
|
|
93
93
|
const tasks = Array.isArray(data.tasks) ? data.tasks : []
|
|
94
94
|
const activeTasks = tasks.filter((task) => !task.archived)
|
|
95
95
|
const boundTask = data.boundTaskId ? tasks.find((task) => task.id === data.boundTaskId) ?? null : null
|
package/src/client/client.ts
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
import { createElement } from 'react'
|
|
18
18
|
import { TaskPanelEntry } from './TaskPanel.tsx'
|
|
19
19
|
import { TaskCommandNode } from './TaskCommandNode.tsx'
|
|
20
|
+
import { registerShowTaskPanelView } from './ShowTaskPanelNode.tsx'
|
|
21
|
+
import { taskUIStore } from './task-ui-store.ts'
|
|
20
22
|
import { createSlashSource } from './slash.ts'
|
|
21
23
|
|
|
22
24
|
const NS = 'dsh-project-memory'
|
|
@@ -26,36 +28,6 @@ export const name = NS
|
|
|
26
28
|
/** Required client services: slots registry, session scopes, commands remote (data 通道). */
|
|
27
29
|
export const inject = ['slots', 'sessions', 'remote', 'remote.commands', 'locale']
|
|
28
30
|
|
|
29
|
-
/**
|
|
30
|
-
* 执行一条命令行,映射成 composer 的 SubmitOutcome。
|
|
31
|
-
* 与 TaskPanel 走同一个 remote.commands.execute 通道(同样的返回信封)。
|
|
32
|
-
* @param commands - ctx.remote.commands
|
|
33
|
-
* @param session - 会话投影(只读 sessionId)
|
|
34
|
-
* @param line - 完整命令行(含前导斜杠)
|
|
35
|
-
* @param attachments - 提交附件(菜单路径恒为空)
|
|
36
|
-
* @returns {kind:'success'|'error', text?}
|
|
37
|
-
*/
|
|
38
|
-
async function runCommand(
|
|
39
|
-
commands: any,
|
|
40
|
-
session: any,
|
|
41
|
-
line: string,
|
|
42
|
-
attachments: readonly unknown[] = [],
|
|
43
|
-
): Promise<{ kind: 'success' | 'error'; text?: string }> {
|
|
44
|
-
const sessionId = session?.sessionId
|
|
45
|
-
if (typeof sessionId !== 'string' || !commands || typeof commands.execute !== 'function') {
|
|
46
|
-
return { kind: 'error', text: `no session / commands service for ${line}` }
|
|
47
|
-
}
|
|
48
|
-
const response: any = await commands.execute(sessionId, line, [...attachments])
|
|
49
|
-
const envelope = response as { ok?: boolean; error?: any; value?: any } | null | undefined
|
|
50
|
-
if (envelope && typeof envelope === 'object' && envelope.ok === false) {
|
|
51
|
-
return { kind: 'error', text: `command.execute failed: ${envelope.error?.code ?? 'unknown'}` }
|
|
52
|
-
}
|
|
53
|
-
const execution = envelope && typeof envelope === 'object' && 'value' in envelope ? envelope.value : envelope
|
|
54
|
-
const result = execution?.result ?? execution
|
|
55
|
-
if (result?.kind === 'error') return { kind: 'error', text: result.text ?? `${line} failed` }
|
|
56
|
-
return { kind: 'success' }
|
|
57
|
-
}
|
|
58
|
-
|
|
59
31
|
/**
|
|
60
32
|
* 注册自建的 `/` 菜单源(见 slash.ts)。
|
|
61
33
|
*
|
|
@@ -79,7 +51,9 @@ function registerSlashSource(ctx: any): void {
|
|
|
79
51
|
}
|
|
80
52
|
const source = createSlashSource(scope, {
|
|
81
53
|
sessions: scope.sessions,
|
|
82
|
-
|
|
54
|
+
// 菜单行是「打开面板的某一页」:直接落到 UI store,不绕宿主命令
|
|
55
|
+
// (面板的 closed/view 是浏览器侧状态,宿主命令碰不到)。
|
|
56
|
+
openView: (view) => taskUIStore.actions.openView(view),
|
|
83
57
|
})
|
|
84
58
|
scope.effect(() => inputTriggers.registerSource(source), 'dsh-project-memory: slash source')
|
|
85
59
|
} catch (err) {
|
|
@@ -116,6 +90,11 @@ export function apply(ctx: any): void {
|
|
|
116
90
|
TaskCommandNode
|
|
117
91
|
))
|
|
118
92
|
}
|
|
93
|
+
|
|
94
|
+
// 工具调用视图:`show_task_panel` 的唯一实际效果就是开面板,而面板状态在浏览器侧;
|
|
95
|
+
// 宿主进程碰不到它(宿主契约里 `ToolRunContext` 没有 `ctx`)。认领这个**工具名**后,
|
|
96
|
+
// 工具结果一渲染就把面板打开 —— 见 ShowTaskPanelNode.tsx。
|
|
97
|
+
registerShowTaskPanelView(slots)
|
|
119
98
|
} catch (err) {
|
|
120
99
|
console.warn(`[${NS}] task panel registration failed:`, err)
|
|
121
100
|
}
|
package/src/client/slash.ts
CHANGED
|
@@ -18,11 +18,18 @@
|
|
|
18
18
|
* 与「指令」组的重复:合并前插件注册了 /tasks、/task、/insight 三条宿主命令,菜单里就是三行
|
|
19
19
|
* 去不掉的原始行 —— 宿主命令只要注册就会出现在目录里,`commands.list()` 与
|
|
20
20
|
* `commands.execute()` 读同一个视图,`CommandDefinition` 也没有 hidden 字段,插件无法隐藏。
|
|
21
|
-
* 现在宿主只剩一条 /tasks(见 src/commands/workflow.js
|
|
22
|
-
*
|
|
21
|
+
* 现在宿主只剩一条 /tasks(见 src/commands/workflow.js),重复降到一行;那一行是插件执行
|
|
22
|
+
* 宿主侧工作的唯一通道(插件没有自己的 client→host RPC)。
|
|
23
|
+
*
|
|
24
|
+
* **这三行不发命令。** 它们是「打开面板的某一页」,而面板的 `closed` / `view` 是浏览器侧
|
|
25
|
+
* UI store 的状态 —— 宿主命令碰不到。旧实现绕了一圈 `remote.commands.execute('/tasks insight
|
|
26
|
+
* list project')`:命令确实在宿主执行了,但客户端渲染命令节点的是按**命令名**分发的
|
|
27
|
+
* `TaskCommandNode`(`name === 'tasks'`),它拿任务解析器去解记忆载荷 → 解析失败 → 面板毫无
|
|
28
|
+
* 变化。于是用户看到的就是"点了没反应"。现在直接调 `openView(view)`。
|
|
23
29
|
*/
|
|
24
30
|
import type { ComponentType } from 'react'
|
|
25
31
|
import { createTranslate, en, zh } from './locales.ts'
|
|
32
|
+
import type { PanelView } from './task-ui-store.ts'
|
|
26
33
|
import { IconChecklistOutline16, IconGlobeOutline16, IconLightOutline16 } from './icons.ts'
|
|
27
34
|
|
|
28
35
|
/** 源的稳定标识:同一 trigger 内唯一,重复注册会抛错。 */
|
|
@@ -47,7 +54,8 @@ interface SlashRow {
|
|
|
47
54
|
/** 额外搜索词:候选 name 是中文,拉丁输入(task / memory…)靠这些命中。 */
|
|
48
55
|
readonly match: readonly string[]
|
|
49
56
|
/** 点击后经 remote.commands.execute 执行的完整命令行。 */
|
|
50
|
-
|
|
57
|
+
/** 点击后要打开的面板视图页。**纯客户端动作**,不绕宿主命令。 */
|
|
58
|
+
readonly view: PanelView
|
|
51
59
|
}
|
|
52
60
|
|
|
53
61
|
/** 菜单三行。顺序即渲染顺序;标题复用面板已有的 view.* 文案,保证与面板视图名一致。 */
|
|
@@ -57,21 +65,21 @@ const ROWS: readonly SlashRow[] = [
|
|
|
57
65
|
descriptionKey: 'slash.tasks-desc',
|
|
58
66
|
icon: IconChecklistOutline16,
|
|
59
67
|
match: ['tasks', 'task'],
|
|
60
|
-
|
|
68
|
+
view: 'task',
|
|
61
69
|
},
|
|
62
70
|
{
|
|
63
71
|
labelKey: 'view.project',
|
|
64
72
|
descriptionKey: 'slash.project-desc',
|
|
65
73
|
icon: IconLightOutline16,
|
|
66
74
|
match: ['memory', 'project', 'insight'],
|
|
67
|
-
|
|
75
|
+
view: 'project',
|
|
68
76
|
},
|
|
69
77
|
{
|
|
70
78
|
labelKey: 'view.global',
|
|
71
79
|
descriptionKey: 'slash.global-desc',
|
|
72
80
|
icon: IconGlobeOutline16,
|
|
73
81
|
match: ['memory', 'global', 'insight'],
|
|
74
|
-
|
|
82
|
+
view: 'global',
|
|
75
83
|
},
|
|
76
84
|
]
|
|
77
85
|
|
|
@@ -79,8 +87,9 @@ const ROWS: readonly SlashRow[] = [
|
|
|
79
87
|
export interface SlashSourceDeps {
|
|
80
88
|
/** 会话服务(`sessions`):把 sessionId 换回会话 scope,用于消费触发 token。 */
|
|
81
89
|
readonly sessions: any
|
|
82
|
-
/**
|
|
83
|
-
|
|
90
|
+
/** 打开面板并切到指定页(`taskUIStore.actions.openView`)。注入而非直接 import,
|
|
91
|
+
* 这样源本身可以在最小替身里单测,不需要真的挂一个 React 面板。 */
|
|
92
|
+
openView(view: PanelView): void
|
|
84
93
|
}
|
|
85
94
|
|
|
86
95
|
/** 翻译函数签名(key 来自 ROWS,不是字面量,故不做类型约束)。 */
|
|
@@ -114,7 +123,7 @@ export function buildSlashCandidates(t: Translate, req: any): readonly Record<st
|
|
|
114
123
|
icon: row.icon,
|
|
115
124
|
// 每一行都带 section → MenuView 不渲染组标题行,只渲染这个标题。
|
|
116
125
|
section: t('slash.group'),
|
|
117
|
-
line
|
|
126
|
+
// **不带 line**:这三行不是命令,是打开面板某一页;动作在 onPick 里按 name 查表得到。
|
|
118
127
|
terms: [title, ...row.match].map((term) => term.toLowerCase()),
|
|
119
128
|
}
|
|
120
129
|
})
|
|
@@ -142,13 +151,18 @@ function consumeSpan(deps: SlashSourceDeps, pick: any): boolean {
|
|
|
142
151
|
}
|
|
143
152
|
|
|
144
153
|
/**
|
|
145
|
-
* 一次菜单点击:消费掉触发 token
|
|
154
|
+
* 一次菜单点击:消费掉触发 token 后**直接打开面板的对应页**。
|
|
155
|
+
*
|
|
156
|
+
* 这三行是「打开某个视图」,不是「执行某条命令」,所以走客户端自己的 UI store:
|
|
157
|
+
* 面板的 `closed` / `view` 都是浏览器侧状态,宿主命令碰不到它们。旧实现执行
|
|
158
|
+
* `remote.commands.execute('/tasks insight list project')`,只在对话里留下一个命令节点,
|
|
159
|
+
* 而客户端渲染命令节点的是按**命令名**分发的 `TaskCommandNode`(`name === 'tasks'`),
|
|
160
|
+
* 它拿任务解析器去解记忆载荷 → 解析失败 → 对面板零影响。**这就是"点了没反应"的原因。**
|
|
146
161
|
*
|
|
147
|
-
* 不返回 claim(回填 `/xxx `
|
|
148
|
-
*
|
|
149
|
-
* 只是草稿里残留的触发文本要用户自己清掉 —— 比回填一个会执行错命令的 claim 安全。
|
|
162
|
+
* 不返回 claim(回填 `/xxx ` 再等回车):claim 会多要一次回车,而这三行没有参数要填。
|
|
163
|
+
* 消费失败时仍然切页 —— 草稿里残留的触发文本要用户自己清掉,比"点了完全没反应"好。
|
|
150
164
|
* @param t - 翻译函数(按当前语言把候选 name 映射回 ROWS)。
|
|
151
|
-
* @param deps -
|
|
165
|
+
* @param deps - 会话服务与 UI store 动作。
|
|
152
166
|
* @param pick - 宿主给的点击载荷。
|
|
153
167
|
* @returns PickOutcome;拿不到可用形状时返回 undefined(菜单照常关闭,草稿不动)。
|
|
154
168
|
*/
|
|
@@ -158,9 +172,11 @@ export function dispatchSlashPick(t: Translate, deps: SlashSourceDeps, pick: any
|
|
|
158
172
|
const row = ROWS.find((candidate) => t(candidate.labelKey) === title)
|
|
159
173
|
if (row === undefined) return undefined
|
|
160
174
|
consumeSpan(deps, pick)
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
})
|
|
175
|
+
try {
|
|
176
|
+
deps.openView(row.view)
|
|
177
|
+
} catch (err) {
|
|
178
|
+
console.warn(`[dsh-project-memory] open view ${row.view} failed:`, err)
|
|
179
|
+
}
|
|
164
180
|
return 'handled'
|
|
165
181
|
}
|
|
166
182
|
|
|
@@ -8,6 +8,19 @@ import { useSyncExternalStore } from 'react'
|
|
|
8
8
|
|
|
9
9
|
const STORAGE_KEY = 'dsh-pm-task-panel-ui'
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* 面板的三个视图页。**单一事实来源**:面板的切页按钮与 `/` 菜单的三行都读它。
|
|
13
|
+
* 两边各抄一份必然漂移 —— 实测就是这样:菜单行去执行宿主命令"开记忆页",而面板的 view
|
|
14
|
+
* 是组件内部 state,谁也够不着它,于是点击没有任何可见效果。
|
|
15
|
+
*/
|
|
16
|
+
export const PANEL_VIEWS = ['task', 'project', 'global'] as const
|
|
17
|
+
export type PanelView = (typeof PANEL_VIEWS)[number]
|
|
18
|
+
|
|
19
|
+
/** 把任意值收敛成一个合法视图页(脏 localStorage / 外部传入都要过这一关)。 */
|
|
20
|
+
export function normalizeView(value: unknown): PanelView {
|
|
21
|
+
return (PANEL_VIEWS as readonly unknown[]).includes(value) ? (value as PanelView) : 'task'
|
|
22
|
+
}
|
|
23
|
+
|
|
11
24
|
interface TaskUIState {
|
|
12
25
|
expandedTaskIds: string[]
|
|
13
26
|
panelPosition: { x: number; y: number }
|
|
@@ -16,6 +29,11 @@ interface TaskUIState {
|
|
|
16
29
|
theme: string
|
|
17
30
|
/** 是否显示悬停提示信息(title 气泡);false 时面板内所有功能提示都不弹出。 */
|
|
18
31
|
showHints: boolean
|
|
32
|
+
/**
|
|
33
|
+
* 当前视图页。放在 store 而不是 TaskPanel 的局部 state —— `/` 菜单那三行就是
|
|
34
|
+
* 「打开某一页」,它们从**组件外面**触发,必须有一个可寻址的落点。
|
|
35
|
+
*/
|
|
36
|
+
view: PanelView
|
|
19
37
|
}
|
|
20
38
|
|
|
21
39
|
function defaultPosition(): { x: number; y: number } {
|
|
@@ -43,6 +61,8 @@ function getDefaultUIState(): TaskUIState {
|
|
|
43
61
|
closed: true,
|
|
44
62
|
theme: typeof parsed.theme === 'string' ? parsed.theme : 'native',
|
|
45
63
|
showHints: parsed.showHints !== false,
|
|
64
|
+
// 视图页可恢复:它是"上次在看哪一页",刷新后仍该停在那里(与 closed 不同)。
|
|
65
|
+
view: normalizeView(parsed.view),
|
|
46
66
|
}
|
|
47
67
|
}
|
|
48
68
|
} catch { /* corrupt state — fall through to defaults */ }
|
|
@@ -54,6 +74,7 @@ function getDefaultUIState(): TaskUIState {
|
|
|
54
74
|
closed: true,
|
|
55
75
|
theme: 'native',
|
|
56
76
|
showHints: true,
|
|
77
|
+
view: 'task',
|
|
57
78
|
}
|
|
58
79
|
}
|
|
59
80
|
|
|
@@ -98,6 +119,28 @@ export const taskUIStore = {
|
|
|
98
119
|
open(): void {
|
|
99
120
|
setUIState({ ...uiState, minimized: false, closed: false })
|
|
100
121
|
},
|
|
122
|
+
/**
|
|
123
|
+
* 切页但不动开合状态(面板里那个循环按钮用)。
|
|
124
|
+
* 非法值一律忽略 —— 这个入口会被菜单行与脏 localStorage 碰到,静默归一比抛错合适。
|
|
125
|
+
*/
|
|
126
|
+
setView(view: PanelView): void {
|
|
127
|
+
if (!(PANEL_VIEWS as readonly unknown[]).includes(view)) return
|
|
128
|
+
if (uiState.view === view) return
|
|
129
|
+
setUIState({ ...uiState, view })
|
|
130
|
+
},
|
|
131
|
+
/**
|
|
132
|
+
* 打开面板并切到指定页 —— `/` 菜单那三行的**全部**动作。
|
|
133
|
+
*
|
|
134
|
+
* 纯客户端:不再绕 `remote.commands.execute('/tasks insight list …')` 一圈。那条路
|
|
135
|
+
* 只在对话里留下一个命令节点,而客户端渲染命令节点的是按**命令名**分发的
|
|
136
|
+
* `TaskCommandNode`(`name === 'tasks'`),它拿任务解析器去解记忆载荷,解析必然失败
|
|
137
|
+
* —— 于是点菜单"开记忆页"什么都不会发生。
|
|
138
|
+
* @param view - 目标视图页;非法值直接忽略(不打开面板,避免"点了没反应还弹窗")。
|
|
139
|
+
*/
|
|
140
|
+
openView(view: PanelView): void {
|
|
141
|
+
if (!(PANEL_VIEWS as readonly unknown[]).includes(view)) return
|
|
142
|
+
setUIState({ ...uiState, view, minimized: false, closed: false })
|
|
143
|
+
},
|
|
101
144
|
minimize(): void {
|
|
102
145
|
setUIState({ ...uiState, minimized: true, closed: false })
|
|
103
146
|
},
|