dsh-tiddlywiki 0.28.0 → 0.28.2

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-tiddlywiki",
3
3
  "description": "把 TiddlyWiki 5 变成你和 Agent 共用的本地文档中心(纯文本 + git):你在 DSH 界面里阅读、写作、记日记,Agent 用 15 个 tiddlywiki_* 工具读写同一批笔记;每条笔记都是你能读、能改、能审计、能随时带走的文件——不是自动记忆插件,不做后台抓取。可选一键发布到微信公众号草稿箱。 Turn TiddlyWiki 5 into a document center you and your agent share, in plain text under git — a knowledge base you can read, edit, audit and take with you, not an auto-memory plugin. Mounts via the official dsh plugin system; no DSH source changes.",
4
- "version": "0.28.0",
4
+ "version": "0.28.2",
5
5
  "type": "module",
6
6
  "keywords": [
7
7
  "dsh",
@@ -112,6 +112,21 @@ export function twProxyFor(
112
112
  }
113
113
  }
114
114
 
115
+ /**
116
+ * Append the target knowledge base to a per-wiki CLIENT url (v0.28.0).
117
+ *
118
+ * The host resolves `?wiki=<id>` for every non-proxy route (the same
119
+ * `targetRuntimeFor` the proxy uses), so this is how a surface — the quick-note
120
+ * card, the editor popup — says WHICH wiki it is talking about.
121
+ *
122
+ * `undefined`/empty leaves the url untouched: that means "the default wiki",
123
+ * which is exactly what every call did before this existed.
124
+ */
125
+ export function withWikiQuery(url: string, wikiId: string | undefined): string {
126
+ if (typeof wikiId !== 'string' || wikiId.length === 0) return url
127
+ return `${url}${url.includes('?') ? '&' : '?'}wiki=${encodeURIComponent(wikiId)}`
128
+ }
129
+
115
130
  /** The subset of the `/sync` JSON body both client callers report on. */
