dsh-sidenote 0.1.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 -0
- package/README.md +60 -0
- package/cordis.patch.yml +16 -0
- package/lib/client.js +2143 -0
- package/lib/index.js +6 -0
- package/lib/types/client/annotate/anchor.d.ts +57 -0
- package/lib/types/client/annotate/chip.d.ts +11 -0
- package/lib/types/client/annotate/draft.d.ts +29 -0
- package/lib/types/client/annotate/format.d.ts +52 -0
- package/lib/types/client/annotate/index.d.ts +2 -0
- package/lib/types/client/annotate/model.d.ts +59 -0
- package/lib/types/client/annotate/overlay.d.ts +27 -0
- package/lib/types/client/annotate/selection.d.ts +49 -0
- package/lib/types/client/bridge.d.ts +19 -0
- package/lib/types/client/index.d.ts +12 -0
- package/lib/types/client/locales.d.ts +77 -0
- package/lib/types/client/sidechat/SideChatPanel.d.ts +2 -0
- package/lib/types/client/sidechat/composer.d.ts +26 -0
- package/lib/types/client/sidechat/index.d.ts +2 -0
- package/lib/types/client/sidechat/model.d.ts +101 -0
- package/lib/types/client/sidechat/open.d.ts +16 -0
- package/lib/types/client/sidechat/slash.d.ts +12 -0
- package/lib/types/context-types.d.ts +335 -0
- package/lib/types/index.d.ts +9 -0
- package/package.json +96 -0
- package/src/client/annotate/anchor.ts +158 -0
- package/src/client/annotate/annotate.module.css +301 -0
- package/src/client/annotate/chip.tsx +115 -0
- package/src/client/annotate/css.d.ts +9 -0
- package/src/client/annotate/draft.ts +67 -0
- package/src/client/annotate/format.ts +83 -0
- package/src/client/annotate/index.tsx +66 -0
- package/src/client/annotate/model.ts +138 -0
- package/src/client/annotate/overlay.tsx +531 -0
- package/src/client/annotate/selection.ts +203 -0
- package/src/client/bridge.ts +19 -0
- package/src/client/index.tsx +22 -0
- package/src/client/locales.ts +175 -0
- package/src/client/sidechat/SideChatPanel.tsx +366 -0
- package/src/client/sidechat/composer.ts +129 -0
- package/src/client/sidechat/css-modules.d.ts +5 -0
- package/src/client/sidechat/index.tsx +56 -0
- package/src/client/sidechat/model.ts +331 -0
- package/src/client/sidechat/open.ts +66 -0
- package/src/client/sidechat/sidechat.module.css +230 -0
- package/src/client/sidechat/slash.ts +84 -0
- package/src/context-types.ts +369 -0
- package/src/index.ts +13 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Selection capture layer (Workitem 02): document-level listeners validate
|
|
3
|
+
* the live selection and publish the latest valid one to the floating
|
|
4
|
+
* toolbar through a small external-store controller. Mirrors the
|
|
5
|
+
* production-proven dsh-sidebar-qa contract, narrowed to assistant messages:
|
|
6
|
+
*
|
|
7
|
+
* - both selection endpoints must sit in the SAME `[data-chat-flow-kind=
|
|
8
|
+
* "assistant-step"]` message node (跨消息划选不弹);
|
|
9
|
+
* - `[data-streaming]` messages (still streaming) are excluded;
|
|
10
|
+
* - `[data-dsh-better-sidebar]` and our own `[data-dsh-sidenote]` DOM are
|
|
11
|
+
* excluded (与侧边栏 viewer 浮层互斥);
|
|
12
|
+
* - the text must be non-blank; over-length selections are admitted with the
|
|
13
|
+
* quote truncated (见 format.ts), never rejected.
|
|
14
|
+
*
|
|
15
|
+
* These are DSH-internal DOM attributes rather than a public contract — an
|
|
16
|
+
* upstream redesign silently degrades the feature (no popup), never crashes.
|
|
17
|
+
*/
|
|
18
|
+
import { truncateQuote } from './format.ts'
|
|
19
|
+
|
|
20
|
+
/** One validated selection, ready for the toolbar. */
|
|
21
|
+
export interface SelectionSnapshot {
|
|
22
|
+
/** Truncated quote (sent to the model / stored on the annotation). */
|
|
23
|
+
readonly text: string
|
|
24
|
+
/** Full selection text (re-anchor needle). */
|
|
25
|
+
readonly anchorText: string
|
|
26
|
+
/** `data-chat-anchor-key` of the owning message, when present. */
|
|
27
|
+
readonly anchorKey: string | undefined
|
|
28
|
+
/** 0-based ordinal of this selection among identical texts in the anchor. */
|
|
29
|
+
readonly occurrence: number
|
|
30
|
+
/** Bounding box for toolbar placement. */
|
|
31
|
+
readonly rect: { readonly left: number; readonly top: number; readonly width: number }
|
|
32
|
+
/** A live clone of the selection range (badge anchor until re-render). */
|
|
33
|
+
readonly range: Range
|
|
34
|
+
readonly sessionId: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SelectionState {
|
|
38
|
+
readonly selection: SelectionSnapshot | null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The assistant message node kind (v1 实测纠正值 — 勿引旧值 `assistant`). */
|
|
42
|
+
export const ASSISTANT_KIND = 'assistant-step'
|
|
43
|
+
|
|
44
|
+
/** Selector roots our UI must never react to (sidebar viewer + our own root). */
|
|
45
|
+
const EXCLUDED_ROOTS = '[data-dsh-better-sidebar], [data-dsh-sidenote]'
|
|
46
|
+
|
|
47
|
+
/** Pure validation of one candidate selection (unit-tested without DOM). */
|
|
48
|
+
export function isEligibleSelection(input: {
|
|
49
|
+
readonly blank: boolean
|
|
50
|
+
readonly sameMessage: boolean
|
|
51
|
+
readonly kind: string
|
|
52
|
+
readonly streaming: boolean
|
|
53
|
+
readonly excluded: boolean
|
|
54
|
+
readonly hasSession: boolean
|
|
55
|
+
}): boolean {
|
|
56
|
+
return !input.blank
|
|
57
|
+
&& input.sameMessage
|
|
58
|
+
&& input.kind === ASSISTANT_KIND
|
|
59
|
+
&& !input.streaming
|
|
60
|
+
&& !input.excluded
|
|
61
|
+
&& input.hasSession
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Resolve the assistant message element owning a DOM node (text nodes → parent). */
|
|
65
|
+
function messageOf(node: Node): HTMLElement | null {
|
|
66
|
+
const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : (node as Element | null)
|
|
67
|
+
if (element === null || typeof element.closest !== 'function') return null
|
|
68
|
+
return element.closest<HTMLElement>(`[data-chat-flow-kind="${ASSISTANT_KIND}"]`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Whether the message is still streaming (unfinished). */
|
|
72
|
+
function isStreaming(message: HTMLElement): boolean {
|
|
73
|
+
return message.hasAttribute('data-streaming') || message.querySelector('[data-streaming]') !== null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The 0-based ordinal of the selection's text among its occurrences in the
|
|
78
|
+
* anchor element — the disambiguator for repeated phrases (re-anchor key).
|
|
79
|
+
*/
|
|
80
|
+
export function occurrenceOf(anchor: HTMLElement, range: Range, text: string): number {
|
|
81
|
+
try {
|
|
82
|
+
const pre = document.createRange()
|
|
83
|
+
pre.selectNodeContents(anchor)
|
|
84
|
+
pre.setEnd(range.startContainer, range.startOffset)
|
|
85
|
+
const before = pre.toString()
|
|
86
|
+
let occurrence = 0
|
|
87
|
+
let at = before.indexOf(text)
|
|
88
|
+
while (at !== -1) {
|
|
89
|
+
occurrence += 1
|
|
90
|
+
at = before.indexOf(text, at + text.length)
|
|
91
|
+
}
|
|
92
|
+
return occurrence
|
|
93
|
+
} catch {
|
|
94
|
+
return 0
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Capture and validate the current window selection, or null when invalid. */
|
|
99
|
+
export function captureSelection(currentSessionId: string): SelectionSnapshot | null {
|
|
100
|
+
const sel = window.getSelection()
|
|
101
|
+
if (sel === null || sel.isCollapsed || sel.rangeCount === 0) return null
|
|
102
|
+
const anchorText = sel.toString()
|
|
103
|
+
const range = sel.getRangeAt(0)
|
|
104
|
+
const startMessage = messageOf(range.startContainer)
|
|
105
|
+
const endMessage = messageOf(range.endContainer)
|
|
106
|
+
const message = startMessage
|
|
107
|
+
const anchor = message?.closest<HTMLElement>('[data-chat-anchor-key]') ?? null
|
|
108
|
+
const eligible = isEligibleSelection({
|
|
109
|
+
blank: anchorText.trim() === '',
|
|
110
|
+
sameMessage: message !== null && message === endMessage,
|
|
111
|
+
kind: message?.dataset.chatFlowKind ?? '',
|
|
112
|
+
streaming: message !== null && isStreaming(message),
|
|
113
|
+
excluded: anchor !== null
|
|
114
|
+
? anchor.closest(EXCLUDED_ROOTS) !== null
|
|
115
|
+
: message?.closest(EXCLUDED_ROOTS) !== null,
|
|
116
|
+
hasSession: currentSessionId !== '',
|
|
117
|
+
})
|
|
118
|
+
if (!eligible || message === null) return null
|
|
119
|
+
const rect = range.getBoundingClientRect()
|
|
120
|
+
return {
|
|
121
|
+
text: truncateQuote(anchorText),
|
|
122
|
+
anchorText,
|
|
123
|
+
anchorKey: anchor?.dataset.chatAnchorKey,
|
|
124
|
+
occurrence: anchor === null ? 0 : occurrenceOf(anchor, range, anchorText),
|
|
125
|
+
rect: { left: rect.left, top: rect.top, width: rect.width },
|
|
126
|
+
range: range.cloneRange(),
|
|
127
|
+
sessionId: currentSessionId,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface SelectionController {
|
|
132
|
+
getSnapshot(): SelectionState
|
|
133
|
+
subscribe(fn: () => void): () => void
|
|
134
|
+
clear(): void
|
|
135
|
+
dispose(): void
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Debounce for selectionchange (ms). */
|
|
139
|
+
const DEBOUNCE_MS = 200
|
|
140
|
+
|
|
141
|
+
/** Create one selection controller (call once per plugin activation). */
|
|
142
|
+
export function createSelectionController(getSessionId: () => string): SelectionController {
|
|
143
|
+
let selection: SelectionSnapshot | null = null
|
|
144
|
+
const listeners = new Set<() => void>()
|
|
145
|
+
let debounceTimer: number | undefined
|
|
146
|
+
|
|
147
|
+
// useSyncExternalStore requires a STABLE snapshot reference between
|
|
148
|
+
// notifications — a fresh object each call would loop React.
|
|
149
|
+
let state: SelectionState = { selection: null }
|
|
150
|
+
|
|
151
|
+
const notify = (): void => {
|
|
152
|
+
state = { selection }
|
|
153
|
+
for (const fn of [...listeners]) fn()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const recompute = (): void => {
|
|
157
|
+
let next: SelectionSnapshot | null = null
|
|
158
|
+
try {
|
|
159
|
+
next = captureSelection(getSessionId())
|
|
160
|
+
} catch {
|
|
161
|
+
next = null
|
|
162
|
+
}
|
|
163
|
+
const changed = (selection === null) !== (next === null)
|
|
164
|
+
|| (selection !== null && next !== null && (
|
|
165
|
+
selection.anchorText !== next.anchorText
|
|
166
|
+
|| selection.anchorKey !== next.anchorKey
|
|
167
|
+
|| selection.sessionId !== next.sessionId
|
|
168
|
+
|| selection.rect.top !== next.rect.top
|
|
169
|
+
|| selection.rect.left !== next.rect.left
|
|
170
|
+
|| selection.rect.width !== next.rect.width
|
|
171
|
+
))
|
|
172
|
+
if (!changed) return
|
|
173
|
+
selection = next
|
|
174
|
+
notify()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const onSelectionChange = (): void => {
|
|
178
|
+
if (debounceTimer !== undefined) window.clearTimeout(debounceTimer)
|
|
179
|
+
debounceTimer = window.setTimeout(recompute, DEBOUNCE_MS)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
document.addEventListener('selectionchange', onSelectionChange)
|
|
183
|
+
document.addEventListener('mouseup', recompute)
|
|
184
|
+
document.addEventListener('keyup', onSelectionChange)
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
getSnapshot: () => state,
|
|
188
|
+
subscribe(fn: () => void): () => void {
|
|
189
|
+
listeners.add(fn)
|
|
190
|
+
return () => { listeners.delete(fn) }
|
|
191
|
+
},
|
|
192
|
+
clear(): void {
|
|
193
|
+
selection = null
|
|
194
|
+
notify()
|
|
195
|
+
},
|
|
196
|
+
dispose(): void {
|
|
197
|
+
document.removeEventListener('selectionchange', onSelectionChange)
|
|
198
|
+
document.removeEventListener('mouseup', recompute)
|
|
199
|
+
document.removeEventListener('keyup', onSelectionChange)
|
|
200
|
+
if (debounceTimer !== undefined) window.clearTimeout(debounceTimer)
|
|
201
|
+
},
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-plugin bridge between the annotate module (Workitem 02/03) and the
|
|
3
|
+
* sidechat module (Workitem 01). The sidechat module installs an
|
|
4
|
+
* implementation on activation; annotate's 「在侧边聊天中提问」 button calls
|
|
5
|
+
* it when present. Module-level singleton by design: exactly one sidechat
|
|
6
|
+
* module and one annotate module exist per activation.
|
|
7
|
+
*/
|
|
8
|
+
import type { SessionId } from '../context-types.ts'
|
|
9
|
+
|
|
10
|
+
export interface SideChatBridge {
|
|
11
|
+
/**
|
|
12
|
+
* Open (or focus) a side chat forked from `sessionId`, seeding its composer
|
|
13
|
+
* draft with `draftText` (quoted selection + annotation). Returns false
|
|
14
|
+
* when no side chat could be opened (caller keeps its own flow).
|
|
15
|
+
*/
|
|
16
|
+
askInSideChat(sessionId: SessionId, draftText: string): boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const sideChatBridge: { current: SideChatBridge | null } = { current: null }
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client half of dsh-sidenote: the side-chat tabs (Workitem 01) and the
|
|
3
|
+
* selection annotations (Workitem 02). Thin consumer of dsh-better-sidebar —
|
|
4
|
+
* it builds no panel chrome (portal/resize/collapse/persistence); the panel
|
|
5
|
+
* container is entirely better-sidebar's.
|
|
6
|
+
*
|
|
7
|
+
* Requires the `betterSidebar` service (hard peer dependency): inject keeps
|
|
8
|
+
* the plugin inactive until better-sidebar provides it.
|
|
9
|
+
*/
|
|
10
|
+
import type { Context } from '../context-types.ts'
|
|
11
|
+
import { attachLocale, type LocaleServiceLike } from './locales.ts'
|
|
12
|
+
import { registerSideChat } from './sidechat/index.tsx'
|
|
13
|
+
import { registerAnnotations } from './annotate/index.tsx'
|
|
14
|
+
|
|
15
|
+
export const inject = ['betterSidebar', 'sessions', 'workspaces', 'slots', 'connection', 'locale']
|
|
16
|
+
|
|
17
|
+
export function apply(ctx: Context): void {
|
|
18
|
+
// 跟随 DSH 通用设置里的语言(locale.preference,Host-backed,实时切换)。
|
|
19
|
+
attachLocale(ctx.locale as LocaleServiceLike | undefined)
|
|
20
|
+
registerSideChat(ctx)
|
|
21
|
+
registerAnnotations(ctx)
|
|
22
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* dsh-sidenote 的双语层(zh/en),跟随 DSH 通用设置里的语言(`ctx.locale`,
|
|
5
|
+
* Host-backed locale.preference,实时切换)。模式照 better-sidebar
|
|
6
|
+
* `src/client/locales.ts`:attachLocale 挂服务,t() 读活动语言;
|
|
7
|
+
* 服务缺失(独立组合/测试)时回退浏览器语言。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const LOCALE_NS = 'dsh-sidenote'
|
|
11
|
+
|
|
12
|
+
/** 英文词典(key 的权威清单)。 */
|
|
13
|
+
export const en = {
|
|
14
|
+
menuTitle: 'Side chat',
|
|
15
|
+
tabBaseTitle: 'Side',
|
|
16
|
+
|
|
17
|
+
// SideChatPanel
|
|
18
|
+
codeCopy: 'Copy',
|
|
19
|
+
codeCopied: 'Copied',
|
|
20
|
+
forkErrorTitle: "Couldn't create the side chat",
|
|
21
|
+
forkErrorHint: 'The main session needs at least one completed turn to fork from. Close this tab with the × on the tab.',
|
|
22
|
+
missingTitle: 'Session no longer exists',
|
|
23
|
+
missingDetail: "This side chat's session was removed and can't be restored.",
|
|
24
|
+
missingHint: 'Close this tab with the × on the tab.',
|
|
25
|
+
preparing: 'Preparing the side chat…',
|
|
26
|
+
historyFailed: 'Failed to load the session history. Close and reopen this tab.',
|
|
27
|
+
emptyTitle: 'Side chat',
|
|
28
|
+
emptyText: 'Forked from the current session and evolves independently. Closing the tab removes it.',
|
|
29
|
+
thinking: 'Thinking',
|
|
30
|
+
writing: 'Writing…',
|
|
31
|
+
stopped: 'Stopped',
|
|
32
|
+
toolLabel: 'Tool',
|
|
33
|
+
failed: 'failed',
|
|
34
|
+
running: 'running…',
|
|
35
|
+
inputPlaceholder: 'Message… (Enter to send, Shift+Enter for a newline)',
|
|
36
|
+
modelLabel: 'Model: {name}',
|
|
37
|
+
modelFollowsMain: 'follows main session',
|
|
38
|
+
stopReply: 'Stop',
|
|
39
|
+
stopReplyTitle: 'Stop the current reply',
|
|
40
|
+
send: 'Send',
|
|
41
|
+
|
|
42
|
+
// /side command
|
|
43
|
+
cmdDesc: 'Open a side chat (forked from the current session)',
|
|
44
|
+
cmdNew: 'New side chat',
|
|
45
|
+
cmdNewDetail: 'Forked from the current session; evolves independently',
|
|
46
|
+
cmdFocus: 'Focus "{title}"',
|
|
47
|
+
cmdFocusDetail: 'Existing side chat',
|
|
48
|
+
|
|
49
|
+
// annotate overlay
|
|
50
|
+
addToConversation: 'Add to conversation',
|
|
51
|
+
askInSideChat: 'Ask in side chat',
|
|
52
|
+
toolbarAria: 'Selection annotations',
|
|
53
|
+
notePlaceholder: 'Add a note (optional)…',
|
|
54
|
+
confirmTitle: 'Confirm',
|
|
55
|
+
saveNoteAria: 'Save note',
|
|
56
|
+
sideNotePlaceholder: 'Add a note for the side chat (optional)…',
|
|
57
|
+
sideNoteAria: 'Side chat note',
|
|
58
|
+
confirmAskAria: 'Confirm and ask',
|
|
59
|
+
openSideFailed: "Couldn't open the side chat. Try again.",
|
|
60
|
+
deleteNote: 'Delete annotation',
|
|
61
|
+
cancel: 'Cancel',
|
|
62
|
+
save: 'Save',
|
|
63
|
+
|
|
64
|
+
// chip
|
|
65
|
+
chipOne: '1 annotation',
|
|
66
|
+
chipMany: '{n} annotations',
|
|
67
|
+
removeTitle: 'Remove annotation',
|
|
68
|
+
removeAria: 'Remove annotation {n}',
|
|
69
|
+
|
|
70
|
+
// model-facing quote format
|
|
71
|
+
noteLine: 'Note: {note}',
|
|
72
|
+
noNote: '(no note)',
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type CopyKey = keyof typeof en
|
|
76
|
+
|
|
77
|
+
/** 中文词典(与 en 同 key)。 */
|
|
78
|
+
export const zh: Record<CopyKey, string> = {
|
|
79
|
+
menuTitle: '侧边聊天',
|
|
80
|
+
tabBaseTitle: '侧边',
|
|
81
|
+
|
|
82
|
+
codeCopy: '复制',
|
|
83
|
+
codeCopied: '已复制',
|
|
84
|
+
forkErrorTitle: '无法创建侧边聊天',
|
|
85
|
+
forkErrorHint: '主会话需要至少一轮已完成的对话才能 fork。点击标签上的 × 可关闭此标签页。',
|
|
86
|
+
missingTitle: '会话已不存在',
|
|
87
|
+
missingDetail: '此侧边聊天的会话已被移除,无法恢复。',
|
|
88
|
+
missingHint: '点击标签上的 × 关闭此标签页。',
|
|
89
|
+
preparing: '正在准备侧边聊天…',
|
|
90
|
+
historyFailed: '会话历史加载失败,可关闭后重新打开此标签页。',
|
|
91
|
+
emptyTitle: '侧边聊天',
|
|
92
|
+
emptyText: '侧边聊天从当前会话 fork,独立演进;关闭标签页后消失。',
|
|
93
|
+
thinking: '思考过程',
|
|
94
|
+
writing: '正在输出…',
|
|
95
|
+
stopped: '已停止',
|
|
96
|
+
toolLabel: '工具',
|
|
97
|
+
failed: '失败',
|
|
98
|
+
running: '执行中…',
|
|
99
|
+
inputPlaceholder: '输入消息…(Enter 发送,Shift+Enter 换行)',
|
|
100
|
+
modelLabel: '模型:{name}',
|
|
101
|
+
modelFollowsMain: '跟随主会话',
|
|
102
|
+
stopReply: '停止',
|
|
103
|
+
stopReplyTitle: '停止当前回复',
|
|
104
|
+
send: '发送',
|
|
105
|
+
|
|
106
|
+
cmdDesc: '打开侧边聊天(从当前会话 fork)',
|
|
107
|
+
cmdNew: '新建侧边聊天',
|
|
108
|
+
cmdNewDetail: '从当前会话 fork,独立演进',
|
|
109
|
+
cmdFocus: '聚焦「{title}」',
|
|
110
|
+
cmdFocusDetail: '已存在的侧边聊天',
|
|
111
|
+
|
|
112
|
+
addToConversation: '添加到对话',
|
|
113
|
+
askInSideChat: '在侧边聊天中提问',
|
|
114
|
+
toolbarAria: '划选注释',
|
|
115
|
+
notePlaceholder: '这里可以写自己的注解…',
|
|
116
|
+
confirmTitle: '确认',
|
|
117
|
+
saveNoteAria: '确认注解',
|
|
118
|
+
sideNotePlaceholder: '给侧边聊天写个注解(可空)…',
|
|
119
|
+
sideNoteAria: '侧边聊天注解',
|
|
120
|
+
confirmAskAria: '确认并提问',
|
|
121
|
+
openSideFailed: '打开侧边聊天失败,请重试',
|
|
122
|
+
deleteNote: '删除注释',
|
|
123
|
+
cancel: '取消',
|
|
124
|
+
save: '保存',
|
|
125
|
+
|
|
126
|
+
chipOne: '1 条注释',
|
|
127
|
+
chipMany: '{n} 条注释',
|
|
128
|
+
removeTitle: '移除注释',
|
|
129
|
+
removeAria: '移除注释 {n}',
|
|
130
|
+
|
|
131
|
+
noteLine: '注解:{note}',
|
|
132
|
+
noNote: '(无注解)',
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The DSH locale service face we consume (subset of LocaleRuntime). */
|
|
136
|
+
export interface LocaleServiceLike {
|
|
137
|
+
getSnapshot(): { active: string }
|
|
138
|
+
subscribe(fn: () => void): () => void
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let localeService: LocaleServiceLike | undefined
|
|
142
|
+
|
|
143
|
+
/** Attach the DSH locale service (call once from the client apply). */
|
|
144
|
+
export function attachLocale(service: LocaleServiceLike | undefined): void {
|
|
145
|
+
localeService = service
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Subscribe to locale switches (component re-render driver). */
|
|
149
|
+
export function subscribeLocale(fn: () => void): () => void {
|
|
150
|
+
return localeService?.subscribe(fn) ?? (() => {})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The active locale id:DSH 语言设置优先,缺失时回退浏览器语言。 */
|
|
154
|
+
function activeLocale(): string {
|
|
155
|
+
return localeService?.getSnapshot().active
|
|
156
|
+
?? (typeof navigator !== 'undefined' ? navigator.language : '')
|
|
157
|
+
?? 'en'
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Translate a copy key; `{name}` placeholders interpolate from `params`. */
|
|
161
|
+
export function t(key: CopyKey, params?: Record<string, string | number>): string {
|
|
162
|
+
const dict = activeLocale().toLowerCase().startsWith('zh') ? zh : en
|
|
163
|
+
let text = dict[key]
|
|
164
|
+
if (params !== undefined) {
|
|
165
|
+
for (const [name, value] of Object.entries(params)) {
|
|
166
|
+
text = text.replaceAll(`{${name}}`, String(value))
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return text
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** 语言切换时驱动组件重渲(useSyncExternalStore 标准接法)。 */
|
|
173
|
+
export function useLocaleTick(): void {
|
|
174
|
+
useSyncExternalStore(subscribeLocale, () => localeService?.getSnapshot().active ?? 'en')
|
|
175
|
+
}
|