dsh-tiddlywiki 0.28.2 → 0.28.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.
- package/README.md +1 -0
- package/docs/CHANGELOG.md +1 -0
- package/lib/client.js +21 -7
- package/lib/index.js +22 -0
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/dock-align.ts +121 -0
- package/src/client/note-widget.ts +10 -1
- package/src/client/quick-note-dock.ts +5 -80
- package/src/client/sidebar-entry.ts +102 -11
- package/src/client/styles.ts +15 -1
- package/src/client/wiki-scope-dock.ts +12 -1
- package/src/host/seed-notes.ts +22 -0
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.3",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"dsh",
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Align a `conversation.input.dock` entry with the composer input card.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS (v0.28.2)
|
|
5
|
+
* -------------------------
|
|
6
|
+
* The dock slot renders entries as a full-width flex column that is a SIBLING of
|
|
7
|
+
* the composer, not inside it. The composer's input card is centred and has its
|
|
8
|
+
* own max-width, so an entry's right edge sits far to the right of the input box
|
|
9
|
+
* — visibly misaligned.
|
|
10
|
+
*
|
|
11
|
+
* The fix is to measure the composer card and pad the entry's right side by the
|
|
12
|
+
* difference. That measurement used to live only inside `quick-note-dock.ts`, so
|
|
13
|
+
* the moment a SECOND dock entry was added (the per-session knowledge-base
|
|
14
|
+
* selector) it simply did not align — nobody had published the rule. This module
|
|
15
|
+
* is that rule, written once.
|
|
16
|
+
*
|
|
17
|
+
* HOW IT FINDS THE CARD
|
|
18
|
+
* ---------------------
|
|
19
|
+
* Climb from the entry until an ancestor's subtree contains a wide text field
|
|
20
|
+
* (>300px — the composer input; dock entries' own inputs are small), then take
|
|
21
|
+
* the widest box between that field and its containing column (the visible card).
|
|
22
|
+
*
|
|
23
|
+
* WHEN IT RE-MEASURES
|
|
24
|
+
* -------------------
|
|
25
|
+
* ResizeObserver on the whole ancestor chain up to the conversation root
|
|
26
|
+
* (`[data-phase]`), plus `resize` / `visibilitychange`, plus a short self-healing
|
|
27
|
+
* interval that stops once the layout has measured cleanly three times (or after
|
|
28
|
+
* 20 tries, so a shell we cannot understand never leaks a timer).
|
|
29
|
+
*
|
|
30
|
+
* @module dsh-tiddlywiki/client/dock-align
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** The measurement/padding key, exported so the gate can assert on it. */
|
|
34
|
+
export const DOCK_ALIGN_MIN_INPUT_WIDTH = 300
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Start aligning `wrap` with the composer input card. Returns the disposer; call
|
|
38
|
+
* it from the component's effect cleanup.
|
|
39
|
+
*
|
|
40
|
+
* A React ref is intentionally NOT taken: the caller passes its own element, so
|
|
41
|
+
* this works for any dock entry regardless of how it renders.
|
|
42
|
+
*/
|
|
43
|
+
export function alignDockEntry(wrap: HTMLElement): () => void {
|
|
44
|
+
/** Find the composer input CARD (see the module doc for the algorithm). */
|
|
45
|
+
const findCard = (): HTMLElement | null => {
|
|
46
|
+
let input: Element | null = null
|
|
47
|
+
let column: HTMLElement | null = null
|
|
48
|
+
let node: HTMLElement | null = wrap
|
|
49
|
+
while (node !== null && node !== document.documentElement) {
|
|
50
|
+
node = node.parentElement
|
|
51
|
+
if (node === null) break
|
|
52
|
+
for (const el of node.querySelectorAll('textarea, [contenteditable]:not([contenteditable="false"]), [role="textbox"]')) {
|
|
53
|
+
if (el.getBoundingClientRect().width > DOCK_ALIGN_MIN_INPUT_WIDTH) { input = el; column = node; break }
|
|
54
|
+
}
|
|
55
|
+
if (column !== null) break
|
|
56
|
+
}
|
|
57
|
+
if (input === null || column === null) return null
|
|
58
|
+
const colW = column.getBoundingClientRect().width
|
|
59
|
+
let cur: HTMLElement | null = input as HTMLElement
|
|
60
|
+
let card: HTMLElement | null = null
|
|
61
|
+
let cardW = 0
|
|
62
|
+
while (cur !== null && cur !== column) {
|
|
63
|
+
const w = cur.getBoundingClientRect().width
|
|
64
|
+
if (w > 0 && w < colW - 8 && w >= cardW) { cardW = w; card = cur }
|
|
65
|
+
cur = cur.parentElement
|
|
66
|
+
}
|
|
67
|
+
return card
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Measure once; returns true when the composer card was found (valid measure). */
|
|
71
|
+
const align = (): boolean => {
|
|
72
|
+
const wrapRect = wrap.getBoundingClientRect()
|
|
73
|
+
const card = findCard()
|
|
74
|
+
const right = card !== null ? card.getBoundingClientRect().right : wrapRect.right
|
|
75
|
+
const pad = Math.max(0, wrapRect.right - right)
|
|
76
|
+
if (wrap.style.paddingRight !== `${pad}px`) wrap.style.paddingRight = `${pad}px`
|
|
77
|
+
return card !== null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
align()
|
|
81
|
+
// A sidebar toggle changes the conversation column width → the centred,
|
|
82
|
+
// max-width composer card moves → the padding must be re-measured. Observing
|
|
83
|
+
// only the dock container misses that (it may not resize at all), so observe
|
|
84
|
+
// the whole ancestor chain up to the conversation root.
|
|
85
|
+
const ro = new ResizeObserver(() => { align() })
|
|
86
|
+
let node: Element | null = wrap
|
|
87
|
+
while (node !== null && node !== document.documentElement) {
|
|
88
|
+
ro.observe(node)
|
|
89
|
+
if (node instanceof HTMLElement && node.hasAttribute('data-phase')) break
|
|
90
|
+
node = node.parentElement
|
|
91
|
+
}
|
|
92
|
+
window.addEventListener('resize', align)
|
|
93
|
+
document.addEventListener('visibilitychange', align)
|
|
94
|
+
|
|
95
|
+
// Self-healing fallback for layout changes we cannot observe. Each tick is a
|
|
96
|
+
// few getBoundingClientRect reads. It stops after 3 clean measurements, and
|
|
97
|
+
// gives up after 20 tries so an unrecognisable shell cannot leak a timer.
|
|
98
|
+
let guardHits = 0
|
|
99
|
+
let guardTicks = 0
|
|
100
|
+
const guard = window.setInterval(() => {
|
|
101
|
+
guardTicks++
|
|
102
|
+
if (!align()) {
|
|
103
|
+
if (guardTicks >= 20) window.clearInterval(guard)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
guardHits++
|
|
107
|
+
if (guardHits >= 3) window.clearInterval(guard)
|
|
108
|
+
}, 1500)
|
|
109
|
+
// The composer card may mount slightly later; re-measure a couple of times.
|
|
110
|
+
const t1 = window.setTimeout(align, 120)
|
|
111
|
+
const t2 = window.setTimeout(align, 600)
|
|
112
|
+
|
|
113
|
+
return () => {
|
|
114
|
+
ro.disconnect()
|
|
115
|
+
window.removeEventListener('resize', align)
|
|
116
|
+
document.removeEventListener('visibilitychange', align)
|
|
117
|
+
window.clearInterval(guard)
|
|
118
|
+
window.clearTimeout(t1)
|
|
119
|
+
window.clearTimeout(t2)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -835,7 +835,16 @@ export function createNoteWidget(): NoteWidgetHandle {
|
|
|
835
835
|
if (disposed) return
|
|
836
836
|
rosterMode = typeof payload?.mode === 'string' ? payload.mode : 'single'
|
|
837
837
|
roster = (Array.isArray(payload?.wikis) ? payload.wikis : []).map((item) => ({ id: item.id, label: item.label, running: item.running }))
|
|
838
|
-
|
|
838
|
+
// 单库:连选择器都不该存在。这里**还有一个**兜底 —— 除 `hidden` 外把 select 禁用、
|
|
839
|
+
// 标签也藏掉,因为"能看见一个没有选项的下拉"比"什么都不显示"糟得多(v0.28.1 修)。
|
|
840
|
+
if (roster.length <= 1) {
|
|
841
|
+
wikiField.hidden = true
|
|
842
|
+
wikiField.style.display = 'none'
|
|
843
|
+
wikiSelect.disabled = true
|
|
844
|
+
return
|
|
845
|
+
}
|
|
846
|
+
wikiField.style.display = ''
|
|
847
|
+
wikiSelect.disabled = false
|
|
839
848
|
const defaultId = typeof payload?.defaultId === 'string' ? payload.defaultId : undefined
|
|
840
849
|
const followFocus = (): void => {
|
|
841
850
|
if (!targetPicked) targetWiki = resolveFocusWiki(roster, defaultId)
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import * as React from 'react'
|
|
18
18
|
import { NOTE_STATE_EVENT, type NoteWidgetHandle } from './note-widget.ts'
|
|
19
19
|
import { fetchUiConfig } from './ui-config.ts'
|
|
20
|
+
import { alignDockEntry } from './dock-align.ts'
|
|
20
21
|
import { isEditorPopupOpen, isEditorPopupBlank, closeEditorPopup } from './editor-popup.ts'
|
|
21
22
|
|
|
22
23
|
/**
|
|
@@ -54,89 +55,13 @@ export function createQuickNoteDock(note: NoteWidgetHandle): () => React.ReactEl
|
|
|
54
55
|
window.removeEventListener(NOTE_STATE_EVENT, onState)
|
|
55
56
|
}
|
|
56
57
|
}, [])
|
|
58
|
+
// 与 composer 输入框右缘对齐:测量逻辑已抽到 client/dock-align.ts(v0.28.2),
|
|
59
|
+
// 因为第二个 dock 条目(会话级知识库选择器)加进来时它没有对齐 —— 规则只写在
|
|
60
|
+
// 这里,别人看不见。
|
|
57
61
|
React.useLayoutEffect(() => {
|
|
58
62
|
const wrap = wrapRef.current
|
|
59
63
|
if (wrap === null) return
|
|
60
|
-
|
|
61
|
-
* Find the composer input CARD: climb from the dock entry until an
|
|
62
|
-
* ancestor's subtree contains a wide text field (>300px — the composer
|
|
63
|
-
* input; dock entries' own inputs are small), then take the widest box
|
|
64
|
-
* between that field and its containing column (the visible card).
|
|
65
|
-
*/
|
|
66
|
-
const findCard = (): HTMLElement | null => {
|
|
67
|
-
let input: Element | null = null
|
|
68
|
-
let column: HTMLElement | null = null
|
|
69
|
-
let node: HTMLElement | null = wrap
|
|
70
|
-
while (node !== null && node !== document.documentElement) {
|
|
71
|
-
node = node.parentElement
|
|
72
|
-
if (node === null) break
|
|
73
|
-
for (const el of node.querySelectorAll('textarea, [contenteditable]:not([contenteditable="false"]), [role="textbox"]')) {
|
|
74
|
-
if (el.getBoundingClientRect().width > 300) { input = el; column = node; break }
|
|
75
|
-
}
|
|
76
|
-
if (column !== null) break
|
|
77
|
-
}
|
|
78
|
-
if (input === null || column === null) return null
|
|
79
|
-
const colW = column.getBoundingClientRect().width
|
|
80
|
-
let cur: HTMLElement | null = input as HTMLElement
|
|
81
|
-
let card: HTMLElement | null = null
|
|
82
|
-
let cardW = 0
|
|
83
|
-
while (cur !== null && cur !== column) {
|
|
84
|
-
const w = cur.getBoundingClientRect().width
|
|
85
|
-
if (w > 0 && w < colW - 8 && w >= cardW) { cardW = w; card = cur }
|
|
86
|
-
cur = cur.parentElement
|
|
87
|
-
}
|
|
88
|
-
return card
|
|
89
|
-
}
|
|
90
|
-
/** 测量一次对齐;返回 true = 找到了 composer 输入卡片(测量有效)。 */
|
|
91
|
-
const align = (): boolean => {
|
|
92
|
-
const wrapRect = wrap.getBoundingClientRect()
|
|
93
|
-
const card = findCard()
|
|
94
|
-
const right = card !== null ? card.getBoundingClientRect().right : wrapRect.right
|
|
95
|
-
const pad = Math.max(0, wrapRect.right - right)
|
|
96
|
-
if (wrap.style.paddingRight !== `${pad}px`) wrap.style.paddingRight = `${pad}px`
|
|
97
|
-
return card !== null
|
|
98
|
-
}
|
|
99
|
-
align()
|
|
100
|
-
// 侧边栏开/关会改变对话列宽度 → composer 输入卡片(居中、有 max-width)的
|
|
101
|
-
// 右缘随之移动。旧实现只观察了 dock 容器,侧边栏变化时它不一定触发 resize,
|
|
102
|
-
// 导致按钮停在上次的位置、不再对齐。这里观察「从 dock 条目一直到对话根节点
|
|
103
|
-
// (带 data-phase)的整条祖先链」,任一祖先尺寸变化都会重测对齐。
|
|
104
|
-
const ro = new ResizeObserver(align)
|
|
105
|
-
let node: Element | null = wrap
|
|
106
|
-
while (node !== null && node !== document.documentElement) {
|
|
107
|
-
ro.observe(node)
|
|
108
|
-
if (node instanceof HTMLElement && node.hasAttribute('data-phase')) break
|
|
109
|
-
node = node.parentElement
|
|
110
|
-
}
|
|
111
|
-
window.addEventListener('resize', align)
|
|
112
|
-
document.addEventListener('visibilitychange', align)
|
|
113
|
-
// 自愈兜底:任何未观测到的布局变化(侧边栏切换、插件重渲染等)也会在
|
|
114
|
-
// 1.5s 内被纠正;每帧只是几次 getBoundingClientRect 读取,开销可忽略。
|
|
115
|
-
// 但常驻定时器没有必要:连续 3 次测量成功(找到 composer 输入卡片)即认为
|
|
116
|
-
// 布局已稳定,清掉 interval;即使一直测不到(异常 shell),也在 20 次后
|
|
117
|
-
// 强制收手,之后由 ResizeObserver + resize + visibilitychange 覆盖。
|
|
118
|
-
let guardHits = 0
|
|
119
|
-
let guardTicks = 0
|
|
120
|
-
const guard = window.setInterval(() => {
|
|
121
|
-
guardTicks++
|
|
122
|
-
if (!align()) {
|
|
123
|
-
if (guardTicks >= 20) window.clearInterval(guard)
|
|
124
|
-
return
|
|
125
|
-
}
|
|
126
|
-
guardHits++
|
|
127
|
-
if (guardHits >= 3) window.clearInterval(guard)
|
|
128
|
-
}, 1500)
|
|
129
|
-
// The composer card may mount slightly later; re-measure a couple of times.
|
|
130
|
-
const t1 = window.setTimeout(align, 120)
|
|
131
|
-
const t2 = window.setTimeout(align, 600)
|
|
132
|
-
return () => {
|
|
133
|
-
ro.disconnect()
|
|
134
|
-
window.removeEventListener('resize', align)
|
|
135
|
-
document.removeEventListener('visibilitychange', align)
|
|
136
|
-
window.clearInterval(guard)
|
|
137
|
-
window.clearTimeout(t1)
|
|
138
|
-
window.clearTimeout(t2)
|
|
139
|
-
}
|
|
64
|
+
return alignDockEntry(wrap)
|
|
140
65
|
}, [])
|
|
141
66
|
const native = mode === 'native'
|
|
142
67
|
const label = native ? '快速笔记' : (open ? '快速笔记(已打开)' : '快速笔记')
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import type { PanelState } from './state.ts'
|
|
13
13
|
import { fetchUiConfig, subscribeUiConfig } from './ui-config.ts'
|
|
14
|
+
import { fetchStatus } from './status-cache.ts'
|
|
15
|
+
import { resolveFocusWiki, setFocusWiki, subscribeFocusWiki } from './wiki-focus.ts'
|
|
14
16
|
|
|
15
17
|
/** Stable data attribute identifying this entry row. */
|
|
16
18
|
export const ENTRY_SELECTOR = '[data-dsh-tw-entry]'
|
|
@@ -46,13 +48,24 @@ function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
|
|
|
46
48
|
return buttons.find(button => !button.matches(ENTRY_SELECTOR) && /新会话|新建会话|new session/i.test(button.textContent ?? ''))
|
|
47
49
|
}
|
|
48
50
|
|
|
49
|
-
/**
|
|
50
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Build one entry row (a detached button; inserted once the shell is up).
|
|
53
|
+
*
|
|
54
|
+
* `wikiId` is optional and only used in multi-wiki mode: clicking the row focuses
|
|
55
|
+
* that knowledge base before toggling the panel, so the row a user clicks is the
|
|
56
|
+
* wiki they get. Single-wiki installs pass nothing and behave exactly as before.
|
|
57
|
+
*/
|
|
58
|
+
function createEntry(
|
|
59
|
+
state: PanelState,
|
|
60
|
+
label: string,
|
|
61
|
+
wikiId?: string,
|
|
62
|
+
): { entry: HTMLButtonElement; labelEl: HTMLSpanElement } {
|
|
51
63
|
const entry = document.createElement('button')
|
|
52
64
|
entry.type = 'button'
|
|
53
65
|
entry.dataset.dshTwEntry = ''
|
|
54
66
|
entry.className = 'dsh-tw-entry'
|
|
55
|
-
entry.
|
|
67
|
+
if (wikiId !== undefined) entry.dataset.wiki = wikiId
|
|
68
|
+
entry.setAttribute('aria-label', `TiddlyWiki 知识库:${label}`)
|
|
56
69
|
const icon = document.createElement('span')
|
|
57
70
|
icon.className = 'dsh-tw-entry-icon'
|
|
58
71
|
icon.innerHTML = ICON
|
|
@@ -60,7 +73,11 @@ function createEntry(state: PanelState, label: string): { entry: HTMLButtonEleme
|
|
|
60
73
|
labelEl.className = 'dsh-tw-entry-label'
|
|
61
74
|
labelEl.textContent = label
|
|
62
75
|
entry.append(icon, labelEl)
|
|
63
|
-
entry.addEventListener('click', () => {
|
|
76
|
+
entry.addEventListener('click', () => {
|
|
77
|
+
// 多库:先把这个库设为「焦点库」,面板随后加载它自己的 /tw/<id>/。
|
|
78
|
+
if (wikiId !== undefined) setFocusWiki(wikiId)
|
|
79
|
+
state.toggle()
|
|
80
|
+
})
|
|
64
81
|
return { entry, labelEl }
|
|
65
82
|
}
|
|
66
83
|
|
|
@@ -98,7 +115,10 @@ function placeEntry(root: HTMLElement, entry: HTMLButtonElement): boolean {
|
|
|
98
115
|
* @returns disposer removing the entry and its observers.
|
|
99
116
|
*/
|
|
100
117
|
export function mountSidebarEntry(state: PanelState): () => void {
|
|
118
|
+
/** 多库:每个**在运行**的库一个入口行,用自己的显示名(作者 2026-09-28 要求)。 */
|
|
119
|
+
const rows = new Map<string, { entry: HTMLButtonElement; labelEl: HTMLSpanElement }>()
|
|
101
120
|
const { entry, labelEl } = createEntry(state, 'TiddlyWiki')
|
|
121
|
+
rows.set('', { entry, labelEl })
|
|
102
122
|
let disposed = false
|
|
103
123
|
/**
|
|
104
124
|
* 自定义显示名:/status 返回 ui.sidebarLabel(设置页「侧边栏入口显示名称」)。
|
|
@@ -114,6 +134,60 @@ export function mountSidebarEntry(state: PanelState): () => void {
|
|
|
114
134
|
}
|
|
115
135
|
void applyLabel()
|
|
116
136
|
const unsubscribeLabel = subscribeUiConfig(() => { void applyLabel() })
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 多库:给**每个在运行**的库挂一个入口行(v0.28.2,作者要求"有多少个 wiki 启动了就按
|
|
140
|
+
* 各自的展示名称显示入口")。
|
|
141
|
+
*
|
|
142
|
+
* 为什么只列「在运行」的:一个入口行点了就该能打开那个库;没在跑的库点开会先卡在启动
|
|
143
|
+
* 上。没在跑的库仍可从右下角「知识库」菜单启动(那里会把它们列全,并标注"未运行")。
|
|
144
|
+
*
|
|
145
|
+
* 单库模式(名册 ≤1):沿用**原来那一个**行与 ui.sidebarLabel —— 行为逐字不变。
|
|
146
|
+
*/
|
|
147
|
+
const applyRoster = async (): Promise<void> => {
|
|
148
|
+
const payload = await fetchStatus()
|
|
149
|
+
if (disposed) return
|
|
150
|
+
const list = Array.isArray(payload?.wikis) ? payload.wikis : []
|
|
151
|
+
const running = list.filter((w) => w.running)
|
|
152
|
+
// 单库:不建额外行,标签仍归 ui.sidebarLabel 管。
|
|
153
|
+
if (running.length <= 1) {
|
|
154
|
+
for (const [key, row] of rows) {
|
|
155
|
+
if (key === '') continue
|
|
156
|
+
row.entry.remove()
|
|
157
|
+
rows.delete(key)
|
|
158
|
+
}
|
|
159
|
+
entry.hidden = false
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
// 多库:默认那行让位给每个库自己的行(否则会出现一个没有库名的第 4 行)。
|
|
163
|
+
entry.hidden = true
|
|
164
|
+
const wanted = new Set(running.map((w) => w.id))
|
|
165
|
+
// 移除已不在运行/已删除的库
|
|
166
|
+
for (const [key, row] of rows) {
|
|
167
|
+
if (key === '' || wanted.has(key)) continue
|
|
168
|
+
row.entry.remove()
|
|
169
|
+
rows.delete(key)
|
|
170
|
+
}
|
|
171
|
+
// 新增/更新
|
|
172
|
+
for (const w of running) {
|
|
173
|
+
let row = rows.get(w.id)
|
|
174
|
+
if (row === undefined) {
|
|
175
|
+
row = createEntry(state, w.label, w.id)
|
|
176
|
+
rows.set(w.id, row)
|
|
177
|
+
}
|
|
178
|
+
row.labelEl.textContent = w.label
|
|
179
|
+
row.entry.setAttribute('aria-label', `TiddlyWiki 知识库:${w.label}`)
|
|
180
|
+
// 当前焦点库高亮
|
|
181
|
+
const focused = resolveFocusWiki(running, payload?.defaultId) === w.id
|
|
182
|
+
if (focused) row.entry.dataset.focus = 'true'
|
|
183
|
+
else delete row.entry.dataset.focus
|
|
184
|
+
}
|
|
185
|
+
// 让放置逻辑把新行插进去(复用同一个 root)
|
|
186
|
+
if (root !== undefined) { placed = false; tryPlace() }
|
|
187
|
+
}
|
|
188
|
+
void applyRoster()
|
|
189
|
+
const unsubscribeFocus = subscribeFocusWiki(() => { void applyRoster() })
|
|
190
|
+
const rosterTimer = window.setInterval(() => { void applyRoster() }, 10_000)
|
|
117
191
|
let root: HTMLElement | undefined
|
|
118
192
|
let placed = false
|
|
119
193
|
/** 兜底轮询定时器:只在「尚未放置」或「shell 重建了 root」时运行。 */
|
|
@@ -135,6 +209,10 @@ export function mountSidebarEntry(state: PanelState): () => void {
|
|
|
135
209
|
}, 2_000)
|
|
136
210
|
}
|
|
137
211
|
|
|
212
|
+
/** 当前应该出现在侧边栏里的行(隐藏的默认行不算)。 */
|
|
213
|
+
const activeRows = (): HTMLButtonElement[] =>
|
|
214
|
+
[...rows.values()].map((r) => r.entry).filter((el) => !el.hidden)
|
|
215
|
+
|
|
138
216
|
const tryPlace = (): void => {
|
|
139
217
|
if (root !== undefined && !root.isConnected) {
|
|
140
218
|
rootObserver.disconnect()
|
|
@@ -144,7 +222,7 @@ export function mountSidebarEntry(state: PanelState): () => void {
|
|
|
144
222
|
startRetry()
|
|
145
223
|
}
|
|
146
224
|
if (placed) {
|
|
147
|
-
if (document.body.contains(
|
|
225
|
+
if (activeRows().every((el) => document.body.contains(el))) return
|
|
148
226
|
rootObserver.disconnect()
|
|
149
227
|
root = undefined
|
|
150
228
|
placed = false
|
|
@@ -152,7 +230,12 @@ export function mountSidebarEntry(state: PanelState): () => void {
|
|
|
152
230
|
}
|
|
153
231
|
root ??= sidebarRoot()
|
|
154
232
|
if (root === undefined) return
|
|
155
|
-
|
|
233
|
+
// 逐行放置:多库时每一行都要插进侧边栏(顺序按 rows 的插入序)。
|
|
234
|
+
let any = false
|
|
235
|
+
for (const el of activeRows()) {
|
|
236
|
+
if (placeEntry(root, el)) any = true
|
|
237
|
+
}
|
|
238
|
+
placed = any
|
|
156
239
|
if (placed) {
|
|
157
240
|
// 放置成功即停掉兜底轮询:后续自愈由 rootObserver/waitObserver 负责。
|
|
158
241
|
stopRetry()
|
|
@@ -164,7 +247,7 @@ export function mountSidebarEntry(state: PanelState): () => void {
|
|
|
164
247
|
const waitObserver = new MutationObserver(() => { tryPlace() })
|
|
165
248
|
waitObserver.observe(document.body, { childList: true, subtree: true })
|
|
166
249
|
|
|
167
|
-
// Self-heal: re-insert in the same frame when a re-render displaces the
|
|
250
|
+
// Self-heal: re-insert in the same frame when a re-render displaces the rows.
|
|
168
251
|
const rootObserver = new MutationObserver(() => {
|
|
169
252
|
if (root === undefined || !root.isConnected) {
|
|
170
253
|
placed = false
|
|
@@ -172,7 +255,9 @@ export function mountSidebarEntry(state: PanelState): () => void {
|
|
|
172
255
|
tryPlace()
|
|
173
256
|
return
|
|
174
257
|
}
|
|
175
|
-
|
|
258
|
+
for (const el of activeRows()) {
|
|
259
|
+
if (!root.contains(el)) placeEntry(root, el)
|
|
260
|
+
}
|
|
176
261
|
})
|
|
177
262
|
|
|
178
263
|
// Belt-and-braces: a late shell mount with no further mutations still heals.
|
|
@@ -180,8 +265,11 @@ export function mountSidebarEntry(state: PanelState): () => void {
|
|
|
180
265
|
startRetry()
|
|
181
266
|
|
|
182
267
|
const syncActive = (): void => {
|
|
183
|
-
|
|
184
|
-
|
|
268
|
+
// 面板是共享的:所有行一起高亮/取消,用户看到的"打开中"状态才一致。
|
|
269
|
+
for (const el of activeRows()) {
|
|
270
|
+
if (state.isOpen()) el.dataset.active = 'true'
|
|
271
|
+
else delete el.dataset.active
|
|
272
|
+
}
|
|
185
273
|
}
|
|
186
274
|
const unsubscribe = state.subscribe(syncActive)
|
|
187
275
|
syncActive()
|
|
@@ -190,10 +278,13 @@ export function mountSidebarEntry(state: PanelState): () => void {
|
|
|
190
278
|
return () => {
|
|
191
279
|
disposed = true
|
|
192
280
|
clearInterval(retry)
|
|
281
|
+
window.clearInterval(rosterTimer)
|
|
193
282
|
waitObserver.disconnect()
|
|
194
283
|
rootObserver.disconnect()
|
|
195
284
|
unsubscribe()
|
|
196
285
|
unsubscribeLabel()
|
|
197
|
-
|
|
286
|
+
unsubscribeFocus()
|
|
287
|
+
for (const row of rows.values()) row.entry.remove()
|
|
288
|
+
rows.clear()
|
|
198
289
|
}
|
|
199
290
|
}
|
package/src/client/styles.ts
CHANGED
|
@@ -24,6 +24,9 @@ const CSS_TEXT = `
|
|
|
24
24
|
}
|
|
25
25
|
.dsh-tw-entry:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); color: var(--dsw-alias-label-primary, inherit); }
|
|
26
26
|
.dsh-tw-entry[data-active="true"] { background: var(--dsw-alias-interactive-bg-active, rgba(128,128,128,.18)); color: var(--dsw-alias-label-primary, inherit); font-weight: 500; }
|
|
27
|
+
/* 多库(v0.28.2):每库一个入口行,当前焦点库加一条左侧标线,和「面板正打开哪个库」区分开
|
|
28
|
+
—— 面板是同一个,但焦点库决定它加载谁。 */
|
|
29
|
+
.dsh-tw-entry[data-focus="true"] { box-shadow: inset 2px 0 0 0 var(--dsw-alias-brand-primary, #3e63dd); }
|
|
27
30
|
.dsh-tw-entry svg { flex: none; }
|
|
28
31
|
.dsh-tw-entry .dsh-tw-entry-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
29
32
|
/* Collapsed rail: the shell narrows the sidebar to a 56px icon rail (dual
|
|
@@ -501,8 +504,13 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
|
|
|
501
504
|
/* ── 会话级知识库选择器(v0.28.0,同一个 conversation.input.dock 槽位)──
|
|
502
505
|
与快速笔记按钮同区、纵向 flex 排列;只在"多于一个可见库"时渲染(组件自己返回
|
|
503
506
|
null),所以单库安装这一整块都不存在。 */
|
|
507
|
+
/* ── 会话级知识库选择器(v0.28.0,同一个 conversation.input.dock 槽位)──
|
|
508
|
+
与快速笔记按钮同区、纵向 flex 排列;只在"多于一个可见库"时渲染(组件自己返回
|
|
509
|
+
null),所以单库安装这一整块都不存在。
|
|
510
|
+
justify-content: flex-end 与 JS 测得的 paddingRight 一起把右缘对齐 composer
|
|
511
|
+
输入框(见 client/dock-align.ts)—— 与 .dsh-tw-dock-note 同一套做法。 */
|
|
504
512
|
.dsh-tw-scope-dock {
|
|
505
|
-
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
|
513
|
+
display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap;
|
|
506
514
|
padding: 4px 8px 0; box-sizing: border-box; width: 100%; font-size: 12px;
|
|
507
515
|
}
|
|
508
516
|
.dsh-tw-scope-label { opacity: .7; }
|
|
@@ -515,6 +523,11 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
|
|
|
515
523
|
.dsh-tw-scope-note { opacity: .75; }
|
|
516
524
|
/* 快速笔记卡片的目标库选择(v0.28.0,R7):只在多库时出现。 */
|
|
517
525
|
.dsh-tw-note-wiki { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; }
|
|
526
|
+
/* ⚠️ hidden 属性在 CSS 里只是 display:none,**任何显式 display 都能盖掉它** ——
|
|
527
|
+
上面那条 display:inline-flex 就盖掉了,于是单库模式下这个空下拉照样显示出来
|
|
528
|
+
(作者 2026-09-28 报障:"有个写入下拉,但是没有选项")。显式尊重 hidden,
|
|
529
|
+
并把这一条写在后面(同优先级靠后者生效)。 */
|
|
530
|
+
.dsh-tw-note-wiki[hidden] { display: none; }
|
|
518
531
|
.dsh-tw-note-wiki-label { opacity: .7; }
|
|
519
532
|
.dsh-tw-note-wiki-select {
|
|
520
533
|
border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
|
|
@@ -522,6 +535,7 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
|
|
|
522
535
|
padding: 2px 6px; border-radius: 999px; max-width: 220px;
|
|
523
536
|
}
|
|
524
537
|
.dsh-tw-note-wiki-hint { font-size: 12px; opacity: .75; }
|
|
538
|
+
.dsh-tw-note-wiki-hint[hidden] { display: none; }
|
|
525
539
|
|
|
526
540
|
/* ── input-dock quick-note button(聊天输入框上方,conversation.input.dock)──
|
|
527
541
|
该槽位渲染为「输入框上方的全宽条目」纵向 flex 排列(todo/cost-meter/goal/
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import * as React from 'react'
|
|
23
23
|
import { fetchStatus } from './status-cache.ts'
|
|
24
|
+
import { alignDockEntry } from './dock-align.ts'
|
|
24
25
|
import { SESSION_WIKI_ENDPOINT } from './endpoints.ts'
|
|
25
26
|
|
|
26
27
|
interface WikiOption { id: string; label: string; running: boolean }
|
|
@@ -39,6 +40,16 @@ export function createWikiScopeDock(): (props: DockProps) => React.ReactElement
|
|
|
39
40
|
const [scope, setScope] = React.useState('')
|
|
40
41
|
const [note, setNote] = React.useState<string | undefined>(undefined)
|
|
41
42
|
const [busy, setBusy] = React.useState(false)
|
|
43
|
+
const wrapRef = React.useRef<HTMLDivElement | null>(null)
|
|
44
|
+
|
|
45
|
+
// 与 composer 输入框右缘对齐(v0.28.2):dock 槽位渲染成输入框的**兄弟节点**,
|
|
46
|
+
// 不做这一步这一行就悬在整列最右端(作者报障:"没有和输入框对齐")。测量逻辑
|
|
47
|
+
// 与快速笔记共用一份 —— client/dock-align.ts。
|
|
48
|
+
React.useLayoutEffect(() => {
|
|
49
|
+
const wrap = wrapRef.current
|
|
50
|
+
if (wrap === null) return
|
|
51
|
+
return alignDockEntry(wrap)
|
|
52
|
+
}, [])
|
|
42
53
|
|
|
43
54
|
React.useEffect(() => {
|
|
44
55
|
if (sessionId === undefined) return
|
|
@@ -91,7 +102,7 @@ export function createWikiScopeDock(): (props: DockProps) => React.ReactElement
|
|
|
91
102
|
|
|
92
103
|
return React.createElement(
|
|
93
104
|
'div',
|
|
94
|
-
{ className: 'dsh-tw-scope-dock' },
|
|
105
|
+
{ ref: wrapRef, className: 'dsh-tw-scope-dock' },
|
|
95
106
|
React.createElement('span', { className: 'dsh-tw-scope-label' }, '知识库'),
|
|
96
107
|
React.createElement(
|
|
97
108
|
'select',
|
package/src/host/seed-notes.ts
CHANGED
|
@@ -64,6 +64,28 @@ const DOC_NOTE_TAIL = `* **TW 编辑器面板**:侧边栏「TiddlyWiki」按
|
|
|
64
64
|
* **设置页**:DSH 设置 → 「TiddlyWiki 知识库」管理插件/主题/语言与运行配置(含「知识库」按钮相关显示开关)。
|
|
65
65
|
* **注入给 Agent 的提示词可配置**(设置页「系统提示词」区块):默认**精简版**只约定同步纪律/标签/链接格式(工具参数由工具 schema 提供,不重复);可切完整版(附参数索引)、追加自定义规范(\`extra\`)、整段替换(\`override\`)或整体停用。保存即生效,无需重启 dsh web;点「查看当前注入文本」可预览下一步实际注入的全文。
|
|
66
66
|
|
|
67
|
+
!! 多知识库(v0.28.0 起)
|
|
68
|
+
|
|
69
|
+
同一个 dsh web 里可以**同时跑多个知识库**,每个库独立配置、独立 git,各自一个 TW 子进程。
|
|
70
|
+
|
|
71
|
+
* **现在有几个库?**右下角「知识库」按钮的菜单会列出全部(● 是当前打开的那个);输入框上方也会有「知识库」下拉,用来选**本会话**让 Agent 用哪个库(单选,选择会被记住)。
|
|
72
|
+
* **加一个库**:DSH 设置 → 「TiddlyWiki 知识库」→ **知识库列表** → 填根目录 + 文件夹名 →「添加知识库」。目录不存在会自动初始化一个全新库;已存在的目录直接接管。
|
|
73
|
+
* **让某个库对 Agent 隐身**:知识库列表里点该行「对 Agent 隐身」。隐身 = Agent 永远碰不到它(不进检索、不进工具、不进会话选择器),**你自己照常用**——适合只读语料、书籍扫描、导入归档。
|
|
74
|
+
* **每个库一套设置**:笔记标签、语言、主题、提示词都在该库自己的配置里。设置页顶部会显示「**配置作用域**:…」,点某一行「配置」即可切换在编辑哪个库。
|
|
75
|
+
* **git 按仓库生效**:多个库可以共用一个仓库(例如两个库是同一仓库下的两个子目录),也可以各自独立仓库。共用仓库时只有一个自动提交者,pull 后只重启内容真的变了的库。
|
|
76
|
+
* **想拆分现有的大库**:见设置页文档或仓库里的 \`docs/wiki-split.md\`——那里有一段可以直接复制给 Agent 的话,它会先只读体检、再问你几个问题、出方案等你确认后才动手。
|
|
77
|
+
|
|
78
|
+
!! 从旧版本升级上来(重要)
|
|
79
|
+
|
|
80
|
+
**升级后不会自动变成多库**,仍然是单库模式(\`mode: single\`),行为与升级前完全一致。
|
|
81
|
+
|
|
82
|
+
* **想继续用单库**:什么都不用做。设置页里「知识库位置(可切换)」只在单库模式出现,用法和以前一样。
|
|
83
|
+
* **想开始用多库**:去设置页「知识库列表」添加第二个库,再把「运行模式」切到**多库**。此后清单里的库都会可用(标了「开局自启」的随 DSH 启动,其余在你打开或选中它时启动)。
|
|
84
|
+
* **控制文件在哪**:\`$DSH_HOME/dsh-tiddlywiki/wikis.json\`(在每本 wiki **之外**,所以它不会随某个库被搬走)。模式、默认库、库清单都在里面;也可以在设置页里改,效果一样。
|
|
85
|
+
* **旧的「知识库位置」指针文件**(\`location.json\`)在单库模式下仍然是权威的——多库模式下它不再参与,改由清单决定。
|
|
86
|
+
* **每个库要有自己的 \`tiddlywiki.info\`**:把一个大库拆成多个目录时,每个目录都要有一个(缺了 TW 认不出它是库)。从旧库整体搬过来的目录通常已经有了。
|
|
87
|
+
* **拆分大库的注意**:书籍/语料这类只读内容建议**独立仓库 + 对 Agent 隐身**;工作与个人笔记可以共用一个仓库、按子目录划分。搬运用 \`git mv\` 保留历史,别用\"复制粘贴再删\"。
|
|
88
|
+
|
|
67
89
|
!! 知识库纪律(四条)
|
|
68
90
|
|
|
69
91
|
1. 开工先 \`tiddlywiki_git_sync action=pull\`(rebase + autostash,真冲突会自动 abort 并报文件)。
|