dsh-codinput 0.3.8 → 0.4.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/CHANGELOG.md +71 -0
- package/NOTICE +37 -4
- package/README.en.md +28 -13
- package/README.md +28 -13
- package/TESTING.md +96 -9
- package/assets/donate-qr.jpg +0 -0
- package/lib/client.js +2875 -98
- package/package.json +5 -2
- package/src/client/cm.ts +19 -1
- package/src/client/command.ts +39 -8
- package/src/client/components/CodinputSurface.tsx +80 -27
- package/src/client/components/EditorPane.tsx +3 -1
- package/src/client/components/InputSlotSeat.tsx +56 -1
- 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/SlashMenu.tsx +294 -41
- package/src/client/components/StatsRow.tsx +40 -23
- package/src/client/components/Toolbar.tsx +284 -48
- package/src/client/components/UserMessageMarkdown.tsx +158 -115
- package/src/client/components/VoiceActivity.tsx +30 -69
- package/src/client/components/slotEntry.tsx +37 -0
- package/src/client/host-types.ts +11 -3
- package/src/client/hotkey.ts +38 -0
- package/src/client/i18n.ts +18 -4
- package/src/client/index.tsx +9 -0
- package/src/client/markdown/code.ts +281 -0
- package/src/client/markdown/extensions.ts +576 -0
- package/src/client/markdown/heavy-assets.d.ts +11 -0
- package/src/client/markdown/heavy-shared.ts +50 -0
- package/src/client/markdown/heavy.ts +159 -0
- package/src/client/markdown/index.ts +289 -0
- package/src/client/markdown/math.ts +134 -0
- package/src/client/markdown/mermaid.ts +358 -0
- package/src/client/markdown/sanitize.ts +103 -0
- package/src/client/markdown/styles.ts +171 -0
- package/src/client/prefs.ts +206 -23
- package/src/client/settings.tsx +15 -3
- package/src/client/stats.ts +92 -20
- package/src/client/styles.ts +55 -30
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-codinput",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "DSH 插件:把聊天输入框变成代码编辑器一样的输入风格(CodeMirror 6 接管 conversation.composer.bar),web 与桌面端通用。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Witherwithwinter",
|
|
@@ -66,7 +66,10 @@
|
|
|
66
66
|
"@codemirror/state": "^6.5.2",
|
|
67
67
|
"@codemirror/view": "^6.36.0",
|
|
68
68
|
"dompurify": "^3.2.4",
|
|
69
|
-
"
|
|
69
|
+
"highlight.js": "^11.12.0",
|
|
70
|
+
"katex": "^0.18.0",
|
|
71
|
+
"marked": "^15.0.6",
|
|
72
|
+
"mermaid": "^12.3.0"
|
|
70
73
|
},
|
|
71
74
|
"devDependencies": {
|
|
72
75
|
"@types/react": "^18.3.18",
|
package/src/client/cm.ts
CHANGED
|
@@ -139,6 +139,19 @@ export function subscribeEditorPrefs(view: EditorView, placeholder: string, edit
|
|
|
139
139
|
});
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* 永远按字面字符渲染:同时关掉 `liga` / `calt`。
|
|
144
|
+
*
|
|
145
|
+
* 连字会把 `->`、`//` 等替换成合并字形,视觉上像「字符消失」;更关键的是
|
|
146
|
+
* DSH 桌面 0.2.2 内置的 Chromium 152 在「`calt` 连字 + 后续任意字符」时会把
|
|
147
|
+
* 连字字形第一格画空(宿主 shaping bug,CSS 层面无法稳定规避)。故本插件不做
|
|
148
|
+
* 连字:编辑器所见即所存,也与预览 / 消息区的纯文本渲染保持一致。
|
|
149
|
+
*/
|
|
150
|
+
const LITERAL_TEXT_STYLES = {
|
|
151
|
+
fontVariantLigatures: 'none',
|
|
152
|
+
fontFeatureSettings: '"liga" 0, "calt" 0',
|
|
153
|
+
} as const;
|
|
154
|
+
|
|
142
155
|
function baseTheme(fontFamily: string): Extension {
|
|
143
156
|
return EditorView.theme({
|
|
144
157
|
'&': { fontSize: '13px', height: '100%' },
|
|
@@ -147,8 +160,13 @@ function baseTheme(fontFamily: string): Extension {
|
|
|
147
160
|
fontFamily,
|
|
148
161
|
lineHeight: '1.6',
|
|
149
162
|
overflow: 'auto',
|
|
163
|
+
...LITERAL_TEXT_STYLES,
|
|
164
|
+
},
|
|
165
|
+
'.cm-content': {
|
|
166
|
+
caretColor: 'var(--dsw-alias-state-business-primary, currentColor)',
|
|
167
|
+
paddingBottom: '8px',
|
|
168
|
+
...LITERAL_TEXT_STYLES,
|
|
150
169
|
},
|
|
151
|
-
'.cm-content': { caretColor: 'var(--dsw-alias-state-business-primary, currentColor)', paddingBottom: '8px' },
|
|
152
170
|
'.cm-line': { padding: '0 8px' },
|
|
153
171
|
// CodeMirror 自绘光标(drawSelection)默认黑色,深色主题下不可见——
|
|
154
172
|
// 显式对齐官方输入框的 caret 颜色(品牌强调色)。
|
package/src/client/command.ts
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
* 贡献。
|
|
4
4
|
*
|
|
5
5
|
* /codinput:输入回车即切换接管(进入/退出),菜单描述随当前状态变化;接管
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* 关闭(官方输入框)时输入同样生效——贡献注册是全局生命周期。命令 token 由
|
|
7
|
+
* 宿主 consume-token 通道消费;插件只在残留时兜底剥离 token 本身,绝不整体
|
|
8
|
+
* 清空草稿(否则「先有草稿再 /codinput 退出」会丢草稿)。侧栏里发起的退出会
|
|
9
|
+
* 经总线请求 body 关掉自己的标签(见 sideinput)。
|
|
9
10
|
*
|
|
10
11
|
*/
|
|
11
12
|
|
|
@@ -17,13 +18,40 @@ import { t } from './i18n';
|
|
|
17
18
|
import { IconCodinputCommand16 } from './icons';
|
|
18
19
|
import { diagWindow, runGuarded } from './diag';
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
/** `/codinput` 命令 token 的规范拼写(宿主命令名即 `codinput`)。 */
|
|
22
|
+
const COMMAND_TOKEN = '/codinput';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 清理 `/codinput` 命令 token 的残留,**只剥 token,不碰草稿其余内容**。
|
|
26
|
+
*
|
|
27
|
+
* 官方命令通道在调用 `ui.run` 之前会先派发 `slash/input-consume-token`
|
|
28
|
+
* (菜单拾取 = exact span,裸命令回车 = bare-token),正常路径此时草稿里
|
|
29
|
+
* 已经没有 token。但「工具行指令按钮程序化唤起菜单」等路径的 span 消费
|
|
30
|
+
* 可能落空,草稿里会残留 "/codinput" 前缀。旧实现无条件 `setDraft('')`
|
|
31
|
+
* 把草稿整体清空——用户先有草稿、再输入 `/codinput` 退出接管时,宿主只
|
|
32
|
+
* 消费掉命令 token,剩下的用户草稿随后被这段无条件清空吞掉(普通/悬浮
|
|
33
|
+
* 形态都中招,退出后内置输入框变空)。
|
|
34
|
+
*
|
|
35
|
+
* 这里沿用宿主 bare-token guard 的判据:只有「token 本身」或「token 后跟
|
|
36
|
+
* 空白」才当作命令 token 处理;"/codinputX" 这类真的用户文本不动。清理后
|
|
37
|
+
* 余下的用户内容原样保留。
|
|
38
|
+
*/
|
|
39
|
+
function stripCommandToken(sessionId: string | undefined): void {
|
|
21
40
|
if (!sessionId) return;
|
|
22
41
|
try {
|
|
23
42
|
const actx = stash.sessions?.scope(sessionId);
|
|
24
|
-
if (actx
|
|
43
|
+
if (!actx || !stash.conversation) return;
|
|
44
|
+
const input = stash.conversation.input.for(actx);
|
|
45
|
+
const draft = input.state?.getSnapshot?.().draft ?? '';
|
|
46
|
+
const leading = draft.replace(/^\s+/, '');
|
|
47
|
+
if (!leading.toLowerCase().startsWith(COMMAND_TOKEN)) return;
|
|
48
|
+
const rest = leading.slice(COMMAND_TOKEN.length);
|
|
49
|
+
// 宿主命令 token 语法:token 必须以行尾/空白收尾。
|
|
50
|
+
if (rest !== '' && !/^\s/.test(rest)) return;
|
|
51
|
+
const next = rest.replace(/^\s+/, '');
|
|
52
|
+
if (next !== draft) input.setDraft(next);
|
|
25
53
|
} catch {
|
|
26
|
-
/*
|
|
54
|
+
/* 会话作用域不可达时忽略(退出接管本身不受影响) */
|
|
27
55
|
}
|
|
28
56
|
}
|
|
29
57
|
|
|
@@ -97,8 +125,12 @@ export function registerCodinputCommand(ctx: ClientContext): void {
|
|
|
97
125
|
ui: {
|
|
98
126
|
kind: 'action',
|
|
99
127
|
run: () => {
|
|
100
|
-
// 卸载会清掉诊断 sessionId
|
|
128
|
+
// 卸载会清掉诊断 sessionId,先捕获(清理必须在卸载前做完,
|
|
129
|
+
// 否则官方输入框挂载时就会带上残留 token)。
|
|
101
130
|
const sessionId = diagWindow().__dshCodinputSessionId ?? undefined;
|
|
131
|
+
// 只剥离命令 token:宿主已派发 consume-token,这里兜住
|
|
132
|
+
// 「程序化唤起菜单导致 span 消费落空」的残留;用户草稿保留。
|
|
133
|
+
stripCommandToken(sessionId);
|
|
102
134
|
savePrefs({ enabled: !loadPrefs().enabled });
|
|
103
135
|
// 侧栏里发起的退出:关掉承载会话的 Codinput 标签(body 经
|
|
104
136
|
// tab 域 actions.close 关闭、卸载撤销承载),主输入回到官方
|
|
@@ -106,7 +138,6 @@ export function registerCodinputCommand(ctx: ClientContext): void {
|
|
|
106
138
|
if (!loadPrefs().enabled) {
|
|
107
139
|
for (const carried of carriedSessionIds()) requestSidebarTabClose(carried);
|
|
108
140
|
}
|
|
109
|
-
setTimeout(() => clearDraftOf(sessionId), 80);
|
|
110
141
|
},
|
|
111
142
|
},
|
|
112
143
|
}),
|
|
@@ -6,8 +6,6 @@
|
|
|
6
6
|
|
|
7
7
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
8
8
|
import type { EditorView } from '@codemirror/view';
|
|
9
|
-
import { marked } from 'marked';
|
|
10
|
-
import * as DOMPurifyModule from 'dompurify';
|
|
11
9
|
import type {
|
|
12
10
|
ComposerKeyboard,
|
|
13
11
|
InputActions,
|
|
@@ -16,15 +14,22 @@ import type {
|
|
|
16
14
|
UseProjection,
|
|
17
15
|
} from '../host-types';
|
|
18
16
|
import { stash } from '../services';
|
|
19
|
-
import { loadPrefs, savePrefs } from '../prefs';
|
|
17
|
+
import { loadPrefs, savePrefs, subscribePrefs } from '../prefs';
|
|
20
18
|
import { addFilesToDraft, bindSessionFilePicker, dragHasFiles } from '../attachments';
|
|
21
19
|
import { diagError, diagSession, diagWindow } from '../diag';
|
|
22
20
|
import { IconEdit, IconEye } from '../icons';
|
|
23
21
|
import { t, useT } from '../i18n';
|
|
22
|
+
import {
|
|
23
|
+
activateMarkdown,
|
|
24
|
+
handleMarkdownClick,
|
|
25
|
+
renderMarkdown,
|
|
26
|
+
type MarkdownImageTarget,
|
|
27
|
+
} from '../markdown';
|
|
24
28
|
import { EditorPane } from './EditorPane';
|
|
25
29
|
import { SlashMenu } from './SlashMenu';
|
|
26
30
|
import { PopupSelectView } from './PopupSelectView';
|
|
27
31
|
import { AttachmentRail } from './AttachmentRail';
|
|
32
|
+
import { ImageLightbox } from './ImageLightbox';
|
|
28
33
|
import { Toolbar } from './Toolbar';
|
|
29
34
|
import { StatsRow } from './StatsRow';
|
|
30
35
|
|
|
@@ -59,12 +64,6 @@ export interface CodinputSurfaceProps {
|
|
|
59
64
|
onDragZonePointerDown?: (event: React.PointerEvent<HTMLDivElement>) => void;
|
|
60
65
|
}
|
|
61
66
|
|
|
62
|
-
marked.setOptions({ gfm: true, breaks: true });
|
|
63
|
-
|
|
64
|
-
/** dompurify 的导出形状随打包条件漂移:默认导出与命名空间都兼容。 */
|
|
65
|
-
const DOMPurify = ((DOMPurifyModule as unknown as { default?: unknown }).default ??
|
|
66
|
-
DOMPurifyModule) as unknown as { sanitize(html: string): string };
|
|
67
|
-
|
|
68
67
|
export function CodinputSurface(props: CodinputSurfaceProps): JSX.Element {
|
|
69
68
|
const { sessionId, state, actions, useProjection, inputStandard, placeholder, disabled, blocked, running, side, hero, chatNodes, float, onDragZonePointerDown } = props;
|
|
70
69
|
// 语言切换时整棵表面重渲染(文案都走 i18n 的 t())。
|
|
@@ -73,12 +72,22 @@ export function CodinputSurface(props: CodinputSurfaceProps): JSX.Element {
|
|
|
73
72
|
const [editOn, setEditOn] = useState<boolean>(() => loadPrefs().defaultEdit);
|
|
74
73
|
const [previewOn, setPreviewOn] = useState<boolean>(() => loadPrefs().defaultPreview);
|
|
75
74
|
const [bodyHeight, setBodyHeight] = useState<number | null>(() => loadPrefs().composerBodyHeight);
|
|
75
|
+
/** Markdown 正文等宽连字(设置 → Markdown 等宽连字):即时跟随 prefs 变化。 */
|
|
76
|
+
const [markdownMono, setMarkdownMono] = useState<boolean>(() => loadPrefs().markdownMono);
|
|
77
|
+
useEffect(() => subscribePrefs(() => setMarkdownMono(loadPrefs().markdownMono)), []);
|
|
76
78
|
/** 拖文件悬停卡片时的落区提示(松手即入草稿附件)。 */
|
|
77
79
|
const [dropActive, setDropActive] = useState(false);
|
|
78
80
|
const cardRef = useRef<HTMLDivElement | null>(null);
|
|
79
81
|
const viewRef = useRef<EditorView | null>(null);
|
|
80
82
|
const caretRef = useRef<number | null>(null);
|
|
81
83
|
const previewRef = useRef<HTMLDivElement | null>(null);
|
|
84
|
+
/** 预览 Markdown 根(图片点灯箱 / 扩展渲染 扩展激活的挂点)。 */
|
|
85
|
+
const previewBodyRef = useRef<HTMLDivElement | null>(null);
|
|
86
|
+
/** Markdown 内 http(s) 图片点击后的大图灯箱。 */
|
|
87
|
+
const [lightbox, setLightbox] = useState<MarkdownImageTarget | null>(null);
|
|
88
|
+
const onPreviewClick = (event: React.MouseEvent<HTMLDivElement>): void => {
|
|
89
|
+
handleMarkdownClick(event, { onImage: setLightbox });
|
|
90
|
+
};
|
|
82
91
|
|
|
83
92
|
// 高度调节:仅顶边把手,1:1 跟手(顶边上移多少、编辑区就高多少),松手持久化。
|
|
84
93
|
// 宽度不做自有调节——沿用官方聊天宽度调节器(wSkVaW_widthHandle):
|
|
@@ -233,23 +242,38 @@ export function CodinputSurface(props: CodinputSurfaceProps): JSX.Element {
|
|
|
233
242
|
actions.submit();
|
|
234
243
|
};
|
|
235
244
|
|
|
236
|
-
//
|
|
245
|
+
// 官方同语义(0.2.2 InputBar):empty = 草稿与附件皆空;machineBusy = 输入机
|
|
246
|
+
// 三个 busy 阶段(adjudicating / submitting / sending)——编辑器软锁、主按钮
|
|
247
|
+
// 不再可提交;sending 时主按钮转 spinner,停止由独立停止钮(interruptible)承接。
|
|
237
248
|
const inert = disabled === true || blocked === true;
|
|
238
249
|
const draftEmpty = (state?.draft ?? '').trim() === '';
|
|
239
250
|
const noAttachments = (state?.attachmentIds.length ?? 0) === 0;
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
251
|
+
const empty = draftEmpty && noAttachments;
|
|
252
|
+
const phase = state?.phase;
|
|
253
|
+
const sending = phase === 'sending';
|
|
254
|
+
const machineBusy = phase === 'adjudicating' || phase === 'submitting' || sending;
|
|
255
|
+
// 官方 primaryStops = running && subagent===null && !sending && (empty || blocked)。
|
|
256
|
+
// (插件侧无 subagent;blocked 走 bar owner props。)
|
|
257
|
+
// 停止形态不随禁用锁死:官方 primaryDisabled 在停止分支只看 stop 是否可用。
|
|
258
|
+
const primaryStops = running === true && !sending && (empty || blocked === true);
|
|
259
|
+
// 官方 primaryDisabled 的非停止分支:empty || disabled || machineBusy || uploadsPending
|
|
260
|
+
// (插件侧草稿附件即传即用,无 uploadsPending;!live 等价于 actions 缺席)。
|
|
261
|
+
const sendDisabled = primaryStops ? false : empty || inert || machineBusy || actions === undefined;
|
|
262
|
+
|
|
263
|
+
// 统一 Markdown 管线(与用户消息同一函数):同步渲染,draft 变更即重算。
|
|
249
264
|
const html = useMemo(
|
|
250
|
-
() =>
|
|
265
|
+
() => renderMarkdown(state?.draft ?? '', { surface: 'preview' }),
|
|
251
266
|
[state?.draft, previewOn],
|
|
252
267
|
);
|
|
268
|
+
// 扩展渲染 扩展(代码高亮 / KaTeX / Mermaid)的 DOM 激活口;无注册扩展时 no-op。
|
|
269
|
+
// 依赖必须含 previewOn:预览是条件渲染,「先粘贴、后开预览」时 html 没变、根节点
|
|
270
|
+
// 却从 null 变成新挂载节点,只靠 [html] 会跳过激活(late-mount 路径:KaTeX 停在
|
|
271
|
+
// 占位、Mermaid 一直 pending、代码块复制按钮无事件)。previewOn 切换时 React 会
|
|
272
|
+
// 先跑上一次 cleanup,再对新根重新激活;side/float 与 normal 共用本效果。
|
|
273
|
+
useEffect(() => {
|
|
274
|
+
if (!previewOn) return undefined;
|
|
275
|
+
return activateMarkdown(previewBodyRef.current, 'preview');
|
|
276
|
+
}, [html, previewOn]);
|
|
253
277
|
|
|
254
278
|
// ---- 分屏同步滚动:两侧按滚动比例互随(内容高度不同,比例映射) ----
|
|
255
279
|
// scroll 不冒泡但走捕获相位,挂在 body 上的 onScrollCapture 能收到两栏的
|
|
@@ -380,15 +404,27 @@ export function CodinputSurface(props: CodinputSurfaceProps): JSX.Element {
|
|
|
380
404
|
placeholder={placeholderText}
|
|
381
405
|
viewRef={viewRef}
|
|
382
406
|
onCaretMoved={caretMoved}
|
|
383
|
-
|
|
407
|
+
// 官方 busy 软锁:adjudicating / submitting / sending 三阶段编辑器只读
|
|
408
|
+
//(EditorPane 内部已覆盖前两者,sending 由这里并入 disabled)。
|
|
409
|
+
disabled={inert || sending}
|
|
384
410
|
autoFocus={true}
|
|
385
411
|
solo={!previewOn}
|
|
386
412
|
stateKey={sessionId ?? 'hero'}
|
|
387
413
|
/>
|
|
388
414
|
) : null}
|
|
389
415
|
{previewOn ? (
|
|
390
|
-
<div className="dci-preview" data-solo={!editOn} ref={previewRef}>
|
|
391
|
-
{html ?
|
|
416
|
+
<div className="dci-preview" data-solo={!editOn} ref={previewRef} onClick={onPreviewClick}>
|
|
417
|
+
{html ? (
|
|
418
|
+
<div
|
|
419
|
+
className="dci-md"
|
|
420
|
+
data-dci-md="preview"
|
|
421
|
+
data-mono={markdownMono ? 'true' : undefined}
|
|
422
|
+
ref={previewBodyRef}
|
|
423
|
+
dangerouslySetInnerHTML={{ __html: html }}
|
|
424
|
+
/>
|
|
425
|
+
) : (
|
|
426
|
+
<span style={{ opacity: 0.4 }}>{t("preview.empty")}</span>
|
|
427
|
+
)}
|
|
392
428
|
</div>
|
|
393
429
|
) : null}
|
|
394
430
|
</div>
|
|
@@ -400,7 +436,11 @@ export function CodinputSurface(props: CodinputSurfaceProps): JSX.Element {
|
|
|
400
436
|
triggers={triggers}
|
|
401
437
|
caretRef={caretRef}
|
|
402
438
|
useProjection={useProjection}
|
|
403
|
-
disabled
|
|
439
|
+
// 官方两个 owner 信号分开下发:inert(disabled)与 blocked。
|
|
440
|
+
// locked = inert || blocked;modelSeatLocked = inert || !live(不含 blocked)。
|
|
441
|
+
disabled={disabled === true}
|
|
442
|
+
blocked={blocked === true}
|
|
443
|
+
running={running === true}
|
|
404
444
|
primaryStops={primaryStops}
|
|
405
445
|
onStop={onStop}
|
|
406
446
|
onNotify={notifyFor(sessionId)}
|
|
@@ -414,9 +454,12 @@ export function CodinputSurface(props: CodinputSurfaceProps): JSX.Element {
|
|
|
414
454
|
<StatsRow useProjection={useProjection} sessionId={sessionId} chatNodes={chatNodes} />
|
|
415
455
|
) : null}
|
|
416
456
|
|
|
417
|
-
{/* 官方 MenuView
|
|
418
|
-
SlashMenu
|
|
419
|
-
|
|
457
|
+
{/* 官方 MenuView 锚定:normal 形态仍以卡片顶上方 absolute 浮层弹出(几何不变);
|
|
458
|
+
side/float 形态由 SlashMenu portal 到 body,按卡片四周空位做自适应 fixed
|
|
459
|
+
浮层(不遮编辑区、不挤压卡片布局)。 */}
|
|
460
|
+
{triggers ? (
|
|
461
|
+
<SlashMenu triggers={triggers} dropdown={side === true || float === true} anchorRef={cardRef} />
|
|
462
|
+
) : null}
|
|
420
463
|
</div>
|
|
421
464
|
|
|
422
465
|
{/* 数据行:主输入/侧栏渲染在卡片下方(官方 hero 新会话态无 stats)。 */}
|
|
@@ -425,6 +468,16 @@ export function CodinputSurface(props: CodinputSurfaceProps): JSX.Element {
|
|
|
425
468
|
) : null}
|
|
426
469
|
|
|
427
470
|
{popup ? <PopupSelectView popup={popup} /> : null}
|
|
471
|
+
|
|
472
|
+
{/* Markdown 图片大图灯箱(用户消息侧同组件同行为)。 */}
|
|
473
|
+
{lightbox ? (
|
|
474
|
+
<ImageLightbox
|
|
475
|
+
src={lightbox.src}
|
|
476
|
+
alt={lightbox.alt}
|
|
477
|
+
labels={{ dialog: t('image.preview'), close: t('image.closePreview') }}
|
|
478
|
+
onClose={() => setLightbox(null)}
|
|
479
|
+
/>
|
|
480
|
+
) : null}
|
|
428
481
|
</div>
|
|
429
482
|
);
|
|
430
483
|
}
|
|
@@ -102,7 +102,9 @@ export function EditorPane(props: EditorPaneProps): JSX.Element {
|
|
|
102
102
|
}
|
|
103
103
|
if (matchEvent(prefsNow.lineBreakHotkey, event) && prefsNow.lineBreakHotkey !== 'enter') {
|
|
104
104
|
event.preventDefault();
|
|
105
|
-
|
|
105
|
+
const head = view.state.selection.main.head;
|
|
106
|
+
// 显式插入换行后必须把光标放到插入点之后,否则 CodeMirror 会把光标留在插入点之前,后续输入落回上一行(B1 行为层:Shift+Enter「看起来没换行」);普通 Enter 走 CM 默认 keymap,无此问题。
|
|
107
|
+
view.dispatch({ changes: { from: head, insert: '\n' }, selection: { anchor: head + 1 }, scrollIntoView: true });
|
|
106
108
|
return true;
|
|
107
109
|
}
|
|
108
110
|
return false;
|
|
@@ -26,7 +26,15 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import { useMemo } from 'react';
|
|
29
|
-
import {
|
|
29
|
+
import type { ReactNode } from 'react';
|
|
30
|
+
import {
|
|
31
|
+
entryId,
|
|
32
|
+
entryInjectFailed,
|
|
33
|
+
firstComponentEntry,
|
|
34
|
+
listRows,
|
|
35
|
+
renderSlotEntry,
|
|
36
|
+
useSlotEntries,
|
|
37
|
+
} from './slotEntry';
|
|
30
38
|
|
|
31
39
|
export interface InputSlotSeatProps {
|
|
32
40
|
/** 官方子槽位全名(如 conversation.input.left)。 */
|
|
@@ -47,3 +55,50 @@ export function InputSlotSeat(props: InputSlotSeatProps): JSX.Element | null {
|
|
|
47
55
|
|
|
48
56
|
return <>{rows.map((entry, index) => renderSlotEntry(entry, entryId(entry, index), { sessionId, standard }))}</>;
|
|
49
57
|
}
|
|
58
|
+
|
|
59
|
+
export interface InputSingleSlotProps {
|
|
60
|
+
/** 官方单值子槽位全名(如 conversation.input.permission / .plan)。 */
|
|
61
|
+
slot: string;
|
|
62
|
+
sessionId: string | undefined;
|
|
63
|
+
/** 宿主下发给本插件条目的 standard props 原样面。 */
|
|
64
|
+
standard: Record<string, unknown> | undefined;
|
|
65
|
+
/** 官方 renderSlot 第二参数的 owner props(如 { locked })。 */
|
|
66
|
+
ownerProps?: Record<string, unknown> | undefined;
|
|
67
|
+
/**
|
|
68
|
+
* 兜底渲染(可选):
|
|
69
|
+
* - 条目 inject 面在插件 fiber 里不可复现(依赖宿主服务面的 inject 会抛
|
|
70
|
+
* `without inject`,见 slotEntry.boundInject)→ 渲染 fallback;
|
|
71
|
+
* - 条目组件自身崩溃 → EntryBoundary 回退 fallback。
|
|
72
|
+
* 缺省 null(槽位有占用者但无法镜像时按官方「画不出来」处理)。
|
|
73
|
+
*/
|
|
74
|
+
fallback?: ReactNode;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 单值槽位镜像(0.2.2 的 `conversation.input.permission` / `.plan` 用)。
|
|
79
|
+
*
|
|
80
|
+
* 官方口径(InputBar.tsx):`renderSlot("conversation.input.permission", { locked })`
|
|
81
|
+
* 与 `renderSlot("conversation.input.plan", { locked })` 位于 `.tools` 内
|
|
82
|
+
* `.modes` 容器(permission 在前)。**槽位已声明但无占用者 = 官方留空**,
|
|
83
|
+
* 因此这里对「已声明 + 空」返回 null,绝不回退自绘控件;「未声明」由调用方
|
|
84
|
+
* 用 `useSlotDeclaration` 区分后决定回退(旧宿主才回退 PermissionMenu)。
|
|
85
|
+
*/
|
|
86
|
+
export function InputSingleSlot(props: InputSingleSlotProps): JSX.Element | null {
|
|
87
|
+
const { slot, sessionId, standard, ownerProps, fallback } = props;
|
|
88
|
+
const entries = useSlotEntries(slot);
|
|
89
|
+
const occupant = useMemo(() => firstComponentEntry(entries), [entries]);
|
|
90
|
+
|
|
91
|
+
// 官方门槛:无会话不画该座位(InputBar 的 sessionId 三元)。
|
|
92
|
+
if (sessionId === undefined || occupant === null) return null;
|
|
93
|
+
|
|
94
|
+
// 让位可行性:inject 面必须在宿主 fiber 上下文里才算数(见 slotEntry)。
|
|
95
|
+
// 不可复现时回退调用方给的兜底(permission 用自绘菜单保住旧功能)。
|
|
96
|
+
if (entryInjectFailed(occupant, sessionId)) return <>{fallback ?? null}</>;
|
|
97
|
+
|
|
98
|
+
return renderSlotEntry(occupant, `single:${entryId(occupant, 0)}`, {
|
|
99
|
+
sessionId,
|
|
100
|
+
standard,
|
|
101
|
+
ownerProps,
|
|
102
|
+
fallback,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
* 更长的子页(模型/推理强度列表)时卡片保持小菜单的 top 向下生长,
|
|
10
10
|
* 表现为「向下弹出」且列表伸出视口外够不着。
|
|
11
11
|
*
|
|
12
|
+
* 锚点按元素实时读取:早期实现把 getBoundingClientRect 快照塞进 state,
|
|
13
|
+
* 重算时读同一份快照,scroll/resize 监听等于空转——布局一变菜单就停在打开
|
|
14
|
+
* 瞬间的位置(表现为「菜单飘到屏幕角落」)。现在每次 place 都读实时 rect,
|
|
15
|
+
* 并在打开期间逐帧比对锚点位置,兜住「不触发 scroll/resize 的重排」。
|
|
16
|
+
*
|
|
12
17
|
* 滚动复刻官方两级结构:卡片 overflow:hidden +
|
|
13
18
|
* max-height: min(360px, 100vh - 96px),列表在 .dci-viewport 内滚,
|
|
14
19
|
* 细滚动条由卡片注入的 --dsh-scrollbar-thumb(l2) 变量着色(官方
|
|
@@ -16,10 +21,17 @@
|
|
|
16
21
|
*/
|
|
17
22
|
|
|
18
23
|
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
|
24
|
+
import { createPortal } from 'react-dom';
|
|
19
25
|
|
|
20
26
|
export interface MenuSurfaceProps {
|
|
21
|
-
/**
|
|
22
|
-
anchor:
|
|
27
|
+
/** 触发器元素:每次重算都读实时 rect,锚点移动后菜单跟随。 */
|
|
28
|
+
anchor: HTMLElement | null;
|
|
29
|
+
/**
|
|
30
|
+
* 打开瞬间的锚点矩形。点击本身可能触发布局变化(输入框失焦、卡片换型),
|
|
31
|
+
* layout effect 时读到的 rect 已不是用户点下去时看到的那个——首次定位优先
|
|
32
|
+
* 用它,保证菜单位置与点击时的按钮重合。
|
|
33
|
+
*/
|
|
34
|
+
initialRect?: { left: number; top: number; right: number; bottom: number } | null;
|
|
23
35
|
onClose(): void;
|
|
24
36
|
children: ReactNode;
|
|
25
37
|
/** 右对齐锚点(trailing 控件用)。 */
|
|
@@ -32,23 +44,32 @@ export interface MenuSurfaceProps {
|
|
|
32
44
|
/** 官方 place() 的视口留距。 */
|
|
33
45
|
const MARGIN = 12;
|
|
34
46
|
|
|
47
|
+
/** 锚点位移阈值(px):小于它视为亚像素抖动,不重排菜单。 */
|
|
48
|
+
const SHIFT_THRESHOLD = 1;
|
|
49
|
+
|
|
35
50
|
export function MenuSurface(props: MenuSurfaceProps): JSX.Element {
|
|
36
|
-
const { anchor, onClose, children, alignEnd, minWidth = 218, side = 'top' } = props;
|
|
51
|
+
const { anchor, initialRect, onClose, children, alignEnd, minWidth = 218, side = 'top' } = props;
|
|
52
|
+
const firstPlaceRef = useRef(true);
|
|
37
53
|
const ref = useRef<HTMLDivElement | null>(null);
|
|
38
54
|
const [pos, setPos] = useState<{ left: number; top: number } | null>(null);
|
|
39
55
|
|
|
40
56
|
useLayoutEffect(() => {
|
|
41
57
|
const el = ref.current;
|
|
42
|
-
if (!el) return;
|
|
58
|
+
if (!el || !anchor) return;
|
|
43
59
|
const place = (): void => {
|
|
44
60
|
// 尺寸取 offset*(官方同款):不受自身 top/left 变更反馈影响。
|
|
45
61
|
const w = el.offsetWidth;
|
|
46
62
|
const h = el.offsetHeight;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (
|
|
51
|
-
|
|
63
|
+
// 锚点尚未布局(或被隐藏)时不落位:宁可保持隐藏,也不写进一组
|
|
64
|
+
// 没有视口约束的坐标(否则菜单会停在视口外,看起来「飘走」)。
|
|
65
|
+
const rect = (firstPlaceRef.current && initialRect) || anchor.getBoundingClientRect();
|
|
66
|
+
if (rect.right - rect.left === 0 || rect.bottom - rect.top === 0 || w === 0 || h === 0) return;
|
|
67
|
+
firstPlaceRef.current = false;
|
|
68
|
+
let x = alignEnd ? rect.right - w : rect.left;
|
|
69
|
+
let y = side === 'top' ? rect.top - 8 - h : rect.bottom + 4;
|
|
70
|
+
x = Math.min(Math.max(x, MARGIN), window.innerWidth - w - MARGIN);
|
|
71
|
+
y = Math.min(Math.max(y, MARGIN), window.innerHeight - h - MARGIN);
|
|
72
|
+
setPos((prev) => (prev && Math.abs(prev.left - x) < 0.5 && Math.abs(prev.top - y) < 0.5 ? prev : { left: x, top: y }));
|
|
52
73
|
};
|
|
53
74
|
place();
|
|
54
75
|
// 内容换页/目录热更/滚动/视口变化都重定位——官方 place 的三路监听。
|
|
@@ -56,7 +77,29 @@ export function MenuSurface(props: MenuSurfaceProps): JSX.Element {
|
|
|
56
77
|
window.addEventListener('resize', place);
|
|
57
78
|
const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(place) : null;
|
|
58
79
|
observer?.observe(el);
|
|
80
|
+
observer?.observe(anchor);
|
|
81
|
+
|
|
82
|
+
// 布局重排可能既不产生 scroll 也不改窗口尺寸(输入框换型、分屏切换、
|
|
83
|
+
// 会话区从空到有内容),上面的监听全都收不到信号。用低频轮询兜住这类
|
|
84
|
+
// 变化:只有锚点位移超过阈值才重排——布局微抖时不跟随,否则菜单会跟着
|
|
85
|
+
// 一帧帧地跳(表现为「来回跳」)。
|
|
86
|
+
const start = anchor.getBoundingClientRect();
|
|
87
|
+
let lastLeft = start.left;
|
|
88
|
+
let lastTop = start.top;
|
|
89
|
+
let raf = 0;
|
|
90
|
+
const tick = (): void => {
|
|
91
|
+
const r = anchor.getBoundingClientRect();
|
|
92
|
+
if (Math.abs(r.left - lastLeft) > SHIFT_THRESHOLD || Math.abs(r.top - lastTop) > SHIFT_THRESHOLD) {
|
|
93
|
+
lastLeft = r.left;
|
|
94
|
+
lastTop = r.top;
|
|
95
|
+
place();
|
|
96
|
+
}
|
|
97
|
+
raf = requestAnimationFrame(tick);
|
|
98
|
+
};
|
|
99
|
+
raf = requestAnimationFrame(tick);
|
|
100
|
+
|
|
59
101
|
return () => {
|
|
102
|
+
cancelAnimationFrame(raf);
|
|
60
103
|
observer?.disconnect();
|
|
61
104
|
window.removeEventListener('scroll', place, true);
|
|
62
105
|
window.removeEventListener('resize', place);
|
|
@@ -66,15 +109,23 @@ export function MenuSurface(props: MenuSurfaceProps): JSX.Element {
|
|
|
66
109
|
useEffect(() => {
|
|
67
110
|
const onDown = (event: PointerEvent): void => {
|
|
68
111
|
if (ref.current && event.target instanceof Node && ref.current.contains(event.target)) return;
|
|
112
|
+
// 落在触发器上时不要在这里关:pointerdown 先于 click,先关掉会让触发器
|
|
113
|
+
// 自己的 onClick 读到 open=false 又开回来,表现为「点一次收不起来」。
|
|
114
|
+
// 开合交给触发器的 onClick 统一 toggle。
|
|
115
|
+
if (anchor && event.target instanceof Node && anchor.contains(event.target)) return;
|
|
69
116
|
onClose();
|
|
70
117
|
};
|
|
71
118
|
window.addEventListener('pointerdown', onDown, true);
|
|
72
119
|
return () => window.removeEventListener('pointerdown', onDown, true);
|
|
73
|
-
}, [onClose]);
|
|
120
|
+
}, [anchor, onClose]);
|
|
74
121
|
|
|
75
122
|
// 未定位的首帧隐藏但仍参与布局(官方 unplaced card 同款)——place() 在
|
|
76
123
|
// layout effect 里同步测得尺寸后即落位,无闪烁。
|
|
77
|
-
|
|
124
|
+
// 必须 portal 到 body:祖先链上的 transform / filter / backdrop-filter /
|
|
125
|
+
// contain 会把 position:fixed 的包含块从视口换成该祖先,left/top 便按它的
|
|
126
|
+
// 坐标系解释,菜单整体偏移到别处(实测能偏出 400px+,看起来「飘到角落」)。
|
|
127
|
+
// 官方数据行面板同样走 createPortal(document.body)。
|
|
128
|
+
return createPortal(
|
|
78
129
|
<div
|
|
79
130
|
ref={ref}
|
|
80
131
|
className="dci-menu"
|
|
@@ -88,7 +139,8 @@ export function MenuSurface(props: MenuSurfaceProps): JSX.Element {
|
|
|
88
139
|
}}
|
|
89
140
|
>
|
|
90
141
|
<div className="dci-viewport">{children}</div>
|
|
91
|
-
</div
|
|
142
|
+
</div>,
|
|
143
|
+
document.body,
|
|
92
144
|
);
|
|
93
145
|
}
|
|
94
146
|
|
|
@@ -76,7 +76,8 @@ export function ModelMenu(props: ModelMenuProps): JSX.Element {
|
|
|
76
76
|
const [open, setOpen] = useState(false);
|
|
77
77
|
const [view, setView] = useState<MenuView>('root');
|
|
78
78
|
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
79
|
-
const [anchor, setAnchor] = useState<
|
|
79
|
+
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
|
80
|
+
const [anchorRect, setAnchorRect] = useState<{ left: number; top: number; right: number; bottom: number } | null>(null);
|
|
80
81
|
|
|
81
82
|
// 服务缺席(installServices 未注入到)时禁用;svcEpoch 轮询兜底见 CodinputSurface。
|
|
82
83
|
const directory = getSessionDirectory(sessionId);
|
|
@@ -89,23 +90,13 @@ export function ModelMenu(props: ModelMenuProps): JSX.Element {
|
|
|
89
90
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
90
91
|
}, [open]);
|
|
91
92
|
|
|
92
|
-
useEffect(() => {
|
|
93
|
-
if (!open) return;
|
|
94
|
-
const onDown = (event: PointerEvent): void => {
|
|
95
|
-
if (refContains(rootMenuRef.current, event.target)) return;
|
|
96
|
-
close();
|
|
97
|
-
};
|
|
98
|
-
window.addEventListener('pointerdown', onDown, true);
|
|
99
|
-
return () => window.removeEventListener('pointerdown', onDown, true);
|
|
100
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
101
|
-
}, [open]);
|
|
102
|
-
|
|
103
93
|
const rootMenuRef = useRef<HTMLDivElement | null>(null);
|
|
104
94
|
|
|
105
95
|
const openMenu = (): void => {
|
|
106
96
|
const el = triggerRef.current;
|
|
107
97
|
if (!el) return;
|
|
108
|
-
setAnchor(el
|
|
98
|
+
setAnchor(el);
|
|
99
|
+
setAnchorRect(el.getBoundingClientRect());
|
|
109
100
|
setView('root');
|
|
110
101
|
setOpen(true);
|
|
111
102
|
};
|
|
@@ -150,9 +141,6 @@ export function ModelMenu(props: ModelMenuProps): JSX.Element {
|
|
|
150
141
|
}
|
|
151
142
|
};
|
|
152
143
|
|
|
153
|
-
const refContains = (el: Element | null, target: EventTarget | null): boolean =>
|
|
154
|
-
!!el && target instanceof Node && el.contains(target);
|
|
155
|
-
|
|
156
144
|
return (
|
|
157
145
|
<div style={{ position: 'relative', minWidth: 0 }}>
|
|
158
146
|
<button
|
|
@@ -179,7 +167,7 @@ export function ModelMenu(props: ModelMenuProps): JSX.Element {
|
|
|
179
167
|
|
|
180
168
|
{open && anchor ? (
|
|
181
169
|
<div ref={rootMenuRef}>
|
|
182
|
-
<MenuSurface anchor={anchor} onClose={close} alignEnd={true} minWidth={240}>
|
|
170
|
+
<MenuSurface anchor={anchor} initialRect={anchorRect} onClose={close} alignEnd={true} minWidth={240}>
|
|
183
171
|
{view === 'root' ? (
|
|
184
172
|
<>
|
|
185
173
|
<button type="button" className="dci-cell" onClick={() => setView('models')}>
|
|
@@ -58,7 +58,8 @@ export function PermissionMenu(props: PermissionMenuProps): JSX.Element | null {
|
|
|
58
58
|
const [confirmation, setConfirmation] = useState<string | null>(null);
|
|
59
59
|
const [acknowledged, setAcknowledged] = useState(false);
|
|
60
60
|
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
61
|
-
const [anchor, setAnchor] = useState<
|
|
61
|
+
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
|
62
|
+
const [anchorRect, setAnchorRect] = useState<{ left: number; top: number; right: number; bottom: number } | null>(null);
|
|
62
63
|
|
|
63
64
|
// locked/无值时收起(官方 useEffect 同语义)。
|
|
64
65
|
useEffect(() => {
|
|
@@ -130,7 +131,11 @@ export function PermissionMenu(props: PermissionMenuProps): JSX.Element | null {
|
|
|
130
131
|
disabled={disabled || busy}
|
|
131
132
|
onMouseDown={(e) => e.preventDefault()}
|
|
132
133
|
onClick={() => {
|
|
133
|
-
if (!open)
|
|
134
|
+
if (!open) {
|
|
135
|
+
const el = triggerRef.current ?? null;
|
|
136
|
+
setAnchor(el);
|
|
137
|
+
setAnchorRect(el ? el.getBoundingClientRect() : null);
|
|
138
|
+
}
|
|
134
139
|
setOpen(!open);
|
|
135
140
|
}}
|
|
136
141
|
>
|
|
@@ -146,7 +151,7 @@ export function PermissionMenu(props: PermissionMenuProps): JSX.Element | null {
|
|
|
146
151
|
</button>
|
|
147
152
|
|
|
148
153
|
{open && anchor ? (
|
|
149
|
-
<MenuSurface anchor={anchor} onClose={close}>
|
|
154
|
+
<MenuSurface anchor={anchor} initialRect={anchorRect} onClose={close}>
|
|
150
155
|
{items.map((item) => {
|
|
151
156
|
const active = item.id === currentValue;
|
|
152
157
|
return (
|