@jaychang1989/dsh-webchat 0.3.0 → 0.4.1
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/NOTICE +10 -3
- package/README.md +44 -39
- package/README.zh.md +43 -43
- package/cordis.patch.yml +8 -7
- package/lib/client.js +203 -1703
- package/lib/index.js +246 -3884
- package/package.json +3 -35
- package/lib/client.js.map +0 -1
- package/lib/types/client/api.d.ts +0 -74
- package/lib/types/client/controller.d.ts +0 -19
- package/lib/types/client/index.d.ts +0 -30
- package/lib/types/client/locales.d.ts +0 -169
- package/lib/types/client/mount.d.ts +0 -23
- package/lib/types/client/panel/Markdown.d.ts +0 -15
- package/lib/types/client/panel/WebChatPanel.d.ts +0 -28
- package/lib/types/client/sidebar-entry-core.d.ts +0 -60
- package/lib/types/client/sidebar-entry.d.ts +0 -22
- package/lib/types/engine/engine.d.ts +0 -247
- package/lib/types/engine/html-md.d.ts +0 -23
- package/lib/types/index.d.ts +0 -63
- package/lib/types/protocol.d.ts +0 -107
- package/lib/types/routes.d.ts +0 -22
- package/lib/types/store.d.ts +0 -69
- package/lib/types/tools.d.ts +0 -27
- package/lib/types/transfer.d.ts +0 -138
- package/src/client/api.ts +0 -90
- package/src/client/controller.ts +0 -43
- package/src/client/css-modules.d.ts +0 -5
- package/src/client/index.ts +0 -75
- package/src/client/locales.ts +0 -172
- package/src/client/mount.tsx +0 -125
- package/src/client/panel/Markdown.tsx +0 -251
- package/src/client/panel/WebChatPanel.tsx +0 -760
- package/src/client/panel/panel.module.css +0 -936
- package/src/client/sidebar-entry-core.ts +0 -207
- package/src/client/sidebar-entry.ts +0 -49
- package/src/engine/engine.ts +0 -1385
- package/src/engine/html-md.ts +0 -126
- package/src/index.ts +0 -212
- package/src/protocol.ts +0 -116
- package/src/routes.ts +0 -307
- package/src/store.ts +0 -217
- package/src/tools.ts +0 -257
- package/src/transfer.ts +0 -602
package/src/client/locales.ts
DELETED
|
@@ -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
|
package/src/client/mount.tsx
DELETED
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
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
|
-
}
|