dsh-markdown-bubble 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -3
- package/package.json +2 -2
- package/src/client.js +353 -26
- package/src/index.js +107 -16
package/README.md
CHANGED
|
@@ -33,9 +33,9 @@ DSH 的用户消息气泡按原样显示你输入的文字(`white-space: pre-w
|
|
|
33
33
|
|
|
34
34
|
- **官方座位替换** —— 经 `ctx.slots` 在 `conversation.chat.node` 的 `user` / `steering` 两个 keyed 座位上以 `priority: -1` 遮蔽宿主渲染器;停用/卸载即恢复宿主自己的气泡,不改源码、不动日志
|
|
35
35
|
- **宿主同款渲染** —— 不自己造 Markdown 管线,直接调用 `@deepseek-ai/dsh-client-ui-primitives` 的 `MarkdownText`:与助手回复同一套解析、高亮、表格、公式与代码卡
|
|
36
|
-
- **引用 chip 保留** ——
|
|
36
|
+
- **引用 chip 保留** —— 调用宿主自己的文本工厂槽 `conversation.user.text`(0.2.1 起投影就住在那里),返回的片段按 chip / 纯文本拆分:chip 仍是可点击的文件 / 技能 chip,纯文本段逐个进 Markdown
|
|
37
37
|
- **操作条与三方插件兼容** —— 时间 + 复制按钮用官方 primitives 重建,操作条保留 `_actions` / `_action` 类名后缀与 `data-clock` 锚点;dsh-edit-turn、dsh-delete-turn、dsh-rerun-turn 的按钮注入照常工作
|
|
38
|
-
- **附件与补充块** ——
|
|
38
|
+
- **附件与补充块** —— 图片走官方的图片画廊工厂槽 `conversation.images.content`,文件卡、引用摘要、`JsonBlock` 补充块与宿主同款
|
|
39
39
|
- **软换行保留** —— 仅对围栏代码之外的续行补 GFM 两空格硬换行;代码块一个字节不动,缩进代码与表格行不误伤
|
|
40
40
|
- **出错兜底** —— 单条消息的渲染错误被错误边界接住,回退为宿主的原始投影,不会连累整条座位被槽位机制摘除
|
|
41
41
|
- **中英双语** 随系统语言切换(代码卡文案复用宿主 `chat` 字典)
|
|
@@ -89,12 +89,30 @@ const a = 1
|
|
|
89
89
|
|
|
90
90
|
### 兼容性
|
|
91
91
|
|
|
92
|
-
- 实测 DSH `0.2.
|
|
92
|
+
- 实测 DSH `0.2.1-rc.2`(web profile,Chromium)。依赖:keyed 槽位 `conversation.chat.node`(`user` / `steering`)、座位上的 `renderFactorySlot` 绑定、行锚点 `data-chat-flow-kind` / `data-pending-steering` / `data-submission-echo`、primitives 导出的 `MarkdownText` / `writeClipboard`。
|
|
93
|
+
- **0.2.1 把扩展点往里挪了**,本插件 0.1.5 已按新契约移植(旧契约的三样东西都不在了):`primitives.projectUserText` 被删除 → 改调文本工厂槽 `conversation.user.text`;座位 props 从 `{ node, renderMessageImages, openFile, openSkill, t }` 变成 `{ node, renderFactorySlot, loadImage, t }` → 图片改调图片画廊工厂槽 `conversation.images.content`。
|
|
94
|
+
- **为什么不能去换内容工厂槽**:`conversation.chat.user.content` 由宿主注册,插件再注册同名工厂会让**宿主自己的聊天插件启动失败**(实测 `web boot: 1 entry did not activate @deepseek-ai/dsh-client-ui-chat: failed`),所以「只换气泡内部」这条路走不通;同理,`conversation.chat.user.images` 这个子槽也不能由插件声明(实测 `slot "conversation.chat.user.images" is already declared (by factory "conversation.chat.user.content")`)。座位替换 + 两个工厂槽调用是当前版本唯一可行且不伤宿主的组合。
|
|
93
95
|
- 与 `dsh-edit-turn`、`dsh-delete-turn`、`dsh-rerun-turn` 协同:三者的按钮注入基于 `[class*="_actions"]` / `[class*="_action"]`,本插件保留该契约;被 dsh-edit-turn 改写(rollback)的消息显示其自己的改写气泡,与本插件无关。
|
|
94
96
|
- 宿主半区为惰性入口(无运行时行为),全部逻辑在 `./client` 浏览器半区;不修改官方源码,不写私有事件类型。
|
|
95
97
|
|
|
98
|
+
### 为什么现在不能换成「替换内容工厂」(B 方案)
|
|
99
|
+
|
|
100
|
+
理想做法是替换宿主的内容工厂槽 `conversation.chat.user.content`:气泡内部(附件 + 气泡 + 引用摘要)全部交回宿主,本插件只换掉正文那一段。这样能删掉自建的行外壳、时间/复制操作条与相关样式约 200 行,也不再依赖座位 props 的形状。**现在做不了,卡在下面三条**(都在 DSH `0.2.1-rc.2` 上实测过;等哪天宿主放开了任意一条,就可以改成 B):
|
|
101
|
+
|
|
102
|
+
| 缺什么 | 实测结果 | 放开之后 |
|
|
103
|
+
|---|---|---|
|
|
104
|
+
| 工厂槽能被插件**遮蔽/替换** | 注册同名工厂不是遮蔽而是硬错误,且会让**宿主自己的聊天插件启动失败**:`web boot: 1 entry did not activate @deepseek-ai/dsh-client-ui-chat: failed` | 直接换成注册 `conversation.chat.user.content`,删掉自建行外壳与操作条 |
|
|
105
|
+
| 子槽能由**多个**声明方共享 | 想让座位条目自己声明 `conversation.chat.user.images` 以拿到 `renderSlot`,被拒:`slot "conversation.chat.user.images" is already declared (by factory "conversation.chat.user.content")`,且整条注册一起失败 | 图片可以用官方的座位/子槽渲染,不必绕工厂槽 |
|
|
106
|
+
| 有一个**公开的**「渲染一条用户消息」入口 | 0.2.1 删掉了 `primitives.projectUserText`,投影只存在于工厂槽内部(现在只能调 `conversation.user.text` / `conversation.images.content` 这两个工厂槽) | 插件插入的气泡(例如 dsh-edit-turn 改写后的提示词)也能走同一条管线,见 [deepseek-harness#10704](https://github.com/deepseek-ai/deepseek-harness/discussions/10704) |
|
|
107
|
+
|
|
108
|
+
也就是说:**座位替换(A)+ 调两个工厂槽**是当前版本唯一不伤宿主的组合;上面三条任意一条放开,都可以迁到 B。
|
|
109
|
+
|
|
96
110
|
### 修复记录
|
|
97
111
|
|
|
112
|
+
- **0.1.5** —— 按 DSH `0.2.1-rc.2` 的新契约移植,并把 `engines.dsh` 从开口的 `>=0.2.0-rc.1` 收成 `>=0.2.1-rc.2`(旧区间会让它在 0.2.1 上照样安装、只静默降级)。
|
|
113
|
+
- **引用 chip 回来了**:`primitives.projectUserText` 在 0.2.1 被删除(真机报 `TypeError: primitives.projectUserText is not a function`),投影改成调用宿主的文本工厂槽 `conversation.user.text`(`facts.sessionLabels` / `facts.skillNames` + `interactive`),chip 拆分逻辑不变。真机复验:控制台里那条降级告警不再出现(0 条),25/25 条用户气泡仍由本插件渲染。
|
|
114
|
+
- **图片附件回来了**:座位 props 里的 `renderMessageImages` 已不存在,改调图片画廊工厂槽 `conversation.images.content`(附件插件自己注册的那个;它的 `MessageImages` 也只是这一步调用)。三条看起来更顺的路都被堵着,README 的「兼容性」一节记了实测报错原文。
|
|
115
|
+
- **性能**:降级投影从「每帧算两次」改成只算一次(`MarkdownBoundary` 的 fallback 改成惰性 thunk,只有真失败时才构造);`toMarkdown()` 加 64 条 LRU 缓存(流式期间同一条消息会被反复重排,而准备源码是 split/join + 逐行正则);传给 `MarkdownText` 的 labels 对象按 `t` 记忆化(宿主自己的 `ReasoningContent` 正是这么做的,每次新建对象会让下游 memo 失效)。
|
|
98
116
|
- **0.1.4** —— 按评审意见把降级路径**合成一个函数**:此前「合成失败」「宿主投影抛错」「最后兜底」各自写了一份降级逻辑(一份调宿主投影、一份内联拼裸文本),三处要人工保持同步。现在只有 `degradedBody()` 一处:先试宿主自己的投影(保住引用 chip 等宿主渲染),投影也抛才退到裸文本;三条降级路径全部走它。内容优先这一点写进函数契约:裸文本是最后一步,只有宿主自己拒绝投影时才到。新增回归:断言 `projectUserText` 全仓只有一次调用、裸文本 span 只有一处构造、降级调用方都走同一函数(再造一份降级该测试转红)。
|
|
99
117
|
- **0.1.3** —— 把「座位 render 绝不抛」这条不变式补完:0.1.2 守住了合成与文案查询,但仍有形状会在**座位自己的渲染里**抛错(`node` 缺失 → `node.data`、`props` 缺失 → 解构本身、`new Date(time)` 遇到 Symbol / BigInt / 抛错的 valueOf、宿主图片渲染器抛错)。这些同样冒泡到槽位机制、退掉整条条目,症状与 0.1.2 修的完全一样。现在三层兜底:座位本体一层 try 并把 `node` / `data` 的取用改成防御式;时钟与附件卡在各自调用点兜底;最后一层 `renderPlainRow` 保证最坏情况只是这一行退回纯文本,而操作条与兄弟插件锚点仍在。新增回归:21 种恶意宿主形状逐一断言不抛、且行与操作条都在。
|
|
100
118
|
- **0.1.2** — 座位组件不再可能因渲染期异常被槽位机制摘除:`composeUserMarkdown` / `buildMarkdownLabels` 在座位渲染过程中运行,抛错不会被错误边界接住(错误边界只能接住其后代,接不住创建它的那次渲染),会冒泡到槽位机制并把该条目整条退掉——屏幕上就是气泡交还给宿主、Markdown 变回源码。现在合成与所有文案查询都在座位内部兜底:任何宿主形状不符(节点结构异常、投影返回不可识别、locale 席位不可调用)都退回宿主原始投影,气泡外壳、操作条与兄弟插件锚点全部保留。`data.content` 非数组、`t` 不可调用等情形一并加进回归测试。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-markdown-bubble",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
]
|
|
49
49
|
},
|
|
50
50
|
"engines": {
|
|
51
|
-
"dsh": ">=0.2.
|
|
51
|
+
"dsh": ">=0.2.1-rc.2"
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
}
|
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.
|
|
52
|
+
const PLUGIN_VERSION = '0.2.0'
|
|
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
|
|
@@ -189,7 +342,27 @@ window.__ModuleLoader__.load({
|
|
|
189
342
|
* @param text - raw logged message text.
|
|
190
343
|
* @returns markdown source for the renderer.
|
|
191
344
|
*/
|
|
345
|
+
// Prepared markdown source, keyed by the raw text. A bubble repaints on
|
|
346
|
+
// every transcript update (a streaming turn re-renders constantly) and the
|
|
347
|
+
// preparation is a split/join plus a regex per line, so the same message was
|
|
348
|
+
// re-walked dozens of times while an unrelated row streamed. Cleared whole
|
|
349
|
+
// when it grows past the cap: the entries are strings, and a long transcript
|
|
350
|
+
// has far fewer distinct message texts than it has repaints.
|
|
351
|
+
const markdownSourceCache = new Map()
|
|
352
|
+
const MARKDOWN_CACHE_LIMIT = 64
|
|
353
|
+
|
|
192
354
|
function toMarkdown(text) {
|
|
355
|
+
const key = typeof text === 'string' ? text : ''
|
|
356
|
+
const cached = markdownSourceCache.get(key)
|
|
357
|
+
if (cached !== undefined) return cached
|
|
358
|
+
const source = prepareMarkdown(key)
|
|
359
|
+
if (markdownSourceCache.size >= MARKDOWN_CACHE_LIMIT) markdownSourceCache.clear()
|
|
360
|
+
markdownSourceCache.set(key, source)
|
|
361
|
+
return source
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** The uncached preparation: CRLF, session wire links, soft breaks. */
|
|
365
|
+
function prepareMarkdown(text) {
|
|
193
366
|
const normalized = text.replace(/\r\n?/gu, '\n')
|
|
194
367
|
return walkOutsideFences(normalized, (line, next) => hardBreakLine(line.replace(SESSION_WIRE_RE, '$1'), next))
|
|
195
368
|
}
|
|
@@ -267,12 +440,24 @@ window.__ModuleLoader__.load({
|
|
|
267
440
|
* @param text - the message text.
|
|
268
441
|
* @param referenceLabels - session recall labels.
|
|
269
442
|
* @param skillNames - skill names.
|
|
270
|
-
* @param
|
|
443
|
+
* @param renderFactorySlot - the seat's Factory binding (chips + plain runs).
|
|
271
444
|
* @returns the degraded body, never null and never a throw.
|
|
272
445
|
*/
|
|
273
|
-
function degradedBody(text, referenceLabels, skillNames,
|
|
446
|
+
function degradedBody(text, referenceLabels, skillNames, renderFactorySlot) {
|
|
274
447
|
try {
|
|
275
|
-
|
|
448
|
+
if (typeof renderFactorySlot !== 'function') throw new Error('the seat handed over no renderFactorySlot')
|
|
449
|
+
// The projection moved into the host's own Factory in 0.2.1-rc.2
|
|
450
|
+
// (`conversation.user.text`, registered by dsh-client-ui-conversation;
|
|
451
|
+
// it owns the keyed child slot that renders a reference chip). The old
|
|
452
|
+
// public primitive `primitives.projectUserText` was REMOVED in the same
|
|
453
|
+
// release, which is why this call used to throw and chips degraded to
|
|
454
|
+
// plain text.
|
|
455
|
+
return renderFactorySlot('conversation.user.text', {
|
|
456
|
+
text,
|
|
457
|
+
facts: { sessionLabels: referenceLabels, skillNames },
|
|
458
|
+
interactive: true,
|
|
459
|
+
ellipsis: false,
|
|
460
|
+
})
|
|
276
461
|
} catch (error) {
|
|
277
462
|
warnOnce('the host user-text projection failed; showing raw text', error)
|
|
278
463
|
return h('span', { className: 'dshmb-plain' }, text)
|
|
@@ -296,13 +481,12 @@ window.__ModuleLoader__.load({
|
|
|
296
481
|
* @param t - the injected chat translator.
|
|
297
482
|
* @param referenceLabels - session recall labels.
|
|
298
483
|
* @param skillNames - skill names.
|
|
299
|
-
* @param
|
|
484
|
+
* @param renderFactorySlot - the seat's Factory binding.
|
|
300
485
|
* @returns the composed body, or null when markdown cannot be produced.
|
|
301
486
|
*/
|
|
302
|
-
function safeMarkdownBody(text, t, referenceLabels, skillNames,
|
|
487
|
+
function safeMarkdownBody(text, t, referenceLabels, skillNames, renderFactorySlot, labels) {
|
|
303
488
|
try {
|
|
304
|
-
|
|
305
|
-
return composeUserMarkdown(text, labels, referenceLabels, skillNames, references)
|
|
489
|
+
return composeUserMarkdown(text, labels, referenceLabels, skillNames, renderFactorySlot)
|
|
306
490
|
} catch (error) {
|
|
307
491
|
warnOnce('markdown composition failed; showing the degraded projection', error)
|
|
308
492
|
return null
|
|
@@ -341,14 +525,14 @@ window.__ModuleLoader__.load({
|
|
|
341
525
|
* @param labels - markdown chrome labels.
|
|
342
526
|
* @param referenceLabels - session recall labels the host associates with the message.
|
|
343
527
|
* @param skillNames - skill names the host loaded for the message.
|
|
344
|
-
* @param
|
|
528
|
+
* @param renderFactorySlot - the seat's Factory binding.
|
|
345
529
|
* @returns the composed body, or null for empty text.
|
|
346
530
|
*/
|
|
347
|
-
function composeUserMarkdown(text, labels, referenceLabels, skillNames,
|
|
531
|
+
function composeUserMarkdown(text, labels, referenceLabels, skillNames, renderFactorySlot) {
|
|
348
532
|
if (typeof text !== 'string' || text === '') return null
|
|
349
533
|
// The same single degraded projection, so a projection that throws inside
|
|
350
534
|
// composition degrades exactly as one that throws on the healthy path.
|
|
351
|
-
const projected = degradedBody(text, referenceLabels, skillNames,
|
|
535
|
+
const projected = degradedBody(text, referenceLabels, skillNames, renderFactorySlot)
|
|
352
536
|
const pieces =
|
|
353
537
|
react.isValidElement(projected) && projected.type === Fragment && Array.isArray(projected.props.children)
|
|
354
538
|
? projected.props.children
|
|
@@ -525,7 +709,12 @@ window.__ModuleLoader__.load({
|
|
|
525
709
|
}
|
|
526
710
|
|
|
527
711
|
render() {
|
|
528
|
-
|
|
712
|
+
if (!this.state.failed) return this.props.children
|
|
713
|
+
const { fallback } = this.props
|
|
714
|
+
// A thunk is called only when it is actually needed: building the
|
|
715
|
+
// degraded projection on every healthy render is wasted work (and, on a
|
|
716
|
+
// host whose projection is gone, a caught throw every time).
|
|
717
|
+
return typeof fallback === 'function' ? fallback() : fallback
|
|
529
718
|
}
|
|
530
719
|
}
|
|
531
720
|
|
|
@@ -615,9 +804,24 @@ window.__ModuleLoader__.load({
|
|
|
615
804
|
}
|
|
616
805
|
}
|
|
617
806
|
|
|
618
|
-
function renderAttachmentEntry({ attachment,
|
|
807
|
+
function renderAttachmentEntry({ attachment, renderFactorySlot, loadImage, compactImages }) {
|
|
619
808
|
if (attachment.type === 'image') {
|
|
620
|
-
|
|
809
|
+
// The host's image gallery, through the SAME Factory seam as the text
|
|
810
|
+
// projection. The three routes that look obvious are all closed on
|
|
811
|
+
// 0.2.1-rc.2: the `renderMessageImages` seat prop is gone, the child
|
|
812
|
+
// slot `conversation.chat.user.images` cannot be declared by a plugin
|
|
813
|
+
// ("already declared (by factory conversation.chat.user.content)",
|
|
814
|
+
// verified live), and `@deepseek-ai/dsh-client-ui-attachment` exports
|
|
815
|
+
// only apply/inject. The gallery Factory is the one that works: the
|
|
816
|
+
// attachment plugin registers `conversation.images.content` at root
|
|
817
|
+
// scope and its own `MessageImages` is nothing but a call to it.
|
|
818
|
+
if (typeof renderFactorySlot !== 'function') throw new Error('the seat handed over no renderFactorySlot')
|
|
819
|
+
return h(Fragment, null, renderFactorySlot('conversation.images.content', {
|
|
820
|
+
images: [attachment.image],
|
|
821
|
+
loadImage,
|
|
822
|
+
align: 'end',
|
|
823
|
+
compact: compactImages,
|
|
824
|
+
}))
|
|
621
825
|
}
|
|
622
826
|
const file = attachment.file ?? {}
|
|
623
827
|
const name = typeof file.name === 'string' ? file.name : ''
|
|
@@ -668,23 +872,31 @@ window.__ModuleLoader__.load({
|
|
|
668
872
|
function renderMarkdownBubble(props) {
|
|
669
873
|
const safeProps = props !== null && typeof props === 'object' ? props : {}
|
|
670
874
|
const node = safeProps.node !== null && typeof safeProps.node === 'object' ? safeProps.node : {}
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
875
|
+
// The seat's props on 0.2.1-rc.2: the two renderer bindings and the image
|
|
876
|
+
// loader. The old `renderMessageImages` / `openFile` / `openSkill` pair is
|
|
877
|
+
// gone; images now go through the declared child slot and reference chips
|
|
878
|
+
// through the `conversation.user.text` Factory.
|
|
879
|
+
const renderFactorySlot = safeProps.renderFactorySlot
|
|
880
|
+
const renderSlot = safeProps.renderSlot
|
|
881
|
+
const loadImage = safeProps.loadImage
|
|
674
882
|
const t = safeProps.t
|
|
675
883
|
const data = node.data !== null && typeof node.data === 'object' ? node.data : {}
|
|
676
884
|
const { text, attachments, rest } = contentParts(Array.isArray(data.content) ? data.content : [])
|
|
677
885
|
const referenceLabels = Array.isArray(data.referenceLabels) ? data.referenceLabels : []
|
|
678
886
|
const skillNames = Array.isArray(data.skillNames) ? data.skillNames : []
|
|
679
|
-
const references = react.useMemo(() => ({ openFile, openSkill }), [openFile, openSkill])
|
|
680
887
|
const compactImages = attachments.length > 1
|
|
681
888
|
const showBubble = text !== '' || rest.length > 0
|
|
889
|
+
// The host memoizes its own label object before handing it to MarkdownText
|
|
890
|
+
// (`ReasoningContent` does exactly this); a fresh object every render
|
|
891
|
+
// defeats that memoization and re-renders the whole markdown tree.
|
|
892
|
+
const labels = react.useMemo(() => buildMarkdownLabels(t), [t])
|
|
682
893
|
|
|
683
894
|
// Never let composition throw out of this render — see safeMarkdownBody.
|
|
684
|
-
//
|
|
685
|
-
// boundary's
|
|
686
|
-
|
|
687
|
-
const
|
|
895
|
+
// ONE degraded body per render: it is the composition guard's projection
|
|
896
|
+
// and the boundary's fallback, so building it eagerly (as this used to)
|
|
897
|
+
// ran the projection twice per render and, on 0.2.1-rc.2, threw twice.
|
|
898
|
+
const body = safeMarkdownBody(text, t, referenceLabels, skillNames, renderFactorySlot, labels)
|
|
899
|
+
const degraded = () => degradedBody(text, referenceLabels, skillNames, renderFactorySlot)
|
|
688
900
|
|
|
689
901
|
// The action strip leads the row in DOM order and is pushed below the
|
|
690
902
|
// bubble by flex `order`. Sibling plugins pick their bar with
|
|
@@ -708,7 +920,8 @@ window.__ModuleLoader__.load({
|
|
|
708
920
|
h(AttachmentEntry, {
|
|
709
921
|
key: `${attachment.type}:${index}`,
|
|
710
922
|
attachment,
|
|
711
|
-
|
|
923
|
+
renderFactorySlot,
|
|
924
|
+
loadImage,
|
|
712
925
|
compactImages,
|
|
713
926
|
}),
|
|
714
927
|
),
|
|
@@ -717,7 +930,7 @@ window.__ModuleLoader__.load({
|
|
|
717
930
|
h(
|
|
718
931
|
'div',
|
|
719
932
|
{ className: 'dshmb-bubble' },
|
|
720
|
-
h(MarkdownBoundary, { key: text, fallback: degraded }, body === null ? degraded : body),
|
|
933
|
+
h(MarkdownBoundary, { key: text, fallback: degraded }, body === null ? degraded() : body),
|
|
721
934
|
rest.map((block, index) =>
|
|
722
935
|
h(primitives.JsonBlock, {
|
|
723
936
|
key: 'rest' + index,
|
|
@@ -762,7 +975,7 @@ window.__ModuleLoader__.load({
|
|
|
762
975
|
text,
|
|
763
976
|
Array.isArray(data.referenceLabels) ? data.referenceLabels : [],
|
|
764
977
|
Array.isArray(data.skillNames) ? data.skillNames : [],
|
|
765
|
-
|
|
978
|
+
safeProps.renderFactorySlot,
|
|
766
979
|
)
|
|
767
980
|
return h(
|
|
768
981
|
'div',
|
|
@@ -842,7 +1055,7 @@ window.__ModuleLoader__.load({
|
|
|
842
1055
|
const tag = document.createElement('style')
|
|
843
1056
|
tag.dataset.plugin = NS
|
|
844
1057
|
tag.dataset.pluginCss = CSS_TAG
|
|
845
|
-
tag.textContent = CSS
|
|
1058
|
+
tag.textContent = CSS + LAYOUT_CSS
|
|
846
1059
|
document.head.appendChild(tag)
|
|
847
1060
|
}
|
|
848
1061
|
|
|
@@ -863,6 +1076,37 @@ window.__ModuleLoader__.load({
|
|
|
863
1076
|
*/
|
|
864
1077
|
function apply(ctx) {
|
|
865
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
|
+
)
|
|
866
1110
|
for (const key of SEATS) {
|
|
867
1111
|
ctx.slots.inject('conversation.chat.node', () =>
|
|
868
1112
|
ctx.slots.register(
|
|
@@ -877,6 +1121,17 @@ window.__ModuleLoader__.load({
|
|
|
877
1121
|
)
|
|
878
1122
|
}
|
|
879
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
|
+
|
|
880
1135
|
globalThis[DEBUG_KEY] = { version: PLUGIN_VERSION, seats: SEATS.slice() }
|
|
881
1136
|
|
|
882
1137
|
ctx.effect(
|
|
@@ -888,7 +1143,79 @@ window.__ModuleLoader__.load({
|
|
|
888
1143
|
)
|
|
889
1144
|
}
|
|
890
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
|
+
|
|
891
1217
|
exports.apply = apply
|
|
1218
|
+
exports.ConfigCard = ConfigCard
|
|
892
1219
|
exports.inject = ['slots', 'locale']
|
|
893
1220
|
exports.PLUGIN_VERSION = PLUGIN_VERSION
|
|
894
1221
|
exports.SEATS = SEATS
|
package/src/index.js
CHANGED
|
@@ -1,30 +1,121 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-markdown-bubble
|
|
2
|
+
* dsh-markdown-bubble —— 宿主半部。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
/**
|
|
17
|
+
/** 稳定插件 id(与 bundle patch、client 模块 id 一致)。 */
|
|
15
18
|
export const PLUGIN_ID = 'dsh-markdown-bubble'
|
|
16
19
|
|
|
17
|
-
/**
|
|
18
|
-
export const PLUGIN_VERSION = '0.
|
|
20
|
+
/** 与 package.json / src/client.js 保持同步。 */
|
|
21
|
+
export const PLUGIN_VERSION = '0.2.0'
|
|
19
22
|
|
|
20
|
-
/** Cordis
|
|
23
|
+
/** Cordis 插件名;bundle patch 用这个名字解析包。 */
|
|
21
24
|
export const name = PLUGIN_ID
|
|
22
25
|
|
|
23
|
-
/**
|
|
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
|
-
*
|
|
28
|
-
* @param {
|
|
88
|
+
* 挂载配置路由。
|
|
89
|
+
* @param {object} ctx - cordis 上下文。
|
|
29
90
|
*/
|
|
30
|
-
export function apply(
|
|
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
|
+
}
|