dsh-plugin-effort-declare 0.1.2 → 0.1.3

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.
@@ -1 +1 @@
1
- {"version":3,"file":"load-drafts.d.ts","sourceRoot":"","sources":["../../../src/client/load-drafts.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,KAAK,EAAE,UAAU,EAAyB,MAAM,qCAAqC,CAAA;AAC5F,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,4CAA4C,CAAA;AAKtF,OAAO,EAA6B,KAAK,UAAU,EAAE,MAAM,mBAAmB,CAAA;AAG9E,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAOhD,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,OAAO,CAAA;IACjB,OAAO,EAAE,MAAM,EAAE,CAAA;IACjB,MAAM,EAAE,UAAU,EAAE,CAAA;IACpB,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED;;;;;;GAMG;AACH,wBAAsB,UAAU,CAC9B,GAAG,EAAE,IAAI,CAAC,UAAU,EAAE,KAAK,CAAC,EAC5B,QAAQ,EAAE,IAAI,CAAC,oBAAoB,EAAE,QAAQ,GAAG,aAAa,CAAC,EAC9D,MAAM,EAAE,SAAS,GAChB,OAAO,CAAC,gBAAgB,CAAC,CA8C3B"}
1
+ {"version":3,"file":"load-drafts.d.ts","sourceRoot":"","sources":["../../../src/client/load-drafts.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,KAAK,EAAE,UAAU,EAAyB,MAAM,qCAAqC,CAAA;AAC5F,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,4CAA4C,CAAA;AAKtF,OAAO,EAA6B,KAAK,UAAU,EAAE,MAAM,mBAAmB,CAAA;AAG9E,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAOhD,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,OAAO,CAAA;IACjB,OAAO,EAAE,MAAM,EAAE,CAAA;IACjB,MAAM,EAAE,UAAU,EAAE,CAAA;IACpB,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,UAAU,CAAA;AAElD,KAAK,cAAc,GAAG,UAAU,CAAC,oBAAoB,CAAC,aAAa,CAAC,CAAC,CAAA;AACrE,KAAK,cAAc,GAAG,IAAI,CAAC,oBAAoB,EAAE,aAAa,GAAG,WAAW,CAAC,CAAA;AAE7E,qEAAqE;AACrE,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,cAAc,EAAE,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAE1F;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAEvF;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,CAAC,SAAS;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,EAC1D,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,EAC1B,IAAI,EAAE;IACJ,UAAU,EAAE,SAAS,MAAM,EAAE,CAAA;IAC7B,cAAc,EAAE,CAAC,CAAA;IACjB,aAAa,EAAE,SAAS,MAAM,EAAE,CAAA;CACjC,GACA,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,CAcnB;AA4BD,qFAAqF;AACrF,wBAAsB,wBAAwB,CAC5C,QAAQ,EAAE,cAAc,EACxB,EAAE,EAAE,MAAM,EACV,QAAQ,EAAE,MAAM,EAChB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC,CAUhC;AAED,6EAA6E;AAC7E,wBAAsB,8BAA8B,CAClD,QAAQ,EAAE,cAAc,EACxB,EAAE,EAAE,MAAM,EACV,QAAQ,EAAE,MAAM,EAChB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC,CAMhC;AAqDD;;;GAGG;AACH,wBAAsB,UAAU,CAC9B,GAAG,EAAE,IAAI,CAAC,UAAU,EAAE,KAAK,CAAC,EAC5B,QAAQ,EAAE,IAAI,CAAC,oBAAoB,EAAE,QAAQ,GAAG,aAAa,CAAC,EAC9D,MAAM,EAAE,SAAS,EACjB,IAAI,GAAE,cAAyB,GAC9B,OAAO,CAAC,gBAAgB,CAAC,CAG3B"}
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Plugin footer attribution. Version and end-year are frozen into the client
3
+ * bundle at pack time; this module only formats the line.
4
+ */
5
+ /** First publication year (LICENSE). Not the user's wall clock. */
6
+ export declare const COPYRIGHT_FROM = 2026;
7
+ export declare const COPYRIGHT_HOLDER = "Stardust";
8
+ /**
9
+ * `0.1.2 © 2026 Stardust` or `0.1.2 © 2026–2027 Stardust`.
10
+ * Throws if version is empty or `to < from` — a bad stamp must not render.
11
+ */
12
+ export declare function formatAttribution(version: string, from: number, to: number): string;
13
+ //# sourceMappingURL=attribution.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attribution.d.ts","sourceRoot":"","sources":["../../../src/core/attribution.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,mEAAmE;AACnE,eAAO,MAAM,cAAc,OAAO,CAAA;AAElC,eAAO,MAAM,gBAAgB,aAAa,CAAA;AAE1C;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,MAAM,CASnF"}
@@ -1 +1 @@
1
- {"version":3,"file":"efforts.d.ts","sourceRoot":"","sources":["../../../src/core/efforts.ts"],"names":[],"mappings":"AAAA,OAAO,EAAmB,KAAK,aAAa,EAAE,MAAM,cAAc,CAAA;AAElE,uEAAuE;AACvE,MAAM,MAAM,gBAAgB,GAAG,OAAO,CAAC,MAAM,CAAC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC,CAAC,CAAA;AAE5E,mEAAmE;AACnE,MAAM,MAAM,OAAO,GAAG,QAAQ,GAAG,OAAO,GAAG,OAAO,CAAA;AAElD,MAAM,MAAM,qBAAqB,GAAG,gBAAgB,GAAG,KAAK,GAAG,SAAS,CAAA;AAExE,kDAAkD;AAClD,wBAAgB,OAAO,CAAC,OAAO,EAAE,gBAAgB,GAAG,SAAS,GAAG;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAM/F;AAED,wEAAwE;AACxE,wBAAgB,QAAQ,CACtB,OAAO,EAAE,gBAAgB,EACzB,IAAI,EAAE,OAAO,EACb,KAAK,EAAE,MAAM,GACZ,gBAAgB,CAMlB;AAED,uEAAuE;AACvE,wBAAgB,QAAQ,CAAC,OAAO,EAAE,gBAAgB,GAAG,SAAS,EAAE,KAAK,EAAE,OAAO,CAAC,aAAa,EAAE,KAAK,CAAC,GAAG,OAAO,CAE7G;AAED,+EAA+E;AAC/E,wBAAgB,WAAW,CACzB,OAAO,EAAE,gBAAgB,EACzB,KAAK,EAAE,OAAO,CAAC,aAAa,EAAE,KAAK,CAAC,EACpC,OAAO,EAAE,OAAO,EAChB,IAAI,CAAC,EAAE,MAAM,GACZ,gBAAgB,CAKlB;AAED,0DAA0D;AAC1D,wBAAgB,eAAe,CAC7B,OAAO,EAAE,gBAAgB,EACzB,KAAK,EAAE,OAAO,CAAC,aAAa,EAAE,KAAK,CAAC,EACpC,IAAI,EAAE,MAAM,GACX,gBAAgB,CAGlB;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,GAAG,UAAU,GAAG,UAAU,GAAG,SAAS,CAwBxG;AAED,sEAAsE;AACtE,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAI3F;AAED,2FAA2F;AAC3F,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,gBAAgB,GAAG,SAAS,CAKtF;AAED,wDAAwD;AACxD,wBAAgB,YAAY,CAC1B,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC5B,OAAO,EAAE,gBAAgB,GAAG,SAAS,GACpC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAQzB"}
1
+ {"version":3,"file":"efforts.d.ts","sourceRoot":"","sources":["../../../src/core/efforts.ts"],"names":[],"mappings":"AAAA,OAAO,EAAmB,KAAK,aAAa,EAAE,MAAM,cAAc,CAAA;AAElE,uEAAuE;AACvE,MAAM,MAAM,gBAAgB,GAAG,OAAO,CAAC,MAAM,CAAC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC,CAAC,CAAA;AAE5E,mEAAmE;AACnE,MAAM,MAAM,OAAO,GAAG,QAAQ,GAAG,OAAO,GAAG,OAAO,CAAA;AAElD,MAAM,MAAM,qBAAqB,GAAG,gBAAgB,GAAG,KAAK,GAAG,SAAS,CAAA;AAExE,kDAAkD;AAClD,wBAAgB,OAAO,CAAC,OAAO,EAAE,gBAAgB,GAAG,SAAS,GAAG;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAM/F;AAED,wEAAwE;AACxE,wBAAgB,QAAQ,CACtB,OAAO,EAAE,gBAAgB,EACzB,IAAI,EAAE,OAAO,EACb,KAAK,EAAE,MAAM,GACZ,gBAAgB,CASlB;AAED,uEAAuE;AACvE,wBAAgB,QAAQ,CAAC,OAAO,EAAE,gBAAgB,GAAG,SAAS,EAAE,KAAK,EAAE,OAAO,CAAC,aAAa,EAAE,KAAK,CAAC,GAAG,OAAO,CAE7G;AAED,+EAA+E;AAC/E,wBAAgB,WAAW,CACzB,OAAO,EAAE,gBAAgB,EACzB,KAAK,EAAE,OAAO,CAAC,aAAa,EAAE,KAAK,CAAC,EACpC,OAAO,EAAE,OAAO,EAChB,IAAI,CAAC,EAAE,MAAM,GACZ,gBAAgB,CAKlB;AAED,0DAA0D;AAC1D,wBAAgB,eAAe,CAC7B,OAAO,EAAE,gBAAgB,EACzB,KAAK,EAAE,OAAO,CAAC,aAAa,EAAE,KAAK,CAAC,EACpC,IAAI,EAAE,MAAM,GACX,gBAAgB,CAGlB;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,GAAG,UAAU,GAAG,UAAU,GAAG,SAAS,CAwBxG;AAED,sEAAsE;AACtE,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAI3F;AAED,2FAA2F;AAC3F,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,gBAAgB,GAAG,SAAS,CAKtF;AAED,wDAAwD;AACxD,wBAAgB,YAAY,CAC1B,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC5B,OAAO,EAAE,gBAAgB,GAAG,SAAS,GACpC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAQzB"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-plugin-effort-declare",
3
3
  "description": "Settings page to declare per-model reasoningEfforts and openai-completions compat on hand-declared llm-pi-ai routes. Writes the official namespace; does not intercept llm/stream or replace the Models page.",
4
- "version": "0.1.2",
4
+ "version": "0.1.3",
5
5
  "private": false,
6
6
  "license": "MIT",
7
7
  "repository": {
package/src/README.en.md CHANGED
@@ -16,7 +16,7 @@ Install: [INSTALL.en.md](../INSTALL.en.md). Usage: root [README.en.md](../README
16
16
  | Path | Role |
17
17
  | --- | --- |
18
18
  | [`index.ts`](./index.ts) | Host entry. Exports `name` and `apply`. v1 registers no host services or adapters; the bundle still needs this entry so `dsh plugin add` can load the package. |
19
- | [`core/`](./core/) | UI-free pure functions: efforts, presets, save ops, draft merge, route filtering. Shared by the client and tests; the v1 host entry does not import them (`tsconfig.host` still compiles `core` for types). |
19
+ | [`core/`](./core/) | UI-free pure functions: efforts, presets, save ops, draft merge, route filtering, footer attribution. Shared by the client and tests; the v1 host entry does not import them (`tsconfig.host` still compiles `core` for types). |
20
20
  | [`client/`](./client/) | Browser half: locale, settings page, `settings.mutate` against `llm-pi-ai`. |
21
21
  | [`css-modules.d.ts`](./css-modules.d.ts) | Types for `*.module.css` (class map plus `cssText` / `cssTagId`). |
22
22
 
package/src/README.md CHANGED
@@ -16,7 +16,7 @@
16
16
  | 路径 | 说明 |
17
17
  | --- | --- |
18
18
  | [`index.ts`](./index.ts) | Host 入口。导出 `name` 与 `apply`。v1 不注册 host 服务、不挂适配器;组合包仍需要这一入口才能被 `dsh plugin add` 加载。 |
19
- | [`core/`](./core/) | 与 UI 无关的纯函数:档位、预设、保存 ops、草稿合并、路由过滤。client 与单测共用;Host 入口 v1 不引用这些函数(`tsconfig.host` 仍编译 `core` 以产出类型)。 |
19
+ | [`core/`](./core/) | 与 UI 无关的纯函数:档位、预设、保存 ops、草稿合并、路由过滤、页脚归因格式。client 与单测共用;Host 入口 v1 不引用这些函数(`tsconfig.host` 仍编译 `core` 以产出类型)。 |
20
20
  | [`client/`](./client/) | 浏览器半区:locale、设置页、对 `llm-pi-ai` 的 `settings.mutate`。 |
21
21
  | [`css-modules.d.ts`](./css-modules.d.ts) | `*.module.css` 模块类型(class map + `cssText` / `cssTagId`)。 |
22
22
 
@@ -30,18 +30,32 @@ import { applyPresetCompat, applyPresetEfforts, PRESETS, type PresetId } from '.
30
30
  import { buildSaveOps } from '../core/path-ops.ts'
31
31
  import { cloneModels, cloneObject } from '../core/paths.ts'
32
32
  import { modelEffortError } from '../core/validate.ts'
33
- import { loadDrafts } from './load-drafts.ts'
33
+ import { PLUGIN_FOOTER_TEXT } from './build-info.ts'
34
+ import {
35
+ foldReloadNotices,
36
+ isOwnDocumentEcho,
37
+ loadDrafts,
38
+ waitForNamespaceRevision,
39
+ waitForNamespaceRevisionChange,
40
+ type LoadDraftsMode,
41
+ } from './load-drafts.ts'
34
42
  import { validateSaveDraft, type SchemaOps } from './schema-ops.ts'
35
43
  import type { EffortDeclareKey } from './locales.ts'
36
44
  import css from './effort-declare.module.css'
37
45
 
38
- export type InvalidationSource = 'settings' | 'directory' | 'writable'
46
+ export type InvalidationSource = 'settings' | 'directory' | 'reset' | 'writable'
47
+
48
+ export type Invalidation =
49
+ | { source: 'writable' }
50
+ | { source: 'directory' }
51
+ | { source: 'reset' }
52
+ | { source: 'settings'; revision: number }
39
53
 
40
54
  export interface EffortDeclareSectionInjected {
41
55
  api: Pick<IApiClient, 'settings' | 'llm'>
42
56
  describe: SettingsDescribeFace
43
57
  schema: SchemaOps
44
- subscribeInvalidate: (listener: (source: InvalidationSource) => void) => () => void
58
+ subscribeInvalidate: (listener: (event: Invalidation) => void) => () => void
45
59
  }
46
60
 
47
61
  export interface EffortDeclareSectionProps extends Partial<EffortDeclareSectionInjected> {
@@ -328,58 +342,133 @@ export function EffortDeclareSection(props: EffortDeclareSectionProps): ReactNod
328
342
  const [notices, setNotices] = useState<Record<string, CardNotice>>({})
329
343
  const generationRef = useRef(0)
330
344
  const draftsRef = useRef(drafts)
345
+ const echoedRevisionRef = useRef<number | undefined>(undefined)
346
+ const pendingRevisionRef = useRef<number | undefined>(undefined)
347
+ const busyRouteRef = useRef<string | null>(null)
348
+ const abortRef = useRef<AbortController | null>(null)
331
349
  draftsRef.current = drafts
332
350
 
333
- const reload = useCallback((preserveDirty: boolean): void => {
351
+ const applyDrafts = (next: RouteDraft[] | ((current: RouteDraft[]) => RouteDraft[])): void => {
352
+ const resolved = typeof next === 'function' ? next(draftsRef.current) : next
353
+ draftsRef.current = resolved
354
+ setDrafts(resolved)
355
+ }
356
+
357
+ const snapshotMode = (): LoadDraftsMode => (
358
+ describe === undefined || describe.getSnapshot().status === 'idle' ? 'ensure' : 'snapshot'
359
+ )
360
+
361
+ const beginGeneration = (): { generation: number; signal: AbortSignal } => {
362
+ abortRef.current?.abort()
363
+ const abort = new AbortController()
364
+ abortRef.current = abort
365
+ return { generation: nextGeneration(generationRef), signal: abort.signal }
366
+ }
367
+
368
+ const failGeneration = (generation: number, failure: unknown): void => {
369
+ if (!generationIsCurrent(generationRef, generation)) return
370
+ setStatus('error')
371
+ setError(failure instanceof Error ? failure.message : t('loadError'))
372
+ }
373
+
374
+ const settleReload = (
375
+ generation: number,
376
+ preserveDirty: boolean,
377
+ result: Awaited<ReturnType<typeof loadDrafts>>,
378
+ ): void => {
379
+ if (!generationIsCurrent(generationRef, generation)) return
380
+ setWritable(result.writable)
381
+ setFormats(result.formats)
382
+ if (result.error !== undefined) {
383
+ setStatus('error')
384
+ setError(result.error)
385
+ return
386
+ }
387
+ const merged = mergeLoadedDrafts(draftsRef.current, result.drafts, { preserveDirty })
388
+ applyDrafts(merged.drafts)
389
+ setNotices(current => foldReloadNotices(current, {
390
+ conflicted: merged.conflicted,
391
+ conflictNotice: { kind: 'conflict', text: t('dirtyConflict') },
392
+ liveProviders: merged.drafts.map(draft => draft.provider),
393
+ }))
394
+ setStatus('ready')
395
+ }
396
+
397
+ const loadSnapshotThenSettle = async (generation: number, preserveDirty: boolean): Promise<void> => {
398
+ if (api === undefined || describe === undefined || schema === undefined) return
399
+ if (!generationIsCurrent(generationRef, generation)) return
400
+ try {
401
+ const result = await loadDrafts(api, describe, schema, 'snapshot')
402
+ settleReload(generation, preserveDirty, result)
403
+ } catch (failure) {
404
+ failGeneration(generation, failure)
405
+ }
406
+ }
407
+
408
+ const reload = useCallback((preserveDirty: boolean, mode: LoadDraftsMode = 'ensure'): void => {
334
409
  if (api === undefined || describe === undefined || schema === undefined) {
335
410
  setStatus('error')
336
411
  setError(t('loadError'))
337
412
  return
338
413
  }
339
- const generation = nextGeneration(generationRef)
414
+ const { generation } = beginGeneration()
340
415
  if (draftsRef.current.length === 0) setStatus('loading')
341
416
  setError('')
342
- void loadDrafts(api, describe, schema).then((result) => {
343
- if (!generationIsCurrent(generationRef, generation)) return
344
- setWritable(result.writable)
345
- setFormats(result.formats)
346
- if (result.error !== undefined) {
347
- setStatus('error')
348
- setError(result.error)
349
- return
350
- }
351
- const merged = mergeLoadedDrafts(draftsRef.current, result.drafts, { preserveDirty })
352
- setDrafts(merged.drafts)
353
- if (merged.conflicted.length > 0) {
354
- setNotices(current => {
355
- const next = { ...current }
356
- for (const provider of merged.conflicted) {
357
- next[provider] = { kind: 'conflict', text: t('dirtyConflict') }
358
- }
359
- return next
360
- })
361
- }
362
- setStatus('ready')
417
+ void loadDrafts(api, describe, schema, mode).then((result) => {
418
+ settleReload(generation, preserveDirty, result)
363
419
  }, (failure: unknown) => {
364
- if (!generationIsCurrent(generationRef, generation)) return
365
- setStatus('error')
366
- setError(failure instanceof Error ? failure.message : t('loadError'))
420
+ failGeneration(generation, failure)
421
+ })
422
+ }, [api, describe, schema, t])
423
+
424
+ const refreshAtRevision = useCallback((revision: number, preserveDirty: boolean): void => {
425
+ if (api === undefined || describe === undefined || schema === undefined) return
426
+ const { generation, signal } = beginGeneration()
427
+ if (draftsRef.current.length === 0) setStatus('loading')
428
+ setError('')
429
+ void waitForNamespaceRevision(describe, LLM_PI_AI_NS, revision, signal).then((outcome) => {
430
+ if (outcome === 'aborted' || !generationIsCurrent(generationRef, generation)) return
431
+ return loadSnapshotThenSettle(generation, preserveDirty)
432
+ }, (failure: unknown) => {
433
+ failGeneration(generation, failure)
367
434
  })
368
435
  }, [api, describe, schema, t])
369
436
 
370
- useEffect(() => { reload(false) }, [reload])
437
+ const flushPendingSettings = (refresh: (revision: number, preserveDirty: boolean) => void): void => {
438
+ const pending = pendingRevisionRef.current
439
+ pendingRevisionRef.current = undefined
440
+ if (pending === undefined) return
441
+ if (isOwnDocumentEcho(echoedRevisionRef.current, pending)) return
442
+ refresh(pending, true)
443
+ }
444
+
445
+ useEffect(() => { reload(false, 'ensure') }, [reload])
446
+ useEffect(() => () => {
447
+ abortRef.current?.abort()
448
+ nextGeneration(generationRef)
449
+ }, [])
371
450
 
372
451
  useEffect(() => {
373
452
  if (props.subscribeInvalidate === undefined) return undefined
374
- return props.subscribeInvalidate((source) => {
375
- if (source === 'writable') {
453
+ return props.subscribeInvalidate((event) => {
454
+ if (event.source === 'writable') {
376
455
  const view = describe?.getSnapshot().view
377
456
  if (view !== undefined) setWritable(view.writable)
378
457
  return
379
458
  }
380
- if (source === 'settings' || source === 'directory') reload(true)
459
+ if (event.source === 'settings') {
460
+ if (busyRouteRef.current !== null) {
461
+ pendingRevisionRef.current = event.revision
462
+ return
463
+ }
464
+ if (isOwnDocumentEcho(echoedRevisionRef.current, event.revision)) return
465
+ refreshAtRevision(event.revision, true)
466
+ return
467
+ }
468
+ if (event.source === 'directory') reload(true, snapshotMode())
469
+ if (event.source === 'reset') reload(true, 'ensure')
381
470
  })
382
- }, [describe, props.subscribeInvalidate, reload])
471
+ }, [describe, props.subscribeInvalidate, refreshAtRevision, reload])
383
472
 
384
473
  const patchNotice = (provider: string, notice: CardNotice | undefined): void => {
385
474
  setNotices(current => {
@@ -392,7 +481,7 @@ export function EffortDeclareSection(props: EffortDeclareSectionProps): ReactNod
392
481
 
393
482
  const save = async (draft: RouteDraft): Promise<void> => {
394
483
  if (api === undefined || describe === undefined || schema === undefined) return
395
- if (status === 'loading' || busyRoute !== null) {
484
+ if (status === 'loading' || busyRouteRef.current !== null) {
396
485
  patchNotice(draft.provider, { kind: 'error', text: t('saveBusy') })
397
486
  return
398
487
  }
@@ -403,6 +492,7 @@ export function EffortDeclareSection(props: EffortDeclareSectionProps): ReactNod
403
492
  patchNotice(draft.provider, { kind: 'error', text: blocking })
404
493
  return
405
494
  }
495
+ busyRouteRef.current = draft.provider
406
496
  setBusyRoute(draft.provider)
407
497
  patchNotice(draft.provider, undefined)
408
498
  try {
@@ -414,7 +504,7 @@ export function EffortDeclareSection(props: EffortDeclareSectionProps): ReactNod
414
504
  afterCompat: draft.compat,
415
505
  })
416
506
  if (ops.length === 0) {
417
- setDrafts(current => current.map(row => row.provider === draft.provider ? alignDraft(row) : row))
507
+ applyDrafts(current => current.map(row => row.provider === draft.provider ? alignDraft(row) : row))
418
508
  return
419
509
  }
420
510
  const willWriteCompat = ops.some(op => (
@@ -454,12 +544,21 @@ export function EffortDeclareSection(props: EffortDeclareSectionProps): ReactNod
454
544
  kind: conflict ? 'conflict' : 'error',
455
545
  text: conflict ? t('conflict') : response.result.error.message,
456
546
  })
457
- if (conflict) reload(true)
547
+ if (conflict) {
548
+ const { generation, signal } = beginGeneration()
549
+ void waitForNamespaceRevisionChange(describe, LLM_PI_AI_NS, draft.revision, signal).then((outcome) => {
550
+ if (outcome === 'aborted' || !generationIsCurrent(generationRef, generation)) return
551
+ return loadSnapshotThenSettle(generation, true)
552
+ }, (failure: unknown) => {
553
+ failGeneration(generation, failure)
554
+ })
555
+ }
458
556
  return
459
557
  }
460
558
  const view = response.result.value
559
+ echoedRevisionRef.current = view.revision
461
560
  describe.acceptView(view)
462
- setDrafts(current => applySaveSuccess(current, draft.provider, {
561
+ applyDrafts(applySaveSuccess(draftsRef.current, draft.provider, {
463
562
  user: view.user ?? {},
464
563
  revision: view.revision,
465
564
  }))
@@ -470,7 +569,9 @@ export function EffortDeclareSection(props: EffortDeclareSectionProps): ReactNod
470
569
  text: failure instanceof Error ? failure.message : t('loadError'),
471
570
  })
472
571
  } finally {
572
+ busyRouteRef.current = null
473
573
  setBusyRoute(null)
574
+ flushPendingSettings(refreshAtRevision)
474
575
  }
475
576
  }
476
577
 
@@ -491,7 +592,7 @@ export function EffortDeclareSection(props: EffortDeclareSectionProps): ReactNod
491
592
  {status === 'error' ? <p className={css.error}>{error}</p> : null}
492
593
  {showReload
493
594
  ? (
494
- <button type="button" className={css.secondaryButton} onClick={() => { reload(true) }}>{t('reload')}</button>
595
+ <button type="button" className={css.secondaryButton} onClick={() => { reload(true, snapshotMode()) }}>{t('reload')}</button>
495
596
  )
496
597
  : null}
497
598
  {showEmpty
@@ -517,12 +618,12 @@ export function EffortDeclareSection(props: EffortDeclareSectionProps): ReactNod
517
618
  t={t}
518
619
  onChange={(next) => {
519
620
  patchNotice(next.provider, undefined)
520
- setDrafts(current => current.map(row => row.provider === next.provider ? next : row))
621
+ applyDrafts(current => current.map(row => row.provider === next.provider ? next : row))
521
622
  }}
522
623
  onSave={(next) => { void save(next) }}
523
624
  onCancel={(next) => {
524
625
  patchNotice(next.provider, undefined)
525
- setDrafts(current => current.map(row => row.provider === next.provider
626
+ applyDrafts(current => current.map(row => row.provider === next.provider
526
627
  ? {
527
628
  ...row,
528
629
  models: cloneModels(row.originalModels),
@@ -535,6 +636,7 @@ export function EffortDeclareSection(props: EffortDeclareSectionProps): ReactNod
535
636
  </ul>
536
637
  )
537
638
  : null}
639
+ <p className={css.footer}>{PLUGIN_FOOTER_TEXT}</p>
538
640
  </div>
539
641
  )
540
642
  }
@@ -17,9 +17,11 @@ Cross-plugin work uses Cordis services only (`connection`, `settingsScope`, `set
17
17
 
18
18
  | File | Role |
19
19
  | --- | --- |
20
- | [`index.ts`](./index.ts) | Registers zh/en copy, CSS (`ctx.effect` insert/remove), and `settings.section` (id `effort-declare`, order 12). `describe.subscribe` only syncs `writable` (it fires for every namespace, including this page’s own `acceptView`). Full reloads come from `settings/document-updated` (`llm-pi-ai` only), `llm/adapters-updated`, and `connection/reset`. `locale: NS` lets the framework inject `t`; `inject` only returns `api` / `describe` / `schema` / `subscribeInvalidate`. |
21
- | [`EffortDeclareSection.tsx`](./EffortDeclareSection.tsx) | Route cards, presets, Off tri-state, advanced protocol switches, save and cancel. Save is exclusive for the whole `llm-pi-ai` namespace; `busy` is “Saving…” on the in-flight card, `saveLocked` disables the others. A successful write folds `acceptView` + `applySaveSuccess` and does not full-reload. |
22
- | [`load-drafts.ts`](./load-drafts.ts) | Builds drafts from `llm.providers` + the settings mirror; drafts come from `user`, protocol classification may use `value`. `thinkingFormat` choices come only from the live schema union. |
20
+ | [`index.ts`](./index.ts) | Registers zh/en copy, CSS (`ctx.effect` insert/remove), and `settings.section` (id `effort-declare`, order 12). `describe.subscribe` only syncs `writable` (it fires for every namespace, including this page’s own `acceptView`). `settings/document-updated` (`llm-pi-ai` only) carries the Host revision; the section skips its own mutate echo, waits until the mirror revision has **caught up**, then `getSnapshot()` — it does not use `ensure()` as refresh. Provider directory changes come from `llm/adapters-updated`; `connection/reset` uses `ensure()` so an in-flight official `load()` is awaited. `locale: NS` lets the framework inject `t`; `inject` only returns `api` / `describe` / `schema` / `subscribeInvalidate`. |
21
+ | [`EffortDeclareSection.tsx`](./EffortDeclareSection.tsx) | Route cards, presets, Off tri-state, advanced protocol switches, save and cancel. Save is exclusive for the whole `llm-pi-ai` namespace (ref-guarded against double-click); `busy` is “Saving…” on the in-flight card, `saveLocked` disables the others. A successful write syncs `draftsRef` immediately and folds `acceptView` + `applySaveSuccess`. External `document-updated` events that arrive during save are replayed afterwards. A reload with no conflict clears that card’s conflict/error notice. Footer shows the version and copyright years frozen at pack time. |
22
+ | [`build-info.ts`](./build-info.ts) | Reads `package.json` version and UTC pack year injected by tsdown `define`, and formats the footer line. |
23
+ | [`globals.d.ts`](./globals.d.ts) | Types for `__PLUGIN_VERSION__` and `__COPYRIGHT_TO__`. |
24
+ | [`load-drafts.ts`](./load-drafts.ts) | Builds drafts from `llm.providers` + the settings mirror; drafts come from `user`, protocol classification may use `value`. `thinkingFormat` choices come only from the live schema union. First paint uses `ensure()`; refresh reads `getSnapshot()` only, with helpers that wait for the namespace revision. |
23
25
  | [`locales.ts`](./locales.ts) | Copy namespace `plugin-effort-declare`. |
24
26
  | [`effort-declare.module.css`](./effort-declare.module.css) | `--dsw-alias-*` tokens only, so dark theme stays correct. |
25
27
  | [`schema-ops.ts`](./schema-ops.ts) | Binds `settingsSchema` as plain callbacks (including `validate`) so the service identity is not passed into React. |
@@ -17,9 +17,11 @@
17
17
 
18
18
  | 文件 | 说明 |
19
19
  | --- | --- |
20
- | [`index.ts`](./index.ts) | 注册 zh/en 文案、CSS(`ctx.effect` 插入/移除)与 `settings.section`(id `effort-declare`,order 12)。`describe.subscribe` 只同步 `writable`(任意 namespace 都会触发,含自己的 `acceptView`)。真正重载来自 `settings/document-updated`(仅 `llm-pi-ai`)、`llm/adapters-updated`、`connection/reset`。`locale: NS` 由框架注入 `t`,inject 只传 `api` / `describe` / `schema` / `subscribeInvalidate`。 |
21
- | [`EffortDeclareSection.tsx`](./EffortDeclareSection.tsx) | 路由卡片、预设、Off 三态、高级协议开关、保存与取消。保存互斥到整个 `llm-pi-ai` namespace;`busy` 只给正在 mutate 的卡显示「保存中」,其它卡 `saveLocked`。自己写入成功走 `acceptView` + `applySaveSuccess`,不再全量 reload |
22
- | [`load-drafts.ts`](./load-drafts.ts) | `llm.providers` + 镜像组装草稿;草稿取自 `user`,协议分类可用 `value`。`thinkingFormat` 可选项只来自现场 schema union。 |
20
+ | [`index.ts`](./index.ts) | 注册 zh/en 文案、CSS(`ctx.effect` 插入/移除)与 `settings.section`(id `effort-declare`,order 12)。`describe.subscribe` 只同步 `writable`(任意 namespace 都会触发,含自己的 `acceptView`)。`settings/document-updated`(仅 `llm-pi-ai`)带上 Host 的 revision;设置页跳过自己这次 mutate 的回声,等镜像 revision **追上**后再 `getSnapshot()`,不把 `ensure()` 当刷新。提供方目录来自 `llm/adapters-updated`;`connection/reset` 走 `ensure()` 以便等待官方镜像的 in-flight `load()`。`locale: NS` 由框架注入 `t`,inject 只传 `api` / `describe` / `schema` / `subscribeInvalidate`。 |
21
+ | [`EffortDeclareSection.tsx`](./EffortDeclareSection.tsx) | 路由卡片、预设、Off 三态、高级协议开关、保存与取消。保存互斥到整个 `llm-pi-ai` namespace(以 ref 防连点);`busy` 只给正在 mutate 的卡显示「保存中」,其它卡 `saveLocked`。自己写入成功立刻同步 `draftsRef` 并走 `acceptView` + `applySaveSuccess`。保存过程中到达的外部 `document-updated` 会在结束后补刷。无冲突的 reload 清掉该卡 conflict/error notice。页脚显示打包时冻结的版本与版权年。 |
22
+ | [`build-info.ts`](./build-info.ts) | 读取 tsdown `define` 注入的 `package.json` version UTC 打包年,拼出页脚文案。 |
23
+ | [`globals.d.ts`](./globals.d.ts) | `__PLUGIN_VERSION__` / `__COPYRIGHT_TO__` 的类型声明。 |
24
+ | [`load-drafts.ts`](./load-drafts.ts) | 从 `llm.providers` + 镜像组装草稿;草稿取自 `user`,协议分类可用 `value`。`thinkingFormat` 可选项只来自现场 schema union。首屏 `ensure()`;刷新只读 `getSnapshot()`,并提供等 namespace revision 的 helper。 |
23
25
  | [`locales.ts`](./locales.ts) | 文案 namespace `plugin-effort-declare`。 |
24
26
  | [`effort-declare.module.css`](./effort-declare.module.css) | 仅使用 `--dsw-alias-*` 设计令牌,保证暗色主题正确。 |
25
27
  | [`schema-ops.ts`](./schema-ops.ts) | 将 `settingsSchema` 收成普通回调(含 `validate`),避免把服务身份带进 React。 |
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Footer line frozen into the client bundle. Do not read the clock or
3
+ * package.json at DSH startup — host apply is empty and the settings page
4
+ * runs in the browser.
5
+ */
6
+ import { COPYRIGHT_FROM, formatAttribution } from '../core/attribution.ts'
7
+
8
+ export const PLUGIN_FOOTER_TEXT = formatAttribution(
9
+ __PLUGIN_VERSION__,
10
+ COPYRIGHT_FROM,
11
+ __COPYRIGHT_TO__,
12
+ )
@@ -276,3 +276,11 @@
276
276
  font-size: 12px;
277
277
  line-height: 18px;
278
278
  }
279
+
280
+ .footer {
281
+ margin: 16px 0 0;
282
+ font-size: 11px;
283
+ line-height: 16px;
284
+ color: var(--dsw-alias-label-tertiary);
285
+ opacity: 0.7;
286
+ }
@@ -0,0 +1,5 @@
1
+ /** `package.json` version, substituted by tsdown `define` at pack time. */
2
+ declare const __PLUGIN_VERSION__: string
3
+
4
+ /** UTC full year at pack time, substituted by tsdown `define`. */
5
+ declare const __COPYRIGHT_TO__: number
@@ -8,7 +8,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
8
8
  import type {} from '@deepseek-ai/dsh-client-locale/client'
9
9
  import type {} from '@deepseek-ai/dsh-api-remotes/client'
10
10
  import { EffortDeclareSection } from './EffortDeclareSection.tsx'
11
- import type { EffortDeclareSectionInjected, InvalidationSource } from './EffortDeclareSection.tsx'
11
+ import type { EffortDeclareSectionInjected, Invalidation } from './EffortDeclareSection.tsx'
12
12
  import { bindSchema } from './schema-ops.ts'
13
13
  import { LLM_PI_AI_NS } from '../core/catalog.ts'
14
14
  import { NS, en, zh, type EffortDeclareKey } from './locales.ts'
@@ -60,23 +60,25 @@ export function apply(ctx: ClientContext): void {
60
60
  })
61
61
  const t = ctx.locale.bind(NS)
62
62
  const describe = ctx.settingsScope.describe()
63
- const invalidation = new Set<(source: InvalidationSource) => void>()
63
+ const invalidation = new Set<(event: Invalidation) => void>()
64
64
 
65
65
  ctx.effect(() => {
66
- const emit = (source: InvalidationSource) => {
67
- for (const listener of invalidation) listener(source)
66
+ const emit = (event: Invalidation) => {
67
+ for (const listener of invalidation) listener(event)
68
68
  }
69
69
  // Mirror subscribe fires on every namespace (theme, locale, own acceptView).
70
- // Only fold writable from that; llm-pi-ai document changes come from the
71
- // Host event below so a dirty card is not treated as a conflict.
70
+ // Only fold writable from that. llm-pi-ai document changes come from
71
+ // settings/document-updated; the section waits until the mirror revision
72
+ // has caught up instead of calling ensure(), and skips the echo of its
73
+ // own mutate. connection/reset uses ensure() so an in-flight load is awaited.
72
74
  const disposers = [
73
- describe.subscribe(() => { emit('writable') }),
74
- ctx.remote.$on('settings/document-updated', (ns: string) => {
75
+ describe.subscribe(() => { emit({ source: 'writable' }) }),
76
+ ctx.remote.$on('settings/document-updated', (ns: string, revision: number) => {
75
77
  if (ns !== LLM_PI_AI_NS) return
76
- emit('settings')
78
+ emit({ source: 'settings', revision })
77
79
  }),
78
- ctx.remote.$on('llm/adapters-updated', () => { emit('directory') }),
79
- ctx.on('connection/reset', () => { emit('directory') }),
80
+ ctx.remote.$on('llm/adapters-updated', () => { emit({ source: 'directory' }) }),
81
+ ctx.on('connection/reset', () => { emit({ source: 'reset' }) }),
80
82
  ]
81
83
  return () => {
82
84
  for (const dispose of disposers) dispose()
@@ -1,6 +1,12 @@
1
1
  /**
2
2
  * Load editable route drafts from llm.providers + the llm-pi-ai namespace.
3
3
  * Drafts come from the user layer; route protocol classification may use effective value.
4
+ *
5
+ * First paint uses `ensure()` (idle-only). Refresh never calls `ensure()`:
6
+ * wait until the mirror subscribe shows a namespace revision at least as new
7
+ * as the Host event, then `getSnapshot()`. Own mutate echoes are identified
8
+ * by revision (including older delayed echoes), not ignored as a generic
9
+ * document-updated.
4
10
  */
5
11
  import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
6
12
  import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client'
@@ -25,19 +31,115 @@ export interface LoadDraftsResult {
25
31
  error?: string
26
32
  }
27
33
 
34
+ export type LoadDraftsMode = 'ensure' | 'snapshot'
35
+
36
+ type MirrorSnapshot = ReturnType<SettingsDescribeFace['getSnapshot']>
37
+ type MirrorDescribe = Pick<SettingsDescribeFace, 'getSnapshot' | 'subscribe'>
38
+
39
+ /** Namespace revision on a describe snapshot, if that row exists. */
40
+ export function namespaceRevision(snapshot: MirrorSnapshot, ns: string): number | undefined {
41
+ return snapshot.view?.namespaces.find(view => view.ns === ns)?.revision
42
+ }
43
+
28
44
  /**
29
- * First paint: `ensure()` (reads only from idle). Never treat ensure as refresh.
30
- * Callers that must not apply a stale settlement compare generation themselves.
31
- *
32
- * `formats` is the live schema union only. Empty means the dropdown has no
33
- * writable choices (stored values stay visible via `thinkingFormatChoices`).
45
+ * True when `incoming` is the Host echo of a mutate this page already folded,
46
+ * or an older revision the snapshot has already passed. `echoed` is undefined
47
+ * until the first successful write.
34
48
  */
35
- export async function loadDrafts(
49
+ export function isOwnDocumentEcho(echoed: number | undefined, incoming: number): boolean {
50
+ return echoed !== undefined && incoming <= echoed
51
+ }
52
+
53
+ /**
54
+ * After a preserve-dirty reload: conflicted cards get a conflict notice;
55
+ * live cards drop leftover conflict/error; saved notices stay; gone cards drop.
56
+ */
57
+ export function foldReloadNotices<T extends { kind: string }>(
58
+ current: Record<string, T>,
59
+ args: {
60
+ conflicted: readonly string[]
61
+ conflictNotice: T
62
+ liveProviders: readonly string[]
63
+ },
64
+ ): Record<string, T> {
65
+ const live = new Set(args.liveProviders)
66
+ const conflicted = new Set(args.conflicted)
67
+ const next: Record<string, T> = {}
68
+ for (const [provider, notice] of Object.entries(current)) {
69
+ if (!live.has(provider)) continue
70
+ if (conflicted.has(provider)) continue
71
+ if (notice.kind === 'conflict' || notice.kind === 'error') continue
72
+ next[provider] = notice
73
+ }
74
+ for (const provider of args.conflicted) {
75
+ next[provider] = args.conflictNotice
76
+ }
77
+ return next
78
+ }
79
+
80
+ function waitUntil(
81
+ describe: MirrorDescribe,
82
+ predicate: () => boolean,
83
+ signal?: AbortSignal,
84
+ ): Promise<boolean> {
85
+ if (signal?.aborted) return Promise.resolve(false)
86
+ if (predicate()) return Promise.resolve(true)
87
+ return new Promise((resolve) => {
88
+ let settled = false
89
+ const finish = (ok: boolean) => {
90
+ if (settled) return
91
+ settled = true
92
+ stop()
93
+ signal?.removeEventListener('abort', onAbort)
94
+ resolve(ok)
95
+ }
96
+ const onAbort = () => { finish(false) }
97
+ const stop = describe.subscribe(() => {
98
+ if (predicate()) finish(true)
99
+ })
100
+ signal?.addEventListener('abort', onAbort)
101
+ if (predicate()) finish(true)
102
+ else if (signal?.aborted) finish(false)
103
+ })
104
+ }
105
+
106
+ /** Resolve when the mirror's namespace revision is at least `revision`, or abort. */
107
+ export async function waitForNamespaceRevision(
108
+ describe: MirrorDescribe,
109
+ ns: string,
110
+ revision: number,
111
+ signal?: AbortSignal,
112
+ ): Promise<'matched' | 'aborted'> {
113
+ const matched = await waitUntil(
114
+ describe,
115
+ () => {
116
+ const current = namespaceRevision(describe.getSnapshot(), ns)
117
+ return current !== undefined && current >= revision
118
+ },
119
+ signal,
120
+ )
121
+ return matched ? 'matched' : 'aborted'
122
+ }
123
+
124
+ /** Resolve when the namespace revision differs from `previous`, or abort. */
125
+ export async function waitForNamespaceRevisionChange(
126
+ describe: MirrorDescribe,
127
+ ns: string,
128
+ previous: number,
129
+ signal?: AbortSignal,
130
+ ): Promise<'changed' | 'aborted'> {
131
+ const changed = await waitUntil(describe, () => {
132
+ const current = namespaceRevision(describe.getSnapshot(), ns)
133
+ return current !== undefined && current !== previous
134
+ }, signal)
135
+ return changed ? 'changed' : 'aborted'
136
+ }
137
+
138
+ async function assembleDrafts(
36
139
  api: Pick<IApiClient, 'llm'>,
37
- describe: Pick<SettingsDescribeFace, 'ensure' | 'getSnapshot'>,
140
+ describe: Pick<SettingsDescribeFace, 'getSnapshot'>,
38
141
  schema: SchemaOps,
39
142
  ): Promise<LoadDraftsResult> {
40
- await describe.ensure()
41
143
  const mirrored = describe.getSnapshot()
42
144
  if (mirrored.view === undefined) {
43
145
  return { writable: false, formats: [], drafts: [], error: mirrored.error ?? undefined }
@@ -83,3 +185,17 @@ export async function loadDrafts(
83
185
  }
84
186
  return { writable: mirrored.view.writable, formats, drafts }
85
187
  }
188
+
189
+ /**
190
+ * `ensure`: first paint / idle recovery (official ensure only reads from idle).
191
+ * `snapshot`: refresh after the mirror revision already moved — do not ensure.
192
+ */
193
+ export async function loadDrafts(
194
+ api: Pick<IApiClient, 'llm'>,
195
+ describe: Pick<SettingsDescribeFace, 'ensure' | 'getSnapshot'>,
196
+ schema: SchemaOps,
197
+ mode: LoadDraftsMode = 'ensure',
198
+ ): Promise<LoadDraftsResult> {
199
+ if (mode === 'ensure') await describe.ensure()
200
+ return assembleDrafts(api, describe, schema)
201
+ }