@jaychang1989/dsh-webchat 0.3.0 → 0.4.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 (44) hide show
  1. package/NOTICE +10 -3
  2. package/README.md +44 -40
  3. package/README.zh.md +43 -44
  4. package/cordis.patch.yml +8 -7
  5. package/lib/client.js +402 -1693
  6. package/lib/index.js +246 -3884
  7. package/package.json +3 -35
  8. package/lib/client.js.map +0 -1
  9. package/lib/types/client/api.d.ts +0 -74
  10. package/lib/types/client/controller.d.ts +0 -19
  11. package/lib/types/client/index.d.ts +0 -30
  12. package/lib/types/client/locales.d.ts +0 -169
  13. package/lib/types/client/mount.d.ts +0 -23
  14. package/lib/types/client/panel/Markdown.d.ts +0 -15
  15. package/lib/types/client/panel/WebChatPanel.d.ts +0 -28
  16. package/lib/types/client/sidebar-entry-core.d.ts +0 -60
  17. package/lib/types/client/sidebar-entry.d.ts +0 -22
  18. package/lib/types/engine/engine.d.ts +0 -247
  19. package/lib/types/engine/html-md.d.ts +0 -23
  20. package/lib/types/index.d.ts +0 -63
  21. package/lib/types/protocol.d.ts +0 -107
  22. package/lib/types/routes.d.ts +0 -22
  23. package/lib/types/store.d.ts +0 -69
  24. package/lib/types/tools.d.ts +0 -27
  25. package/lib/types/transfer.d.ts +0 -138
  26. package/src/client/api.ts +0 -90
  27. package/src/client/controller.ts +0 -43
  28. package/src/client/css-modules.d.ts +0 -5
  29. package/src/client/index.ts +0 -75
  30. package/src/client/locales.ts +0 -172
  31. package/src/client/mount.tsx +0 -125
  32. package/src/client/panel/Markdown.tsx +0 -251
  33. package/src/client/panel/WebChatPanel.tsx +0 -760
  34. package/src/client/panel/panel.module.css +0 -936
  35. package/src/client/sidebar-entry-core.ts +0 -207
  36. package/src/client/sidebar-entry.ts +0 -49
  37. package/src/engine/engine.ts +0 -1385
  38. package/src/engine/html-md.ts +0 -126
  39. package/src/index.ts +0 -212
  40. package/src/protocol.ts +0 -116
  41. package/src/routes.ts +0 -307
  42. package/src/store.ts +0 -217
  43. package/src/tools.ts +0 -257
  44. package/src/transfer.ts +0 -602
