@ljwei-stak/dsh-model-router 0.14.0 → 0.15.0

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.
@@ -331,9 +331,9 @@ function budgetSettings(config) {
331
331
  }
332
332
  }
333
333
 
334
- function budgetFor(config, runs, estimateUsd) {
334
+ function budgetFor(config, saved, estimateUsd) {
335
335
  const settings = budgetSettings(config)
336
- const spent = spending(runs)
336
+ const spent = spending(saved.runs, undefined, saved.archivedSpending)
337
337
  return { ...budgetCheck({ estimateUsd, spent, ...settings }), spent, ...settings }
338
338
  }
339
339
 
@@ -386,7 +386,7 @@ export async function createRoutePlan(ctx, task, config = {}, options = {}) {
386
386
  preset: routingPresetOf(config, options.preset),
387
387
  loggedOutToolIds: loggedOut,
388
388
  })
389
- return { ...plan, budgetStatus: budgetFor(config, saved.runs, plan.estimatedCost) }
389
+ return { ...plan, budgetStatus: budgetFor(config, saved, plan.estimatedCost) }
390
390
  }
391
391
 
392
392
  /** One bounded, independent call through the same official LLM service. */
@@ -626,10 +626,10 @@ export async function planAssignment(ctx, task, config = {}, options = {}) {
626
626
  budgetUsd, installedToolIds: installed, runnableToolIds, preset: presetId, loggedOutToolIds: loggedOut,
627
627
  })
628
628
  let plan = planWith(preset, options.budgetUsd)