116
131
  export interface SyncResultPayload {
117
132
  ok?: boolean
@@ -29,8 +29,9 @@
29
29
  import { toast } from './toast.ts'
30
30
  import { openEditorPopup, isEditorPopupOpen, isEditorPopupBlank } from './editor-popup.ts'
31
31
  import { buildMarkdownEditor, type MarkdownEditor } from './markdown-editor.ts'
32
+ import { EDIT_ENDPOINT, GET_ENDPOINT, NOTE_ENDPOINT, RECENT_ENDPOINT, TAGS_ENDPOINT, UPLOAD_ENDPOINT, resolveTwUrl, twProxyFor, withWikiQuery } from './endpoints.ts'
32
33
  import { fetchStatus } from './status-cache.ts'
33
- import { EDIT_ENDPOINT, GET_ENDPOINT, NOTE_ENDPOINT, RECENT_ENDPOINT, TAGS_ENDPOINT, UPLOAD_ENDPOINT, resolveTwUrl } from './endpoints.ts'
34
+ import { resolveFocusWiki, subscribeFocusWiki } from './wiki-focus.ts'
34
35
 
35
36
  const MAX_UPLOAD_BYTES = 64 * 1024 * 1024
36
37
 
@@ -179,7 +180,7 @@ async function fetchDefaultTag(): Promise<string> {
179
180
  }
180
181
 
181
182
  /** Multi-tag chip editor with autocomplete from the wiki's existing tags. */
182
- function buildTagEditor(opts: { onChange?: () => void } = {}): {
183
+ function buildTagEditor(opts: { onChange?: () => void; wikiQuery?: (url: string) => string } = {}): {
183
184
  el: HTMLDivElement
184
185
  /** Commits any pending input first, then returns the chips (user actions). */
185
186
  getTags: () => string[]
@@ -247,15 +248,24 @@ function buildTagEditor(opts: { onChange?: () => void } = {}): {
247
248
  for (const raw of input.value.split(/\s+/)) addTag(raw)
248
249
  }
249
250
 
250
- // Existing tags, fetched lazily once per widget lifetime.
251
+ // Existing tags, fetched lazily once per widget lifetime — PER TARGET WIKI
252
+ // (v0.28.0): the card can be pointed at another knowledge base, and its tag
253
+ // suggestions must come from the same one the note is written to. The memo is
254
+ // keyed by the resolved URL, so switching the target re-reads automatically
255
+ // (no reset call, nothing to forget).
251
256
  let knownTags: string[] = []
252
257
  let tagsPromise: Promise<string[]> | undefined
258
+ let tagsPromiseKey: string | undefined
253
259
  const ensureTags = (): Promise<string[]> => {
254
- tagsPromise ??= fetch(TAGS_ENDPOINT, { signal: AbortSignal.timeout(5_000) })
255
- .then((r) => (r.ok ? (r.json() as Promise<{ tags?: string[] }>) : Promise.resolve<{ tags?: string[] }>({})))
256
- .then((p) => [...(p.tags ?? [])].sort((a, b) => a.localeCompare(b, 'zh')))
257
- .catch(() => [])
258
- void tagsPromise.then((list) => { knownTags = list })
260
+ const url = opts.wikiQuery?.(TAGS_ENDPOINT) ?? TAGS_ENDPOINT
261
+ if (tagsPromise === undefined || tagsPromiseKey !== url) {
262
+ tagsPromiseKey = url
263
+ tagsPromise = fetch(url, { signal: AbortSignal.timeout(5_000) })
264
+ .then((r) => (r.ok ? (r.json() as Promise<{ tags?: string[] }>) : Promise.resolve<{ tags?: string[] }>({})))
265
+ .then((p) => [...(p.tags ?? [])].sort((a, b) => a.localeCompare(b, 'zh')))
266
+ .catch(() => [])
267
+ void tagsPromise.then((list) => { knownTags = list })
268
+ }
259
269
  return tagsPromise
260
270
  }
261
271
 
@@ -359,14 +369,17 @@ interface BuiltUi {
359
369
  /**
360
370
  * Upload one file to the wiki (raw body, name in ?name=), then insert a
361
371
  * Markdown image/link line at the caret of the given editor.
372
+ *
373
+ * `wikiQuery` scopes the upload to the CARD's target knowledge base (v0.28.0):
374
+ * an attachment must land in the same wiki as the note that references it.
362
375
  */
363
- async function uploadInto(file: File, editor: MarkdownEditor): Promise<void> {
376
+ async function uploadInto(file: File, editor: MarkdownEditor, wikiQuery: (url: string) => string = (url) => url): Promise<void> {
364
377
  if (file.size > MAX_UPLOAD_BYTES) {
365
378
  toast(`文件过大(≤ ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)}MB)`)
366
379
  return
367
380
  }
368
381
  try {
369
- const res = await fetch(`${UPLOAD_ENDPOINT}?name=${encodeURIComponent(file.name)}`, {
382
+ const res = await fetch(wikiQuery(`${UPLOAD_ENDPOINT}?name=${encodeURIComponent(file.name)}`), {
370
383
  method: 'POST',
371
384
  headers: { 'content-type': file.type || 'application/octet-stream' },
372
385
  body: file,
@@ -424,6 +437,28 @@ export function createNoteWidget(): NoteWidgetHandle {
424
437
  /** build() 时注册的 pagehide 落盘回调(dispose 需回收)。 */
425
438
  let onPageHide: (() => void) | undefined
426
439
  let defaultTag = 'inbox'
440
+ /**
441
+ * WHICH knowledge base this card works on (v0.28.0, 需求 R7).
442
+ *
443
+ * Default = the GUI's focused wiki (你在看哪个库,快速笔记就进哪个库), and the card's
444
+ * own selector overrides it for as long as the card is open. `undefined` = the
445
+ * default wiki, i.e. exactly what every call used to do — single-wiki installs
446
+ * never see a change (and never see the selector).
447
+ *
448
+ * EVERY call of the card must be scoped together: 标签建议 / 最近 / 草稿读取 /
449
+ * 附件上传 / 保存 / 以及随后弹出的编辑器。只改保存那一处就会出现"标签列表来自 A、
450
+ * 笔记写进 B",比不做还糟。
451
+ */
452
+ let targetWiki: string | undefined
453
+ /** True once the user picked a wiki in THIS card (stop following the focus). */
454
+ let targetPicked = false
455
+ /** The roster's mode, for the per-wiki proxy base of the editor popup. */
456
+ let rosterMode = 'single'
457
+ /** The knowledge-base roster (empty in single mode / before /status lands). */
458
+ let roster: Array<{ id: string; label: string; running: boolean }> = []
459
+ /** 焦点库订阅(dispose 需回收)。 */
460
+ let focusOff: (() => void) | undefined
461
+ const wikiQuery = (url: string): string => withWikiQuery(url, targetWiki)
427
462
  let draftTimer: number | undefined
428
463
  let recentOpen = false
429
464
  /**
@@ -550,7 +585,7 @@ export function createNoteWidget(): NoteWidgetHandle {
550
585
  */
551
586
  const postEditAndOpen = async (title: string, text: string, tags: string[]): Promise<boolean> => {
552
587
  try {
553
- const res = await fetch(EDIT_ENDPOINT, {
588
+ const res = await fetch(wikiQuery(EDIT_ENDPOINT), {
554
589
  method: 'POST',
555
590
  headers: { 'content-type': 'application/json' },
556
591
  body: JSON.stringify({ title, tags, text }),
@@ -580,7 +615,10 @@ export function createNoteWidget(): NoteWidgetHandle {
580
615
  // LAN, Tailscale, HTTPS) — and, on the DSH desktop app (`dsh-app:`), to
581
616
  // the host's loopback HTTP base, without which TW's sync adaptor refuses
582
617
  // to load and the native editor would come up read-only.
583
- const popupUrl = `${resolveTwUrl(payload.twUrl, payload.twUrlAbsolute)}#${encodeURIComponent(payload.draftTitle)}`
618
+ // 弹窗编辑器必须落在**同一个**库上(v0.28.0):写入的是 A、编辑器打开 B(空的)
619
+ // 是这张卡片最容易出现的"看起来成功了"的失败。
620
+ const bases = twProxyFor(rosterMode, targetWiki, payload.twUrl, payload.twUrlAbsolute)
621
+ const popupUrl = `${resolveTwUrl(bases.relative, bases.absolute)}#${encodeURIComponent(payload.draftTitle)}`
584
622
  openEditorPopup(popupUrl, payload.title ?? title)
585
623
  toast(`已在弹出窗口打开「${payload.title ?? title}」编辑器`)
586
624
  return true
@@ -598,7 +636,7 @@ export function createNoteWidget(): NoteWidgetHandle {
598
636
  /** Load a tiddler into the editor (recent picker click). */
599
637
  const loadNote = async (title: string): Promise<void> => {
600
638
  try {
601
- const res = await fetch(`${GET_ENDPOINT}?title=${encodeURIComponent(title)}`, { signal: AbortSignal.timeout(10_000) })
639
+ const res = await fetch(wikiQuery(`${GET_ENDPOINT}?title=${encodeURIComponent(title)}`), { signal: AbortSignal.timeout(10_000) })
602
640
  const payload = (await res.json().catch(() => null)) as { ok?: boolean; title?: string; text?: string; tags?: string[]; notFound?: boolean; error?: string; modified?: string | null; revision?: number | null } | null
603
641
  if (!res.ok || payload?.ok !== true || typeof payload.title !== 'string') {
604
642
  const reason = payload?.notFound === true ? '不存在' : (payload?.error ?? `HTTP ${res.status}`)
@@ -640,7 +678,7 @@ export function createNoteWidget(): NoteWidgetHandle {
640
678
  ui.recentWrap.append(loading)
641
679
  void (async () => {
642
680
  try {
643
- const res = await fetch(`${RECENT_ENDPOINT}?limit=15`, { signal: AbortSignal.timeout(10_000) })
681
+ const res = await fetch(wikiQuery(`${RECENT_ENDPOINT}?limit=15`), { signal: AbortSignal.timeout(10_000) })
644
682
  const payload = (await res.json().catch(() => null)) as { ok?: boolean; items?: unknown[]; error?: string } | null
645
683
  if (!recentOpen || ui === undefined) return
646
684
  ui.recentWrap.replaceChildren()
@@ -749,6 +787,82 @@ export function createNoteWidget(): NoteWidgetHandle {
749
787
  titleInput.setAttribute('aria-label', '笔记标题(默认时间戳)')
750
788
  const tagEditor = buildTagEditor({ onChange: scheduleDraft })
751
789
  fields.append(titleInput, tagEditor.el)
790
+
791
+ // ── 目标知识库(v0.28.0,R7)────────────────────────────────────────────
792
+ // 只在"多于一个库"时出现(单库安装连这个元素都隐藏)。默认跟随 GUI 焦点库,
793
+ // 在这里改只影响这张卡片(`targetPicked` 之后不再跟焦点跑)。
794
+ const wikiField = document.createElement('label')
795
+ wikiField.className = 'dsh-tw-note-wiki'
796
+ wikiField.hidden = true
797
+ const wikiLabel = document.createElement('span')
798
+ wikiLabel.className = 'dsh-tw-note-wiki-label'
799
+ wikiLabel.textContent = '写入'
800
+ const wikiSelect = document.createElement('select')
801
+ wikiSelect.className = 'dsh-tw-note-wiki-select'
802
+ wikiSelect.setAttribute('aria-label', '这条笔记写进哪个知识库')
803
+ wikiField.append(wikiLabel, wikiSelect)
804
+ const wikiHint = document.createElement('span')
805
+ wikiHint.className = 'dsh-tw-note-wiki-hint'
806
+ wikiHint.hidden = true
807
+ fields.append(wikiField, wikiHint)
808
+ /**
809
+ * Flag a target that is not running: `/note` cannot serve it (`deps.client`
810
+ * is undefined), and the honest answer is to say so BEFORE the user writes a
811
+ * paragraph and loses it. We deliberately do NOT auto-start here — the
812
+ * 「知识库」 menu is the place that starts a wiki, and a save route spawning
813
+ * processes is not something a card should trigger by accident.
814
+ */
815
+ const markStopped = (): void => {
816
+ const wiki = roster.find((item) => item.id === targetWiki)
817
+ const stopped = wiki !== undefined && !wiki.running
818
+ wikiHint.hidden = !stopped
819
+ wikiHint.textContent = stopped ? `${wiki.label} 当前没在运行:先在右下角「知识库」菜单里打开它` : ''
820
+ }
821
+ wikiSelect.addEventListener('change', () => {
822
+ targetPicked = true
823
+ targetWiki = wikiSelect.value.length > 0 ? wikiSelect.value : undefined
824
+ markStopped()
825
+ })
826
+
827
+ /**
828
+ * Read the roster and decide the card's default target (v0.28.0).
829
+ *
830
+ * Before this resolves, `targetWiki` is undefined = the plain endpoints, and
831
+ * with `roster.length <= 1` it STAYS undefined — a single-wiki install keeps
832
+ * byte-identical behaviour and never sees the selector.
833
+ */
834
+ void fetchStatus().then((payload) => {
835
+ if (disposed) return
836
+ rosterMode = typeof payload?.mode === 'string' ? payload.mode : 'single'
837
+ roster = (Array.isArray(payload?.wikis) ? payload.wikis : []).map((item) => ({ id: item.id, label: item.label, running: item.running }))
838
+ if (roster.length <= 1) return
839
+ const defaultId = typeof payload?.defaultId === 'string' ? payload.defaultId : undefined
840
+ const followFocus = (): void => {
841
+ if (!targetPicked) targetWiki = resolveFocusWiki(roster, defaultId)
842
+ }
843
+ followFocus()
844
+ wikiSelect.replaceChildren(
845
+ ...roster.map((item) => {
846
+ const option = document.createElement('option')
847
+ option.value = item.id
848
+ option.textContent = item.running ? item.label : `${item.label}(未运行)`
849
+ return option
850
+ }),
851
+ )
852
+ const fallback = document.createElement('option')
853
+ fallback.value = ''
854
+ fallback.textContent = '默认库'
855
+ wikiSelect.append(fallback)
856
+ wikiSelect.value = targetWiki ?? ''
857
+ wikiField.hidden = false
858
+ markStopped()
859
+ // 未在本卡片里手动选过时,跟随 GUI 的焦点库(你在看哪个库,笔记就进哪个库)。
860
+ focusOff = subscribeFocusWiki(() => {
861
+ followFocus()
862
+ wikiSelect.value = targetWiki ?? ''
863
+ markStopped()
864
+ })
865
+ })
752
866
  titleInput.addEventListener('input', scheduleDraft)
753
867
 
754
868
  // Mod-Enter save routes through doSave, which is assigned below (the editor
@@ -772,7 +886,7 @@ export function createNoteWidget(): NoteWidgetHandle {
772
886
  fileInput.hidden = true
773
887
  uploadBtn.addEventListener('click', () => { fileInput.click() })
774
888
  fileInput.addEventListener('change', () => {
775
- for (const file of Array.from(fileInput.files ?? [])) void uploadInto(file, editor)
889
+ for (const file of Array.from(fileInput.files ?? [])) void uploadInto(file, editor, wikiQuery)
776
890
  fileInput.value = ''
777
891
  })
778
892
 
@@ -794,7 +908,7 @@ export function createNoteWidget(): NoteWidgetHandle {
794
908
  editor.el.classList.remove('dsh-tw-note-drop')
795
909
  const files = event.dataTransfer?.files
796
910
  if (files === undefined || files.length === 0) return
797
- for (const file of Array.from(files)) void uploadInto(file, editor)
911
+ for (const file of Array.from(files)) void uploadInto(file, editor, wikiQuery)
798
912
  })
799
913
 
800
914
  const foot = document.createElement('div')
@@ -902,7 +1016,7 @@ export function createNoteWidget(): NoteWidgetHandle {
902
1016
  if (loadedToken.modified !== undefined) body.expectedModified = loadedToken.modified
903
1017
  if (loadedToken.revision !== undefined) body.expectedRevision = loadedToken.revision
904
1018
  }
905
- const res = await fetch(NOTE_ENDPOINT, {
1019
+ const res = await fetch(wikiQuery(NOTE_ENDPOINT), {
906
1020
  method: 'POST',
907
1021
  headers: { 'content-type': 'application/json' },
908
1022
  body: JSON.stringify(body),
@@ -1096,6 +1210,8 @@ export function createNoteWidget(): NoteWidgetHandle {
1096
1210
  },
1097
1211
  dispose() {
1098
1212
  disposed = true
1213
+ focusOff?.()
1214
+ focusOff = undefined
1099
1215
  if (onPageHide !== undefined) {
1100
1216
  window.removeEventListener('pagehide', onPageHide)
1101
1217
  onPageHide = undefined
@@ -1014,6 +1014,32 @@ function renderWikiListSection(body: HTMLElement, isDisposed: () => boolean, ref
1014
1014
  }
1015
1015
  }
1016
1016
 
1017
+ /**
1018
+ * The DEFAULT wiki's folder change goes through `/admin/wiki/switch`, which is
1019
+ * already mode-aware host-side (single → pointer file + restart with rollback;
1020
+ * multi → edit the registry and let the farm reconcile) and refuses a location
1021
+ * that collides with / nests inside another registered wiki.
1022
+ */
1023
+ const postLocation = async (url: string, body: unknown, busyLabel: string): Promise<void> => {
1024
+ status.textContent = busyLabel
1025
+ try {
1026
+ const data = await fetchJson<{ ok?: boolean; error?: string }>(url, {
1027
+ method: 'POST',
1028
+ headers: { 'content-type': 'application/json' },
1029
+ body: JSON.stringify(body),
1030
+ signal: AbortSignal.timeout(180_000),
1031
+ })
1032
+ if (isDisposed()) return
1033
+ if (data.ok !== true) toast(`失败:${data.error ?? '未知错误'}`)
1034
+ await load()
1035
+ await refresh()
1036
+ } catch (err) {
1037
+ if (isDisposed()) return
1038
+ toast(`失败:${err instanceof Error ? err.message : String(err)}`)
1039
+ await load()
1040
+ }
1041
+ }
1042
+
1017
1043
  const render = (view: WikisView): void => {
1018
1044
  status.textContent = view.error !== undefined && view.error.length > 0 ? `⚠️ ${view.error}` : ''
1019
1045
  modeRow.replaceChildren(make('span', 'dsh-tw-settings-label', '运行模式:'))
@@ -1085,6 +1111,27 @@ function renderWikiListSection(body: HTMLElement, isDisposed: () => boolean, ref
1085
1111
  const ok = window.confirm(`把「${wiki.label}」移出清单?\n\n**目录与内容不会被删除**(仍在 ${wiki.path}),只是插件不再管理它。`)
1086
1112
  if (ok) void post({ action: 'remove', id: wiki.id }, '移出中…')
1087
1113
  })
1114
+ // 「改目录」只在多库模式出现(v0.28.1):单库模式下这件事由「知识库位置」负责
1115
+ // (它写 wiki 之外的指针文件),两处并存会让用户不知道以哪个为准。
1116
+ if (view.mode === 'multi') {
1117
+ const move = make('button', 'dsh-tw-settings-btn dsh-tw-settings-chipbtn', '改目录')
1118
+ move.type = 'button'
1119
+ move.addEventListener('click', () => {
1120
+ const root = window.prompt(`把「${wiki.label}」的根目录换成?\n\n当前:${wiki.root}`, wiki.root)
1121
+ if (root === null) return
1122
+ if (root.trim().length === 0) {
1123
+ toast('根目录不能为空')
1124
+ return
1125
+ }
1126
+ const name = window.prompt(`文件夹名(当前:${wiki.name};填 . 表示直接用上面这个根目录)`, wiki.name)
1127
+ if (name === null) return
1128
+ const target = { root: root.trim(), name: name.trim().length > 0 ? name.trim() : wiki.name }
1129
+ void (isDefault
1130
+ ? postLocation(WIKI_SWITCH_ENDPOINT, target, '搬动中(会停/起这个库)…')
1131
+ : post({ action: 'update', wiki: { ...wiki, ...target } }, '保存中…'))
1132
+ })
1133
+ actions.append(move)
1134
+ }
1088
1135
  actions.append(configure, power, makeDefault, visibility, autostart, remove)
1089
1136
  row.append(actions)
1090
1137
  list.append(row)
@@ -1139,7 +1186,34 @@ function withWiki(url: string): string {
1139
1186
  return editingWiki === undefined ? url : `${url}?wiki=${encodeURIComponent(editingWiki)}`
1140
1187
  }
1141
1188
 
1189
+ /**
1190
+ * 「知识库位置」只在**单库模式**下渲染(v0.28.1)。
1191
+ *
1192
+ * 为什么按模式二选一,而不是两处并存:这一块的语义是「把唯一的那个库换到别处」——
1193
+ * 它写的是 wiki 之外的指针文件 `location.json`,措辞也是单库时代的("切换到这个位置" /
1194
+ * "恢复为配置默认")。多库模式下每个库的目录属于「知识库列表」里那一行(`改目录`),
1195
+ * 两处都能改同一件事 = 重复,而且这一块读起来像旧时代残留(作者 2026-09-28 反馈)。
1196
+ *
1197
+ * 宿主侧**没有**冲突:`/admin/wiki/switch` 早就按模式分派(single → 指针文件 + 带回滚的
1198
+ * 重启;multi → 改清单 + 让 farm 收敛)。所以这里只是把**界面**收敃到一处,不动接口。
1199
+ *
1200
+ * 读不到模式时按单库渲染(保守:宁可多显示一个"确实能用"的区块,也不要什么都不给)。
1201
+ */
1142
1202
  function renderWikiLocationSection(body: HTMLElement, isDisposed: () => boolean, refresh: () => Promise<void>): void {
1203
+ void (async () => {
1204
+ try {
1205
+ const view = await fetchJson<WikisView>(WIKI_LIST_ENDPOINT)
1206
+ if (isDisposed()) return
1207
+ if (view.mode === 'multi') return
1208
+ } catch {
1209
+ /* 读不到模式:按单库渲染 */
1210
+ }
1211
+ if (isDisposed()) return
1212
+ renderWikiLocationSectionBody(body, isDisposed, refresh)
1213
+ })()
1214
+ }
1215
+
1216
+ function renderWikiLocationSectionBody(body: HTMLElement, isDisposed: () => boolean, refresh: () => Promise<void>): void {
1143
1217
  const section = make('section', 'dsh-tw-settings-section')
1144
1218
  section.append(make('h3', 'dsh-tw-settings-h', '知识库位置(可切换)'))
1145
1219
  const status = make('div', 'dsh-tw-settings-muted', '读取中…')
@@ -513,6 +513,15 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
513
513
  }
514
514
  .dsh-tw-scope-select:disabled { opacity: .6; }
515
515
  .dsh-tw-scope-note { opacity: .75; }
516
+ /* 快速笔记卡片的目标库选择(v0.28.0,R7):只在多库时出现。 */
517
+ .dsh-tw-note-wiki { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; }
518
+ .dsh-tw-note-wiki-label { opacity: .7; }
519
+ .dsh-tw-note-wiki-select {
520
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
521
+ background: var(--dsw-alias-bg-layer-2, #fff); color: inherit; font: inherit; font-size: 12px;
522
+ padding: 2px 6px; border-radius: 999px; max-width: 220px;
523
+ }
524
+ .dsh-tw-note-wiki-hint { font-size: 12px; opacity: .75; }
516
525
 
517
526
  /* ── input-dock quick-note button(聊天输入框上方,conversation.input.dock)──
518
527
  该槽位渲染为「输入框上方的全宽条目」纵向 flex 排列(todo/cost-meter/goal/
package/src/index.ts CHANGED
@@ -779,6 +779,27 @@ export function apply(ctx: HostCtx, rawConfig: TiddlywikiConfig = {}): void {
779
779
 
780
780
  const startupTask = (async () => {
781
781
  try {
782
+ /**
783
+ * Ship the 「拆分知识库」 skill FIRST (v0.28.0).
784
+ *
785
+ * It is deliberately independent of the wiki: it only writes a file into
786
+ * `$DSH_HOME/skills`, and it must still happen when the wiki is slow to
787
+ * start, fails to start, or the user is mid-migration with a broken config.
788
+ * (It used to sit after `farm.startAll()` — which meant a wiki that never
789
+ * became ready also silently never installed the skill. Found by restarting
790
+ * a real host, not by a test.)
791
+ *
792
+ * A plugin cannot register a skill ROOT, so writing into the user's own root
793
+ * is the only zero-config path; it only ever touches its own file (the
794
+ * marker line decides — see host/skill-install.ts).
795
+ */
796
+ try {
797
+ const installed = await installSplitSkill()
798
+ if (installed.action === 'failed') console.warn('[dsh-tiddlywiki] 拆库 skill 安装失败:', installed.error)
799
+ else console.info(`[dsh-tiddlywiki] 拆库 skill:${installed.action}(${installed.path})`)
800
+ } catch (err) {
801
+ console.warn('[dsh-tiddlywiki] 拆库 skill 安装异常:', err)
802
+ }
782
803
  // THE CONTROL FILE FIRST (v0.28.0): it carries the mode (single/multi), the
783
804
  // wiki list and the default id, and it must be read before anything starts
784
805
  // — which is exactly why it lives OUTSIDE every wiki (wiki-registry.ts).
@@ -825,19 +846,6 @@ export function apply(ctx: HostCtx, rawConfig: TiddlywikiConfig = {}): void {
825
846
  await farm.disposeAll()
826
847
  return
827
848
  }
828
- // Ship the 「拆分知识库」 skill (v0.28.0). DSH's container for a multi-step
829
- // procedure is a skill, and a plugin cannot register a skill ROOT — so it
830
- // writes into the user's own root, and only ever touches its own file (the
831
- // marker line decides; see host/skill-install.ts).
832
- if (!disposed) {
833
- try {
834
- const installed = await installSplitSkill()
835
- if (installed.action === 'failed') console.warn('[dsh-tiddlywiki] 拆库 skill 安装失败:', installed.error)
836
- else console.info(`[dsh-tiddlywiki] 拆库 skill:${installed.action}(${installed.path})`)
837
- } catch (err) {
838
- console.warn('[dsh-tiddlywiki] 拆库 skill 安装异常:', err)
839
- }
840
- }
841
849
  // Clip bridge: bind once on the DEFAULT wiki's configured port (works even
842
850
  // while disabled — every request re-checks the effective enabled flag, so
843
851
  // the settings-page toggle applies without a dsh web restart).