dsh-markdown-bubble 0.1.5 → 0.2.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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/client.js +269 -2
  3. package/src/index.js +107 -16
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-markdown-bubble",
3
- "version": "0.1.5",
3
+ "version": "0.2.1",
4
4
  "description": "Markdown rendering for DeepSeek Harness sent user messages: every message bubble renders lists, code, tables, quotes and inline formatting through the host's own Markdown pipeline, while queues, previews and the composer keep their plain projection.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/client.js CHANGED
@@ -49,7 +49,7 @@ window.__ModuleLoader__.load({
49
49
  const NS = 'dsh-markdown-bubble'
50
50
 
51
51
  /** Keep in sync with package.json and src/index.js. */
52
- const PLUGIN_VERSION = '0.1.5'
52
+ const PLUGIN_VERSION = '0.2.1'
53
53
 
54
54
  /** Presence marker the live verifier reads. */
55
55
  const DEBUG_KEY = '__DSH_MARKDOWN_BUBBLE__'
@@ -58,6 +58,159 @@ window.__ModuleLoader__.load({
58
58
  const CSS_TAG = NS + '/markdown-bubble.css'
59
59
 
60
60
  /** Chat-node seats this renderer replaces; the host serves both with one view. */
61
+ /**
62
+ * 外观(layout)覆盖。
63
+ *
64
+ * 默认(`bubble`)一条规则都不命中,现有的气泡外观分毫不变;只有页面被
65
+ * 标记成 `full` 时才换成长条记录式:铺满对话列、去掉气泡底色、下方一条
66
+ * 细分隔线。规则只碰本插件自己的类名,绝不改宿主命名的节点。
67
+ */
68
+ const LAYOUT_CSS = `
69
+ /* Pro(模仿 Google AI Studio):整条用户消息是一个和输入框同宽的圆角框——
70
+ 细边框、透明底,框内第一行是「User · 时:分」,正文在下面。 */
71
+ html[data-dshmb-layout="full"] .dshmb-stack{max-width:100%!important;width:100%!important;align-items:stretch!important}
72
+ html[data-dshmb-layout="full"] .dshmb-bubble{
73
+ display:block!important;
74
+ width:100%!important;max-width:100%!important;
75
+ box-sizing:border-box;
76
+ background:transparent!important;
77
+ /* 静默态:只有顶上一条横线,没有框。 */
78
+ border:0!important;
79
+ /* 静默态也有 1px 的边框,只是颜色透明——这样悬浮时只过渡颜色,
80
+ 不会出现「先黑后灰」的闪色,也不会因为突然多出 1px 而跳动。 */
81
+ border:1px solid transparent!important;
82
+ border-radius:var(--dsw-radius-xl,16px)!important;
83
+ position:relative;
84
+ padding:12px 16px!important;
85
+ text-align:left!important;
86
+ transition:border-color .12s ease, border-radius .12s ease, box-shadow .12s ease;
87
+ }
88
+
89
+ /* 刚发出、还没落盘的那一帧由宿主自己的 UserMessageBubble 画(data-message-bubble),
90
+ 它没有 .dshmb-* 类。这里把它也按 Pro 的样子铺开,避免「先闪一下右侧气泡」。 */
91
+ html[data-dshmb-layout="full"] [data-chat-flow-kind="user"] [data-message-bubble],
92
+ html[data-dshmb-layout="full"] [data-chat-flow-kind="steering"] [data-message-bubble]{
93
+ width:100%!important;max-width:100%!important;
94
+ box-sizing:border-box;
95
+ background:transparent!important;
96
+ border:1px solid transparent!important;
97
+ border-radius:var(--dsw-radius-xl,16px)!important;
98
+ padding:12px 16px!important;
99
+ text-align:left!important;
100
+ }
101
+ html[data-dshmb-layout="full"] [data-chat-flow-kind="user"] [data-message-bubble]::before,
102
+ html[data-dshmb-layout="full"] [data-chat-flow-kind="steering"] [data-message-bubble]::before{
103
+ content:"";position:absolute;left:16px;right:16px;top:0;height:1px;
104
+ background:var(--dsw-alias-border-l2,rgba(16,24,40,.16));
105
+ }
106
+ html[data-dshmb-layout="full"] [data-chat-flow-kind="user"] [data-message-bubble]{position:relative}
107
+
108
+ /* 一轮结束后的动作条与下一轮之间的呼吸:宿主默认只留 4px,太挤。 */
109
+ html[data-dshmb-layout="full"] [data-chat-flow-kind="turn-tail"]{margin-bottom:16px!important}
110
+
111
+ /* 静默顶线:两端内收一个圆角半径,长度 == 悬浮框顶边的直线段。 */
112
+ html[data-dshmb-layout="full"] .dshmb-bubble::before{
113
+ content:"";position:absolute;left:16px;right:16px;top:0;height:1px;
114
+ background:var(--dsw-alias-border-l2,rgba(16,24,40,.16));
115
+ transition:opacity .12s ease;
116
+ }
117
+ html[data-dshmb-layout="full"] [data-chat-flow-kind="user"]:hover .dshmb-bubble::before,
118
+ html[data-dshmb-layout="full"] [data-chat-flow-kind="steering"]:hover .dshmb-bubble::before,
119
+ html[data-dshmb-layout="full"] .dshmb-bubble:hover::before{opacity:0}
120
+
121
+ /* 悬浮态:整框出现(四边 + 圆角),移开又只剩顶线。 */
122
+ html[data-dshmb-layout="full"] [data-chat-flow-kind="user"]:hover .dshmb-bubble,
123
+ html[data-dshmb-layout="full"] [data-chat-flow-kind="steering"]:hover .dshmb-bubble,
124
+ html[data-dshmb-layout="full"] .dshmb-bubble:hover{
125
+ border-color:var(--dsw-alias-border-l2,rgba(16,24,40,.16))!important;
126
+ border-radius:var(--dsw-radius-xl,16px)!important;
127
+ }
128
+ html[data-dshmb-layout="full"] .dshmb-md{text-align:left!important}
129
+ /* 框要「高」一点:正文照常排版,框底多留 20px 呼吸空间。 */
130
+ html[data-dshmb-layout="full"] .dshmb-bubble{padding-bottom:20px!important}
131
+ html[data-dshmb-layout="full"] .dshmb-attachments{justify-content:flex-start!important}
132
+ /* 抬头:User · 03:06 */
133
+ .dshmb-pro-head{display:flex;align-items:center;justify-content:space-between;gap:6px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,#81858c);margin:0 0 6px}
134
+ .dshmb-pro-head b{font-weight:500;color:var(--dsw-alias-label-secondary,#4b5872)}
135
+ `
136
+
137
+ /**
138
+ * Pro 的抬头:`User · 03:06`。
139
+ *
140
+ * 时间不自己算——宿主把消息时间写在时钟元素的 `data-clock` 上;拷贝它,
141
+ * 就不会和官方的显示口径(今天/月日/年月日)分叉。
142
+ *
143
+ * @param {Element} row - 消息行。
144
+ */
145
+ function decorateProHeader(row) {
146
+ if (typeof document === 'undefined' || !document.body) return
147
+ const full = document.body.dataset.dshmbLayout === 'full'
148
+ const bubble = row.querySelector('.dshmb-bubble')
149
+ if (!bubble) return
150
+ const existing = bubble.querySelector(':scope > .dshmb-pro-head')
151
+ if (!full) {
152
+ if (existing) existing.remove()
153
+ return
154
+ }
155
+ if (existing) return
156
+ const head = document.createElement('div')
157
+ head.className = 'dshmb-pro-head'
158
+ const who = document.createElement('b')
159
+ who.textContent = row.getAttribute('data-chat-flow-kind') === 'steering' ? 'You' : 'User'
160
+ head.appendChild(who)
161
+ // 本行的动作入口(编辑 / 重跑 / 删除)搬进抬头行右侧——只搬本行自己的,
162
+ // 绝不跨行,否则会像之前那样把上一行的删除按钮吸过来。
163
+ bubble.insertBefore(head, bubble.firstChild)
164
+ }
165
+
166
+ /** 扫一遍所有用户行,补/撤 Pro 抬头。 */
167
+ function decorateAllRows() {
168
+ if (typeof document === 'undefined') return
169
+ for (const row of document.querySelectorAll('[data-chat-flow-kind="user"], [data-chat-flow-kind="steering"]')) {
170
+ decorateProHeader(row)
171
+ }
172
+ }
173
+
174
+ /** 本地缓存键:让下一帧之前就能知道外观,避免新消息先画成气泡再跳变。 */
175
+ const LAYOUT_CACHE_KEY = 'dsh-markdown-bubble:layout'
176
+
177
+ /**
178
+ * 同步地把外观标记打到 `<html>` 上。
179
+ *
180
+ * 宿主配置是异步读的(一次 fetch),但新消息到来的第一帧必须已经是正确的
181
+ * 外观,否则会「先画成右边的气泡,闪一下才变成 Pro」。所以真实来源仍是宿主
182
+ * 配置,这里只用一个本地缓存做首帧的提前量,读到配置后再校正。
183
+ *
184
+ * @param {'bubble' | 'full' | null} [value] - 要写入的外观;省略则读缓存。
185
+ */
186
+ function markLayout(value) {
187
+ if (typeof document === 'undefined') return
188
+ try {
189
+ const layout = value ?? (typeof localStorage !== 'undefined' ? localStorage.getItem(LAYOUT_CACHE_KEY) : null)
190
+ if (layout === 'full') document.documentElement.dataset.dshmbLayout = 'full'
191
+ else if (layout === 'bubble') document.documentElement.dataset.dshmbLayout = 'bubble'
192
+ } catch {
193
+ /* 没有 document/localStorage 就交给宿主 */
194
+ }
195
+ }
196
+
197
+ // 模块加载即执行:赶在第一帧之前。
198
+ markLayout()
199
+
200
+ /**
201
+ * 读宿主记住的外观;失败一律当默认(气泡)。
202
+ * @returns {Promise<'bubble' | 'full'>} 外观。
203
+ */
204
+ async function readLayout() {
205
+ try {
206
+ const response = await fetch('/api/dsh-markdown-bubble/config', { headers: { accept: 'application/json' } })
207
+ const data = await response.json()
208
+ return data && data.layout === 'full' ? 'full' : 'bubble'
209
+ } catch {
210
+ return 'bubble'
211
+ }
212
+ }
213
+
61
214
  const SEATS = ['user', 'steering']
62
215
 
63
216
  // Seat priority: the host's default is 0, the plugin sits below it, and it
@@ -902,7 +1055,7 @@ window.__ModuleLoader__.load({
902
1055
  const tag = document.createElement('style')
903
1056
  tag.dataset.plugin = NS
904
1057
  tag.dataset.pluginCss = CSS_TAG
905
- tag.textContent = CSS
1058
+ tag.textContent = CSS + LAYOUT_CSS
906
1059
  document.head.appendChild(tag)
907
1060
  }
908
1061
 
@@ -923,6 +1076,37 @@ window.__ModuleLoader__.load({
923
1076
  */
924
1077
  function apply(ctx) {
925
1078
  injectStyles()
1079
+ // 外观由宿主记住(跨窗口、跨重启一致);读到之前先按默认气泡渲染,
1080
+ // 读到了再切换,避免闪一下错的样子。
1081
+ void readLayout().then((layout) => {
1082
+ try {
1083
+ if (typeof localStorage !== 'undefined') localStorage.setItem(LAYOUT_CACHE_KEY, layout)
1084
+ } catch {
1085
+ /* 存不进去也没关系 */
1086
+ }
1087
+ markLayout(layout)
1088
+ decorateAllRows()
1089
+ })
1090
+ // 消息行会随时重渲染:轻量地补抬头,不监听整棵文档的文本变化。
1091
+ let scheduled = false
1092
+ const schedule = () => {
1093
+ if (scheduled) return
1094
+ scheduled = true
1095
+ const run = () => {
1096
+ scheduled = false
1097
+ decorateAllRows()
1098
+ }
1099
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run)
1100
+ else setTimeout(run, 16)
1101
+ }
1102
+ const observer = typeof MutationObserver === 'function' ? new MutationObserver(schedule) : null
1103
+ if (observer !== null) observer.observe(document.body, { childList: true, subtree: true })
1104
+ ctx.effect(
1105
+ () => () => {
1106
+ if (observer !== null) observer.disconnect()
1107
+ },
1108
+ NS + ': pro header observer',
1109
+ )
926
1110
  for (const key of SEATS) {
927
1111
  ctx.slots.inject('conversation.chat.node', () =>
928
1112
  ctx.slots.register(
@@ -937,6 +1121,17 @@ window.__ModuleLoader__.load({
937
1121
  )
938
1122
  }
939
1123
 
1124
+ // 独立安装时也要有设置卡:插件页的 `plugins.bundle.config`,
1125
+ // key 用包名,和 dsh-context 等插件同一约定。
1126
+ if (ctx.slots !== null && ctx.slots !== undefined && typeof ctx.slots.inject === 'function') {
1127
+ ctx.slots.inject('plugins.bundle.config', () =>
1128
+ ctx.slots.register(
1129
+ { name: 'plugins.bundle.config', key: 'dsh-markdown-bubble', locale: NS },
1130
+ ConfigCard,
1131
+ ),
1132
+ )
1133
+ }
1134
+
940
1135
  globalThis[DEBUG_KEY] = { version: PLUGIN_VERSION, seats: SEATS.slice() }
941
1136
 
942
1137
  ctx.effect(
@@ -948,7 +1143,79 @@ window.__ModuleLoader__.load({
948
1143
  )
949
1144
  }
950
1145
 
1146
+ /**
1147
+ * 设置卡:选择气泡外观。
1148
+ *
1149
+ * 写入宿主配置(跨窗口、跨重启),并立刻改页面标记——两种外观都是同一份
1150
+ * 渲染管线,切换不需要重载。
1151
+ *
1152
+ * @returns {object} React 元素。
1153
+ */
1154
+ function ConfigCard() {
1155
+ const [layout, setLayout] = react.useState(null)
1156
+ const [error, setError] = react.useState('')
1157
+ react.useEffect(() => {
1158
+ void readLayout().then(setLayout)
1159
+ }, [])
1160
+ const choose = (next) => {
1161
+ fetch('/api/dsh-markdown-bubble/config', {
1162
+ method: 'POST',
1163
+ headers: { 'content-type': 'application/json' },
1164
+ body: JSON.stringify({ layout: next }),
1165
+ })
1166
+ .then(async (response) => {
1167
+ const data = await response.json().catch(() => ({}))
1168
+ if (!response.ok || data.ok !== true) throw new Error(data.error ? String(data.error) : `HTTP ${response.status}`)
1169
+ const nextLayout = data.layout === 'full' ? 'full' : 'bubble'
1170
+ setLayout(nextLayout)
1171
+ setError('')
1172
+ try {
1173
+ if (typeof localStorage !== 'undefined') localStorage.setItem(LAYOUT_CACHE_KEY, nextLayout)
1174
+ } catch {
1175
+ /* 存不进去也没关系 */
1176
+ }
1177
+ markLayout(nextLayout)
1178
+ })
1179
+ .catch((failure) => setError(String(failure && failure.message ? failure.message : failure)))
1180
+ }
1181
+ const option = (value, title, hint) =>
1182
+ react.createElement(
1183
+ 'button',
1184
+ {
1185
+ key: value,
1186
+ type: 'button',
1187
+ onClick: () => choose(value),
1188
+ style: {
1189
+ display: 'flex',
1190
+ flexDirection: 'column',
1191
+ alignItems: 'flex-start',
1192
+ gap: 2,
1193
+ padding: '8px 12px',
1194
+ borderRadius: 8,
1195
+ cursor: 'pointer',
1196
+ textAlign: 'left',
1197
+ border: '1px solid ' + (layout === value ? 'var(--dsw-alias-button-primary-fill,#4d6bfe)' : 'var(--dsw-alias-border-l2,rgba(16,24,40,.16))'),
1198
+ background: layout === value ? 'var(--dsw-alias-bg-layer-2,transparent)' : 'transparent',
1199
+ color: 'inherit',
1200
+ },
1201
+ },
1202
+ [
1203
+ react.createElement('span', { key: 't', style: { fontSize: 13, fontWeight: 600 } }, title),
1204
+ react.createElement('span', { key: 'h', style: { fontSize: 12, opacity: 0.7 } }, hint),
1205
+ ],
1206
+ )
1207
+ return react.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } }, [
1208
+ react.createElement('div', { key: 'title', style: { fontSize: 13, fontWeight: 600 } }, '气泡外观'),
1209
+ react.createElement('div', { key: 'options', style: { display: 'flex', gap: 8, flexWrap: 'wrap' } }, [
1210
+ option('bubble', '气泡 Lite', '右对齐气泡,宽度约对话列的 70%'),
1211
+ option('full', '气泡 Pro', '铺满对话列、与输入框同宽,无气泡底色'),
1212
+ ]),
1213
+ error === '' ? null : react.createElement('p', { key: 'err', style: { fontSize: 12, color: 'var(--dsw-alias-state-error-primary,#c93a31)', margin: 0 } }, `保存失败:${error}`),
1214
+ ])
1215
+ }
1216
+
951
1217
  exports.apply = apply
1218
+ exports.ConfigCard = ConfigCard
952
1219
  exports.inject = ['slots', 'locale']
953
1220
  exports.PLUGIN_VERSION = PLUGIN_VERSION
954
1221
  exports.SEATS = SEATS
package/src/index.js CHANGED
@@ -1,30 +1,121 @@
1
1
  /**
2
- * dsh-markdown-bubble — host half.
2
+ * dsh-markdown-bubble —— 宿主半部。
3
3
  *
4
- * The plugin has no host-side behavior: a sent message's bubble is drawn by
5
- * the browser's chat view, and how it renders is a client-side projection of
6
- * the same text the drawer already receives. The host row exists so the
7
- * package is mounted by the Loader — the client-module scanner scans Loader
8
- * entries for packages declaring `dsh.client` — and so the bundle patch has a
9
- * row id to insert.
4
+ * 渲染本身完全在浏览器半部;宿主这边只负责一件事:**记住用户选的外观**
5
+ * (`layout: 'bubble' | 'full'`),并通过一条只读本机路由把它交给页面。
6
+ *
7
+ * 为什么需要它:外观是客户端的事,但「用户选了什么」必须跨重启、跨窗口一致,
8
+ * 不能存在某个 tab 的 localStorage 里。配置文件放在
9
+ * `~/.dsh/dsh-markdown-bubble/config.json`,坏了就当默认值,不影响渲染。
10
10
  *
11
11
  * @module dsh-markdown-bubble
12
12
  */
13
+ import fs from 'node:fs'
14
+ import os from 'node:os'
15
+ import path from 'node:path'
13
16
 
14
- /** Stable plugin id (matches the bundle patch's insert id and the client module id). */
17
+ /** 稳定插件 id(与 bundle patch、client 模块 id 一致)。 */
15
18
  export const PLUGIN_ID = 'dsh-markdown-bubble'
16
19
 
17
- /** Keep in sync with package.json and src/client.js. */
18
- export const PLUGIN_VERSION = '0.1.5'
20
+ /** 与 package.json / src/client.js 保持同步。 */
21
+ export const PLUGIN_VERSION = '0.2.1'
19
22
 
20
- /** Cordis plugin name; the bundle patch resolves the package by this row. */
23
+ /** Cordis 插件名;bundle patch 用这个名字解析包。 */
21
24
  export const name = PLUGIN_ID
22
25
 
23
- /** Services the inert entry needs: none. */
24
- export const inject = []
26
+ /** 需要 webServer 注册配置路由。 */
27
+ export const inject = ['webServer']
28
+
29
+ /** 路由前缀。 */
30
+ const ROUTE_PREFIX = '/api/dsh-markdown-bubble'
31
+
32
+ /** 合法外观。 */
33
+ const LAYOUTS = new Set(['bubble', 'full'])
34
+
35
+ /** 配置路径。 */
36
+ export function configPath(home = os.homedir()) {
37
+ return path.join(home, '.dsh', 'dsh-markdown-bubble', 'config.json')
38
+ }
39
+
40
+ /**
41
+ * 读配置;缺失或损坏回默认值。
42
+ * @param {string} [home] - 家目录(测试用)。
43
+ * @returns {{ layout: 'bubble' | 'full' }} 配置。
44
+ */
45
+ export function readConfig(home = os.homedir()) {
46
+ try {
47
+ const parsed = JSON.parse(fs.readFileSync(configPath(home), 'utf8'))
48
+ const layout = typeof parsed?.layout === 'string' ? parsed.layout : ''
49
+ return { layout: LAYOUTS.has(layout) ? layout : 'bubble' }
50
+ } catch {
51
+ return { layout: 'bubble' }
52
+ }
53
+ }
54
+
55
+ /**
56
+ * 写配置(先临时文件再改名)。
57
+ * @param {{ layout: string }} next - 新配置。
58
+ * @param {string} [home] - 家目录(测试用)。
59
+ * @returns {{ layout: 'bubble' | 'full' }} 落盘后的配置。
60
+ */
61
+ export function writeConfig(next, home = os.homedir()) {
62
+ const layout = LAYOUTS.has(next?.layout) ? next.layout : 'bubble'
63
+ const file = configPath(home)
64
+ try {
65
+ fs.mkdirSync(path.dirname(file), { recursive: true })
66
+ fs.writeFileSync(file + '.tmp', JSON.stringify({ layout }))
67
+ fs.renameSync(file + '.tmp', file)
68
+ } catch {
69
+ // 写不进去就用内存里的值,界面不必因此报错。
70
+ }
71
+ return { layout }
72
+ }
73
+
74
+ /** 只放行本机请求。 */
75
+ function isLoopback(req) {
76
+ const address = req.socket?.remoteAddress ?? ''
77
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
78
+ }
79
+
80
+ /** 发送 JSON。 */
81
+ function sendJson(res, status, payload) {
82
+ res.statusCode = status
83
+ res.setHeader('content-type', 'application/json; charset=utf-8')
84
+ res.end(JSON.stringify(payload))
85
+ }
25
86
 
26
87
  /**
27
- * Inert apply: the browser half owns every behavior.
28
- * @param {import('@deepseek-ai/cordis').Context} _ctx - owning context.
88
+ * 挂载配置路由。
89
+ * @param {object} ctx - cordis 上下文。
29
90
  */
30
- export function apply(_ctx) {}
91
+ export function apply(ctx) {
92
+ const webServer = ctx.get('webServer')
93
+ const register = (server, fiber) => {
94
+ fiber.effect(() =>
95
+ server.register({
96
+ kind: 'exact',
97
+ path: `${ROUTE_PREFIX}/config`,
98
+ handler: (req, res) => {
99
+ if (!isLoopback(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' })
100
+ if (req.method === 'GET') return sendJson(res, 200, { ok: true, ...readConfig() })
101
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'GET or POST' })
102
+ let raw = ''
103
+ req.on('data', (chunk) => {
104
+ raw += chunk
105
+ if (raw.length > 4096) req.destroy()
106
+ })
107
+ req.on('end', () => {
108
+ try {
109
+ const body = raw === '' ? {} : JSON.parse(raw)
110
+ sendJson(res, 200, { ok: true, ...writeConfig(body) })
111
+ } catch {
112
+ sendJson(res, 400, { ok: false, error: 'malformed JSON body' })
113
+ }
114
+ })
115
+ },
116
+ }),
117
+ )
118
+ }
119
+ if (webServer) register(webServer, ctx)
120
+ else if (typeof ctx.inject === 'function') ctx.inject(['webServer'], (sub) => register(sub.webServer, sub))
121
+ }