629
- let budget = budgetFor(config, saved.runs, plan.estimatedCost)
629
+ let budget = budgetFor(config, saved, plan.estimatedCost)
630
630
  if (budget.exceeded && budget.action === 'downgrade' && !direct) {
631
631
  const cheaper = planWith('economy', budget.remainingUsd > 0 ? budget.remainingUsd : options.budgetUsd)
632
- const recheck = budgetFor(config, saved.runs, cheaper.estimatedCost)
632
+ const recheck = budgetFor(config, saved, cheaper.estimatedCost)
633
633
  if (!recheck.exceeded) {
634
634
  budget = { ...recheck, downgraded: true, downgradedFrom: preset,
635
635
  message: `原方案${budget.message} 已自动降级为“省钱优先”方案(预估 ${cheaper.estimatedCost === null ? '价格待配置' : formatUsd(cheaper.estimatedCost)})。` }
@@ -768,7 +768,7 @@ export async function rerunRecordedStep(ctx, config = {}, { runId, packageId, pr
768
768
  budget: null, message: '续跑可编辑团队步骤会在新的独立 Git 工作树中套用之前的改动并修改文件,需要先确认。' }
769
769
  }
770
770
  const sameRoute = !override || (override.provider === target.recommendedProvider && override.model === target.recommendedModel)
771
- const budget = budgetFor(config, saved.runs, sameRoute ? target.estimatedCost : null)
771
+ const budget = budgetFor(config, saved, sameRoute ? target.estimatedCost : null)
772
772
  if (budget.exceeded && confirmOverBudget !== true) {
773
773
  return { paused: true, budget: { ...budget, paused: true, message: `${budget.message} 已暂停重跑,请确认后再试。` }, run }
774
774
  }
@@ -967,7 +967,7 @@ export async function planToolRun(ctx, config, args = {}, { signal } = {}) {
967
967
  modelId = text(args.cliModel)
968
968
  }
969
969
  const estimate = route ? createPlanFromRoutes(taskText, routes, { mode: 'direct', directProvider: route.provider, directModel: route.model }).estimatedCost : null
970
- const budget = budgetFor(config, (await savedState()).runs, Number.isFinite(estimate) ? estimate : null)
970
+ const budget = budgetFor(config, await savedState(), Number.isFinite(estimate) ? estimate : null)
971
971
  return { toolId, tool, taskText, mode, modelId, route, routes, estimate: Number.isFinite(estimate) ? estimate : null, budget,
972
972
  provider: route?.provider ?? '', model: route?.model ?? '', cliModel: text(args.cliModel) || null }
973
973
  }
@@ -1013,7 +1013,7 @@ export async function rateRecordedResult({ runId, packageId, rating } = {}) {
1013
1013
  /** Everything the workbench shows: recent runs, spending, budget, learned biases and settings. */
1014
1014
  export async function ledgerSummary(config = {}, { limit = 30 } = {}) {
1015
1015
  const saved = await savedState()
1016
- const spent = spending(saved.runs)
1016
+ const spent = spending(saved.runs, undefined, saved.archivedSpending)
1017
1017
  const settings = budgetSettings(config)
1018
1018
  return {
1019
1019
  runs: saved.runs.slice(-limit).reverse().map(run => ({
@@ -1692,7 +1692,7 @@ function registerOfficialToolModels(ctx, config) {
1692
1692
  runnableToolIds: installed.filter(id => supported.has(id)),
1693
1693
  preset: routingPresetOf(config),
1694
1694
  })
1695
- const budget = budgetFor(config, (await savedState()).runs, plan.estimatedCost)
1695
+ const budget = budgetFor(config, await savedState(), plan.estimatedCost)
1696
1696
  if (budget.exceeded && args.confirmOverBudget !== true) {
1697
1697
  return jsonValue({ status: 'paused-budget', budget, plan,
1698
1698
  notice: `${budget.message} 已暂停团队执行。请向用户确认后以 confirmOverBudget: true 重新调用。` })
@@ -14,6 +14,7 @@
14
14
  import { mkdir, open, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
15
15
  import { homedir } from 'node:os'
16
16
  import { dirname, isAbsolute, join, resolve } from 'node:path'
17
+ import { archiveSpending, sanitizeArchivedSpending } from './run-ledger.mjs'
17
18
 
18
19
  export const MAX_RUNS = 200
19
20
  const STATE_VERSION = 1
@@ -38,7 +39,7 @@ const LOCK_STALE_MS = 15_000
38
39
  const LOCK_TIMEOUT_MS = 10_000
39
40
 
40
41
  function emptyState() {
41
- return { version: STATE_VERSION, onboarding: { completedAt: null }, health: null, quota: {}, authFailures: {}, runs: [], notices: [], terminalSessions: [] }
42
+ return { version: STATE_VERSION, onboarding: { completedAt: null }, health: null, quota: {}, authFailures: {}, runs: [], archivedSpending: sanitizeArchivedSpending(), notices: [], terminalSessions: [] }
42
43
  }
43
44
 
44
45
  function sanitizeNotices(value) {
@@ -47,19 +48,27 @@ function sanitizeNotices(value) {
47
48
  : []
48
49
  }
49
50
 
50
- function sanitize(value) {
51
+ function retainRuns(state, maxRuns) {
52
+ const removed = Math.max(0, state.runs.length - maxRuns)
53
+ state.archivedSpending = archiveSpending(state.runs.slice(0, removed), state.archivedSpending)
54
+ state.runs = state.runs.slice(removed)
55
+ return state
56
+ }
57
+
58
+ function sanitize(value, maxRuns) {
51
59
  if (!value || typeof value !== 'object' || value.version !== STATE_VERSION) return emptyState()
52
- return {
60
+ return retainRuns({
53
61
  version: STATE_VERSION,
54
62
  onboarding: { completedAt: Number.isFinite(value.onboarding?.completedAt) ? value.onboarding.completedAt : null },
55
63
  health: value.health && Array.isArray(value.health.tools) ? value.health : null,
56
64
  quota: value.quota && typeof value.quota === 'object' && !Array.isArray(value.quota) ? value.quota : {},
57
65
  authFailures: Object.fromEntries(Object.entries(value.authFailures && typeof value.authFailures === 'object' && !Array.isArray(value.authFailures) ? value.authFailures : {})
58
66
  .filter(([, entry]) => Number.isFinite(entry?.at))),
59
- runs: Array.isArray(value.runs) ? value.runs.filter(run => run && typeof run.id === 'string').slice(-MAX_RUNS) : [],
67
+ runs: Array.isArray(value.runs) ? value.runs.filter(run => run && typeof run.id === 'string') : [],
68
+ archivedSpending: sanitizeArchivedSpending(value.archivedSpending),
60
69
  notices: sanitizeNotices(value.notices),
61
70
  terminalSessions: sanitizeTerminalSessions(value.terminalSessions),
62
- }
71
+ }, maxRuns)
63
72
  }
64
73
 
65
74
  function sanitizeTerminalSessions(value) {
@@ -110,6 +119,7 @@ async function acquireLock(lockFile, { staleMs = LOCK_STALE_MS, timeoutMs = LOCK
110
119
  }
111
120
 
112
121
  export function createRouterState({ file = defaultStatePath(), maxRuns = MAX_RUNS, now = Date.now } = {}) {
122
+ const historyLimit = Number.isInteger(maxRuns) && maxRuns > 0 ? Math.min(maxRuns, MAX_RUNS) : MAX_RUNS
113
123
  let cache = null
114
124
  let cacheKey = ''
115
125
  let chain = Promise.resolve()
@@ -127,7 +137,7 @@ export function createRouterState({ file = defaultStatePath(), maxRuns = MAX_RUN
127
137
  if (error?.code === 'ENOENT') return { state: emptyState(), corrupt: false }
128
138
  throw error
129
139
  }
130
- try { return { state: sanitize(JSON.parse(raw)), corrupt: false } }
140
+ try { return { state: sanitize(JSON.parse(raw), historyLimit), corrupt: false } }
131
141
  catch {
132
142
  const at = now()
133
143
  const backup = `${file}.corrupt-${stamp(at)}`
@@ -171,7 +181,7 @@ export function createRouterState({ file = defaultStatePath(), maxRuns = MAX_RUN
171
181
  const state = await load()
172
182
  if (pendingNotices.length) { state.notices.push(...pendingNotices); pendingNotices = [] }
173
183
  const result = await change(state)
174
- state.runs = state.runs.slice(-maxRuns)
184
+ retainRuns(state, historyLimit)
175
185
  state.notices = sanitizeNotices(state.notices)
176
186
  state.terminalSessions = sanitizeTerminalSessions(state.terminalSessions)
177
187
  await persist(state)
@@ -63,34 +63,94 @@ const pad = value => String(value).padStart(2, '0')
63
63
  export const dayKey = at => { const date = new Date(at); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` }
64
64
  export const monthKey = at => { const date = new Date(at); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}` }
65
65
 
66
- /** Recorded spending for today and this month, plus packages whose cost is unknown. */
67
- export function spending(runs, at = Date.now()) {
68
- const today = dayKey(at)
69
- const month = monthKey(at)
70
- const total = { today: 0, month: 0, unknownToday: 0, unknownMonth: 0, subscriptionToday: 0, subscriptionMonth: 0, subscriptionRunsToday: 0, subscriptionRunsMonth: 0 }
66
+ /** Detailed history can be evicted without losing the current budget totals. */
67
+ export const MAX_SPENDING_DAYS = 90
68
+ export const MAX_SPENDING_MONTHS = 24
69
+ const emptyPeriodSpending = () => ({ costUsd: 0, unknown: 0, subscriptionUsd: 0, subscriptionRuns: 0 })
70
+
71
+ function periodSpending(value) {
72
+ const amount = field => finite(value?.[field]) && value[field] >= 0 ? value[field] : 0
73
+ return { costUsd: amount('costUsd'), unknown: Math.floor(amount('unknown')),
74
+ subscriptionUsd: amount('subscriptionUsd'), subscriptionRuns: Math.floor(amount('subscriptionRuns')) }
75
+ }
76
+
77
+ /** Bounded calendar aggregates contain amounts and counts, never tasks or answers. */
78
+ export function sanitizeArchivedSpending(value) {
79
+ const periods = (entries, pattern, limit) => Object.fromEntries(Object.entries(entries && typeof entries === 'object' && !Array.isArray(entries) ? entries : {})
80
+ .filter(([key, entry]) => pattern.test(key) && entry && typeof entry === 'object' && !Array.isArray(entry))
81
+ .sort(([left], [right]) => left.localeCompare(right)).slice(-limit)
82
+ .map(([key, entry]) => [key, periodSpending(entry)]))
83
+ return {
84
+ days: periods(value?.days, /^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/, MAX_SPENDING_DAYS),
85
+ months: periods(value?.months, /^\d{4}-(?:0[1-9]|1[0-2])$/, MAX_SPENDING_MONTHS),
86
+ }
87
+ }
88
+
89
+ function addArchivedSpending(target, source) {
90
+ const archive = sanitizeArchivedSpending(source)
91
+ for (const period of ['days', 'months']) {
92
+ for (const [key, amount] of Object.entries(archive[period])) {
93
+ const total = target[period][key] ??= emptyPeriodSpending()
94
+ for (const field of Object.keys(amount)) total[field] += amount[field]
95
+ }
96
+ }
97
+ }
98
+
99
+ function* spendingEntries(runs) {
71
100
  for (const run of Array.isArray(runs) ? runs : []) {
72
- for (const item of [...(run.packages ?? []), ...(run.reviews ?? [])]) {
101
+ for (const item of [...(Array.isArray(run?.packages) ? run.packages : []), ...(Array.isArray(run?.reviews) ? run.reviews : [])]) {
102
+ if (!item || typeof item !== 'object') continue
73
103
  const when = item.finishedAt ?? run.createdAt
74
- if (!finite(when)) continue
75
- const inMonth = monthKey(when) === month
76
- if (!inMonth) continue
77
- const inDay = dayKey(when) === today
104
+ if (!finite(when) || !finite(new Date(when).getTime())) continue
105
+ const amount = emptyPeriodSpending()
78
106
  if (item.billing === 'subscription') {
79
- // API-equivalent reference only; a subscription login is not budget spend.
107
+ // A subscription login is reference cost only, never budget spend.
80
108
  if (item.ran !== true) continue
81
- total.subscriptionRunsMonth += 1
82
- if (inDay) total.subscriptionRunsToday += 1
83
- if (finite(item.referenceCostUsd)) {
84
- total.subscriptionMonth += item.referenceCostUsd
85
- if (inDay) total.subscriptionToday += item.referenceCostUsd
86
- }
87
- } else if (finite(item.costUsd)) {
88
- total.month += item.costUsd
89
- if (inDay) total.today += item.costUsd
90
- } else if (item.ran === true) {
91
- total.unknownMonth += 1
92
- if (inDay) total.unknownToday += 1
93
- }
109
+ amount.subscriptionRuns = 1
110
+ if (finite(item.referenceCostUsd) && item.referenceCostUsd >= 0) amount.subscriptionUsd = item.referenceCostUsd
111
+ } else if (finite(item.costUsd) && item.costUsd >= 0) amount.costUsd = item.costUsd
112
+ else if (item.ran === true) amount.unknown = 1
113
+ else continue
114
+ yield { when, amount }
115
+ }
116
+ }
117
+ }
118
+
119
+ /** Fold only evicted records into the archive; retained records are counted live. */
120
+ export function archiveSpending(runs, archivedSpending) {
121
+ const archive = sanitizeArchivedSpending(archivedSpending)
122
+ for (const run of Array.isArray(runs) ? runs : []) addArchivedSpending(archive, run?.priorAttemptSpending)
123
+ for (const { when, amount } of spendingEntries(runs)) {
124
+ for (const [periods, key] of [[archive.days, dayKey(when)], [archive.months, monthKey(when)]]) {
125
+ const total = periods[key] ??= emptyPeriodSpending()
126
+ for (const field of Object.keys(amount)) total[field] += amount[field]
127
+ }
128
+ }
129
+ return sanitizeArchivedSpending(archive)
130
+ }
131
+
132
+ /** Recorded spending, including evicted history, for the local day and month. */
133
+ export function spending(runs, at = Date.now(), archivedSpending = null) {
134
+ const today = dayKey(at)
135
+ const month = monthKey(at)
136
+ const archive = sanitizeArchivedSpending(archivedSpending)
137
+ for (const run of Array.isArray(runs) ? runs : []) addArchivedSpending(archive, run?.priorAttemptSpending)
138
+ const daily = periodSpending(archive.days[today])
139
+ const monthly = periodSpending(archive.months[month])
140
+ const total = { today: daily.costUsd, month: monthly.costUsd, unknownToday: daily.unknown, unknownMonth: monthly.unknown,
141
+ subscriptionToday: daily.subscriptionUsd, subscriptionMonth: monthly.subscriptionUsd,
142
+ subscriptionRunsToday: daily.subscriptionRuns, subscriptionRunsMonth: monthly.subscriptionRuns }
143
+ for (const { when, amount } of spendingEntries(runs)) {
144
+ if (monthKey(when) !== month) continue
145
+ total.month += amount.costUsd
146
+ total.unknownMonth += amount.unknown
147
+ total.subscriptionMonth += amount.subscriptionUsd
148
+ total.subscriptionRunsMonth += amount.subscriptionRuns
149
+ if (dayKey(when) === today) {
150
+ total.today += amount.costUsd
151
+ total.unknownToday += amount.unknown
152
+ total.subscriptionToday += amount.subscriptionUsd
153
+ total.subscriptionRunsToday += amount.subscriptionRuns
94
154
  }
95
155
  }
96
156
  return total
@@ -248,6 +308,10 @@ export function buildRunRecord({ id, createdAt, task, plan, execution, preset =
248
308
  export function mergeRerun(run, execution, { rerunIds = [], pricingFor = () => null, billingFor = billingOf, finishedAt = Date.now() } = {}) {
249
309
  const results = Array.isArray(execution?.packages) ? execution.packages : []
250
310
  const retried = new Set(rerunIds)
311
+ // Retrying replaces the visible result, but earlier attempts still incurred
312
+ // charges (or unknown usage). Keep their calendar totals before replacement.
313
+ const replaced = run.packages.filter(stored => retried.has(stored.id) && results.some(item => item.id === stored.id))
314
+ run.priorAttemptSpending = archiveSpending([{ createdAt: run.createdAt, packages: replaced }], run.priorAttemptSpending)
251
315
  run.packages = run.packages.map(stored => {
252
316
  const result = results.find(item => item.id === stored.id)
253
317
  if (!result || !retried.has(stored.id)) return stored
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.0 — 2026-10-03
4
+
5
+ - Reorganize the workbench into task/execution, model configuration, official tools, and budget/security pages. Keep panels mounted so drafts, installation polling, and terminal sessions survive navigation; add keyboard tab navigation and panel-width responsive layouts.
6
+ - Show local routing results immediately after task planning, with expandable work-package details and human-readable dependency names.
7
+ - Discard pending execution previews when their inputs change, and confirm only the request that was actually previewed. Invalidate local plans when model profiles or routing settings change externally.
8
+ - Preserve API spending, unknown-call counts, and subscription reference costs when detailed history is pruned or a step is retried. Retain 200 detailed runs plus bounded calendar aggregates; previously discarded costs cannot be recovered.
9
+ - Add `npm run preview:ui`: a localhost-only preview of the actual client with in-memory fixtures, five normal/error/empty/onboarding/delayed scenarios, and no real model calls or terminal processes.
10
+ - Rewrite the English/Chinese README, installation instructions, and workbench guide around the current capabilities, source setup, execution boundaries, and local-data behavior. Include the guide and a clearly labeled simulated UI screenshot in the package.
11
+
12
+ Validation: 220 tests passed, with 3 existing Windows-specific skips; the generated client and packaged file bytes were checked. Browser preview checks cover navigation, narrow panels, validation, draft persistence, and stale-preview rejection. Real vendor accounts, paid CLI calls, and Harness Desktop end-to-end execution were not exercised in this release's validation.
13
+
3
14
  ## 0.14.0 — 2026-10-03
4
15
 
5
16
  Stable release under the npm `latest` tag. It folds in the 0.14.0-beta.1 – beta.4 prereleases (published under `next` only). **After updating, quit Harness completely (including the tray icon) and start it again**: Harness serves the new workbench UI at once, but the running Host keeps the plugin code it already imported, so the new Host methods are missing until a full restart. The workbench now warns when the Host and client versions differ.
@@ -2,17 +2,19 @@
2
2
 
3
3
  模型路由从 **0.12.0** 起独立发布;GAL 是新的 **0.1.0** 插件。两者均声明支持官方 **DeepSeek Harness Desktop 0.2.0-rc.1 / 0.2.0-rc.2**,可分别安装和更新。
4
4
 
5
+ 当前路由版本为 **0.15.0**。本版包括四页工作台、规划后直接展示路由建议、执行输入变化后丢弃旧预览,以及历史裁剪和重试后的费用累计保留。源码开发可使用独立模拟 UI 预览;安装时选择下方精确版本。
6
+
5
7
  [路由 README](README.zh.md) · [迁移说明](MIGRATION.md) · [独立 GAL README](https://github.com/Alice-Marx/deepseek-harness-galgame#readme)
6
8
 
7
9
  ## 1. 选择需要的插件
8
10
 
9
11
  | 需求 | 添加插件输入框 | 安装后的入口 |
10
12
  | --- | --- | --- |
11
- | 分析复杂度、按质量和费用选模型、调用官方工具 | `@ljwei-stak/dsh-model-router@0.14.0` | 模型路由 |
13
+ | 分析复杂度、按质量和费用选模型、调用官方工具 | `@ljwei-stak/dsh-model-router@0.15.0` | 模型路由 |
12
14
  | 剧情、立绘、音乐、存档、自由对话 | `@ljwei-stak/dsh-galgame@0.1.0` | Gal 模块 |
13
15
  | 两者都要 | 分别安装上面两个包 | 模型路由 + Gal 模块 |
14
16
 
15
- 路由保留旧 npm 包名供原用户升级,包名中的 `galgame` 是历史名称;**路由 0.12.0 不含 GAL 入口或剧情美术**。GAL 不依赖路由,路由也不依赖 GAL。
17
+ 旧 npm 包名 `@ljwei-stak/model-router-galgame` 最后版本为 0.13.0,之后不再更新;新安装使用 `@ljwei-stak/dsh-model-router`。自 0.12.0 起路由不含 GAL 入口或剧情美术。GAL 不依赖路由,路由也不依赖 GAL。
16
18
 
17
19
  路由正式版发布在 npm **`latest`** 标签,预发布版在 `next`。推荐填写精确版本。不要省略版本号:`latest` 或镜像状态可能选择与当前宿主不兼容的旧包。
18
20
 
@@ -22,7 +24,7 @@
22
24
  2. 在“包名、GitHub 仓库地址或本地目录路径”输入框填一个完整包名,例如:
23
25
 
24
26
  ```text
25
- @ljwei-stak/dsh-model-router@0.14.0
27
+ @ljwei-stak/dsh-model-router@0.15.0
26
28
  ```
27
29
 
28
30
  只想安装 GAL 时填写:
@@ -37,8 +39,8 @@
37
39
  https://registry.npmjs.org/
38
40
  ```
39
41
 
40
- 4. 核对预览的包名、版本和宿主兼容信息,安装并启用。有重启提示时重启桌面程序。
41
- 5. 检查插件详情与侧边栏:路由应为 **0.14.0 / 模型路由**;GAL 应为 **0.1.0 / Gal 模块**。仅装路由时没有 GAL 入口是预期行为。
42
+ 4. 核对预览的包名、版本和宿主兼容信息,安装并启用。完全退出 Harness(包括托盘进程)再启动,确保 Host 加载新代码。
43
+ 5. 检查插件详情与侧边栏:路由应为 **0.15.0 / 模型路由**;GAL 应为 **0.1.0 / Gal 模块**。仅装路由时没有 GAL 入口是预期行为。
42
44
 
43
45
  普通用户无需 `npm install -g`,该命令不会把插件注册到当前 Harness profile。
44
46
 
@@ -46,13 +48,13 @@
46
48
 
47
49
  进入对应 Release 下载 `.tgz`:
48
50
 
49
- - [模型路由 Releases](https://github.com/Alice-Marx/dsh-model-router/releases)(也可运行 `npm pack @ljwei-stak/dsh-model-router@0.14.0` 获取同一安装包)
51
+ - [模型路由 Releases](https://github.com/Alice-Marx/dsh-model-router/releases)(也可运行 `npm pack @ljwei-stak/dsh-model-router@0.15.0` 获取同一安装包)
50
52
  - [独立 GAL v0.1.0](https://github.com/Alice-Marx/deepseek-harness-galgame/releases/tag/v0.1.0)
51
53
 
52
54
  在“添加插件”填写下载文件的**绝对路径**,例如:
53
55
 
54
56
  ```text
55
- D:\Plugins\ljwei-stak-dsh-model-router-0.14.0.tgz
57
+ D:\Plugins\ljwei-stak-dsh-model-router-0.15.0.tgz
56
58
  ```
57
59
 
58
60
  ```text
@@ -62,7 +64,7 @@ D:\Plugins\ljwei-stak-dsh-galgame-0.1.0.tgz
62
64
  如附有 `.sha256` 文件,用 PowerShell 计算并比较摘要:
63
65
 
64
66
  ```powershell
65
- Get-FileHash -Algorithm SHA256 -LiteralPath 'D:\Plugins\ljwei-stak-dsh-model-router-0.14.0.tgz'
67
+ Get-FileHash -Algorithm SHA256 -LiteralPath 'D:\Plugins\ljwei-stak-dsh-model-router-0.15.0.tgz'
66
68
  Get-FileHash -Algorithm SHA256 -LiteralPath 'D:\Plugins\ljwei-stak-dsh-galgame-0.1.0.tgz'
67
69
  ```
68
70
 
@@ -72,7 +74,7 @@ Get-FileHash -Algorithm SHA256 -LiteralPath 'D:\Plugins\ljwei-stak-dsh-galgame-0
72
74
 
73
75
  1. 在旧合并插件的 GAL 播放器导出需要保留的剧情进度,并备份整个 Harness profile。每份 JSON 仅保存**当前剧情状态**,不含所有手动槽、设置、已读记录或本地音乐;重要槽位可依次读档再导出。
74
76
  2. 在**同一个 profile**安装独立 `@ljwei-stak/dsh-galgame@0.1.0`。
75
- 3. 0.13.0 起包名改为 `@ljwei-stak/dsh-model-router`(旧包名最后版本为 0.13.0,内容相同,之后不再更新):在插件管理器卸载原 `@ljwei-stak/model-router-galgame`(保留 profile 与应用数据),再添加 `@ljwei-stak/dsh-model-router@0.14.0`。不要两个同时安装(条目 id 与工具名相同);条目 id 未变,路由设置与执行记录会保留。完成两项更新后再玩剧情,避免同时操作旧合并入口和新入口。
77
+ 3. 0.13.0 起包名改为 `@ljwei-stak/dsh-model-router`(旧包名最后版本为 0.13.0,内容相同,之后不再更新):在插件管理器卸载原 `@ljwei-stak/model-router-galgame`(保留 profile 与应用数据),再添加 `@ljwei-stak/dsh-model-router@0.15.0`。不要两个同时安装(条目 id 与工具名相同);条目 id 未变,路由设置与执行记录会保留。完成两项更新后再玩剧情,避免同时操作旧合并入口和新入口。
76
78
  4. 现在路由与 GAL 各有一个入口。独立 GAL 沿用旧存档 localStorage 键,核对当前进度和三槽;更换 profile 或进度未恢复时,先切换到匹配剧目,再导入 JSON 备份。
77
79
  5. 后续分别更新需要的插件;只用路由可以不启用或不安装 GAL。保留 profile 与应用数据,卸载插件时不要清理故事数据。
78
80
 
@@ -80,18 +82,22 @@ GAL 核心只包含**《回声之城·正篇》**与**《旧城迁移篇:未
80
82
 
81
83
  ## 5. 模型路由第一次使用
82
84
 
83
- 1. 在 Harness 官方**模型**页配置供应商、模型与凭据。插件只读取准确的 `provider/model` 目录,不另存 API Key。
84
- 2. 打开**模型路由 → 逐模型价格与能力**,逐条填写自报质量评分、核对过的输入/输出单价(USD / 百万 token)、擅长方向;缺价留空。厂商 CLI 模型名 `cliModel` 可选,必须是该 CLI 接受的名字。
85
- 3. 在**任务规划**输入目标、步骤与验收标准。单任务给一项建议,团队分工把复合任务拆成有依赖的工作包。预算是本地估价目标,0 表示不设估价约束,不限制实际厂商账单。
86
- 4. 点**生成路由建议**,向下滚过模型档案编辑区,查看**路由建议**和各工作包。规划不启动模型。
87
- 5. 在**官方工具**卡片检测、安装或修复可信执行入口;ZCode 使用原厂安装窗口,用户选择安装目录后重新检测。就绪只证明本地入口通过检查。
88
- 6. 实际执行时进入官方会话、选工作区,明确请求 `model_router_consult`、`model_router_tool_run` 或 `model_router_team_execute`。可编辑运行需要干净 Git 仓库与宿主工具审批。
85
+ 0.15.0 工作台分成**任务与执行、模型配置、官方工具、预算与安全**四页。切换页面保留安装任务、终端会话和未完成的输入。
86
+
87
+ 1. 在 Harness 官方**模型**页配置供应商、模型与凭据。插件读取准确的 `provider/model` 目录,模型档案不保存 API Key。
88
+ 2. 在**模型配置**刷新目录,逐条填写自报质量评分、核对过的输入/输出单价(USD / 百万 token)和擅长方向;缺价留空。可选 `cliModel` 必须是该厂商 CLI 已验证接受的名字。
89
+ 3. 在**任务与执行 → 任务规划**输入目标、步骤和验收标准。单任务推荐一条路线,团队模式为足够复杂的任务拆依赖工作包,指定模型跳过其他路线比较。预算是本地估价目标,`0` 表示不设本次估价约束,不限制厂商账单。
90
+ 4. 点**生成路由建议**。建议直接显示在规划之后;生成建议在本机完成,不启动模型或 CLI。
91
+ 5. 需要真实只读执行时,使用**在工作台执行**,填写存在的工作区绝对路径(留空沿用上次运行)。先**预览执行计划**、核对路线/费用和全部确认原因,再明确确认执行。宿主启动前会重查;确认后才发生真实调用,结果写入执行记录。
92
+ 6. 在**官方工具**检测、安装/更新工具并核对入口与登录。ZCode 打开原厂安装窗口后仍需用户选择目录并完成安装,再体检。可信入口就绪不等于账号和模型有权限。
93
+ 7. 在**预算与安全**查看每日/月度预算、订阅失败策略及可读/写边界。官方工具终端不经过 Harness 沙箱,继承用户登录环境,需单独确认启动。
94
+ 8. 需要 CLI 修改文件时进入官方会话、选择工作区,明确请求 `model_router_tool_run` 或 `model_router_team_execute` 的 `workspace-write` 模式。可编辑托管执行要求干净 Git 仓库、宿主审批和进程沙箱。`model_router_execute` 用于只读路由执行,`model_router_consult` 用于模型 API 意见。
89
95
 
90
- 完整页面教程、参数和结果位置见[工作台使用指南](docs/WORKBENCH_USER_GUIDE.zh.md),算法推导见[README](README.zh.md#路由算法从输入到分配)。
96
+ 完整教程见[工作台使用指南](docs/WORKBENCH_USER_GUIDE.zh.md);路线算法、订阅与数据保存说明见[README](README.zh.md)。
91
97
 
92
- Claude、Codex、MiMo、Grok 支持只读或可编辑;Kimi、MiniMax、ZCode 无界面模式仅在获批的隔离可编辑工作区运行。CLI 模型名可能不同于 Harness 目录;多数 CLI 不提供可核验的实际模型 ID,要看厂商记录与账单。
98
+ 注册表包含 Kimi、Claude、Codex、MiniMax、MiMo、Grok、Gemini 和 ZCode。Claude、Codex、MiMo、Grok 支持单工具/团队只读或可编辑执行;Kimi、MiniMax、ZCode 仅支持获批的可编辑托管执行。Gemini 有路由无界面适配器,不进入单工具/团队托管执行器。详见[官方工具与托管模式](README.zh.md#官方工具与托管模式)。CLI 模型名可能不同于 Harness 目录;多数 CLI 不提供可核验的实际模型 ID,要看厂商记录与账单。
93
99
 
94
- 希望官方 CLI 尽量装在非 C 盘时,先按自己的空间规划配置 npm 全局前缀。MiniMax Windows npm 原生依赖失败时使用锁定哈希的官方安装脚本兜底,内容变化会拒绝执行;Grok 非 C 盘 npm 安装的数据目录与登录须使用一致的 `GROK_HOME`。
100
+ 安装使用固定官方 npm 包的 `@latest` 或 ZCode 签名安装器。希望 npm 工具安装在非 C 盘时,先按自己的空间规划配置全局前缀;Grok 数据目录与登录应使用一致的 `GROK_HOME`。MiniMax Windows 原生依赖失败时,从固定官方 HTTPS 来源下载最新版安装脚本,仅检查大小和基本内容结构后原样执行,**没有锁定该安装脚本的哈希**。安装后的 MiniMax CLI 代码另按已安装版本的官方 npm tarball 摘要核验。MiMo/Grok 同样使用官方 npm 内容证明;适用的 Claude/Codex/ZCode Windows 入口验证发布者签名,ZCode 内置脚本还有每个签名构建的首次信任摘要检查。具体规则见[执行器](.dsh-plugin/shared/official-tool-executor.mjs)和[npm 内容核验](.dsh-plugin/shared/npm-attestation.mjs)。
95
101
 
96
102
  ## 6. GAL 第一次使用
97
103
 
@@ -104,31 +110,33 @@ Claude、Codex、MiMo、Grok 支持只读或可编辑;Kimi、MiniMax、ZCode
104
110
  | 检查 | 预期 |
105
111
  | --- | --- |
106
112
  | 宿主版本 | 0.2.0-rc.1 或 0.2.0-rc.2;包括 `rc` 后缀。 |
107
- | 路由独立安装 | 0.14.0 详情、模型路由入口、目录和模型档案可用;不出现旧合并 GAL 入口。 |
113
+ | 路由独立安装 | 0.15.0 详情、模型路由入口、目录和模型档案可用;不出现旧合并 GAL 入口。 |
108
114
  | GAL 独立安装 | 0.1.0 详情、Gal 模块入口、标题与两部核心剧情可用;不要求安装路由。 |
109
115
  | 共同安装 | 两个入口各一份,路由工具和 GAL 播放器均能使用。 |
110
116
  | 存档迁移 | 同 profile 可读旧键;跨 profile 用匹配剧目 JSON 导入;旧篇目的数据不覆盖。 |
111
117
  | 本地规划 | 简单与复杂工作包分配可解释;价格未知保持未知;生成计划不调用模型。 |
112
118
  | 官方工具 | 探测、固定来源安装、修复、取消和日志可检查;登录另验。 |
113
- | 真实执行 | 在测试仓库核对审批、隔离工作区、补丁整合、厂商实际模型与计费。 |
119
+ | 工作台只读执行 | 预览不启动模型,明确确认后执行;核对记录中的渠道、错误与费用。 |
120
+ | 可编辑执行 | 在干净测试仓库核对审批、隔离工作区、补丁整合、厂商实际模型与计费。 |
114
121
 
115
- 构建、自动测试、包内容和发布校验由本次总项目报告记录。真实账号调用与用户现用 profile 的验收仍需账号持有人进行。
122
+ 0.15.0 的自动测试验证为 220 项通过、3 项 Windows 平台跳过;组件 UI 预览检查使用模拟数据。本次验证未覆盖真实 CLI/账号调用或 Harness Desktop 端到端操作,上表中的安装后验收仍需在实际环境中完成。
116
123
 
117
124
  ## 8. 开发者从完整源码构建
118
125
 
119
- 路由仓库检出 `v0.14.0`;GAL 仓库检出 `v0.1.0`。使用 Node.js **22.19+** 与 pnpm,各自在根目录运行:
126
+ 路由需要 Node.js **22.19+** 与 **pnpm 10.34.6**。从完整源码仓库根目录构建;复现 0.15.0 时检出对应发布标签,旧标签保留其当时内容。运行:
120
127
 
121
128
  ```powershell
122
- pnpm install --frozen-lockfile
129
+ pnpm install --frozen-lockfile --strict-peer-dependencies
123
130
  npm run build:client
124
131
  npm test
125
132
  npm run check:client
126
- pnpm peers check
127
- npm pack --pack-destination dist
133
+ npm run pack:local
128
134
  ```
129
135
 
130
- 两项目的生成包互相独立。客户端有修改时必须重建,再将包绝对路径填入插件管理器验证。
136
+ pnpm 10 没有 `pnpm peers check`;使用严格依赖安装。客户端修改后必须重建,通过 `check:client` 再打包。将 `dist` 中本地包的绝对路径填入插件管理器,在测试 profile 验证;本地开发改动需独立验证,不能仅凭版本号判断包内容相同。
137
+
138
+ 仅查看 UI 可运行 `npm run preview:ui` 并打开[本地预览](http://127.0.0.1:4173)。这是模拟目录、工具和执行记录,不启动 Harness Host、真实 CLI 或付费模型;不能代替桌面集成验收。GAL 独立仓库的开发与验证步骤以其自身 README 为准。
131
139
 
132
140
  ## 历史兼容提醒
133
141
 
134
- 合并版 **0.11.1 / 0.11.0** 声明 rc.1 与 rc.2;**0.10.2** 是未发布的本地兼容修复。已发布 **0.10.1** 仅声明 rc.1,rc.2 拒绝安装是 peer 依赖问题,换镜像不能修复;**0.8.0** 使用 0.1.7-rc.2 依赖,不用于当前宿主。历史 Git 标签和安装包保留,详见 [README 版本表](README.zh.md#历史版本)。
142
+ 合并版 **0.11.1 / 0.11.0** 声明 rc.1 与 rc.2;**0.10.2** 是未发布的本地兼容修复。已发布 **0.10.1** 仅声明 rc.1,rc.2 拒绝安装是 peer 依赖问题,换镜像不能修复;**0.8.0** 使用 0.1.7-rc.2 依赖,不用于当前宿主。历史 Git 标签和安装包保留,详见[更新记录](CHANGELOG.md)和[迁移说明](MIGRATION.md)。