@jaychang1989/dsh-webchat 0.3.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.
Files changed (45) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +18 -0
  3. package/README.md +78 -0
  4. package/README.zh.md +83 -0
  5. package/cordis.patch.yml +22 -0
  6. package/lib/client.js +1725 -0
  7. package/lib/client.js.map +1 -0
  8. package/lib/index.js +3887 -0
  9. package/lib/types/client/api.d.ts +74 -0
  10. package/lib/types/client/controller.d.ts +19 -0
  11. package/lib/types/client/index.d.ts +30 -0
  12. package/lib/types/client/locales.d.ts +169 -0
  13. package/lib/types/client/mount.d.ts +23 -0
  14. package/lib/types/client/panel/Markdown.d.ts +15 -0
  15. package/lib/types/client/panel/WebChatPanel.d.ts +28 -0
  16. package/lib/types/client/sidebar-entry-core.d.ts +60 -0
  17. package/lib/types/client/sidebar-entry.d.ts +22 -0
  18. package/lib/types/engine/engine.d.ts +247 -0
  19. package/lib/types/engine/html-md.d.ts +23 -0
  20. package/lib/types/index.d.ts +63 -0
  21. package/lib/types/protocol.d.ts +107 -0
  22. package/lib/types/routes.d.ts +22 -0
  23. package/lib/types/store.d.ts +69 -0
  24. package/lib/types/tools.d.ts +27 -0
  25. package/lib/types/transfer.d.ts +138 -0
  26. package/package.json +96 -0
  27. package/src/client/api.ts +90 -0
  28. package/src/client/controller.ts +43 -0
  29. package/src/client/css-modules.d.ts +5 -0
  30. package/src/client/index.ts +75 -0
  31. package/src/client/locales.ts +172 -0
  32. package/src/client/mount.tsx +125 -0
  33. package/src/client/panel/Markdown.tsx +251 -0
  34. package/src/client/panel/WebChatPanel.tsx +760 -0
  35. package/src/client/panel/panel.module.css +936 -0
  36. package/src/client/sidebar-entry-core.ts +207 -0
  37. package/src/client/sidebar-entry.ts +49 -0
  38. package/src/engine/engine.ts +1385 -0
  39. package/src/engine/html-md.ts +126 -0
  40. package/src/index.ts +212 -0
  41. package/src/protocol.ts +116 -0
  42. package/src/routes.ts +307 -0
  43. package/src/store.ts +217 -0
  44. package/src/tools.ts +257 -0
  45. package/src/transfer.ts +602 -0
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Panel view mounting. The conversation slot is single-occupant and external
3
+ * plugins cannot declare slots, so the panel takes over the center column at
4
+ * the DOM level (same strategy as dsh-ssh / dsh-task-board): a container is
5
+ * appended inside the center column as an extra trailing child React never
6
+ * manages, and a stylesheet rule hides the conversation content while the
7
+ * panel is active. Toggling is a data attribute on <html> — no React
8
+ * involvement, so the conversation subtree underneath stays mounted and
9
+ * stateful.
10
+ */
11
+ import { createRoot, type Root } from 'react-dom/client'
12
+ import type { ISessions, IWorkspaces } from '@deepseek-ai/dsh-client-runtime/client'
13
+ import type { WebChatApi } from './api.ts'
14
+ import type { PanelController } from './controller.ts'
15
+ import type { WebChatKey } from './locales.ts'
16
+ import { WebChatPanel } from './panel/WebChatPanel.tsx'
17
+ import css from './panel/panel.module.css'
18
+
19
+ /** The injected panel container (kept in the DOM, hidden when inactive). */
20
+ export const PANEL_VIEW_SELECTOR = '[data-dsh-webchat-view]'
21
+
22
+ const CONVERSATION_COLUMN_SELECTOR = '[data-pane="conversation"], [class*="centerCol"]'
23
+ const ACTIVE_ATTR = 'data-dsh-webchat-active'
24
+ /** The sibling panels' activation attributes, removed when this panel opens. */
25
+ const OTHER_ACTIVE_ATTRS = ['data-dsh-taskboard-active', 'data-dsh-ssh-active']
26
+ /** Cross-plugin activation event; detail is the activating panel name. */
27
+ const ACTIVATE_EVENT = 'dsh-panel-activate'
28
+ const PANEL_NAME = 'webchat'
29
+
30
+ /** Find the center column, or undefined while the frame is not mounted. */
31
+ function conversationColumn(): HTMLElement | undefined {
32
+ return document.querySelector<HTMLElement>(CONVERSATION_COLUMN_SELECTOR) ?? undefined
33
+ }
34
+
35
+ export interface MountPanelDeps {
36
+ sessions: ISessions
37
+ workspaces: IWorkspaces
38
+ api: WebChatApi
39
+ controller: PanelController
40
+ /** Locale accessor bound to the plugin's registered dictionaries. */
41
+ tt: (key: WebChatKey) => string
42
+ /** Resolve the current session's cwd (for transfers / exports). */
43
+ currentCwd: () => string | undefined
44
+ }
45
+
46
+ /**
47
+ * Mount the panel React tree into the center column and bind its visibility
48
+ * to the controller's panelOpen state.
49
+ * @param deps - sessions, api, controller, locale accessor.
50
+ * @returns disposer unmounting the tree and restoring the column.
51
+ */
52
+ export function mountPanel(deps: MountPanelDeps): () => void {
53
+ const { sessions, workspaces, api, controller, tt, currentCwd } = deps
54
+ let root: Root | undefined
55
+ let container: HTMLDivElement | undefined
56
+
57
+ const ensure = (): void => {
58
+ if (container !== undefined) {
59
+ if (container.isConnected) return
60
+ // The conversation pane was replaced; drop the stale tree and remount.
61
+ root?.unmount()
62
+ root = undefined
63
+ container.remove()
64
+ container = undefined
65
+ }
66
+ const column = conversationColumn()
67
+ if (column === undefined) return
68
+ container = document.createElement('div')
69
+ container.dataset.dshWebchatView = ''
70
+ container.dataset.dshPlugin = 'webchat'
71
+ container.className = css.view
72
+ column.appendChild(container)
73
+ root = createRoot(container)
74
+ root.render(<WebChatPanel api={api} tt={tt} sessions={sessions} workspaces={workspaces} currentCwd={currentCwd} />)
75
+ }
76
+
77
+ // The frame mounts after boot settlement; watch for the column's arrival.
78
+ const waitObserver = new MutationObserver(() => { ensure() })
79
+ waitObserver.observe(document.body, { childList: true, subtree: true })
80
+
81
+ const applyActive = (): void => {
82
+ if (controller.getSnapshot().panelOpen) {
83
+ // Single-occupant center column: opening this panel must evict sibling
84
+ // panels (task board / ssh), both their html attributes and their
85
+ // controller state, otherwise visibility rules fight.
86
+ for (const attr of OTHER_ACTIVE_ATTRS) document.documentElement.removeAttribute(attr)
87
+ document.documentElement.setAttribute(ACTIVE_ATTR, '')
88
+ document.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: PANEL_NAME }))
89
+ } else {
90
+ document.documentElement.removeAttribute(ACTIVE_ATTR)
91
+ }
92
+ }
93
+ const onOtherActivate = (event: Event): void => {
94
+ const detail = (event as CustomEvent).detail
95
+ if ((detail === 'taskboard' || detail === 'ssh') && controller.getSnapshot().panelOpen) {
96
+ controller.close()
97
+ }
98
+ }
99
+ // Jump out on sidebar context clicks: clicking a session/workspace row
100
+ // hands the center column back to the conversation. Capture phase.
101
+ const SIDEBAR_ROW_SELECTOR = '[class*="sessionRow"], [class*="projectRow"], [class*="searchResultRow"], [class*="searchResultWorkspace"], [class*="newSession"]'
102
+ const onClickSidebarRow = (event: MouseEvent): void => {
103
+ if (!controller.getSnapshot().panelOpen) return
104
+ const target = event.target as HTMLElement | null
105
+ if (target === null) return
106
+ if (target.closest(SIDEBAR_ROW_SELECTOR) !== null) controller.close()
107
+ }
108
+ document.addEventListener('click', onClickSidebarRow, true)
109
+ document.addEventListener(ACTIVATE_EVENT, onOtherActivate)
110
+ const unsubscribe = controller.subscribe(applyActive)
111
+ applyActive()
112
+ ensure()
113
+
114
+ return () => {
115
+ document.removeEventListener('click', onClickSidebarRow, true)
116
+ document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
117
+ waitObserver.disconnect()
118
+ unsubscribe()
119
+ document.documentElement.removeAttribute(ACTIVE_ATTR)
120
+ root?.unmount()
121
+ root = undefined
122
+ container?.remove()
123
+ container = undefined
124
+ }
125
+ }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Minimal markdown renderer for the web-chat panel. Deliberately small and
3
+ * dependency-free: headings, paragraphs, bold/italic, inline + fenced code,
4
+ * links, DeepSeek `[citation:N]` markers (rendered as superscript badges),
5
+ * unordered/ordered lists, blockquotes, GFM tables, horizontal rules, and
6
+ * <details> passthrough (used for DeepSeek R1 thinking blocks). Anything
7
+ * unrecognized renders as plain text — never raw HTML.
8
+ */
9
+
10
+ import { createElement, type ReactNode } from 'react'
11
+ import css from './panel.module.css'
12
+
13
+ /** Escape text for safe rendering (no dangerouslySetInnerHTML anywhere). */
14
+ function esc(text: string): string {
15
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
16
+ }
17
+
18
+ /**
19
+ * Inline markdown → React nodes. Single tokenizer pass over the string:
20
+ * code spans win, then links, then `[citation:…]` markers, then bold, then
21
+ * italic — each non-token run is escaped text. Bold/italic recurse so a
22
+ * strong span can contain a citation or link.
23
+ */
24
+ function inline(text: string, keyBase = 'i'): ReactNode[] {
25
+ const nodes: ReactNode[] = []
26
+ // eslint-disable-next-line no-useless-escape
27
+ const re = /(`[^`]+`)|(\[[^\]]+\]\([^)\s]+\))|(\[citation:[^\]]+\])|(\*\*[^*]+\*\*|__[^_]+__)|(\*[^*\n]+\*|_[^_\n]+_)/g
28
+ let last = 0
29
+ let index = 0
30
+ let match: RegExpExecArray | null
31
+ while ((match = re.exec(text)) !== null) {
32
+ if (match.index > last) nodes.push(esc(text.slice(last, match.index)))
33
+ const key = `${keyBase}-${index}`
34
+ if (match[1] !== undefined) {
35
+ // `code`
36
+ nodes.push(createElement('code', { key: `${key}-code` }, match[1].slice(1, -1)))
37
+ } else if (match[2] !== undefined) {
38
+ // [label](url)
39
+ const link = /^\[([^\]]+)\]\(([^)\s]+)\)$/.exec(match[2])
40
+ nodes.push(createElement('a', { key: `${key}-a`, href: link![2], target: '_blank', rel: 'noreferrer' }, link![1]))
41
+ } else if (match[3] !== undefined) {
42
+ // [citation:N] / [citation:N1,N2] — DeepSeek web search markers.
43
+ const nums = match[3].slice('[citation:'.length, -1).trim()
44
+ nodes.push(createElement('sup', { key: `${key}-cite`, className: css.citation }, nums))
45
+ } else if (match[4] !== undefined) {
46
+ // **bold** / __bold__
47
+ const inner = match[4].slice(2, -2)
48
+ nodes.push(createElement('strong', { key: `${key}-b` }, ...inline(inner, `${key}-b`)))
49
+ } else if (match[5] !== undefined) {
50
+ // *italic* / _italic_
51
+ const inner = match[5].slice(1, -1)
52
+ nodes.push(createElement('em', { key: `${key}-i` }, ...inline(inner, `${key}-i`)))
53
+ }
54
+ last = re.lastIndex
55
+ index++
56
+ }
57
+ if (last < text.length) nodes.push(esc(text.slice(last)))
58
+ return nodes
59
+ }
60
+
61
+ /** One fenced code block → <pre><code>. */
62
+ function codeBlock(language: string, body: string, key: string): ReactNode {
63
+ return createElement(
64
+ 'pre',
65
+ { key },
66
+ createElement('code', { className: language === '' ? undefined : `language-${language}` }, body),
67
+ )
68
+ }
69
+
70
+ /** Split a table row into cells, honoring escaped pipes (\|). */
71
+ function splitCells(line: string): string[] {
72
+ const cells: string[] = []
73
+ let current = ''
74
+ let escaped = false
75
+ for (const ch of line) {
76
+ if (escaped) { current += ch; escaped = false }
77
+ else if (ch === '\\') { escaped = true }
78
+ else if (ch === '|') { cells.push(current); current = '' }
79
+ else { current += ch }
80
+ }
81
+ cells.push(current)
82
+ // Drop the leading/trailing empty artifacts produced by a leading/trailing
83
+ // pipe, so `| a | b |` and `a | b` both yield ['a','b'].
84
+ let start = 0
85
+ let end = cells.length
86
+ while (start < end && cells[start].trim() === '') start++
87
+ while (end > start && cells[end - 1].trim() === '') end--
88
+ return cells.slice(start, end).map(cell => cell.trim())
89
+ }
90
+
91
+ /** True when a line is a GFM table delimiter row (`:---`, `---:`, `:---:`, …). */
92
+ function isDelimiterRow(line: string): boolean {
93
+ const cells = splitCells(line)
94
+ return cells.length > 0 && cells.every(cell => /^:?-{3,}:?$/.test(cell))
95
+ }
96
+
97
+ /** Render one block of markdown text into React nodes. */
98
+ export function renderMarkdown(source: string): ReactNode[] {
99
+ const raw = source.replace(/\r\n/g, '\n').trim()
100
+ if (raw === '') return []
101
+ const blocks: ReactNode[] = []
102
+ const lines = raw.split('\n')
103
+ let index = 0
104
+ let blockIndex = 0
105
+
106
+ const push = (node: ReactNode): void => {
107
+ blocks.push(createElement('div', { key: `b${blockIndex++}` }, node))
108
+ }
109
+
110
+ while (index < lines.length) {
111
+ const line = lines[index]
112
+ const trimmed = line.trim()
113
+
114
+ // fenced code
115
+ const fence = /^```([\w+-]*)\s*$/.exec(trimmed)
116
+ if (fence !== null) {
117
+ const language = fence[1] ?? ''
118
+ const body: string[] = []
119
+ index++
120
+ while (index < lines.length && !/^```\s*$/.test(lines[index].trim())) {
121
+ body.push(lines[index])
122
+ index++
123
+ }
124
+ index++ // closing fence
125
+ push(codeBlock(language, body.join('\n'), `code${blockIndex}`))
126
+ continue
127
+ }
128
+
129
+ // headings
130
+ const heading = /^(#{1,6})\s+(.*)$/.exec(trimmed)
131
+ if (heading !== null) {
132
+ const level = heading[1].length
133
+ push(createElement(`h${level}` as 'h1', null, ...inline(heading[2], `h${blockIndex}`)))
134
+ index++
135
+ continue
136
+ }
137
+
138
+ // GFM table (header row containing '|' immediately followed by a delimiter
139
+ // row). Checked before the horizontal-rule branch so a delimiter like
140
+ // `--- | ---` is never mistaken for an hr.
141
+ if (line.includes('|') && lines[index + 1] !== undefined && isDelimiterRow(lines[index + 1])) {
142
+ const header = splitCells(line)
143
+ index += 2
144
+ const rows: string[][] = []
145
+ while (index < lines.length) {
146
+ const row = lines[index]
147
+ if (row.trim() === '' || !row.includes('|')) break
148
+ rows.push(splitCells(row))
149
+ index++
150
+ }
151
+ const width = Math.max(header.length, ...rows.map(row => row.length))
152
+ const rowNode = (cells: string[], rowIndex: number): ReactNode =>
153
+ createElement('tr', { key: rowIndex }, Array.from({ length: width }, (_, col) =>
154
+ createElement('td', { key: col }, ...inline(cells[col] ?? '', `t${blockIndex}-${rowIndex}-${col}`))))
155
+ blocks.push(createElement(
156
+ 'div',
157
+ { key: `tw${blockIndex}`, className: css.tableWrap },
158
+ createElement('table', { key: `t${blockIndex}` },
159
+ createElement('thead', null, createElement('tr', null, header.map((cell, col) =>
160
+ createElement('th', { key: col }, ...inline(cell, `th${blockIndex}-${col}`))))),
161
+ createElement('tbody', null, rows.map(rowNode))),
162
+ ))
163
+ blockIndex++
164
+ continue
165
+ }
166
+
167
+ // horizontal rule
168
+ if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
169
+ push(createElement('hr', { key: `hr${blockIndex}` }))
170
+ index++
171
+ continue
172
+ }
173
+
174
+ // blockquote (collect consecutive quote lines)
175
+ if (trimmed.startsWith('>')) {
176
+ const quote: string[] = []
177
+ while (index < lines.length && lines[index].trim().startsWith('>')) {
178
+ quote.push(lines[index].trim().replace(/^>\s?/, ''))
179
+ index++
180
+ }
181
+ push(createElement('blockquote', { key: `q${blockIndex}` }, ...renderMarkdown(quote.join('\n'))))
182
+ continue
183
+ }
184
+
185
+ // details (DeepSeek R1 thinking)
186
+ if (/^<details>/.test(trimmed)) {
187
+ const body: string[] = []
188
+ const summaryMatch = /^<summary>\s*(.*?)\s*<\/summary>/.exec(trimmed)
189
+ index++
190
+ while (index < lines.length && !/^<\/details>/.test(lines[index].trim())) {
191
+ body.push(lines[index])
192
+ index++
193
+ }
194
+ index++ // </details>
195
+ push(createElement(
196
+ 'details',
197
+ { key: `d${blockIndex}` },
198
+ createElement('summary', null, summaryMatch?.[1] ?? '详情'),
199
+ ...renderMarkdown(body.join('\n')),
200
+ ))
201
+ continue
202
+ }
203
+
204
+ // unordered list
205
+ if (/^[-*+]\s+/.test(trimmed)) {
206
+ const items: string[] = []
207
+ while (index < lines.length && /^[-*+]\s+/.test(lines[index].trim())) {
208
+ items.push(lines[index].trim().replace(/^[-*+]\s+/, ''))
209
+ index++
210
+ }
211
+ push(createElement('ul', { key: `ul${blockIndex}` }, items.map((item, itemIndex) =>
212
+ createElement('li', { key: itemIndex }, ...inline(item, `uli${blockIndex}-${itemIndex}`)))))
213
+ continue
214
+ }
215
+
216
+ // ordered list
217
+ if (/^\d+[.)]\s+/.test(trimmed)) {
218
+ const items: string[] = []
219
+ while (index < lines.length && /^\d+[.)]\s+/.test(lines[index].trim())) {
220
+ items.push(lines[index].trim().replace(/^\d+[.)]\s+/, ''))
221
+ index++
222
+ }
223
+ push(createElement('ol', { key: `ol${blockIndex}` }, items.map((item, itemIndex) =>
224
+ createElement('li', { key: itemIndex }, ...inline(item, `oli${blockIndex}-${itemIndex}`)))))
225
+ continue
226
+ }
227
+
228
+ // plain paragraph: collect consecutive non-empty, non-special lines
229
+ const paragraph: string[] = []
230
+ while (index < lines.length) {
231
+ const current = lines[index].trim()
232
+ if (current === '') break
233
+ if (/^(```|#{1,6}\s|[-*+]\s|\d+[.)]\s|>\s|<\/(details|table)>)/.test(current)) break
234
+ if (current.includes('|') && lines[index + 1] !== undefined && isDelimiterRow(lines[index + 1])) break
235
+ paragraph.push(current)
236
+ index++
237
+ }
238
+ if (paragraph.length === 0) {
239
+ index++
240
+ continue
241
+ }
242
+ push(createElement('p', { key: `p${blockIndex}` }, ...inline(paragraph.join(' '), `p${blockIndex}`)))
243
+ }
244
+
245
+ return blocks
246
+ }
247
+
248
+ /** The markdown renderer component. */
249
+ export function Markdown({ source }: { source: string }): ReactNode {
250
+ return createElement('div', { className: css.markdown }, ...renderMarkdown(source))
251
+ }