@@ -1,172 +0,0 @@
1
- /**
2
- * dsh-webchat surface copy: zh is the key source, en mirrors every key. The
3
- * key union (not an object shape) is what the locale machinery wants —
4
- * LocaleKeysOf<N> intersects the namespace with `string`, so the namespace
5
- * value must itself be the key union (same pattern as dsh-ssh).
6
- */
7
-
8
- export const zh = {
9
- 'entry.label': '网页聊天',
10
- 'entry.tooltip': 'DeepSeek 网页端聊天(deepseek-chat,Codex ChatGPT 模式)',
11
- 'panel.title': 'DeepSeek 网页端聊天',
12
- 'status.engine.stopped': '浏览器未启动',
13
- 'status.engine.launching': '浏览器启动中…',
14
- 'status.engine.ready': '浏览器就绪',
15
- 'status.engine.error': '浏览器错误',
16
- 'status.loggedIn': '已登录',
17
- 'status.notLoggedIn': '未登录',
18
- 'status.unknown': '状态未知',
19
- 'action.openLogin': '打开登录窗口',
20
- 'action.closeBrowser': '关闭浏览器',
21
- 'action.newChat': '新对话',
22
- 'action.recover': '从网页恢复',
23
- 'action.refresh': '刷新',
24
- 'action.send': '发送',
25
- 'action.stop': '停止生成',
26
- 'action.transferToHarness': '转移到 Harness',
27
- 'action.exportFile': '导出为文件',
28
- 'transfer.mode.distill': '蒸馏为任务简报',
29
- 'transfer.mode.raw': '原始记录',
30
- 'transfer.mode.hint': '蒸馏:用 harness LLM 把对话提炼成可执行任务简报;原始记录:原样放入新会话首条消息',
31
- 'transfer.workspace.label': '目标工作区',
32
- 'transfer.workspace.ungrouped': '未分组',
33
- 'transfer.workspace.new': '新建工作区…',
34
- 'transfer.workspace.hint': '转移到所选工作区;未分组则不归入任何工作区',
35
- 'transfer.workspace.created': '已创建并选中工作区 {title}',
36
- 'transfer.workspace.createFailed': '创建工作区失败:{error}',
37
- 'transfer.session.label': '延续到会话',
38
- 'transfer.session.hint': '选择已有 harness 会话以延续(把简报作为新消息追加);默认新建会话',
39
- 'transfer.session.new': '新建会话',
40
- 'transfer.attached.failed': '会话已创建({sessionId}),但归入工作区失败,现为未分组',
41
- 'handoff.eyebrow': '交付到 Harness',
42
- 'toggle.deepThink': '深度思考',
43
- 'toggle.search': '智能搜索',
44
- 'toggle.deepThink.hint': '开启 R1 深度思考',
45
- 'toggle.search.hint': '开启联网检索',
46
- 'sidebar.empty': '暂无对话',
47
- 'chats.count': '{count} 条',
48
- 'role.you': '你',
49
- 'chat.rename': '重命名',
50
- 'chat.delete': '删除',
51
- 'chat.delete.confirm': '确认删除?',
52
- 'chat.clearAll': '清空全部',
53
- 'chat.clearAll.confirm': '确认清空全部?',
54
- 'chat.rename.placeholder': '输入新标题…',
55
- 'chat.rename.ok': '确定',
56
- 'chat.rename.cancel': '取消',
57
- 'chat.manage.hint': '管理本地保存的聊天记录(不影响网页端)',
58
- 'toast.rename.done': '已重命名',
59
- 'toast.rename.failed': '重命名失败:{error}',
60
- 'toast.delete.done': '已删除该对话',
61
- 'toast.delete.failed': '删除失败:{error}',
62
- 'toast.clear.done': '已清空全部对话',
63
- 'toast.clear.failed': '清空失败:{error}',
64
- 'model.deepseek-chat': 'DeepSeek',
65
- 'model.deepseek-reasoner': 'DeepSeek',
66
- 'composer.placeholder': '给 DeepSeek 发送消息…(将发送到网页端)',
67
- 'composer.hint': 'Enter 发送 · Shift+Enter 换行',
68
- 'composer.busy': '正在生成回复…',
69
- 'composer.notLoggedIn': '请先登录 DeepSeek 网页端',
70
- 'composer.attach': '图片',
71
- 'composer.attach.hint': '添加本地图片(绝对路径,逗号/换行分隔)',
72
- 'composer.attach.placeholder': '图片绝对路径,逗号或换行分隔',
73
- 'empty.eyebrow': 'DeepSeek 网页端 · 桥接开发',
74
- 'empty.title': '先聊,再交付',
75
- 'empty.body': '登录网页端即可开始对话;任何对话都能蒸馏成任务简报,转入 Harness 会话继续开发。',
76
- 'transfer.done': '已创建 Harness 会话 {sessionId},正在为你打开…',
77
- 'transfer.continued': '已延续 Harness 会话 {sessionId},正在为你打开…',
78
- 'transfer.failed': '转移到 Harness 失败:{error}',
79
- 'export.done': '已导出到工作区文件:{filePath}',
80
- 'export.failed': '导出失败:{error}',
81
- 'recover.hint': '列出网页端尚未导入本地的会话',
82
- 'recover.empty': '网页端没有未导入的会话',
83
- 'recover.done': '已恢复「{title}」',
84
- 'msg.empty': '(空)',
85
- 'msg.error': '(该回复可能不完整:{error})',
86
- 'streaming': '生成中',
87
- } as const
88
-
89
- /** Bilingual balance enforced: en must cover every zh key. */
90
- export const en = {
91
- 'entry.label': 'Web Chat',
92
- 'entry.tooltip': 'DeepSeek web chat (deepseek-chat, Codex ChatGPT mode)',
93
- 'panel.title': 'DeepSeek Web Chat',
94
- 'status.engine.stopped': 'Browser not started',
95
- 'status.engine.launching': 'Starting browser…',
96
- 'status.engine.ready': 'Browser ready',
97
- 'status.engine.error': 'Browser error',
98
- 'status.loggedIn': 'Logged in',
99
- 'status.notLoggedIn': 'Not logged in',
100
- 'status.unknown': 'Unknown',
101
- 'action.openLogin': 'Open login window',
102
- 'action.closeBrowser': 'Close browser',
103
- 'action.newChat': 'New chat',
104
- 'action.recover': 'Recover from web',
105
- 'action.refresh': 'Refresh',
106
- 'action.send': 'Send',
107
- 'action.stop': 'Stop',
108
- 'action.transferToHarness': 'Transfer to Harness',
109
- 'action.exportFile': 'Export to file',
110
- 'transfer.mode.distill': 'Distill to task brief',
111
- 'transfer.mode.raw': 'Raw transcript',
112
- 'transfer.mode.hint': 'Distill: condense the chat into an executable brief via the harness LLM; Raw: replay the full transcript as the session\'s first message',
113
- 'transfer.workspace.label': 'Target workspace',
114
- 'transfer.workspace.ungrouped': 'Ungrouped',
115
- 'transfer.workspace.new': 'New workspace…',
116
- 'transfer.workspace.hint': 'Transfer into the chosen workspace; ungrouped leaves it out of any workspace',
117
- 'transfer.workspace.created': 'Created and selected workspace {title}',
118
- 'transfer.workspace.createFailed': 'Create workspace failed: {error}',
119
- 'transfer.session.label': 'Continue into session',
120
- 'transfer.session.hint': 'Continue into an existing harness session (append the brief as a new message); default creates a new one',
121
- 'transfer.session.new': 'New session',
122
- 'transfer.attached.failed': 'Session created ({sessionId}) but attaching to workspace failed; it is ungrouped',
123
- 'handoff.eyebrow': 'Handoff to Harness',
124
- 'toggle.deepThink': 'DeepThink',
125
- 'toggle.search': 'Search',
126
- 'toggle.deepThink.hint': 'Enable R1 deep think',
127
- 'toggle.search.hint': 'Enable web search',
128
- 'sidebar.empty': 'No chats',
129
- 'chats.count': '{count} msgs',
130
- 'role.you': 'You',
131
- 'chat.rename': 'Rename',
132
- 'chat.delete': 'Delete',
133
- 'chat.delete.confirm': 'Delete?',
134
- 'chat.clearAll': 'Clear all',
135
- 'chat.clearAll.confirm': 'Clear all?',
136
- 'chat.rename.placeholder': 'New title…',
137
- 'chat.rename.ok': 'OK',
138
- 'chat.rename.cancel': 'Cancel',
139
- 'chat.manage.hint': 'Manage locally saved transcripts (does not affect the web)',
140
- 'toast.rename.done': 'Renamed',
141
- 'toast.rename.failed': 'Rename failed: {error}',
142
- 'toast.delete.done': 'Chat deleted',
143
- 'toast.delete.failed': 'Delete failed: {error}',
144
- 'toast.clear.done': 'All chats cleared',
145
- 'toast.clear.failed': 'Clear failed: {error}',
146
- 'model.deepseek-chat': 'DeepSeek',
147
- 'model.deepseek-reasoner': 'DeepSeek',
148
- 'composer.placeholder': 'Message DeepSeek… (sent via the web)',
149
- 'composer.hint': 'Enter to send · Shift+Enter for newline',
150
- 'composer.busy': 'Generating…',
151
- 'composer.notLoggedIn': 'Sign in to DeepSeek web first',
152
- 'composer.attach': 'Images',
153
- 'composer.attach.hint': 'Attach local images (absolute paths, comma/newline separated)',
154
- 'composer.attach.placeholder': 'Image absolute paths (comma or newline separated)',
155
- 'empty.eyebrow': 'DeepSeek Web · Bridge to Code',
156
- 'empty.title': 'Chat first, hand off later',
157
- 'empty.body': 'Sign in to the web and start chatting; any chat can be distilled into a task brief and handed off to Harness.',
158
- 'transfer.done': 'Created Harness session {sessionId}; opening it…',
159
- 'transfer.continued': 'Continued Harness session {sessionId}; opening it…',
160
- 'transfer.failed': 'Transfer failed: {error}',
161
- 'export.done': 'Exported to workspace file: {filePath}',
162
- 'export.failed': 'Export failed: {error}',
163
- 'recover.hint': 'List web conversations not yet imported locally',
164
- 'recover.empty': 'No unimported web conversations',
165
- 'recover.done': 'Recovered "{title}"',
166
- 'msg.empty': '(empty)',
167
- 'msg.error': '(reply may be incomplete: {error})',
168
- 'streaming': 'generating',
169
- } satisfies Record<keyof typeof zh, string>
170
-
171
- /** Key union of the web-chat surface copy. */
172
- export type WebChatKey = keyof typeof zh
@@ -1,125 +0,0 @@
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
- }
@@ -1,251 +0,0 @@
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
- }