dsh-tiddlywiki 0.27.2 → 0.28.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.
- package/LICENSE +21 -21
- package/README.md +3 -0
- package/cordis.patch.yml +13 -13
- package/docs/CHANGELOG.md +2 -1
- package/docs/code-review-2026-09-17.md +151 -0
- package/docs/features.md +5 -2
- package/docs/usage.md +34 -3
- package/docs/wiki-split.md +97 -0
- package/lib/client.js +54 -27
- package/lib/index.js +2803 -517
- package/lib/index.js.map +1 -1
- package/package.json +5 -4
- package/skills/wiki-split/SKILL.md +106 -0
- package/src/client/endpoints.ts +37 -0
- package/src/client/index.ts +14 -0
- package/src/client/knowledge-fab.ts +62 -3
- package/src/client/markdown-editor.ts +128 -128
- package/src/client/panel.ts +2 -1
- package/src/client/settings-page.ts +217 -3
- package/src/client/state.ts +39 -39
- package/src/client/status-cache.ts +17 -0
- package/src/client/styles.ts +27 -0
- package/src/client/toast.ts +22 -22
- package/src/client/tw-frame.ts +28 -4
- package/src/client/wiki-focus.ts +70 -0
- package/src/client/wiki-scope-dock.ts +116 -0
- package/src/host/admin.ts +175 -35
- package/src/host/git.ts +50 -5
- package/src/host/path-key.ts +35 -0
- package/src/host/prompt.ts +24 -0
- package/src/host/repo-committers.ts +149 -0
- package/src/host/routes.ts +254 -81
- package/src/host/seeds.ts +51 -10
- package/src/host/session-scope.ts +154 -0
- package/src/host/skill-install.ts +121 -0
- package/src/host/tools.ts +141 -49
- package/src/host/wiki-farm.ts +417 -0
- package/src/host/wiki-instance.ts +596 -0
- package/src/host/wiki-registry.ts +601 -0
- package/src/host/wiki.ts +19 -0
- package/src/index.ts +686 -459
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
ADMIN_WIKI_LOCATION_ENDPOINT as WIKI_LOCATION_ENDPOINT,
|
|
37
37
|
ADMIN_WIKI_RESET_ENDPOINT as WIKI_RESET_ENDPOINT,
|
|
38
38
|
ADMIN_WIKI_SWITCH_ENDPOINT as WIKI_SWITCH_ENDPOINT,
|
|
39
|
+
ADMIN_WIKIS_ENDPOINT as WIKI_LIST_ENDPOINT,
|
|
39
40
|
SYNC_ENDPOINT,
|
|
40
41
|
describeSyncResult,
|
|
41
42
|
type SyncResultPayload,
|
|
@@ -189,7 +190,7 @@ function mountSettingsPage(container: HTMLElement): () => void {
|
|
|
189
190
|
// 首屏先给占位(v0.24.x):以前 await 期间什么都不画,整块白屏看起来像插件坏了。
|
|
190
191
|
if (!rendered) body.replaceChildren(make('div', 'dsh-tw-settings-muted', '加载中…'))
|
|
191
192
|
try {
|
|
192
|
-
const state = await fetchJson<AdminState>(STATE_ENDPOINT)
|
|
193
|
+
const state = await fetchJson<AdminState>(withWiki(STATE_ENDPOINT))
|
|
193
194
|
if (disposed) return
|
|
194
195
|
rendered = true
|
|
195
196
|
loadError.hidden = true
|
|
@@ -520,7 +521,7 @@ function renderConfigSection(body: HTMLElement, config: Record<string, unknown>,
|
|
|
520
521
|
// Send the DRAFT (v0.22.7): the host builds the text from these values
|
|
521
522
|
// without saving them, so the preview follows the dropdown immediately
|
|
522
523
|
// instead of showing the still-saved mode until 保存配置 is clicked.
|
|
523
|
-
const data = await fetchJson<{ ok?: boolean; draft?: boolean; enabled?: boolean; mode?: string; length?: number; text?: string; error?: string }>(PROMPT_ENDPOINT, {
|
|
524
|
+
const data = await fetchJson<{ ok?: boolean; draft?: boolean; enabled?: boolean; mode?: string; length?: number; text?: string; error?: string }>(withWiki(PROMPT_ENDPOINT), {
|
|
524
525
|
method: 'POST',
|
|
525
526
|
headers: { 'content-type': 'application/json' },
|
|
526
527
|
body: JSON.stringify({
|
|
@@ -604,7 +605,7 @@ function renderConfigSection(body: HTMLElement, config: Record<string, unknown>,
|
|
|
604
605
|
return
|
|
605
606
|
}
|
|
606
607
|
try {
|
|
607
|
-
await fetchJson(CONFIG_ENDPOINT, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(patch) })
|
|
608
|
+
await fetchJson(withWiki(CONFIG_ENDPOINT), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(patch) })
|
|
608
609
|
// ui.* 已落盘:清掉客户端缓存,下一次读取立即拿到新值(否则最长 15s 才生效)。
|
|
609
610
|
invalidateUiConfig()
|
|
610
611
|
configState.saveError = undefined
|
|
@@ -931,6 +932,213 @@ function renderCatalogSection(
|
|
|
931
932
|
* stops and restarts the TW child in place — a few seconds, and it can fail
|
|
932
933
|
* (in which case the host rolls back and says so).
|
|
933
934
|
*/
|
|
935
|
+
/** One knowledge base as `GET /admin/wikis` reports it (v0.28.0). */
|
|
936
|
+
interface WikiListItemView {
|
|
937
|
+
id: string
|
|
938
|
+
label: string
|
|
939
|
+
root: string
|
|
940
|
+
name: string
|
|
941
|
+
path: string
|
|
942
|
+
agentVisible: boolean
|
|
943
|
+
autostart: boolean
|
|
944
|
+
running: boolean
|
|
945
|
+
status: string
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/** `GET`/`POST /dsh-tiddlywiki/admin/wikis` payload (v0.28.0). */
|
|
949
|
+
interface WikisView {
|
|
950
|
+
ok?: boolean
|
|
951
|
+
error?: string
|
|
952
|
+
mode?: string
|
|
953
|
+
defaultId?: string
|
|
954
|
+
source?: string
|
|
955
|
+
registryFile?: string
|
|
956
|
+
warnings?: string[]
|
|
957
|
+
wikis?: WikiListItemView[]
|
|
958
|
+
change?: { started?: string[]; stopped?: string[]; updated?: string[]; errors?: Array<{ id: string; message: string }> }
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* 知识库列表(v0.28.0)—— 设置页的**第一块**。
|
|
963
|
+
*
|
|
964
|
+
* 为什么排在最前:多库的其余一切都要先有第二个库才能用,而在此之前多库模式只能靠
|
|
965
|
+
* 手写 `$DSH_HOME/dsh-tiddlywiki/wikis.json` 打开。
|
|
966
|
+
*
|
|
967
|
+
* 它显示的是**控制文件**(用户编辑的那份),与"现在跑着什么"是两件事:single 模式下
|
|
968
|
+
* farm 跑的是从旧指针合成的单条清单,而文件里可能已经列了好几个候选。所以每行同时给出
|
|
969
|
+
* 「在不在清单里」与「现在跑没跑」。
|
|
970
|
+
*
|
|
971
|
+
* 关于「编辑哪个库的配置」:常规配置区作用于**默认库**(★ 那一行)——多库时这是刻意的
|
|
972
|
+
* 简化,`git.*` 实际按仓库生效(见下),其余字段各库自己一份。
|
|
973
|
+
*/
|
|
974
|
+
function renderWikiListSection(body: HTMLElement, isDisposed: () => boolean, refresh: () => Promise<void>): void {
|
|
975
|
+
const section = make('section', 'dsh-tw-settings-section')
|
|
976
|
+
section.append(make('h3', 'dsh-tw-settings-h', '知识库列表'))
|
|
977
|
+
const status = make('div', 'dsh-tw-settings-muted', '读取中…')
|
|
978
|
+
const modeRow = make('div', 'dsh-tw-settings-row')
|
|
979
|
+
const list = make('div')
|
|
980
|
+
const addRoot = make('input', 'dsh-tw-settings-input')
|
|
981
|
+
addRoot.placeholder = '根目录(绝对路径,如 D:\\notes)'
|
|
982
|
+
const addName = make('input', 'dsh-tw-settings-input')
|
|
983
|
+
addName.placeholder = '文件夹名(如 books)'
|
|
984
|
+
const addLabel = make('input', 'dsh-tw-settings-input')
|
|
985
|
+
addLabel.placeholder = '显示名(留空用文件夹名)'
|
|
986
|
+
const addBtn = make('button', 'dsh-tw-settings-btn dsh-tw-settings-primary', '添加知识库')
|
|
987
|
+
addBtn.type = 'button'
|
|
988
|
+
const addRow = make('div', 'dsh-tw-settings-row dsh-tw-settings-field')
|
|
989
|
+
addRow.append(addRoot, addName, addLabel, addBtn)
|
|
990
|
+
const hint = make('div', 'dsh-tw-settings-muted', '多库模式下每个知识库是一个独立的 TW 子进程(约 150MB):标了「开局自启」的随 DSH 一起起,其余在你打开或选中它时启动。对 Agent 隐身的库不进会话选择器,检索/工具也永远不碰它。')
|
|
991
|
+
section.append(status, modeRow, list, addRow, hint)
|
|
992
|
+
body.append(section)
|
|
993
|
+
|
|
994
|
+
/** One action, then re-read (the response already carries the new state). */
|
|
995
|
+
const post = async (payload: Record<string, unknown>, busyLabel: string): Promise<void> => {
|
|
996
|
+
status.textContent = busyLabel
|
|
997
|
+
try {
|
|
998
|
+
const data = await fetchJson<WikisView>(WIKI_LIST_ENDPOINT, {
|
|
999
|
+
method: 'POST',
|
|
1000
|
+
headers: { 'content-type': 'application/json' },
|
|
1001
|
+
body: JSON.stringify(payload),
|
|
1002
|
+
// 新增/启动一个库可能要 `--init server` 并冷启动 TW(实测大库 40s+)。
|
|
1003
|
+
signal: AbortSignal.timeout(180_000),
|
|
1004
|
+
})
|
|
1005
|
+
if (isDisposed()) return
|
|
1006
|
+
const failures = data.change?.errors ?? []
|
|
1007
|
+
if (failures.length > 0) toast(`部分操作失败:${failures.map((item) => `${item.id}:${item.message}`).join(';')}`)
|
|
1008
|
+
render(data)
|
|
1009
|
+
await refresh()
|
|
1010
|
+
} catch (err) {
|
|
1011
|
+
if (isDisposed()) return
|
|
1012
|
+
toast(`操作失败:${err instanceof Error ? err.message : String(err)}`)
|
|
1013
|
+
await load()
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
const render = (view: WikisView): void => {
|
|
1018
|
+
status.textContent = view.error !== undefined && view.error.length > 0 ? `⚠️ ${view.error}` : ''
|
|
1019
|
+
modeRow.replaceChildren(make('span', 'dsh-tw-settings-label', '运行模式:'))
|
|
1020
|
+
const modes: Array<[string, string]> = [
|
|
1021
|
+
['single', '单库(只跑默认库)'],
|
|
1022
|
+
['multi', '多库(清单里的库都可用)'],
|
|
1023
|
+
]
|
|
1024
|
+
for (const [value, text] of modes) {
|
|
1025
|
+
const active = view.mode === value
|
|
1026
|
+
const chip = make('button', `dsh-tw-settings-btn dsh-tw-settings-chipbtn${active ? ' dsh-tw-settings-primary' : ''}`, text)
|
|
1027
|
+
chip.type = 'button'
|
|
1028
|
+
chip.disabled = active
|
|
1029
|
+
chip.addEventListener('click', () => {
|
|
1030
|
+
const ok = window.confirm(value === 'multi'
|
|
1031
|
+
? '切换到多库模式:清单里标了「开局自启」的知识库都会启动(每个库一个 TW 子进程)。继续?'
|
|
1032
|
+
: '切换到单库模式:除默认库外的知识库会被停掉(内容与清单都不受影响)。继续?')
|
|
1033
|
+
if (ok) void post({ action: 'set-mode', mode: value }, '切换模式中…')
|
|
1034
|
+
})
|
|
1035
|
+
modeRow.append(chip)
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
list.replaceChildren()
|
|
1039
|
+
const wikis = view.wikis ?? []
|
|
1040
|
+
for (const wiki of wikis) {
|
|
1041
|
+
const row = make('div', 'dsh-tw-settings-kbrow')
|
|
1042
|
+
const isDefault = wiki.id === view.defaultId
|
|
1043
|
+
row.append(make('div', 'dsh-tw-settings-label', `${isDefault ? '★ ' : ''}${wiki.label}(${wiki.id})`))
|
|
1044
|
+
row.append(make('div', 'dsh-tw-settings-muted', [
|
|
1045
|
+
wiki.running ? '运行中' : '未运行',
|
|
1046
|
+
wiki.agentVisible ? 'Agent 可见' : 'Agent 隐身',
|
|
1047
|
+
wiki.autostart ? '开局自启' : '按需启动',
|
|
1048
|
+
wiki.path,
|
|
1049
|
+
].join(' · ')))
|
|
1050
|
+
|
|
1051
|
+
const actions = make('div', 'dsh-tw-settings-row')
|
|
1052
|
+
// 「配置」把常规配置区切到这个库(每个库的配置存在它自己的 config tiddler 里,
|
|
1053
|
+
// 不说清楚就会出现"改了但不生效")。
|
|
1054
|
+
const configuring = editingWiki === wiki.id
|
|
1055
|
+
const configure = make('button', `dsh-tw-settings-btn dsh-tw-settings-chipbtn${configuring ? ' dsh-tw-settings-primary' : ''}`, configuring ? '正在配置' : '配置')
|
|
1056
|
+
configure.type = 'button'
|
|
1057
|
+
configure.disabled = configuring
|
|
1058
|
+
configure.addEventListener('click', () => {
|
|
1059
|
+
editingWiki = wiki.id
|
|
1060
|
+
void refresh()
|
|
1061
|
+
})
|
|
1062
|
+
const power = make('button', 'dsh-tw-settings-btn dsh-tw-settings-chipbtn', wiki.running ? '停止' : '启动')
|
|
1063
|
+
power.type = 'button'
|
|
1064
|
+
power.addEventListener('click', () => {
|
|
1065
|
+
void post({ action: wiki.running ? 'stop' : 'start', id: wiki.id }, wiki.running ? '停止中…' : '启动中…')
|
|
1066
|
+
})
|
|
1067
|
+
const makeDefault = make('button', 'dsh-tw-settings-btn dsh-tw-settings-chipbtn', '设为默认')
|
|
1068
|
+
makeDefault.type = 'button'
|
|
1069
|
+
makeDefault.disabled = isDefault
|
|
1070
|
+
makeDefault.addEventListener('click', () => { void post({ action: 'set-default', id: wiki.id }, '保存中…') })
|
|
1071
|
+
const visibility = make('button', 'dsh-tw-settings-btn dsh-tw-settings-chipbtn', wiki.agentVisible ? '对 Agent 隐身' : '对 Agent 可见')
|
|
1072
|
+
visibility.type = 'button'
|
|
1073
|
+
visibility.addEventListener('click', () => {
|
|
1074
|
+
void post({ action: 'update', wiki: { ...wiki, agentVisible: !wiki.agentVisible } }, '保存中…')
|
|
1075
|
+
})
|
|
1076
|
+
const autostart = make('button', 'dsh-tw-settings-btn dsh-tw-settings-chipbtn', wiki.autostart ? '取消开局自启' : '开局自启')
|
|
1077
|
+
autostart.type = 'button'
|
|
1078
|
+
autostart.addEventListener('click', () => {
|
|
1079
|
+
void post({ action: 'update', wiki: { ...wiki, autostart: !wiki.autostart } }, '保存中…')
|
|
1080
|
+
})
|
|
1081
|
+
const remove = make('button', 'dsh-tw-settings-btn dsh-tw-settings-chipbtn', '移出列表')
|
|
1082
|
+
remove.type = 'button'
|
|
1083
|
+
remove.disabled = wikis.length <= 1
|
|
1084
|
+
remove.addEventListener('click', () => {
|
|
1085
|
+
const ok = window.confirm(`把「${wiki.label}」移出清单?\n\n**目录与内容不会被删除**(仍在 ${wiki.path}),只是插件不再管理它。`)
|
|
1086
|
+
if (ok) void post({ action: 'remove', id: wiki.id }, '移出中…')
|
|
1087
|
+
})
|
|
1088
|
+
actions.append(configure, power, makeDefault, visibility, autostart, remove)
|
|
1089
|
+
row.append(actions)
|
|
1090
|
+
list.append(row)
|
|
1091
|
+
}
|
|
1092
|
+
// 新增时默认沿用第一个库的根目录(多数情况就是同一个父目录)。
|
|
1093
|
+
if (addRoot.value.length === 0 && typeof wikis[0]?.root === 'string') addRoot.value = wikis[0].root
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
addBtn.addEventListener('click', () => {
|
|
1097
|
+
const root = addRoot.value.trim()
|
|
1098
|
+
const name = addName.value.trim()
|
|
1099
|
+
if (root.length === 0 || name.length === 0) {
|
|
1100
|
+
toast('请填写根目录与文件夹名')
|
|
1101
|
+
return
|
|
1102
|
+
}
|
|
1103
|
+
const wiki: Record<string, unknown> = { root, name }
|
|
1104
|
+
const label = addLabel.value.trim()
|
|
1105
|
+
if (label.length > 0) wiki.label = label
|
|
1106
|
+
void post({ action: 'add', wiki }, '添加中(新库需要初始化与冷启动,请稍候)…')
|
|
1107
|
+
})
|
|
1108
|
+
|
|
1109
|
+
const load = async (): Promise<void> => {
|
|
1110
|
+
if (isDisposed()) return
|
|
1111
|
+
try {
|
|
1112
|
+
const view = await fetchJson<WikisView>(WIKI_LIST_ENDPOINT)
|
|
1113
|
+
if (isDisposed()) return
|
|
1114
|
+
render(view)
|
|
1115
|
+
} catch (err) {
|
|
1116
|
+
if (isDisposed()) return
|
|
1117
|
+
status.textContent = `读取知识库列表失败:${err instanceof Error ? err.message : String(err)}`
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
void load()
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* WHICH knowledge base the config block is editing (v0.28.0).
|
|
1125
|
+
*
|
|
1126
|
+
* Every per-wiki setting lives in that wiki's OWN
|
|
1127
|
+
* `$:/plugins/dsh-tiddlywiki/config` tiddler, so `/admin/state`, `/admin/config`
|
|
1128
|
+
* and `/admin/prompt` must be told which one. Without this, configuring the books
|
|
1129
|
+
* wiki would silently edit the DEFAULT wiki — a user would set `git.remote`, see
|
|
1130
|
+
* it saved, and never learn why pushes still went to the other repository.
|
|
1131
|
+
*
|
|
1132
|
+
* `undefined` = the default wiki, which is exactly what every route did before
|
|
1133
|
+
* this existed (and what a single-wiki install always gets).
|
|
1134
|
+
*/
|
|
1135
|
+
let editingWiki: string | undefined
|
|
1136
|
+
|
|
1137
|
+
/** Append the editing scope to a per-wiki admin URL. */
|
|
1138
|
+
function withWiki(url: string): string {
|
|
1139
|
+
return editingWiki === undefined ? url : `${url}?wiki=${encodeURIComponent(editingWiki)}`
|
|
1140
|
+
}
|
|
1141
|
+
|
|
934
1142
|
function renderWikiLocationSection(body: HTMLElement, isDisposed: () => boolean, refresh: () => Promise<void>): void {
|
|
935
1143
|
const section = make('section', 'dsh-tw-settings-section')
|
|
936
1144
|
section.append(make('h3', 'dsh-tw-settings-h', '知识库位置(可切换)'))
|
|
@@ -1322,7 +1530,13 @@ function renderMain(body: HTMLElement, state: AdminState, refresh: () => Promise
|
|
|
1322
1530
|
configState.host = host
|
|
1323
1531
|
configState.signature = signature
|
|
1324
1532
|
}
|
|
1533
|
+
// 配置作用域(v0.28.0):每个库的配置存在它自己的 config tiddler 里,含糊其辞就会
|
|
1534
|
+
// 出现"改了但不生效"——所以这一行必须说清下面的表单在编辑哪个库。
|
|
1535
|
+
body.append(make('div', 'dsh-tw-settings-muted', editingWiki === undefined
|
|
1536
|
+
? '配置作用域:默认知识库(在「知识库列表」里点某行的「配置」可切到那个库)'
|
|
1537
|
+
: `配置作用域:${editingWiki}(在「知识库列表」里改;编辑的是该库自己的配置 tiddler)`))
|
|
1325
1538
|
body.append(configState.host)
|
|
1539
|
+
renderWikiListSection(body, isDisposed, refresh)
|
|
1326
1540
|
renderWikiLocationSection(body, isDisposed, refresh)
|
|
1327
1541
|
renderCatalogSection(body, state.info, state.catalog, state.runtimePlugins, refresh, catalogPending)
|
|
1328
1542
|
renderSeedsSection(body, isDisposed)
|
package/src/client/state.ts
CHANGED
|
@@ -1,39 +1,39 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tiny pub/sub panel state shared by the sidebar entry and the center panel.
|
|
3
|
-
*
|
|
4
|
-
* @module dsh-tiddlywiki/client/state
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
export type PanelListener = (open: boolean) => void
|
|
8
|
-
|
|
9
|
-
export class PanelState {
|
|
10
|
-
private open = false
|
|
11
|
-
private readonly listeners = new Set<PanelListener>()
|
|
12
|
-
|
|
13
|
-
isOpen(): boolean {
|
|
14
|
-
return this.open
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
toggle(): void {
|
|
18
|
-
this.set(!this.open)
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
openPanel(): void {
|
|
22
|
-
this.set(true)
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
closePanel(): void {
|
|
26
|
-
this.set(false)
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
set(value: boolean): void {
|
|
30
|
-
if (this.open === value) return
|
|
31
|
-
this.open = value
|
|
32
|
-
for (const listener of [...this.listeners]) listener(value)
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
subscribe(listener: PanelListener): () => void {
|
|
36
|
-
this.listeners.add(listener)
|
|
37
|
-
return () => { this.listeners.delete(listener) }
|
|
38
|
-
}
|
|
39
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Tiny pub/sub panel state shared by the sidebar entry and the center panel.
|
|
3
|
+
*
|
|
4
|
+
* @module dsh-tiddlywiki/client/state
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type PanelListener = (open: boolean) => void
|
|
8
|
+
|
|
9
|
+
export class PanelState {
|
|
10
|
+
private open = false
|
|
11
|
+
private readonly listeners = new Set<PanelListener>()
|
|
12
|
+
|
|
13
|
+
isOpen(): boolean {
|
|
14
|
+
return this.open
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
toggle(): void {
|
|
18
|
+
this.set(!this.open)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
openPanel(): void {
|
|
22
|
+
this.set(true)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
closePanel(): void {
|
|
26
|
+
this.set(false)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
set(value: boolean): void {
|
|
30
|
+
if (this.open === value) return
|
|
31
|
+
this.open = value
|
|
32
|
+
for (const listener of [...this.listeners]) listener(value)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
subscribe(listener: PanelListener): () => void {
|
|
36
|
+
this.listeners.add(listener)
|
|
37
|
+
return () => { this.listeners.delete(listener) }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -43,6 +43,23 @@ export interface StatusPayload {
|
|
|
43
43
|
behind?: number
|
|
44
44
|
}
|
|
45
45
|
note?: { tag?: string }
|
|
46
|
+
/**
|
|
47
|
+
* The knowledge-base roster (v0.28.0). `mode` decides whether per-wiki paths
|
|
48
|
+
* are used at all; `defaultId` is what the agent falls back to. Deliberately
|
|
49
|
+
* WITHOUT per-wiki git status — that would spawn up to five git processes per
|
|
50
|
+
* wiki on every poll.
|
|
51
|
+
*/
|
|
52
|
+
mode?: string
|
|
53
|
+
defaultId?: string
|
|
54
|
+
wikis?: Array<{
|
|
55
|
+
id: string
|
|
56
|
+
label: string
|
|
57
|
+
status: string
|
|
58
|
+
agentVisible: boolean
|
|
59
|
+
autostart: boolean
|
|
60
|
+
running: boolean
|
|
61
|
+
path: string
|
|
62
|
+
}>
|
|
46
63
|
ui?: {
|
|
47
64
|
showQuickNote?: boolean
|
|
48
65
|
showQuickNoteDock?: boolean
|
package/src/client/styles.ts
CHANGED
|
@@ -299,6 +299,12 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
|
|
|
299
299
|
/* ── settings page (config panel §13) ────────────────────────── */
|
|
300
300
|
.dsh-tw-settings { display: flex; flex-direction: column; gap: 10px; padding: 12px 16px; min-width: 0; }
|
|
301
301
|
.dsh-tw-settings-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
|
302
|
+
/* 知识库列表里的一行(v0.28.0):卡片式,便于扫读"哪个库、在哪、在不在跑"。 */
|
|
303
|
+
.dsh-tw-settings-kbrow {
|
|
304
|
+
display: flex; flex-direction: column; gap: 4px;
|
|
305
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.12)); border-radius: 8px;
|
|
306
|
+
padding: 8px 10px; margin: 0 0 8px;
|
|
307
|
+
}
|
|
302
308
|
.dsh-tw-settings-chip {
|
|
303
309
|
font-size: 12px; padding: 2px 10px; border-radius: 999px;
|
|
304
310
|
border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.25));
|
|
@@ -486,6 +492,27 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
|
|
|
486
492
|
}
|
|
487
493
|
.dsh-tw-fab-item:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); }
|
|
488
494
|
.dsh-tw-fab-item:active { background: var(--dsw-alias-interactive-bg-active, rgba(128,128,128,.18)); }
|
|
495
|
+
/* 多知识库切换分组(v0.28.0):一行小标题 + 每库一行;当前焦点用 ● 标记。 */
|
|
496
|
+
.dsh-tw-fab-group {
|
|
497
|
+
font-size: 11px; opacity: .6; padding: 6px 9px 2px; letter-spacing: .04em;
|
|
498
|
+
}
|
|
499
|
+
.dsh-tw-fab-item[data-current="1"] { font-weight: 600; }
|
|
500
|
+
|
|
501
|
+
/* ── 会话级知识库选择器(v0.28.0,同一个 conversation.input.dock 槽位)──
|
|
502
|
+
与快速笔记按钮同区、纵向 flex 排列;只在"多于一个可见库"时渲染(组件自己返回
|
|
503
|
+
null),所以单库安装这一整块都不存在。 */
|
|
504
|
+
.dsh-tw-scope-dock {
|
|
505
|
+
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
|
506
|
+
padding: 4px 8px 0; box-sizing: border-box; width: 100%; font-size: 12px;
|
|
507
|
+
}
|
|
508
|
+
.dsh-tw-scope-label { opacity: .7; }
|
|
509
|
+
.dsh-tw-scope-select {
|
|
510
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
|
|
511
|
+
background: var(--dsw-alias-bg-layer-2, #fff); color: inherit; font: inherit; font-size: 12px;
|
|
512
|
+
padding: 3px 8px; border-radius: 999px; max-width: 260px;
|
|
513
|
+
}
|
|
514
|
+
.dsh-tw-scope-select:disabled { opacity: .6; }
|
|
515
|
+
.dsh-tw-scope-note { opacity: .75; }
|
|
489
516
|
|
|
490
517
|
/* ── input-dock quick-note button(聊天输入框上方,conversation.input.dock)──
|
|
491
518
|
该槽位渲染为「输入框上方的全宽条目」纵向 flex 排列(todo/cost-meter/goal/
|
package/src/client/toast.ts
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One-shot transient toast, shared by the quick-note widget and the settings
|
|
3
|
-
* page. Reuses a single body-level element (`.dsh-tw-toast`) with a show
|
|
4
|
-
* class; the element is created lazily and styled by styles.ts.
|
|
5
|
-
*
|
|
6
|
-
* @module dsh-tiddlywiki/client/toast
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
export function toast(message: string): void {
|
|
10
|
-
let el = document.querySelector<HTMLElement>('.dsh-tw-toast')
|
|
11
|
-
if (el === null) {
|
|
12
|
-
el = document.createElement('div')
|
|
13
|
-
el.className = 'dsh-tw-toast'
|
|
14
|
-
document.body.append(el)
|
|
15
|
-
}
|
|
16
|
-
el.textContent = message
|
|
17
|
-
el.classList.add('dsh-tw-toast-show')
|
|
18
|
-
clearTimeout((el as HTMLElement & { __twToastTimer?: number }).__twToastTimer)
|
|
19
|
-
;(el as HTMLElement & { __twToastTimer?: number }).__twToastTimer = window.setTimeout(() => {
|
|
20
|
-
el?.classList.remove('dsh-tw-toast-show')
|
|
21
|
-
}, 2_500)
|
|
22
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* One-shot transient toast, shared by the quick-note widget and the settings
|
|
3
|
+
* page. Reuses a single body-level element (`.dsh-tw-toast`) with a show
|
|
4
|
+
* class; the element is created lazily and styled by styles.ts.
|
|
5
|
+
*
|
|
6
|
+
* @module dsh-tiddlywiki/client/toast
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export function toast(message: string): void {
|
|
10
|
+
let el = document.querySelector<HTMLElement>('.dsh-tw-toast')
|
|
11
|
+
if (el === null) {
|
|
12
|
+
el = document.createElement('div')
|
|
13
|
+
el.className = 'dsh-tw-toast'
|
|
14
|
+
document.body.append(el)
|
|
15
|
+
}
|
|
16
|
+
el.textContent = message
|
|
17
|
+
el.classList.add('dsh-tw-toast-show')
|
|
18
|
+
clearTimeout((el as HTMLElement & { __twToastTimer?: number }).__twToastTimer)
|
|
19
|
+
;(el as HTMLElement & { __twToastTimer?: number }).__twToastTimer = window.setTimeout(() => {
|
|
20
|
+
el?.classList.remove('dsh-tw-toast-show')
|
|
21
|
+
}, 2_500)
|
|
22
|
+
}
|
package/src/client/tw-frame.ts
CHANGED
|
@@ -27,8 +27,9 @@
|
|
|
27
27
|
* @module dsh-tiddlywiki/client/tw-frame
|
|
28
28
|
*/
|
|
29
29
|
import * as React from 'react'
|
|
30
|
-
import { RESTART_ENDPOINT, resolveTwUrl } from './endpoints.ts'
|
|
30
|
+
import { RESTART_ENDPOINT, resolveTwUrl, twProxyFor } from './endpoints.ts'
|
|
31
31
|
import { fetchStatus } from './status-cache.ts'
|
|
32
|
+
import { getFocusWiki, subscribeFocusWiki } from './wiki-focus.ts'
|
|
32
33
|
import { attachThemeSync, setThemeSyncConfig } from './theme-sync.ts'
|
|
33
34
|
|
|
34
35
|
/** Cross-plugin activation event; detail is the activating panel name. */
|
|
@@ -140,7 +141,16 @@ export interface TwFrameSurface {
|
|
|
140
141
|
* (starting / error / running, iframe URL, theme config, shared chip label)
|
|
141
142
|
* and every tiddler-hash navigation is implemented here ONCE.
|
|
142
143
|
*/
|
|
143
|
-
|
|
144
|
+
/**
|
|
145
|
+
* Per-surface hooks (v0.28.0). `wikiId` answers "which knowledge base should
|
|
146
|
+
* this surface embed?" — the center panel and the rightbar tab both embed the
|
|
147
|
+
* wiki the user focused, and reload when that choice changes.
|
|
148
|
+
*/
|
|
149
|
+
export interface TwFrameHooks {
|
|
150
|
+
wikiId?: () => string | undefined
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function createTwFrameSurface(skin: TwFrameSkin, hooks: TwFrameHooks = {}): TwFrameSurface {
|
|
144
154
|
let visible = false
|
|
145
155
|
let started = false
|
|
146
156
|
let disposed = false
|
|
@@ -318,7 +328,10 @@ export function createTwFrameSurface(skin: TwFrameSkin): TwFrameSurface {
|
|
|
318
328
|
// resolveTwUrl prefers the host's ABSOLUTE loopback base when THIS page
|
|
319
329
|
// is not on http(s) (the DSH desktop app's `dsh-app:` renderer), because
|
|
320
330
|
// TW refuses to load its sync adaptor anywhere else — see resolveTwUrl.
|
|
321
|
-
|
|
331
|
+
// WHICH knowledge base (v0.28.0): the focused one in multi mode, else the
|
|
332
|
+
// bare path (= the default wiki, exactly as before).
|
|
333
|
+
const bases = twProxyFor(payload.mode, hooks.wikiId?.(), payload.twProxy, payload.twProxyAbsolute)
|
|
334
|
+
showFrame(resolveTwUrl(bases.relative, bases.absolute))
|
|
322
335
|
} else if (typeof payload.url === 'string') {
|
|
323
336
|
showFrame(payload.url)
|
|
324
337
|
} else {
|
|
@@ -355,6 +368,16 @@ export function createTwFrameSurface(skin: TwFrameSkin): TwFrameSurface {
|
|
|
355
368
|
}
|
|
356
369
|
document.addEventListener(PANEL_RELOAD_EVENT, onReloadRequest)
|
|
357
370
|
|
|
371
|
+
/**
|
|
372
|
+
* Switching the focused knowledge base needs a different proxy path, so the
|
|
373
|
+
* frame reloads (v0.28.0). TW reloads inside the iframe — one iframe cannot
|
|
374
|
+
* host two editors, and silently keeping the old wiki's data while the UI says
|
|
375
|
+
* otherwise is exactly the confusion this feature must not create.
|
|
376
|
+
*/
|
|
377
|
+
const unsubscribeFocus = subscribeFocusWiki(() => {
|
|
378
|
+
if (!disposed && visible) void doRefresh()
|
|
379
|
+
})
|
|
380
|
+
|
|
358
381
|
const build = (): HTMLDivElement => {
|
|
359
382
|
if (view !== undefined) return view
|
|
360
383
|
const viewEl = document.createElement('div')
|
|
@@ -429,6 +452,7 @@ export function createTwFrameSurface(skin: TwFrameSkin): TwFrameSurface {
|
|
|
429
452
|
if (disposed) return
|
|
430
453
|
disposed = true
|
|
431
454
|
document.removeEventListener(PANEL_RELOAD_EVENT, onReloadRequest)
|
|
455
|
+
unsubscribeFocus()
|
|
432
456
|
clearRetry()
|
|
433
457
|
for (const timer of hashWaitTimers) window.clearTimeout(timer)
|
|
434
458
|
hashWaitTimers.clear()
|
|
@@ -505,7 +529,7 @@ export function createTwFrameController(host: HTMLElement, signal: AbortSignal):
|
|
|
505
529
|
// liveFrames forever. Hand back a no-op controller and build nothing.
|
|
506
530
|
if (signal.aborted) return disposedController()
|
|
507
531
|
|
|
508
|
-
const surface = createTwFrameSurface(RIGHTBAR_SKIN)
|
|
532
|
+
const surface = createTwFrameSurface(RIGHTBAR_SKIN, { wikiId: getFocusWiki })
|
|
509
533
|
host.append(surface.build())
|
|
510
534
|
|
|
511
535
|
let disposed = false
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which knowledge base the GUI is currently working in (v0.28.0).
|
|
3
|
+
*
|
|
4
|
+
* The GUI is single-focus by design: the center panel and the rightbar tab each
|
|
5
|
+
* embed ONE wiki's editor at a time (`/tw/<id>/…`), and the FAB lists the others.
|
|
6
|
+
* The choice is remembered PER BROWSER (localStorage) rather than per session —
|
|
7
|
+
* it is a viewing preference, and reopening the GUI should land where you left
|
|
8
|
+
* off.
|
|
9
|
+
*
|
|
10
|
+
* A remembered id is only a HINT: `resolveFocusWiki()` validates it against the
|
|
11
|
+
* live roster from `/status` and falls back to the registry's default, so
|
|
12
|
+
* removing or renaming a wiki can never strand the panel on a 404.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-tiddlywiki/client/wiki-focus
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** localStorage key holding the focused wiki id. */
|
|
18
|
+
const STORAGE_KEY = 'dsh-tiddlywiki.focusWiki'
|
|
19
|
+
|
|
20
|
+
function readStored(): string | undefined {
|
|
21
|
+
try {
|
|
22
|
+
const raw = window.localStorage.getItem(STORAGE_KEY)
|
|
23
|
+
return raw !== null && raw.length > 0 ? raw : undefined
|
|
24
|
+
} catch {
|
|
25
|
+
// Private mode / storage disabled: the focus simply is not remembered.
|
|
26
|
+
return undefined
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let focusId: string | undefined = readStored()
|
|
31
|
+
const listeners = new Set<(id: string | undefined) => void>()
|
|
32
|
+
|
|
33
|
+
/** The remembered wiki id (may not exist any more — use `resolveFocusWiki`). */
|
|
34
|
+
export function getFocusWiki(): string | undefined {
|
|
35
|
+
return focusId
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Remember a new focused wiki (and notify every surface). */
|
|
39
|
+
export function setFocusWiki(id: string | undefined): void {
|
|
40
|
+
if (id === focusId) return
|
|
41
|
+
focusId = id
|
|
42
|
+
try {
|
|
43
|
+
if (id === undefined) window.localStorage.removeItem(STORAGE_KEY)
|
|
44
|
+
else window.localStorage.setItem(STORAGE_KEY, id)
|
|
45
|
+
} catch {
|
|
46
|
+
/* a preference that cannot be persisted is still honoured for this page */
|
|
47
|
+
}
|
|
48
|
+
for (const listener of [...listeners]) listener(id)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Subscribe to focus changes; returns the unsubscribe function. */
|
|
52
|
+
export function subscribeFocusWiki(listener: (id: string | undefined) => void): () => void {
|
|
53
|
+
listeners.add(listener)
|
|
54
|
+
return () => { listeners.delete(listener) }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The minimum a roster entry must expose to be focusable. */
|
|
58
|
+
export interface FocusableWiki { id: string }
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The wiki the GUI should actually show: the remembered one when it still
|
|
62
|
+
* exists, else the registry default, else the first entry. `undefined` only when
|
|
63
|
+
* there is nothing to show at all.
|
|
64
|
+
*/
|
|
65
|
+
export function resolveFocusWiki(roster: readonly FocusableWiki[], defaultId: string | undefined): string | undefined {
|
|
66
|
+
if (roster.length === 0) return focusId
|
|
67
|
+
if (focusId !== undefined && roster.some((entry) => entry.id === focusId)) return focusId
|
|
68
|
+
if (defaultId !== undefined && roster.some((entry) => entry.id === defaultId)) return defaultId
|
|
69
|
+
return roster[0]?.id
|
|
70
|
+
}
|