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/README.md +2 -0
- package/docs/CHANGELOG.md +2 -0
- package/docs/features.md +1 -1
- package/docs/usage.md +1 -0
- package/lib/client.js +28 -19
- package/lib/index.js +21 -7
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/endpoints.ts +15 -0
- package/src/client/note-widget.ts +133 -17
- package/src/client/settings-page.ts +74 -0
- package/src/client/styles.ts +9 -0
- package/src/index.ts +21 -13
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.
|
|
4
|
+
"version": "0.28.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"dsh",
|
package/src/client/endpoints.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
.
|
|
258
|
-
|
|
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
|
-
|
|
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)}
|
|
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
|
|
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', '读取中…')
|
package/src/client/styles.ts
CHANGED
|
@@ -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).
|