dsh-codinput 0.3.7 → 0.3.9
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 +59 -13
- package/NOTICE +18 -3
- package/README.en.md +11 -7
- package/README.md +11 -7
- package/TESTING.md +26 -5
- package/lib/client.js +66 -56
- package/package.json +2 -2
- package/src/client/components/MenuSurface.tsx +64 -12
- package/src/client/components/ModelMenu.tsx +5 -17
- package/src/client/components/PermissionMenu.tsx +8 -3
- package/src/client/components/StatsRow.tsx +199 -78
- package/src/client/components/UserMessageMarkdown.tsx +115 -30
- package/src/client/i18n.ts +13 -9
- package/src/client/index.tsx +6 -1
- package/src/client/settings.tsx +8 -3
- package/src/client/styles.ts +40 -24
|
@@ -20,12 +20,12 @@
|
|
|
20
20
|
* 出极简 JSON 块(官方 JsonBlock 的降级形态)。
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
23
|
+
import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
24
24
|
import { Marked, type MarkedExtension } from 'marked';
|
|
25
25
|
import * as DOMPurifyModule from 'dompurify';
|
|
26
26
|
import type { SlotRegisterOptions } from '../host-types';
|
|
27
27
|
import { diagError } from '../diag';
|
|
28
|
-
import { bindForeign, t, type TranslateParams } from '../i18n';
|
|
28
|
+
import { bindForeign, t, useT, type TranslateParams } from '../i18n';
|
|
29
29
|
import { FileGlyph, fileExtension, fileSizeText } from './AttachmentRail';
|
|
30
30
|
|
|
31
31
|
/* ---------- 注册 ---------- */
|
|
@@ -75,7 +75,9 @@ export interface UserMessageMarkdownProps {
|
|
|
75
75
|
|
|
76
76
|
interface ContentParts {
|
|
77
77
|
text: string;
|
|
78
|
-
|
|
78
|
+
/** 图片:官方 contentParts 把它包成 { attachment: ImageAttachmentRef }——官方 ImageGallery
|
|
79
|
+
* 的 React key 读 image.attachment.attachmentId;裸 ref 会让 key 计算访问 preview.url 崩。 */
|
|
80
|
+
attachments: ({ type: 'image'; image: { attachment: unknown } } | { type: 'file'; file: { name?: string; bytes?: number } })[];
|
|
79
81
|
rest: unknown[];
|
|
80
82
|
}
|
|
81
83
|
|
|
@@ -87,7 +89,7 @@ function contentParts(content: readonly unknown[] | undefined): ContentParts {
|
|
|
87
89
|
for (const block of content) {
|
|
88
90
|
const b = block as Partial<TextBlock> | Partial<ImageBlock> | Partial<FileBlock> | null;
|
|
89
91
|
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 });
|
|
92
|
+
else if (b && b.type === 'image' && b.attachment !== undefined) parts.attachments.push({ type: 'image', image: { attachment: b.attachment } });
|
|
91
93
|
else if (b && b.type === 'file' && b.attachment !== undefined) parts.attachments.push({ type: 'file', file: (b.attachment ?? {}) as { name?: string; bytes?: number } });
|
|
92
94
|
else parts.rest.push(block);
|
|
93
95
|
}
|
|
@@ -99,7 +101,24 @@ function contentParts(content: readonly unknown[] | undefined): ContentParts {
|
|
|
99
101
|
|
|
100
102
|
/** dompurify 的导出形状随打包条件漂移:默认导出与命名空间都兼容。 */
|
|
101
103
|
const DOMPurify = ((DOMPurifyModule as unknown as { default?: unknown }).default ??
|
|
102
|
-
DOMPurifyModule) as unknown as { sanitize(html: string): string };
|
|
104
|
+
DOMPurifyModule) as unknown as { sanitize(html: string, options?: Record<string, unknown>): string };
|
|
105
|
+
|
|
106
|
+
/** 用户消息是「不可信 Markdown」面:marked 会原样透传原始 HTML,官方 MarkdownText 则是
|
|
107
|
+
* mdast→React 逐节点渲染且链接/图片只放行 http(s)/mailto。这里用 DOMPurify 白名单收敛到
|
|
108
|
+
* Markdown 产出面——禁 style/form/控件/媒体等原始 HTML(<style> 一旦进 DOM 会全局生效),
|
|
109
|
+
* URI 只留 http(s)/mailto,行内 style 一律去掉。 */
|
|
110
|
+
const SANITIZE_OPTIONS = {
|
|
111
|
+
ALLOWED_TAGS: [
|
|
112
|
+
'p', 'br', 'strong', 'em', 'del', 'code', 'pre', 'blockquote', 'ul', 'ol', 'li', 'a', 'img',
|
|
113
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'input', 'span',
|
|
114
|
+
],
|
|
115
|
+
ALLOWED_ATTR: [
|
|
116
|
+
'href', 'src', 'alt', 'title', 'target', 'rel', 'type', 'checked', 'disabled', 'start',
|
|
117
|
+
'colspan', 'rowspan', 'align', 'data-dci-wire',
|
|
118
|
+
],
|
|
119
|
+
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:)/i,
|
|
120
|
+
FORBID_ATTR: ['style'],
|
|
121
|
+
};
|
|
103
122
|
|
|
104
123
|
function escapeHtml(text: string): string {
|
|
105
124
|
return text.replace(
|
|
@@ -135,7 +154,7 @@ const mdParser = new Marked({ gfm: true, breaks: true, extensions: [sessionWireE
|
|
|
135
154
|
|
|
136
155
|
function renderMarkdown(text: string): string {
|
|
137
156
|
try {
|
|
138
|
-
return DOMPurify.sanitize(mdParser.parse(text) as string);
|
|
157
|
+
return DOMPurify.sanitize(mdParser.parse(text) as string, SANITIZE_OPTIONS);
|
|
139
158
|
} catch (error) {
|
|
140
159
|
diagError('umsg.markdown', error);
|
|
141
160
|
return `<p>${escapeHtml(text).replace(/\n/g, '<br>')}</p>`;
|
|
@@ -284,12 +303,15 @@ function chipClickGuard(event: MouseEvent): boolean {
|
|
|
284
303
|
return false;
|
|
285
304
|
}
|
|
286
305
|
|
|
287
|
-
function chipElement(range: ChipRange,
|
|
306
|
+
function chipElement(range: ChipRange, actionsRef: React.MutableRefObject<ChipActions>): HTMLElement {
|
|
307
|
+
const actions = actionsRef.current;
|
|
308
|
+
// 官方 projectUserText:只有 file 与 /skill 可点(folder / session 是纯 span);
|
|
309
|
+
// 点击时读 actionsRef.current,回调后到时无需重新装饰 DOM。
|
|
288
310
|
const clickable =
|
|
289
311
|
range.clickable &&
|
|
290
|
-
(range.kind === 'file'
|
|
312
|
+
(range.kind === 'file'
|
|
291
313
|
? typeof actions.openFile === 'function'
|
|
292
|
-
: typeof actions.openSkill === 'function');
|
|
314
|
+
: range.kind === 'slash' && typeof actions.openSkill === 'function');
|
|
293
315
|
const chip = document.createElement(clickable ? 'button' : 'span');
|
|
294
316
|
if (clickable) chip.setAttribute('type', 'button');
|
|
295
317
|
chip.className = 'dci-umsg-chip';
|
|
@@ -301,8 +323,9 @@ function chipElement(range: ChipRange, actions: ChipActions): HTMLElement {
|
|
|
301
323
|
if (clickable) {
|
|
302
324
|
chip.addEventListener('click', (event: Event) => {
|
|
303
325
|
if (chipClickGuard(event as MouseEvent)) return;
|
|
304
|
-
|
|
305
|
-
|
|
326
|
+
const live = actionsRef.current;
|
|
327
|
+
if (range.kind === 'slash') live.openSkill?.(range.display.slice(1));
|
|
328
|
+
else live.openFile?.(range.label.slice(1).replace(/^"|"$/gu, ''));
|
|
306
329
|
});
|
|
307
330
|
}
|
|
308
331
|
return chip;
|
|
@@ -314,19 +337,19 @@ function rangeRank(range: ChipRange): number {
|
|
|
314
337
|
}
|
|
315
338
|
|
|
316
339
|
/** 把一段文本按 ranges 切开,plain 部分留原文本节点,token 位换成 chip 元素。 */
|
|
317
|
-
function splitTextNode(node: Text, ranges: ChipRange[],
|
|
340
|
+
function splitTextNode(node: Text, ranges: ChipRange[], actionsRef: React.MutableRefObject<ChipActions>): void {
|
|
318
341
|
const fragment = document.createDocumentFragment();
|
|
319
342
|
let cursor = 0;
|
|
320
343
|
for (const range of ranges) {
|
|
321
344
|
if (range.start > cursor) fragment.appendChild(document.createTextNode(node.data.slice(cursor, range.start)));
|
|
322
|
-
fragment.appendChild(chipElement(range,
|
|
345
|
+
fragment.appendChild(chipElement(range, actionsRef));
|
|
323
346
|
cursor = range.end;
|
|
324
347
|
}
|
|
325
348
|
if (cursor < node.data.length) fragment.appendChild(document.createTextNode(node.data.slice(cursor)));
|
|
326
349
|
node.parentNode?.replaceChild(fragment, node);
|
|
327
350
|
}
|
|
328
351
|
|
|
329
|
-
function chipTextNodes(root: HTMLElement,
|
|
352
|
+
function chipTextNodes(root: HTMLElement, actionsRef: React.MutableRefObject<ChipActions>): void {
|
|
330
353
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
|
331
354
|
acceptNode(node: Node): number {
|
|
332
355
|
const parent = (node as Text).parentElement;
|
|
@@ -342,11 +365,12 @@ function chipTextNodes(root: HTMLElement, actions: ChipActions): void {
|
|
|
342
365
|
nodes.push(current as Text);
|
|
343
366
|
current = walker.nextNode();
|
|
344
367
|
}
|
|
368
|
+
const { skillNames, referenceLabels } = actionsRef.current;
|
|
345
369
|
for (const node of nodes) {
|
|
346
370
|
const ranges = [
|
|
347
371
|
...sessionWireRanges(node.data),
|
|
348
|
-
...sessionLabelRanges(node.data,
|
|
349
|
-
...genericTokenRanges(node.data,
|
|
372
|
+
...sessionLabelRanges(node.data, referenceLabels),
|
|
373
|
+
...genericTokenRanges(node.data, skillNames),
|
|
350
374
|
];
|
|
351
375
|
if (ranges.length === 0) continue;
|
|
352
376
|
ranges.sort((a, b) => a.start - b.start || rangeRank(a) - rangeRank(b) || b.end - a.end);
|
|
@@ -358,19 +382,43 @@ function chipTextNodes(root: HTMLElement, actions: ChipActions): void {
|
|
|
358
382
|
cursor = range.end;
|
|
359
383
|
}
|
|
360
384
|
if (picked.length === 0) continue;
|
|
361
|
-
splitTextNode(node, picked,
|
|
385
|
+
splitTextNode(node, picked, actionsRef);
|
|
362
386
|
}
|
|
363
387
|
}
|
|
364
388
|
|
|
365
389
|
/** innerHTML 落地后的整备:链接新开页、表格出滚动壳、线格式与 @/// token 换 chip。 */
|
|
366
|
-
function decorateMarkdownRoot(root: HTMLElement | null,
|
|
390
|
+
function decorateMarkdownRoot(root: HTMLElement | null, actionsRef: React.MutableRefObject<ChipActions>): void {
|
|
367
391
|
if (!root) return;
|
|
368
392
|
try {
|
|
369
|
-
|
|
393
|
+
// 官方 sanitizeUrl 只放行 http(s)/mailto:其余链接(含 DOMPurify 已剥掉 href 的残链)
|
|
394
|
+
// 没有可点面,拆回纯文本;http(s) 统一新开页。
|
|
395
|
+
for (const anchor of Array.from(root.querySelectorAll('a'))) {
|
|
370
396
|
const href = anchor.getAttribute('href') ?? '';
|
|
371
|
-
if (
|
|
372
|
-
|
|
373
|
-
|
|
397
|
+
if (/^https?:/i.test(href)) {
|
|
398
|
+
anchor.setAttribute('target', '_blank');
|
|
399
|
+
anchor.setAttribute('rel', 'noreferrer noopener');
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
if (/^mailto:/i.test(href)) {
|
|
403
|
+
anchor.setAttribute('rel', 'noreferrer noopener');
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
anchor.replaceWith(...Array.from(anchor.childNodes));
|
|
407
|
+
}
|
|
408
|
+
// 官方 remoteImageUrl 只放行 http(s);相对/data: 图片降级成 alt 文本,不留破图。
|
|
409
|
+
for (const img of Array.from(root.querySelectorAll('img'))) {
|
|
410
|
+
if (/^https?:/i.test(img.getAttribute('src') ?? '')) continue;
|
|
411
|
+
img.replaceWith(document.createTextNode(img.getAttribute('alt') ?? ''));
|
|
412
|
+
}
|
|
413
|
+
// 只保留 GFM 任务清单的只读复选框(marked 产出 checked/disabled,type 可能被
|
|
414
|
+
// sanitize 剥掉),其余 <input> 一律移除——不让用户消息里出现可编辑表单控件。
|
|
415
|
+
for (const input of Array.from(root.querySelectorAll('input'))) {
|
|
416
|
+
if (!input.hasAttribute('disabled')) {
|
|
417
|
+
input.remove();
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
input.setAttribute('type', 'checkbox');
|
|
421
|
+
input.setAttribute('disabled', '');
|
|
374
422
|
}
|
|
375
423
|
for (const table of Array.from(root.querySelectorAll('table'))) {
|
|
376
424
|
const parent = table.parentElement;
|
|
@@ -382,9 +430,9 @@ function decorateMarkdownRoot(root: HTMLElement | null, actions: ChipActions): v
|
|
|
382
430
|
}
|
|
383
431
|
for (const placeholder of Array.from(root.querySelectorAll('span[data-dci-wire]'))) {
|
|
384
432
|
const label = placeholder.getAttribute('data-dci-wire') ?? '';
|
|
385
|
-
placeholder.replaceWith(chipElement({ start: 0, end: 0, kind: 'session', label, display: label, clickable: false },
|
|
433
|
+
placeholder.replaceWith(chipElement({ start: 0, end: 0, kind: 'session', label, display: label, clickable: false }, actionsRef));
|
|
386
434
|
}
|
|
387
|
-
chipTextNodes(root,
|
|
435
|
+
chipTextNodes(root, actionsRef);
|
|
388
436
|
} catch (error) {
|
|
389
437
|
diagError('umsg.decorate', error);
|
|
390
438
|
}
|
|
@@ -487,7 +535,7 @@ function CheckGlyph(): JSX.Element {
|
|
|
487
535
|
function MessageFileCard({ file }: { file: { name?: string; bytes?: number } }): JSX.Element {
|
|
488
536
|
const name = typeof file.name === 'string' ? file.name : '';
|
|
489
537
|
const bytes = typeof file.bytes === 'number' ? file.bytes : 0;
|
|
490
|
-
const meta = [fileExtension(name).toUpperCase().slice(0, 8),
|
|
538
|
+
const meta = [fileExtension(name).toUpperCase().slice(0, 8), fileSizeText(bytes)].filter(Boolean).join(' ');
|
|
491
539
|
return (
|
|
492
540
|
<span className="dci-umsg-file" title={name}>
|
|
493
541
|
<span className="dci-umsg-fileglyph">
|
|
@@ -501,8 +549,29 @@ function MessageFileCard({ file }: { file: { name?: string; bytes?: number } }):
|
|
|
501
549
|
);
|
|
502
550
|
}
|
|
503
551
|
|
|
504
|
-
|
|
552
|
+
/** 旧/异形宿主缺 renderMessageImages 座位 prop 时的图片降级卡(不静默丢内容)。 */
|
|
553
|
+
function MessageImageFallback({ image }: { image: { attachment: unknown } }): JSX.Element {
|
|
554
|
+
const att = (image.attachment ?? {}) as { name?: unknown; mediaType?: unknown; bytes?: unknown };
|
|
555
|
+
const name = typeof att.name === 'string' && att.name !== '' ? att.name : t('umsg.image');
|
|
556
|
+
const media = typeof att.mediaType === 'string' ? att.mediaType.replace(/^image\//i, '').toUpperCase() : '';
|
|
557
|
+
const meta = [media, typeof att.bytes === 'number' ? fileSizeText(att.bytes) : ''].filter(Boolean).join(' ');
|
|
558
|
+
return (
|
|
559
|
+
<span className="dci-umsg-file" data-image-fallback="true" title={name}>
|
|
560
|
+
<span className="dci-umsg-fileglyph">
|
|
561
|
+
<FileGlyph />
|
|
562
|
+
</span>
|
|
563
|
+
<span className="dci-umsg-filecontent">
|
|
564
|
+
<span className="dci-umsg-filename">{name}</span>
|
|
565
|
+
<span className="dci-umsg-filemeta">{meta}</span>
|
|
566
|
+
</span>
|
|
567
|
+
</span>
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function UserMessageMarkdownView(props: UserMessageMarkdownProps): JSX.Element | null {
|
|
505
572
|
const { node, renderMessageImages, openFile, openSkill } = props;
|
|
573
|
+
// 订阅宿主 locale 快照:语言切换时复制钮/引用摘要等文案即时刷新(其它组件同款)。
|
|
574
|
+
useT();
|
|
506
575
|
const data = (node?.data ?? {}) as UserMessageData;
|
|
507
576
|
const content = data.content;
|
|
508
577
|
const referenceLabels = useMemo(() => (Array.isArray(data.referenceLabels) ? data.referenceLabels : []), [data.referenceLabels]);
|
|
@@ -515,17 +584,30 @@ export function UserMessageMarkdown(props: UserMessageMarkdownProps): JSX.Elemen
|
|
|
515
584
|
const bodyRef = useRef<HTMLDivElement | null>(null);
|
|
516
585
|
const actionsRef = useRef<ChipActions>({ openFile, openSkill, skillNames, referenceLabels });
|
|
517
586
|
actionsRef.current = { openFile, openSkill, skillNames, referenceLabels };
|
|
587
|
+
// 引用标签 / 技能名单后到(投影刷新)时重跑装饰,补齐 @引用与 /技能 chip。
|
|
518
588
|
useEffect(() => {
|
|
519
|
-
decorateMarkdownRoot(bodyRef.current, actionsRef
|
|
520
|
-
}, [html]);
|
|
589
|
+
decorateMarkdownRoot(bodyRef.current, actionsRef);
|
|
590
|
+
}, [html, referenceLabels, skillNames]);
|
|
521
591
|
|
|
522
592
|
const [copied, setCopied] = useState(false);
|
|
593
|
+
const copyTimer = useRef<number | null>(null);
|
|
594
|
+
// 卸载时清掉「已复制」回退定时器,避免卸载后的 setState。
|
|
595
|
+
useEffect(
|
|
596
|
+
() => () => {
|
|
597
|
+
if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
|
|
598
|
+
},
|
|
599
|
+
[],
|
|
600
|
+
);
|
|
523
601
|
const onCopy = useCallback(() => {
|
|
524
602
|
if (copied || text === '') return;
|
|
525
603
|
void writeClipboard(text).then((ok) => {
|
|
526
604
|
if (!ok) return;
|
|
527
605
|
setCopied(true);
|
|
528
|
-
|
|
606
|
+
if (copyTimer.current !== null) window.clearTimeout(copyTimer.current);
|
|
607
|
+
copyTimer.current = window.setTimeout(() => {
|
|
608
|
+
copyTimer.current = null;
|
|
609
|
+
setCopied(false);
|
|
610
|
+
}, 1000);
|
|
529
611
|
});
|
|
530
612
|
}, [copied, text]);
|
|
531
613
|
|
|
@@ -544,7 +626,7 @@ export function UserMessageMarkdown(props: UserMessageMarkdownProps): JSX.Elemen
|
|
|
544
626
|
<Fragment key={`image:${index}`}>
|
|
545
627
|
{renderMessageImages
|
|
546
628
|
? renderMessageImages({ images: [att.image], align: 'end', compact: compactImages })
|
|
547
|
-
:
|
|
629
|
+
: <MessageImageFallback image={att.image} />}
|
|
548
630
|
</Fragment>
|
|
549
631
|
) : (
|
|
550
632
|
<MessageFileCard key={`file:${index}`} file={att.file} />
|
|
@@ -589,3 +671,6 @@ export function UserMessageMarkdown(props: UserMessageMarkdownProps): JSX.Elemen
|
|
|
589
671
|
</div>
|
|
590
672
|
);
|
|
591
673
|
}
|
|
674
|
+
|
|
675
|
+
/** 与官方 UserMessageNodeView 一致用 memo 包裹:历史节点快照重建时不连坐重渲染。 */
|
|
676
|
+
export const UserMessageMarkdown = memo(UserMessageMarkdownView);
|
package/src/client/i18n.ts
CHANGED
|
@@ -100,6 +100,7 @@ const ZH: Dict = {
|
|
|
100
100
|
'umsg.clock.md': '{m}月{d}日',
|
|
101
101
|
'umsg.clock.ymd': '{y}年{m}月{d}日',
|
|
102
102
|
'umsg.extraBlock': '额外内容块',
|
|
103
|
+
'umsg.image': '图片',
|
|
103
104
|
'set.lineNumbers': '显示行号',
|
|
104
105
|
'set.lineNumbersDesc': '当前行与行号高亮常开',
|
|
105
106
|
'set.font': '编辑器字体',
|
|
@@ -212,6 +213,7 @@ const EN: Dict = {
|
|
|
212
213
|
'umsg.clock.md': '{m}/{d}',
|
|
213
214
|
'umsg.clock.ymd': '{y}/{m}/{d}',
|
|
214
215
|
'umsg.extraBlock': 'Extra content block',
|
|
216
|
+
'umsg.image': 'Image',
|
|
215
217
|
'set.lineNumbers': 'Show line numbers',
|
|
216
218
|
'set.lineNumbersDesc': 'The current line and its gutter number stay highlighted',
|
|
217
219
|
'set.font': 'Editor font',
|
|
@@ -307,16 +309,18 @@ export function installI18n(locale: unknown): void {
|
|
|
307
309
|
* 的翻译函数;宿主 face 未就绪或 bind 失败返回 null。结果按 ns 缓存。
|
|
308
310
|
*/
|
|
309
311
|
export function bindForeign(ns: string): ((key: string, params?: TranslateParams) => string) | null {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
312
|
+
const cached = foreignBinds.get(ns);
|
|
313
|
+
if (cached) return cached;
|
|
314
|
+
// 只缓存成功的绑定:宿主 locale 服务可能晚于首次调用就绪,null 缓存会让
|
|
315
|
+
// 之后所有文案永远拿不到官方词典。
|
|
316
|
+
let bound: ((key: string, params?: TranslateParams) => string) | null = null;
|
|
317
|
+
try {
|
|
318
|
+
bound = hostFace?.bind?.(ns) ?? null;
|
|
319
|
+
} catch {
|
|
320
|
+
bound = null;
|
|
318
321
|
}
|
|
319
|
-
|
|
322
|
+
if (bound) foreignBinds.set(ns, bound);
|
|
323
|
+
return bound;
|
|
320
324
|
}
|
|
321
325
|
|
|
322
326
|
export function subscribeI18n(fn: () => void): () => void {
|
package/src/client/index.tsx
CHANGED
|
@@ -56,7 +56,12 @@ export function apply(ctx: ClientContext): void {
|
|
|
56
56
|
const orig = w.console.error.bind(w.console);
|
|
57
57
|
w.console.error = (...args: unknown[]): void => {
|
|
58
58
|
const first = args.find((a) => a instanceof Error);
|
|
59
|
-
if (first)
|
|
59
|
+
if (first) {
|
|
60
|
+
// 与 diagError 同口径限长:长会话里 console.error 不应无界堆积。
|
|
61
|
+
const errs = w.__dshCodinputErrs as unknown as unknown[];
|
|
62
|
+
errs.push(first);
|
|
63
|
+
if (errs.length > 200) errs.splice(0, errs.length - 200);
|
|
64
|
+
}
|
|
60
65
|
orig(...args);
|
|
61
66
|
};
|
|
62
67
|
}
|
package/src/client/settings.tsx
CHANGED
|
@@ -109,7 +109,8 @@ function Selector(props: {
|
|
|
109
109
|
const { value, options, onChange, ariaLabel } = props;
|
|
110
110
|
const [open, setOpen] = useState(false);
|
|
111
111
|
const btnRef = useRef<HTMLButtonElement | null>(null);
|
|
112
|
-
const [anchor, setAnchor] = useState<
|
|
112
|
+
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
|
113
|
+
const [anchorRect, setAnchorRect] = useState<{ left: number; top: number; right: number; bottom: number } | null>(null);
|
|
113
114
|
const current = options.find((o) => o.value === value);
|
|
114
115
|
return (
|
|
115
116
|
<>
|
|
@@ -121,7 +122,11 @@ function Selector(props: {
|
|
|
121
122
|
aria-expanded={open}
|
|
122
123
|
aria-label={ariaLabel}
|
|
123
124
|
onClick={() => {
|
|
124
|
-
if (!open)
|
|
125
|
+
if (!open) {
|
|
126
|
+
const el = btnRef.current ?? null;
|
|
127
|
+
setAnchor(el);
|
|
128
|
+
setAnchorRect(el ? el.getBoundingClientRect() : null);
|
|
129
|
+
}
|
|
125
130
|
setOpen(!open);
|
|
126
131
|
}}
|
|
127
132
|
>
|
|
@@ -129,7 +134,7 @@ function Selector(props: {
|
|
|
129
134
|
<IconChevronOfficial size={14} />
|
|
130
135
|
</button>
|
|
131
136
|
{open && anchor ? (
|
|
132
|
-
<MenuSurface anchor={anchor} side="bottom" alignEnd onClose={() => setOpen(false)} minWidth={200}>
|
|
137
|
+
<MenuSurface anchor={anchor} initialRect={anchorRect} side="bottom" alignEnd onClose={() => setOpen(false)} minWidth={200}>
|
|
133
138
|
{options.map((opt) => (
|
|
134
139
|
<button
|
|
135
140
|
key={opt.value}
|
package/src/client/styles.ts
CHANGED
|
@@ -412,13 +412,19 @@ button.dci-attach-body { color: inherit; font: inherit; text-align: left; backgr
|
|
|
412
412
|
height: 36px; color: var(--dsw-alias-label-primary, inherit); border: none; border-radius: 18px; outline: none;
|
|
413
413
|
padding: 0 14px; font-size: 13px; min-width: 260px; max-width: 320px; }
|
|
414
414
|
.dci-set-input:focus { outline: 2px solid var(--dsw-alias-border-l3, transparent); }
|
|
415
|
+
/* 开关:官方 primitives Switch 逐值(Switch.module.css)。ON 轨道走
|
|
416
|
+
--dsw-alias-brand-primary——该 token 自 0.2.0-rc.2 起浅色主题即近黑(#0f1115)、
|
|
417
|
+
深色主题近白(#f9fafb),桌面 rc.3 同款;同 token 跟随宿主主题,就是官方样式的来源。 */
|
|
415
418
|
.dci-switch { box-sizing: border-box; position: relative; flex: none; width: 36px; height: 20px; padding: 2px;
|
|
416
|
-
border: 0; border-radius:
|
|
419
|
+
border: 0; border-radius: 999px; corner-shape: round;
|
|
420
|
+
background: var(--dsw-alias-border-l3, color-mix(in srgb, currentColor 30%, transparent));
|
|
417
421
|
cursor: pointer; }
|
|
418
422
|
.dci-switch[aria-checked="true"] { background: var(--dsw-alias-brand-primary, color-mix(in srgb, currentColor 70%, transparent)); }
|
|
419
|
-
.dci-switch:
|
|
420
|
-
.dci-switch
|
|
423
|
+
.dci-switch:disabled { cursor: default; opacity: 0.5; }
|
|
424
|
+
.dci-switch:focus-visible { outline: var(--dsw-focus-ring-width, 2px) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary, currentColor)); outline-offset: 2px; }
|
|
425
|
+
.dci-switch::after { content: ''; display: block; width: 16px; height: 16px; border-radius: 50%; corner-shape: round;
|
|
421
426
|
background: var(--dsw-alias-label-primary-foreground, #fff); transition: transform 120ms ease; }
|
|
427
|
+
.dci-switch[aria-checked="false"]::after { background: var(--dsw-alias-switch-thumb, #fff); }
|
|
422
428
|
.dci-switch[aria-checked="true"]::after { transform: translateX(16px); }
|
|
423
429
|
|
|
424
430
|
|
|
@@ -440,15 +446,17 @@ button.dci-attach-body { color: inherit; font: inherit; text-align: left; backgr
|
|
|
440
446
|
padding: 4px 0 0; font-size: calc(var(--dsh-content-font-size-secondary, 13px) - 1px);
|
|
441
447
|
line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
|
|
442
448
|
justify-content: center; gap: 12px; display: flex; }
|
|
443
|
-
.dci-stat-anchor { min-width: 0; display: inline-flex;
|
|
449
|
+
.dci-stat-anchor { min-width: 0; display: inline-flex; }
|
|
444
450
|
|
|
445
|
-
/* 数据行详情面板(官方 bRhRbq
|
|
446
|
-
|
|
451
|
+
/* 数据行详情面板(官方 bRhRbq 复刻:portal 到 body、fixed、锚点上方弹出)。
|
|
452
|
+
背景与圆角逐值同官方:--dsw-specific-menu 优先(macOS 桌面上官方把它覆写成不透明),
|
|
453
|
+
旧宿主/皮肤缺该 token 时回退 --dsw-menu-surface-fill 链,不落到硬编码深色。 */
|
|
454
|
+
.dci-stat-panel { z-index: 1100; box-sizing: border-box; background: var(--dsw-specific-menu, var(--dsw-menu-surface-fill, rgba(30, 30, 34, 0.98)));
|
|
447
455
|
backdrop-filter: var(--dsw-menu-backdrop-filter, none);
|
|
448
456
|
color: var(--dsw-alias-label-secondary, inherit); --dsw-elevation-stroke-color: var(--dsw-alias-border-l1, transparent);
|
|
449
457
|
width: max-content; min-width: min(300px, 100vw - 24px); max-width: min(440px, 100vw - 24px); text-align: left;
|
|
450
458
|
box-shadow: var(--dsw-elevation-prominent, 0 10px 34px rgba(0, 0, 0, 0.35)); cursor: default; border: 0;
|
|
451
|
-
border-radius: 12px; padding: 16px; font-size: 12px; line-height: 18px; position:
|
|
459
|
+
border-radius: var(--dsw-radius-lg, 12px); padding: 16px; font-size: 12px; line-height: 18px; position: fixed; }
|
|
452
460
|
.dci-stat-panelTitle { color: var(--dsw-alias-label-primary, inherit); justify-content: space-between; gap: 16px;
|
|
453
461
|
margin-bottom: 8px; font-weight: 500; display: flex; }
|
|
454
462
|
.dci-stat-panelLabel { align-items: center; gap: 6px; min-width: 0; display: inline-flex; }
|
|
@@ -460,36 +468,40 @@ button.dci-attach-body { color: inherit; font: inherit; text-align: left; backgr
|
|
|
460
468
|
.dci-stat-details dt, .dci-stat-details dd { min-width: 0; margin: 0; }
|
|
461
469
|
.dci-stat-details dd { color: var(--dsw-alias-label-secondary, inherit); font-variant-numeric: tabular-nums; text-align: right; }
|
|
462
470
|
.dci-stat-pill { font: inherit; box-sizing: border-box; max-width: 100%; color: var(--dsw-alias-label-tertiary, currentColor);
|
|
463
|
-
white-space: nowrap; background: transparent; border: none; border-radius:
|
|
471
|
+
white-space: nowrap; background: transparent; border: none; border-radius: 999px; corner-shape: round;
|
|
472
|
+
line-height: inherit; align-items: center; gap: 6px;
|
|
464
473
|
padding: 1px 8px; display: inline-flex; cursor: pointer; font-variant-numeric: tabular-nums; }
|
|
465
474
|
.dci-stat-pill svg { flex: none; width: 14px; height: 14px; }
|
|
466
|
-
.dci-stat-pill:hover { background: var(--dsw-alias-interactive-bg-hover, color-mix(in srgb, currentColor 8%, transparent));
|
|
475
|
+
.dci-stat-pill:hover, .dci-stat-pill[aria-expanded="true"] { background: var(--dsw-alias-interactive-bg-hover, color-mix(in srgb, currentColor 8%, transparent));
|
|
467
476
|
color: var(--dsw-alias-label-secondary, inherit); }
|
|
468
477
|
.dci-stat-label { text-overflow: ellipsis; min-width: 0; overflow: hidden; }
|
|
469
478
|
.dci-stat-sep { color: var(--dsw-alias-separator-primary, currentColor); margin: 0 6px; }
|
|
470
479
|
|
|
471
480
|
/* ===== 上下文小圈(官方 JObwrW 复刻:双环仪表 + 分段面板)
|
|
472
481
|
0.1.7-rc.2 起官方把它挪进卡片下方 dock 行(stats 胶囊之后),trigger 变为「圈+百分比」药丸 ===== */
|
|
473
|
-
.dci-gauge-root { flex: none; display: inline-flex;
|
|
482
|
+
.dci-gauge-root { flex: none; display: inline-flex; }
|
|
474
483
|
.dci-gauge-trigger { border-radius: var(--dsw-radius-sm, 8px); color: var(--dsw-alias-label-tertiary, currentColor);
|
|
475
|
-
font-family: inherit; font-size: 13px; font-variant-numeric: tabular-nums;
|
|
484
|
+
font-family: inherit; font-size: var(--dsh-content-font-size-secondary, 13px); font-variant-numeric: tabular-nums;
|
|
485
|
+
line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); white-space: nowrap;
|
|
476
486
|
cursor: pointer; background: transparent; border: none; flex: none; align-items: center; gap: 6px; padding: 1px 8px; display: inline-flex; }
|
|
477
487
|
.dci-gauge-trigger:hover, .dci-gauge-trigger[aria-expanded="true"] { background: var(--dsw-alias-interactive-bg-hover, color-mix(in srgb, currentColor 8%, transparent));
|
|
478
488
|
color: var(--dsw-alias-label-secondary, inherit); }
|
|
479
489
|
.dci-gauge-track { fill: none; stroke: var(--dsw-alias-border-l3, currentColor); stroke-width: 2px; }
|
|
480
490
|
.dci-gauge-fill { fill: none; stroke: var(--dsw-alias-label-tertiary, currentColor); stroke-width: 2px; stroke-linecap: round; }
|
|
481
|
-
|
|
491
|
+
/* 面板 portal 到 body + fixed,left/top 由 useAnchoredPanel(官方 useAnchoredPosition
|
|
492
|
+
移植:面板左缘对齐锚点左缘、面板底距锚点顶 8px、视口 margin 12 夹取)下发。 */
|
|
493
|
+
.dci-gauge-panel { z-index: 1100; box-sizing: border-box; background: var(--dsw-specific-menu, var(--dsw-menu-surface-fill, rgba(30, 30, 34, 0.98)));
|
|
482
494
|
backdrop-filter: var(--dsw-menu-backdrop-filter, none);
|
|
483
495
|
--dsw-elevation-stroke-color: var(--dsw-alias-border-l1, transparent); width: min(264px, 100vw - 24px);
|
|
484
496
|
box-shadow: var(--dsw-elevation-prominent, 0 10px 34px rgba(0, 0, 0, 0.35));
|
|
485
497
|
color: var(--dsw-alias-label-secondary, inherit); cursor: default; border: 0; border-radius: var(--dsw-radius-lg, 12px); padding: 12px;
|
|
486
|
-
font-size: 12px; line-height: 20px; position:
|
|
498
|
+
font-size: 12px; line-height: 20px; position: fixed; }
|
|
487
499
|
.dci-gauge-header { align-items: center; gap: 6px; display: flex; }
|
|
488
500
|
.dci-gauge-figures { font-variant-numeric: tabular-nums; color: var(--dsw-alias-label-primary, inherit); margin-left: auto; font-weight: 500; }
|
|
489
501
|
.dci-gauge-percent { color: var(--dsw-alias-label-primary, inherit); font-weight: 500; }
|
|
490
502
|
.dci-gauge-headline { color: var(--dsw-alias-label-tertiary, currentColor); }
|
|
491
503
|
.dci-gauge-bar { background: var(--dsw-alias-interactive-bg-hover, color-mix(in srgb, currentColor 8%, transparent));
|
|
492
|
-
border-radius: 999px; gap: 1px; height: 4px; margin: 10px 0 12px; display: flex; overflow: hidden; }
|
|
504
|
+
border-radius: 999px; corner-shape: round; gap: 1px; height: 4px; margin: 10px 0 12px; display: flex; overflow: hidden; }
|
|
493
505
|
.dci-gauge-segment { background: var(--meter-tint, var(--dsw-alias-label-tertiary, currentColor)); border-radius: 1px;
|
|
494
506
|
flex: 0 0 auto; min-width: 2px; height: 100%; }
|
|
495
507
|
.dci-gauge-swatch { background: var(--meter-tint, currentColor); vertical-align: baseline; border-radius: 2px;
|
|
@@ -569,13 +581,11 @@ button.dci-attach-body { color: inherit; font: inherit; text-align: left; backgr
|
|
|
569
581
|
border: none; justify-content: center; align-items: center; padding: 6px; display: inline-flex; }
|
|
570
582
|
.dci-umsg-action svg { width: calc(15px + var(--dsh-content-font-delta, 0px)); height: calc(15px + var(--dsh-content-font-delta, 0px)); }
|
|
571
583
|
.dci-umsg-action:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); }
|
|
572
|
-
/*
|
|
573
|
-
|
|
584
|
+
/* 悬停显形(官方 MessageItem.module.css 同款):data-actions-reveal 挂在 assistant 的
|
|
585
|
+
turn-tail 根上、不在用户消息的祖先链里,用户消息的实际驱动只有 flowItem 的
|
|
586
|
+
data-chat-flow-kind 兄弟关系(后一条 user/steering 存在即隐藏,悬停/聚焦显形)。 */
|
|
574
587
|
@media (hover: hover) {
|
|
575
|
-
[data-actions-reveal="hover"] .dci-umsg-actions,
|
|
576
588
|
:is([data-chat-flow-kind="user"], [data-chat-flow-kind="steering"]):has(~ :is([data-chat-flow-kind="user"], [data-chat-flow-kind="steering"])) .dci-umsg-actions { opacity: 0; transition: opacity 80ms; }
|
|
577
|
-
[data-actions-reveal="hover"]:hover .dci-umsg-actions,
|
|
578
|
-
[data-actions-reveal="hover"]:focus-within .dci-umsg-actions,
|
|
579
589
|
:is([data-chat-flow-kind="user"], [data-chat-flow-kind="steering"]):hover .dci-umsg-actions,
|
|
580
590
|
:is([data-chat-flow-kind="user"], [data-chat-flow-kind="steering"]):focus-within .dci-umsg-actions { opacity: 1; }
|
|
581
591
|
}
|
|
@@ -658,9 +668,15 @@ button.dci-umsg-chip:focus-visible { box-shadow: 0 0 0 2px var(--dsw-focus-ring-
|
|
|
658
668
|
`;
|
|
659
669
|
|
|
660
670
|
export function injectStyles(): void {
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
el
|
|
665
|
-
|
|
671
|
+
const el = document.getElementById(STYLE_ID);
|
|
672
|
+
// 已有本插件样式(宿主热替换/原地更新插件后重 apply)时按内容原位刷新:
|
|
673
|
+
// 旧 <style> 还挂着会让新版新增的类(如 dci-umsg-*)永远注入不进去。
|
|
674
|
+
if (el) {
|
|
675
|
+
if (el.textContent !== CSS) el.textContent = CSS;
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
const tag = document.createElement('style');
|
|
679
|
+
tag.id = STYLE_ID;
|
|
680
|
+
tag.textContent = CSS;
|
|
681
|
+
document.head.appendChild(tag);
|
|
666
682
|
}
|