dsh-codinput 0.3.5 → 0.3.7
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/CHANGELOG.md +39 -0
- package/README.md +4 -0
- package/TESTING.md +3 -0
- package/lib/client.js +159 -40
- package/package.json +2 -1
- package/src/client/components/AttachmentRail.tsx +3 -3
- package/src/client/components/InputSlotSeat.tsx +3 -34
- package/src/client/components/ModelSlot.tsx +83 -0
- package/src/client/components/StatsRow.tsx +17 -17
- package/src/client/components/Toolbar.tsx +8 -2
- package/src/client/components/UserMessageMarkdown.tsx +591 -0
- package/src/client/components/slotEntry.tsx +140 -26
- package/src/client/host-types.ts +8 -0
- package/src/client/i18n.ts +16 -0
- package/src/client/index.tsx +41 -0
- package/src/client/prefs.ts +3 -0
- package/src/client/settings.tsx +7 -0
- package/src/client/styles.ts +118 -0
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 用户消息 Markdown 渲染:conversation.chat.node 键控座的 user / steering
|
|
3
|
+
* 遮蔽条目(prefs.userMarkdown 开启时挂载,关闭即卸载还座官方)。
|
|
4
|
+
*
|
|
5
|
+
* 官方 UserStyleBubble 复刻:右对齐气泡(附件行 + 气泡 + 引用摘要行 + 操作行),
|
|
6
|
+
* 气泡文本在官方纯文本排版之上按 Markdown 渲染(marked gfm+breaks + DOMPurify,
|
|
7
|
+
* 与编辑器预览同一管线),Markdown 样式逐条移植官方 MarkdownText.module.css。
|
|
8
|
+
* @/ 引用 chip、/技能 chip、复制钮、时间戳、点开文件/技能等行为逐值对齐官方
|
|
9
|
+
* projectUserText(dsh-client-ui-primitives,MIT,见 LICENSE 归属段)。
|
|
10
|
+
*
|
|
11
|
+
* 挂载面(v0.3.7 取证,宿主 0.2.x web):chat.view 的 flowItem 渲染点
|
|
12
|
+
* `renderSlot("conversation.chat.node", owner, { entryKey: routedNode.kind })`,
|
|
13
|
+
* 官方以 key=user/steering 注册 UserMessageNodeView;键控座同 key 低 priority
|
|
14
|
+
* 者胜(动态包守卫对插件 register 强制分配负遮蔽秩,与 bar 接管同机制)。
|
|
15
|
+
* 发送瞬间的本地回显(PendingSubmissionBubble)不经本槽位,保持官方纯文本,
|
|
16
|
+
* 落库后即换 Markdown 形态。
|
|
17
|
+
*
|
|
18
|
+
* 已知边界(刻意不做):KaTeX 数学公式、Shiki 代码高亮与 CodeBlock 语言横幅、
|
|
19
|
+
* 链接分类图标不渲染(官方 MarkdownText 的完整面超出自含范围);未知内容块
|
|
20
|
+
* 出极简 JSON 块(官方 JsonBlock 的降级形态)。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
24
|
+
import { Marked, type MarkedExtension } from 'marked';
|
|
25
|
+
import * as DOMPurifyModule from 'dompurify';
|
|
26
|
+
import type { SlotRegisterOptions } from '../host-types';
|
|
27
|
+
import { diagError } from '../diag';
|
|
28
|
+
import { bindForeign, t, type TranslateParams } from '../i18n';
|
|
29
|
+
import { FileGlyph, fileExtension, fileSizeText } from './AttachmentRail';
|
|
30
|
+
|
|
31
|
+
/* ---------- 注册 ---------- */
|
|
32
|
+
|
|
33
|
+
export function userMarkdownRegistrations(): { options: SlotRegisterOptions; component: unknown }[] {
|
|
34
|
+
// user:落库用户消息;steering:落库插队消息。同一气泡渲染。
|
|
35
|
+
return (['user', 'steering'] as const).map((key) => ({
|
|
36
|
+
options: {
|
|
37
|
+
name: 'conversation.chat.node',
|
|
38
|
+
key,
|
|
39
|
+
priority: -1,
|
|
40
|
+
registrant: 'dsh-codinput',
|
|
41
|
+
},
|
|
42
|
+
component: UserMessageMarkdown,
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* ---------- 官方形状(消费切面,鸭子类型) ---------- */
|
|
47
|
+
|
|
48
|
+
interface TextBlock {
|
|
49
|
+
type: 'text';
|
|
50
|
+
text?: string;
|
|
51
|
+
}
|
|
52
|
+
interface ImageBlock {
|
|
53
|
+
type: 'image';
|
|
54
|
+
attachment?: unknown;
|
|
55
|
+
}
|
|
56
|
+
interface FileBlock {
|
|
57
|
+
type: 'file';
|
|
58
|
+
attachment?: { name?: string; bytes?: number };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface UserMessageData {
|
|
62
|
+
content?: readonly unknown[];
|
|
63
|
+
referenceLabels?: readonly string[];
|
|
64
|
+
skillNames?: readonly string[];
|
|
65
|
+
time?: unknown;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface UserMessageMarkdownProps {
|
|
69
|
+
node?: { data?: UserMessageData } | undefined;
|
|
70
|
+
/** 官方 renderMessageImages 座位 prop(图片缩略/灯箱全官方)。 */
|
|
71
|
+
renderMessageImages?: ((args: { images: readonly unknown[]; align: string; compact?: boolean }) => React.ReactNode) | undefined;
|
|
72
|
+
openFile?: ((path: string) => void) | undefined;
|
|
73
|
+
openSkill?: ((name: string) => void) | undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface ContentParts {
|
|
77
|
+
text: string;
|
|
78
|
+
attachments: ({ type: 'image'; image: unknown } | { type: 'file'; file: { name?: string; bytes?: number } })[];
|
|
79
|
+
rest: unknown[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 官方 contentParts 逐值复刻:文本串接、图片/文件附件、未知块入 rest。 */
|
|
83
|
+
function contentParts(content: readonly unknown[] | undefined): ContentParts {
|
|
84
|
+
const parts: ContentParts = { text: '', attachments: [], rest: [] };
|
|
85
|
+
if (!Array.isArray(content)) return parts;
|
|
86
|
+
const texts: string[] = [];
|
|
87
|
+
for (const block of content) {
|
|
88
|
+
const b = block as Partial<TextBlock> | Partial<ImageBlock> | Partial<FileBlock> | null;
|
|
89
|
+
if (b && b.type === 'text' && typeof b.text === 'string') texts.push(b.text);
|
|
90
|
+
else if (b && b.type === 'image' && b.attachment !== undefined) parts.attachments.push({ type: 'image', image: b.attachment });
|
|
91
|
+
else if (b && b.type === 'file' && b.attachment !== undefined) parts.attachments.push({ type: 'file', file: (b.attachment ?? {}) as { name?: string; bytes?: number } });
|
|
92
|
+
else parts.rest.push(block);
|
|
93
|
+
}
|
|
94
|
+
parts.text = texts.join('');
|
|
95
|
+
return parts;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* ---------- Markdown 管线(marked + DOMPurify,与编辑器预览同源) ---------- */
|
|
99
|
+
|
|
100
|
+
/** dompurify 的导出形状随打包条件漂移:默认导出与命名空间都兼容。 */
|
|
101
|
+
const DOMPurify = ((DOMPurifyModule as unknown as { default?: unknown }).default ??
|
|
102
|
+
DOMPurifyModule) as unknown as { sanitize(html: string): string };
|
|
103
|
+
|
|
104
|
+
function escapeHtml(text: string): string {
|
|
105
|
+
return text.replace(
|
|
106
|
+
/[&<>"']/g,
|
|
107
|
+
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] as string,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 会话引用的落库线格式:@[标签](dsh-session:…)(官方 SESSION_WIRE_RE)。 */
|
|
112
|
+
const SESSION_WIRE_SRC = /^@\[([^\]\n]+)\]\(dsh-session:[^)\s]+\)/;
|
|
113
|
+
|
|
114
|
+
/** marked 扩展:线格式不进通用链接解析(DOMPurify 会剥掉 dsh-session: href),
|
|
115
|
+
* 渲染成带 data 的占位 span,装饰期换成会话 chip。 */
|
|
116
|
+
const sessionWireExtension: NonNullable<MarkedExtension['extensions']>[number] = {
|
|
117
|
+
name: 'dshSessionWire',
|
|
118
|
+
level: 'inline',
|
|
119
|
+
start(src: string): number | undefined {
|
|
120
|
+
const i = src.indexOf('@[');
|
|
121
|
+
return i < 0 ? undefined : i;
|
|
122
|
+
},
|
|
123
|
+
tokenizer(src: string) {
|
|
124
|
+
const m = SESSION_WIRE_SRC.exec(src);
|
|
125
|
+
if (!m) return undefined;
|
|
126
|
+
return { type: 'dshSessionWire', raw: m[0], label: m[1] } as never;
|
|
127
|
+
},
|
|
128
|
+
renderer(token) {
|
|
129
|
+
const label = String((token as { label?: unknown }).label ?? '');
|
|
130
|
+
return `<span data-dci-wire="${escapeHtml(label)}">${escapeHtml(label)}</span>`;
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const mdParser = new Marked({ gfm: true, breaks: true, extensions: [sessionWireExtension] });
|
|
135
|
+
|
|
136
|
+
function renderMarkdown(text: string): string {
|
|
137
|
+
try {
|
|
138
|
+
return DOMPurify.sanitize(mdParser.parse(text) as string);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
diagError('umsg.markdown', error);
|
|
141
|
+
return `<p>${escapeHtml(text).replace(/\n/g, '<br>')}</p>`;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* ---------- 引用 chip(官方 projectUserText 逐值复刻) ---------- */
|
|
146
|
+
|
|
147
|
+
/** 裸 @token / /token 允许携带的句尾标点(官方 TRAILING_PUNCTUATION_RE)。 */
|
|
148
|
+
const TRAILING_PUNCTUATION_RE = /[.,;:!?,。;:!?]+$/u;
|
|
149
|
+
/** 通用引用 token:/指令、@"带空格路径"、@路径(官方正则)。 */
|
|
150
|
+
const REF_TOKEN_RE = /(^|\s)(\/[\w-]+(?=\s|$)|@"[^"\n]+"|@[^\s]+)/gu;
|
|
151
|
+
|
|
152
|
+
type ChipKind = 'session' | 'file' | 'folder' | 'slash';
|
|
153
|
+
|
|
154
|
+
interface ChipRange {
|
|
155
|
+
start: number;
|
|
156
|
+
end: number;
|
|
157
|
+
kind: ChipKind;
|
|
158
|
+
/** 原样 label(含 @ 或 /,title 用)。 */
|
|
159
|
+
label: string;
|
|
160
|
+
display: string;
|
|
161
|
+
clickable: boolean;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
interface ChipActions {
|
|
165
|
+
openFile?: ((path: string) => void) | undefined;
|
|
166
|
+
openSkill?: ((name: string) => void) | undefined;
|
|
167
|
+
skillNames: readonly string[];
|
|
168
|
+
referenceLabels: readonly string[];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function sessionWireRanges(text: string): ChipRange[] {
|
|
172
|
+
const ranges: ChipRange[] = [];
|
|
173
|
+
const re = /@\[([^\]\n]+)\]\(dsh-session:[^)\s]+\)/gu;
|
|
174
|
+
let wire: RegExpExecArray | null;
|
|
175
|
+
while ((wire = re.exec(text)) !== null) {
|
|
176
|
+
ranges.push({
|
|
177
|
+
start: wire.index,
|
|
178
|
+
end: wire.index + wire[0].length,
|
|
179
|
+
kind: 'session',
|
|
180
|
+
label: wire[0],
|
|
181
|
+
display: wire[1],
|
|
182
|
+
clickable: false,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return ranges;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function sessionLabelRanges(text: string, labels: readonly string[]): ChipRange[] {
|
|
189
|
+
const ranges: ChipRange[] = [];
|
|
190
|
+
for (const rawLabel of [...new Set(labels)].sort((a, b) => b.length - a.length)) {
|
|
191
|
+
const label = `@${rawLabel}`;
|
|
192
|
+
let start = text.indexOf(label);
|
|
193
|
+
while (start >= 0) {
|
|
194
|
+
ranges.push({ start, end: start + label.length, kind: 'session', label, display: rawLabel, clickable: false });
|
|
195
|
+
start = text.indexOf(label, start + label.length);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return ranges;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function genericTokenRanges(text: string, skillNames: readonly string[]): ChipRange[] {
|
|
202
|
+
const ranges: ChipRange[] = [];
|
|
203
|
+
REF_TOKEN_RE.lastIndex = 0;
|
|
204
|
+
let m: RegExpExecArray | null;
|
|
205
|
+
while ((m = REF_TOKEN_RE.exec(text)) !== null) {
|
|
206
|
+
const tokenStart = m.index + m[1].length;
|
|
207
|
+
const raw = m[2];
|
|
208
|
+
const label = raw.startsWith('@"') ? raw : raw.replace(TRAILING_PUNCTUATION_RE, '');
|
|
209
|
+
if (label.length <= 1) continue;
|
|
210
|
+
if (label.startsWith('/')) {
|
|
211
|
+
if (!skillNames.includes(label.slice(1))) continue;
|
|
212
|
+
ranges.push({ start: tokenStart, end: tokenStart + label.length, kind: 'slash', label, display: label, clickable: true });
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const path = label.slice(1).replace(/^"|"$/gu, '');
|
|
216
|
+
const kind: ChipKind = path.endsWith('/') ? 'folder' : 'file';
|
|
217
|
+
const segments = path.split(/[\\/]/u).filter(Boolean);
|
|
218
|
+
const display = segments[segments.length - 1] ?? path;
|
|
219
|
+
ranges.push({ start: tokenStart, end: tokenStart + label.length, kind, label, display, clickable: true });
|
|
220
|
+
}
|
|
221
|
+
return ranges;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/* ---------- 装饰期(innerHTML 落地后的 DOM 后处理) ---------- */
|
|
225
|
+
|
|
226
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
227
|
+
|
|
228
|
+
function svgPath(d: string, stroke: boolean): SVGPathElement {
|
|
229
|
+
const path = document.createElementNS(SVG_NS, 'path');
|
|
230
|
+
path.setAttribute('d', d);
|
|
231
|
+
path.setAttribute(stroke ? 'stroke' : 'fill', 'currentColor');
|
|
232
|
+
return path;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function refIconSvg(kind: ChipKind): SVGSVGElement | null {
|
|
236
|
+
const svg = document.createElementNS(SVG_NS, 'svg');
|
|
237
|
+
svg.setAttribute('viewBox', '0 0 16 16');
|
|
238
|
+
svg.setAttribute('fill', 'none');
|
|
239
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
240
|
+
svg.setAttribute('class', 'dci-umsg-chipicon');
|
|
241
|
+
if (kind === 'session') {
|
|
242
|
+
svg.appendChild(svgPath('M5 6.75H11', true));
|
|
243
|
+
svg.appendChild(svgPath('M5 9H8', true));
|
|
244
|
+
svg.appendChild(
|
|
245
|
+
svgPath(
|
|
246
|
+
'M2.37067 11.2497C1.5872 9.89252 1.32042 8.29798 1.61945 6.7597C1.91847 5.22141 2.76317 3.84293 3.99801 2.87809C5.23285 1.91325 6.7747 1.427 8.33964 1.50888C9.90458 1.59076 11.3873 2.23526 12.5147 3.32369C13.6422 4.41232 14.3384 5.8717 14.4751 7.43304C14.6118 8.99438 14.1797 10.5525 13.2585 11.8205C12.3372 13.0885 10.9889 13.9809 9.4617 14.3334C8.18666 14.6277 6.8587 14.529 5.64964 14.0601C5.17095 13.8745 4.76937 13.4929 4.26509 13.3963C3.67389 13.2832 2.95232 13.5595 2.0377 14.3334',
|
|
247
|
+
true,
|
|
248
|
+
),
|
|
249
|
+
);
|
|
250
|
+
return svg;
|
|
251
|
+
}
|
|
252
|
+
if (kind === 'file') {
|
|
253
|
+
svg.appendChild(svgPath('M4.9375 5.90295H11.0625', true));
|
|
254
|
+
svg.appendChild(svgPath('M4.9375 9.02991H8.27841', true));
|
|
255
|
+
svg.appendChild(
|
|
256
|
+
svgPath(
|
|
257
|
+
'M12.5 1.32617C13.3039 1.32617 14 1.95171 14 2.77637V13.2246C13.9996 14.0489 13.3036 14.6738 12.5 14.6738H3.5C2.69637 14.6738 2.00042 14.0489 2 13.2246V2.77637C2 1.95171 2.69613 1.32617 3.5 1.32617H12.5ZM3.5 2.32617C3.1993 2.32617 3 2.55186 3 2.77637V13.2246C3.00044 13.4489 3.19963 13.6738 3.5 13.6738H12.5C12.8004 13.6738 12.9996 13.4489 13 13.2246V2.77637C13 2.55186 12.8007 2.32617 12.5 2.32617H3.5Z',
|
|
258
|
+
false,
|
|
259
|
+
),
|
|
260
|
+
);
|
|
261
|
+
return svg;
|
|
262
|
+
}
|
|
263
|
+
if (kind === 'folder') {
|
|
264
|
+
svg.appendChild(
|
|
265
|
+
svgPath(
|
|
266
|
+
'M1.50439 3.11059C1.50439 2.55831 1.95211 2.1106 2.50439 2.1106H5.43389C5.67773 2.1106 5.91318 2.19969 6.09593 2.36113L7.71649 3.79265C7.89924 3.95409 8.1347 4.04319 8.3785 4.04319H13.4958C14.0481 4.04319 14.4958 4.4909 14.4958 5.04319V12.8894C14.4958 13.4417 14.0481 13.8894 13.4958 13.8894H2.50439C1.95211 13.8894 1.50439 13.4417 1.50439 12.8894V4.04319V3.11059Z',
|
|
267
|
+
true,
|
|
268
|
+
),
|
|
269
|
+
);
|
|
270
|
+
svg.appendChild(svgPath('M3.63501 7.66614H12.3647', true));
|
|
271
|
+
return svg;
|
|
272
|
+
}
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** 官方点击守卫:双击与按住划选不触发(避免打断复制选区)。 */
|
|
277
|
+
function chipClickGuard(event: MouseEvent): boolean {
|
|
278
|
+
if (event.detail > 1) return true;
|
|
279
|
+
const target = event.currentTarget;
|
|
280
|
+
if (event.detail !== 0 && target instanceof Node) {
|
|
281
|
+
const doc = target.ownerDocument;
|
|
282
|
+
if (doc?.getSelection()?.isCollapsed === false) return true;
|
|
283
|
+
}
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function chipElement(range: ChipRange, actions: ChipActions): HTMLElement {
|
|
288
|
+
const clickable =
|
|
289
|
+
range.clickable &&
|
|
290
|
+
(range.kind === 'file' || range.kind === 'folder'
|
|
291
|
+
? typeof actions.openFile === 'function'
|
|
292
|
+
: typeof actions.openSkill === 'function');
|
|
293
|
+
const chip = document.createElement(clickable ? 'button' : 'span');
|
|
294
|
+
if (clickable) chip.setAttribute('type', 'button');
|
|
295
|
+
chip.className = 'dci-umsg-chip';
|
|
296
|
+
chip.dataset.chip = range.kind;
|
|
297
|
+
chip.title = range.label;
|
|
298
|
+
const icon = refIconSvg(range.kind);
|
|
299
|
+
if (icon) chip.appendChild(icon);
|
|
300
|
+
chip.appendChild(document.createTextNode(range.display));
|
|
301
|
+
if (clickable) {
|
|
302
|
+
chip.addEventListener('click', (event: Event) => {
|
|
303
|
+
if (chipClickGuard(event as MouseEvent)) return;
|
|
304
|
+
if (range.kind === 'file' || range.kind === 'folder') actions.openFile?.(range.label.slice(1).replace(/^"|"$/gu, ''));
|
|
305
|
+
else actions.openSkill?.(range.display.slice(1));
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
return chip;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** 官方排序轴:start 升序 → session 先于 generic → 更长者先。 */
|
|
312
|
+
function rangeRank(range: ChipRange): number {
|
|
313
|
+
return range.kind === 'session' ? 0 : 1;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** 把一段文本按 ranges 切开,plain 部分留原文本节点,token 位换成 chip 元素。 */
|
|
317
|
+
function splitTextNode(node: Text, ranges: ChipRange[], actions: ChipActions): void {
|
|
318
|
+
const fragment = document.createDocumentFragment();
|
|
319
|
+
let cursor = 0;
|
|
320
|
+
for (const range of ranges) {
|
|
321
|
+
if (range.start > cursor) fragment.appendChild(document.createTextNode(node.data.slice(cursor, range.start)));
|
|
322
|
+
fragment.appendChild(chipElement(range, actions));
|
|
323
|
+
cursor = range.end;
|
|
324
|
+
}
|
|
325
|
+
if (cursor < node.data.length) fragment.appendChild(document.createTextNode(node.data.slice(cursor)));
|
|
326
|
+
node.parentNode?.replaceChild(fragment, node);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function chipTextNodes(root: HTMLElement, actions: ChipActions): void {
|
|
330
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
|
331
|
+
acceptNode(node: Node): number {
|
|
332
|
+
const parent = (node as Text).parentElement;
|
|
333
|
+
if (!parent) return NodeFilter.FILTER_REJECT;
|
|
334
|
+
// 代码/链接里不做 chip(改写内容会破坏代码与链接文本)。
|
|
335
|
+
if (parent.closest('pre, code, a, .dci-umsg-chip')) return NodeFilter.FILTER_REJECT;
|
|
336
|
+
return (node as Text).data.length === 0 ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT;
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
const nodes: Text[] = [];
|
|
340
|
+
let current = walker.nextNode();
|
|
341
|
+
while (current) {
|
|
342
|
+
nodes.push(current as Text);
|
|
343
|
+
current = walker.nextNode();
|
|
344
|
+
}
|
|
345
|
+
for (const node of nodes) {
|
|
346
|
+
const ranges = [
|
|
347
|
+
...sessionWireRanges(node.data),
|
|
348
|
+
...sessionLabelRanges(node.data, actions.referenceLabels),
|
|
349
|
+
...genericTokenRanges(node.data, actions.skillNames),
|
|
350
|
+
];
|
|
351
|
+
if (ranges.length === 0) continue;
|
|
352
|
+
ranges.sort((a, b) => a.start - b.start || rangeRank(a) - rangeRank(b) || b.end - a.end);
|
|
353
|
+
const picked: ChipRange[] = [];
|
|
354
|
+
let cursor = 0;
|
|
355
|
+
for (const range of ranges) {
|
|
356
|
+
if (range.start < cursor) continue;
|
|
357
|
+
picked.push(range);
|
|
358
|
+
cursor = range.end;
|
|
359
|
+
}
|
|
360
|
+
if (picked.length === 0) continue;
|
|
361
|
+
splitTextNode(node, picked, actions);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** innerHTML 落地后的整备:链接新开页、表格出滚动壳、线格式与 @/// token 换 chip。 */
|
|
366
|
+
function decorateMarkdownRoot(root: HTMLElement | null, actions: ChipActions): void {
|
|
367
|
+
if (!root) return;
|
|
368
|
+
try {
|
|
369
|
+
for (const anchor of Array.from(root.querySelectorAll('a[href]'))) {
|
|
370
|
+
const href = anchor.getAttribute('href') ?? '';
|
|
371
|
+
if (!/^https?:/i.test(href)) continue;
|
|
372
|
+
anchor.setAttribute('target', '_blank');
|
|
373
|
+
anchor.setAttribute('rel', 'noreferrer noopener');
|
|
374
|
+
}
|
|
375
|
+
for (const table of Array.from(root.querySelectorAll('table'))) {
|
|
376
|
+
const parent = table.parentElement;
|
|
377
|
+
if (!parent || parent.classList.contains('dci-umsg-tscroll')) continue;
|
|
378
|
+
const wrap = document.createElement('div');
|
|
379
|
+
wrap.className = 'dci-umsg-tscroll';
|
|
380
|
+
parent.insertBefore(wrap, table);
|
|
381
|
+
wrap.appendChild(table);
|
|
382
|
+
}
|
|
383
|
+
for (const placeholder of Array.from(root.querySelectorAll('span[data-dci-wire]'))) {
|
|
384
|
+
const label = placeholder.getAttribute('data-dci-wire') ?? '';
|
|
385
|
+
placeholder.replaceWith(chipElement({ start: 0, end: 0, kind: 'session', label, display: label, clickable: false }, actions));
|
|
386
|
+
}
|
|
387
|
+
chipTextNodes(root, actions);
|
|
388
|
+
} catch (error) {
|
|
389
|
+
diagError('umsg.decorate', error);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/* ---------- 官方 locale(chat 命名空间)优先,缺失退内置词典 ---------- */
|
|
394
|
+
|
|
395
|
+
interface TranslatorLike {
|
|
396
|
+
(key: string, params?: TranslateParams): string;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function officialText(key: string, fallbackKey: string, params?: TranslateParams): string {
|
|
400
|
+
const chatT = bindForeign('chat') as TranslatorLike | null;
|
|
401
|
+
if (chatT) {
|
|
402
|
+
try {
|
|
403
|
+
const value = chatT(key, params);
|
|
404
|
+
if (value && value !== key) return value;
|
|
405
|
+
} catch {
|
|
406
|
+
/* 落内置词典 */
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return t(fallbackKey, params);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function referenceSeparator(): string {
|
|
413
|
+
const chatT = bindForeign('chat') as TranslatorLike | null;
|
|
414
|
+
if (chatT) {
|
|
415
|
+
try {
|
|
416
|
+
const value = chatT('message.referenceSeparator');
|
|
417
|
+
if (value && value !== 'message.referenceSeparator') return value;
|
|
418
|
+
} catch {
|
|
419
|
+
/* 落内置词典 */
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return t('umsg.referenceSummary').includes('引用') ? '、' : ', ';
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function pad2(n: number): string {
|
|
426
|
+
return n < 10 ? `0${n}` : String(n);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** 官方 formatMessageClock 复刻:当天 HH:MM,跨天「M月D日 HH:MM」,跨年带年份。 */
|
|
430
|
+
function messageClock(time: unknown, now = Date.now()): string {
|
|
431
|
+
const ms = typeof time === 'number' ? time : typeof time === 'string' ? Date.parse(time) : NaN;
|
|
432
|
+
if (!Number.isFinite(ms)) return '';
|
|
433
|
+
const d = new Date(ms);
|
|
434
|
+
const n = new Date(now);
|
|
435
|
+
const clock = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
|
436
|
+
if (d.getFullYear() === n.getFullYear() && d.getMonth() === n.getMonth() && d.getDate() === n.getDate()) return clock;
|
|
437
|
+
const params = { y: d.getFullYear(), m: d.getMonth() + 1, d: d.getDate() };
|
|
438
|
+
return `${officialText(d.getFullYear() === n.getFullYear() ? 'clock.md' : 'clock.ymd', d.getFullYear() === n.getFullYear() ? 'umsg.clock.md' : 'umsg.clock.ymd', params)} ${clock}`;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function writeClipboard(text: string): Promise<boolean> {
|
|
442
|
+
try {
|
|
443
|
+
await navigator.clipboard.writeText(text);
|
|
444
|
+
return true;
|
|
445
|
+
} catch {
|
|
446
|
+
try {
|
|
447
|
+
const area = document.createElement('textarea');
|
|
448
|
+
area.value = text;
|
|
449
|
+
area.style.position = 'fixed';
|
|
450
|
+
area.style.opacity = '0';
|
|
451
|
+
document.body.appendChild(area);
|
|
452
|
+
area.select();
|
|
453
|
+
const ok = document.execCommand('copy');
|
|
454
|
+
area.remove();
|
|
455
|
+
return ok;
|
|
456
|
+
} catch {
|
|
457
|
+
return false;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/* ---------- 图标(官方 MessageIconActions glyph 逐字路径) ---------- */
|
|
463
|
+
|
|
464
|
+
function CopyGlyph(): JSX.Element {
|
|
465
|
+
return (
|
|
466
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
467
|
+
<rect x="1.52075" y="4.07373" width="10.3932" height="10.3932" rx="2" stroke="currentColor" />
|
|
468
|
+
<path
|
|
469
|
+
d="M11.9792 1.53296C13.36 1.53296 14.4792 2.65225 14.4792 4.03296V9.42847C14.4792 10.3756 13.9521 11.1987 13.1755 11.6228V10.3298C13.3652 10.0787 13.4792 9.7674 13.4792 9.42847V4.03296C13.4792 3.20453 12.8077 2.53296 11.9792 2.53296H6.58374C6.27966 2.53301 5.99684 2.6235 5.7605 2.77905H4.42358C4.85652 2.03463 5.66056 1.53304 6.58374 1.53296H11.9792Z"
|
|
470
|
+
fill="currentColor"
|
|
471
|
+
/>
|
|
472
|
+
</svg>
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function CheckGlyph(): JSX.Element {
|
|
477
|
+
return (
|
|
478
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
479
|
+
<path d="M2.25 8.5L5.49732 11.7473C5.90519 12.1552 6.57263 12.1344 6.95426 11.7018L13.75 4" stroke="currentColor" />
|
|
480
|
+
</svg>
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/* ---------- 组件 ---------- */
|
|
485
|
+
|
|
486
|
+
/** 消息文件卡(官方 FileCard 复刻;glyph 用通用文件形,与草稿附件轨一致)。 */
|
|
487
|
+
function MessageFileCard({ file }: { file: { name?: string; bytes?: number } }): JSX.Element {
|
|
488
|
+
const name = typeof file.name === 'string' ? file.name : '';
|
|
489
|
+
const bytes = typeof file.bytes === 'number' ? file.bytes : 0;
|
|
490
|
+
const meta = [fileExtension(name).toUpperCase().slice(0, 8), bytes > 0 ? fileSizeText(bytes) : ''].filter(Boolean).join(' ');
|
|
491
|
+
return (
|
|
492
|
+
<span className="dci-umsg-file" title={name}>
|
|
493
|
+
<span className="dci-umsg-fileglyph">
|
|
494
|
+
<FileGlyph />
|
|
495
|
+
</span>
|
|
496
|
+
<span className="dci-umsg-filecontent">
|
|
497
|
+
<span className="dci-umsg-filename">{name}</span>
|
|
498
|
+
<span className="dci-umsg-filemeta">{meta}</span>
|
|
499
|
+
</span>
|
|
500
|
+
</span>
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export function UserMessageMarkdown(props: UserMessageMarkdownProps): JSX.Element | null {
|
|
505
|
+
const { node, renderMessageImages, openFile, openSkill } = props;
|
|
506
|
+
const data = (node?.data ?? {}) as UserMessageData;
|
|
507
|
+
const content = data.content;
|
|
508
|
+
const referenceLabels = useMemo(() => (Array.isArray(data.referenceLabels) ? data.referenceLabels : []), [data.referenceLabels]);
|
|
509
|
+
const skillNames = useMemo(() => (Array.isArray(data.skillNames) ? data.skillNames : []), [data.skillNames]);
|
|
510
|
+
const { text, attachments, rest } = useMemo(() => contentParts(content), [content]);
|
|
511
|
+
const compactImages = attachments.length > 1;
|
|
512
|
+
const showBubble = text !== '' || rest.length > 0;
|
|
513
|
+
|
|
514
|
+
const html = useMemo(() => (text === '' ? '' : renderMarkdown(text)), [text]);
|
|
515
|
+
const bodyRef = useRef<HTMLDivElement | null>(null);
|
|
516
|
+
const actionsRef = useRef<ChipActions>({ openFile, openSkill, skillNames, referenceLabels });
|
|
517
|
+
actionsRef.current = { openFile, openSkill, skillNames, referenceLabels };
|
|
518
|
+
useEffect(() => {
|
|
519
|
+
decorateMarkdownRoot(bodyRef.current, actionsRef.current);
|
|
520
|
+
}, [html]);
|
|
521
|
+
|
|
522
|
+
const [copied, setCopied] = useState(false);
|
|
523
|
+
const onCopy = useCallback(() => {
|
|
524
|
+
if (copied || text === '') return;
|
|
525
|
+
void writeClipboard(text).then((ok) => {
|
|
526
|
+
if (!ok) return;
|
|
527
|
+
setCopied(true);
|
|
528
|
+
window.setTimeout(() => setCopied(false), 1000);
|
|
529
|
+
});
|
|
530
|
+
}, [copied, text]);
|
|
531
|
+
|
|
532
|
+
if (!showBubble && attachments.length === 0 && referenceLabels.length === 0 && data.time === undefined) {
|
|
533
|
+
// 空消息:不渲染任何东西(避免空操作行突兀)。
|
|
534
|
+
return null;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return (
|
|
538
|
+
<div className="dci-umsg-row">
|
|
539
|
+
<div className="dci-umsg-stack">
|
|
540
|
+
{attachments.length > 0 && (
|
|
541
|
+
<div className="dci-umsg-attrow" data-message-attachments="true">
|
|
542
|
+
{attachments.map((att, index) =>
|
|
543
|
+
att.type === 'image' ? (
|
|
544
|
+
<Fragment key={`image:${index}`}>
|
|
545
|
+
{renderMessageImages
|
|
546
|
+
? renderMessageImages({ images: [att.image], align: 'end', compact: compactImages })
|
|
547
|
+
: null}
|
|
548
|
+
</Fragment>
|
|
549
|
+
) : (
|
|
550
|
+
<MessageFileCard key={`file:${index}`} file={att.file} />
|
|
551
|
+
),
|
|
552
|
+
)}
|
|
553
|
+
</div>
|
|
554
|
+
)}
|
|
555
|
+
{showBubble && (
|
|
556
|
+
<div className="dci-umsg-bubble">
|
|
557
|
+
{text !== '' && <div className="dci-umsg-md" ref={bodyRef} dangerouslySetInnerHTML={{ __html: html }} />}
|
|
558
|
+
{rest.map((block, index) => (
|
|
559
|
+
<pre key={index} className="dci-umsg-extra">
|
|
560
|
+
{officialText('message.extraBlock', 'umsg.extraBlock')}
|
|
561
|
+
{'\n'}
|
|
562
|
+
{JSON.stringify(block, null, 2)}
|
|
563
|
+
</pre>
|
|
564
|
+
))}
|
|
565
|
+
</div>
|
|
566
|
+
)}
|
|
567
|
+
{referenceLabels.length > 0 && (
|
|
568
|
+
<div className="dci-umsg-refsum">
|
|
569
|
+
{officialText('message.referenceSummary', 'umsg.referenceSummary', {
|
|
570
|
+
labels: referenceLabels.join(referenceSeparator()),
|
|
571
|
+
})}
|
|
572
|
+
</div>
|
|
573
|
+
)}
|
|
574
|
+
<div className="dci-umsg-actions" data-clock="start">
|
|
575
|
+
{data.time !== undefined && messageClock(data.time) !== '' && (
|
|
576
|
+
<span className="dci-umsg-time">{messageClock(data.time)}</span>
|
|
577
|
+
)}
|
|
578
|
+
<button
|
|
579
|
+
type="button"
|
|
580
|
+
className="dci-umsg-action"
|
|
581
|
+
aria-label={copied ? t('umsg.copied') : t('umsg.copy')}
|
|
582
|
+
title={copied ? t('umsg.copied') : t('umsg.copy')}
|
|
583
|
+
onClick={onCopy}
|
|
584
|
+
>
|
|
585
|
+
{copied ? <CheckGlyph /> : <CopyGlyph />}
|
|
586
|
+
</button>
|
|
587
|
+
</div>
|
|
588
|
+
</div>
|
|
589
|
+
</div>
|
|
590
|
+
);
|
|
591
|
+
}
|