dsh-codinput 0.1.1 → 0.3.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 +48 -0
- package/README.en.md +32 -26
- package/README.md +34 -28
- package/TESTING.md +10 -2
- package/assets/icon-white.svg +11 -0
- package/assets/icon.png +0 -0
- package/assets/icon.svg +11 -1
- package/assets/preview-ball-detail.png +0 -0
- package/assets/preview-float.png +0 -0
- package/assets/preview-normal.png +0 -0
- package/assets/preview-side.png +0 -0
- package/assets/preview-split.png +0 -0
- package/lib/client.js +67 -37
- package/package.json +1 -1
- package/src/client/attachments.ts +34 -0
- package/src/client/command.ts +52 -12
- package/src/client/components/AttachmentRail.tsx +20 -1
- package/src/client/components/CodinputBar.tsx +13 -35
- package/src/client/components/CodinputSurface.tsx +42 -8
- package/src/client/components/ImageLightbox.tsx +61 -0
- package/src/client/components/StatsRow.tsx +76 -43
- package/src/client/components/Toolbar.tsx +36 -42
- package/src/client/components/VoiceActivity.tsx +172 -0
- package/src/client/host-types.ts +24 -1
- package/src/client/i18n.ts +37 -2
- package/src/client/icons.tsx +33 -27
- package/src/client/services.ts +5 -0
- package/src/client/sidebar.tsx +66 -7
- package/src/client/sideinput.ts +33 -7
- package/src/client/stats.ts +156 -53
- package/src/client/styles.ts +37 -7
package/package.json
CHANGED
|
@@ -37,6 +37,40 @@ export function addFilesToDraft(
|
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 把接管面绑定为会话的文件拾取器(官方 bindFilePicker 契约:官方 InputBar
|
|
43
|
+
* 挂载时绑自己、卸载解绑;slash「添加 · 文件」行的 available =
|
|
44
|
+
* shell.canPickFiles——不绑则接管期间该行消失)。available 恒 true(接管
|
|
45
|
+
* 面挂载期间才绑着),open 打开原生文件对话框、选定即入草稿。
|
|
46
|
+
* @returns 解绑函数;shell 不可达或宿主无 bindFilePicker 时 undefined。
|
|
47
|
+
*/
|
|
48
|
+
export function bindSessionFilePicker(sessionId: string): (() => void) | undefined {
|
|
49
|
+
let picker: HTMLInputElement | null = null;
|
|
50
|
+
const shell = stash.conversation?.input.shell(sessionId) as
|
|
51
|
+
| (Record<string, unknown> & { bindFilePicker?: (p: { available(): boolean; open(): void }) => () => void })
|
|
52
|
+
| undefined;
|
|
53
|
+
if (typeof shell?.bindFilePicker !== 'function') return undefined;
|
|
54
|
+
return shell.bindFilePicker({
|
|
55
|
+
available: () => true,
|
|
56
|
+
open: () => {
|
|
57
|
+
picker?.remove();
|
|
58
|
+
const input = document.createElement('input');
|
|
59
|
+
picker = input;
|
|
60
|
+
input.type = 'file';
|
|
61
|
+
input.multiple = true;
|
|
62
|
+
input.style.display = 'none';
|
|
63
|
+
input.addEventListener('change', () => {
|
|
64
|
+
input.remove();
|
|
65
|
+
if (picker === input) picker = null;
|
|
66
|
+
addFilesToDraft(sessionId, stash.conversation?.input.shell(sessionId) ?? undefined, input.files);
|
|
67
|
+
});
|
|
68
|
+
document.body.appendChild(input);
|
|
69
|
+
input.click();
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
40
74
|
/** 拖拽中是否带着文件(dragover 阶段 dataTransfer.files 还是空的,只有 types 可读)。 */
|
|
41
75
|
export function dragHasFiles(dataTransfer: DataTransfer | null): boolean {
|
|
42
76
|
return dataTransfer !== null && Array.from(dataTransfer.types).includes('Files');
|
package/src/client/command.ts
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* /codinput
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* /codinput 命令与「添加 · 文件」补位:经官方命令通道注册的 action 型客户端
|
|
3
|
+
* 贡献。
|
|
4
|
+
*
|
|
5
|
+
* /codinput:输入回车即切换接管(进入/退出),菜单描述随当前状态变化;接管
|
|
6
|
+
* 关闭(官方输入框)时输入同样生效——贡献注册是全局生命周期。菜单拾取路径
|
|
7
|
+
* 会把 "/codinput" 插进草稿而不提交,切换后需清空当前草稿。侧栏里发起的退
|
|
8
|
+
* 出会经总线请求 body 关掉自己的标签(见 sideinput)。
|
|
9
|
+
*
|
|
6
10
|
*/
|
|
7
11
|
|
|
8
12
|
import type { ClientContext } from './host-types';
|
|
9
13
|
import { stash } from './services';
|
|
10
|
-
import { loadPrefs, savePrefs } from './prefs';
|
|
14
|
+
import { loadPrefs, savePrefs, subscribePrefs } from './prefs';
|
|
15
|
+
import { carriedSessionIds, requestSidebarTabClose } from './sideinput';
|
|
11
16
|
import { t } from './i18n';
|
|
12
17
|
import { IconCodinputCommand16 } from './icons';
|
|
13
18
|
import { diagWindow, runGuarded } from './diag';
|
|
@@ -22,6 +27,22 @@ function clearDraftOf(sessionId: string | undefined): void {
|
|
|
22
27
|
}
|
|
23
28
|
}
|
|
24
29
|
|
|
30
|
+
/**
|
|
31
|
+
* 单行搬移:把 name 的行移动到 locate 给出的目标下标(其余行原序不动)。
|
|
32
|
+
* 官方空查询走 sectionRows(「添加/指令」按使用序固定节构),贡献默认追加
|
|
33
|
+
* 在「指令」节尾——整列排序会把小节排散、节头重复渲染,只能单行搬。
|
|
34
|
+
*/
|
|
35
|
+
function moveRow<T extends { name: string }>(list: readonly T[], name: string, locate: (rows: readonly T[]) => number): T[] {
|
|
36
|
+
const at = list.findIndex((row) => row.name === name);
|
|
37
|
+
if (at === -1) return [...list];
|
|
38
|
+
const target = locate(list);
|
|
39
|
+
if (target === at || target === at + 1) return [...list];
|
|
40
|
+
const moved = [...list];
|
|
41
|
+
moved.splice(at, 1);
|
|
42
|
+
moved.splice(target > at ? target - 1 : target, 0, list[at]);
|
|
43
|
+
return moved;
|
|
44
|
+
}
|
|
45
|
+
|
|
25
46
|
export function registerCodinputCommand(ctx: ClientContext): void {
|
|
26
47
|
runGuarded('command.register', () => {
|
|
27
48
|
ctx.inject(['commandUi'], (scope: Record<string, unknown>) => {
|
|
@@ -33,24 +54,37 @@ export function registerCodinputCommand(ctx: ClientContext): void {
|
|
|
33
54
|
diagWindow().__dshCodinputShell['command'] = 'commandUi-unavailable';
|
|
34
55
|
return;
|
|
35
56
|
}
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
57
|
+
// 指令栏位置:codinput 按字典序插到「指令」节内第一个 name 大于它的行
|
|
58
|
+
// 之前(codinput < compact → 节首);file 补位插到「添加」节首(官方
|
|
59
|
+
// 使用序里 file 排该节第一——节名是本地化的,用同节已知行反查)。
|
|
39
60
|
let restoreOrder: (() => void) | undefined;
|
|
40
61
|
if (typeof commandUi.candidates === 'function') {
|
|
41
62
|
const original = commandUi.candidates.bind(commandUi);
|
|
42
63
|
commandUi.candidates = (session: unknown, req: { query: string }) =>
|
|
43
64
|
Promise.resolve(original(session, req)).then((rows) => {
|
|
44
65
|
if (req.query !== '' || !Array.isArray(rows)) return rows;
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
)
|
|
66
|
+
let list = rows as { name: string; section?: string }[];
|
|
67
|
+
const addSection = list.find((row) => row.name === 'goal' || row.name === 'plan' || row.name === 'feedback')?.section;
|
|
68
|
+
if (addSection !== undefined) {
|
|
69
|
+
list = moveRow(list, 'file', (all) => {
|
|
70
|
+
const first = all.findIndex((row) => row.section === addSection && row.name !== 'file');
|
|
71
|
+
return first === -1 ? all.length : first;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
list = moveRow(list, 'codinput', (all) => {
|
|
75
|
+
const own = all.find((row) => row.name === 'codinput');
|
|
76
|
+
const first = all.findIndex(
|
|
77
|
+
(row) => row.section === own?.section && row.name !== 'codinput' && row.name.toLowerCase() > 'codinput',
|
|
78
|
+
);
|
|
79
|
+
return first === -1 ? all.length : first;
|
|
80
|
+
});
|
|
81
|
+
return list;
|
|
48
82
|
});
|
|
49
83
|
restoreOrder = () => {
|
|
50
84
|
commandUi.candidates = original;
|
|
51
85
|
};
|
|
52
86
|
}
|
|
53
|
-
ctx.effect(() => restoreOrder, 'dsh-codinput:
|
|
87
|
+
ctx.effect(() => restoreOrder, 'dsh-codinput: slash order patch');
|
|
54
88
|
ctx.effect(
|
|
55
89
|
() =>
|
|
56
90
|
commandUi.register({
|
|
@@ -66,6 +100,12 @@ export function registerCodinputCommand(ctx: ClientContext): void {
|
|
|
66
100
|
// 卸载会清掉诊断 sessionId,先捕获再延迟清草稿。
|
|
67
101
|
const sessionId = diagWindow().__dshCodinputSessionId ?? undefined;
|
|
68
102
|
savePrefs({ enabled: !loadPrefs().enabled });
|
|
103
|
+
// 侧栏里发起的退出:关掉承载会话的 Codinput 标签(body 经
|
|
104
|
+
// tab 域 actions.close 关闭、卸载撤销承载),主输入回到官方
|
|
105
|
+
// 输入框。承载会话从总线查——诊断 sessionId 可能为 null。
|
|
106
|
+
if (!loadPrefs().enabled) {
|
|
107
|
+
for (const carried of carriedSessionIds()) requestSidebarTabClose(carried);
|
|
108
|
+
}
|
|
69
109
|
setTimeout(() => clearDraftOf(sessionId), 80);
|
|
70
110
|
},
|
|
71
111
|
},
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { useEffect, useRef, useState } from 'react';
|
|
18
|
+
import { ImageLightbox } from './ImageLightbox';
|
|
18
19
|
import type { ConversationFace, DraftAttachmentId, InputState } from '../host-types';
|
|
19
20
|
import { stash } from '../services';
|
|
20
21
|
import { useSnapshot } from './useSnapshot';
|
|
@@ -72,6 +73,8 @@ export function AttachmentRail(props: AttachmentRailProps): JSX.Element | null {
|
|
|
72
73
|
const uploads = useSnapshot(conversation?.fileUploads) as Record<string, UploadLike> | undefined;
|
|
73
74
|
const railRef = useRef<HTMLDivElement | null>(null);
|
|
74
75
|
const [overflow, setOverflow] = useState<{ left: boolean; right: boolean }>({ left: false, right: false });
|
|
76
|
+
// 原图预览(官方 ComposerAttachments 同款:缩略图点击 → lightbox)。
|
|
77
|
+
const [preview, setPreview] = useState<{ url: string; name: string } | null>(null);
|
|
75
78
|
|
|
76
79
|
const attachments =
|
|
77
80
|
state === undefined || typeof conversation?.resolveDraftAttachments !== 'function'
|
|
@@ -123,7 +126,15 @@ export function AttachmentRail(props: AttachmentRailProps): JSX.Element | null {
|
|
|
123
126
|
if (isImage) {
|
|
124
127
|
return (
|
|
125
128
|
<div className="dci-attach-item" key={att.id}>
|
|
126
|
-
<button
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
className="dci-attach-thumb"
|
|
132
|
+
title={t('image.openOriginal')}
|
|
133
|
+
aria-label={t('image.openOriginalLabel', { name: att.file.name })}
|
|
134
|
+
onClick={() => {
|
|
135
|
+
if (att.previewUrl) setPreview({ url: att.previewUrl, name: att.file.name });
|
|
136
|
+
}}
|
|
137
|
+
>
|
|
127
138
|
{att.previewUrl ? <img src={att.previewUrl} alt={att.file.name} /> : <FileGlyph size={28} />}
|
|
128
139
|
</button>
|
|
129
140
|
<button
|
|
@@ -203,6 +214,14 @@ export function AttachmentRail(props: AttachmentRailProps): JSX.Element | null {
|
|
|
203
214
|
<ArrowGlyph dir="right" />
|
|
204
215
|
</button>
|
|
205
216
|
) : null}
|
|
217
|
+
{preview ? (
|
|
218
|
+
<ImageLightbox
|
|
219
|
+
src={preview.url}
|
|
220
|
+
alt={preview.name}
|
|
221
|
+
labels={{ dialog: t('image.preview'), close: t('image.closePreview') }}
|
|
222
|
+
onClose={() => setPreview(null)}
|
|
223
|
+
/>
|
|
224
|
+
) : null}
|
|
206
225
|
</div>
|
|
207
226
|
);
|
|
208
227
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 接管条目:conversation.composer.bar(single,priority -1 遮蔽官方条目)。
|
|
3
3
|
* 卸载本条目即官方输入框原样恢复(槽位体系一等机制),草稿不丢。
|
|
4
|
-
* 另从 useChat
|
|
4
|
+
* 另从 useChat 订阅官方 legacy 节点数组(sessionStats 投影缺席时按官方
|
|
5
|
+
* deriveStats 派生轮次/decode 计时,见 stats.ts),供数据行使用。
|
|
5
6
|
*
|
|
6
7
|
* 三态单实例(任一时刻恰好一个入口,且不与官方输入框共存):
|
|
7
8
|
* - `normal`:接管卡片在原输入位;
|
|
@@ -15,7 +16,7 @@
|
|
|
15
16
|
* 高于持久化的 normal/float,因此不可能出现 side+float 并存或双实例。
|
|
16
17
|
*/
|
|
17
18
|
|
|
18
|
-
import { useEffect, useLayoutEffect,
|
|
19
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
19
20
|
import { createPortal } from 'react-dom';
|
|
20
21
|
import type { SessionMaybeStandard, SlotRegisterOptions } from '../host-types';
|
|
21
22
|
import { loadPrefs, savePrefs, subscribePrefs, type CodinputPrefs } from '../prefs';
|
|
@@ -34,39 +35,14 @@ interface BarOwner {
|
|
|
34
35
|
/** 槽位作用域里的 chat 会话钩子(ui-session chat 源贡献)。 */
|
|
35
36
|
type BarProps = BarOwner & SessionMaybeStandard & { useChat?: <S>(sel: (s: unknown) => S) => S };
|
|
36
37
|
|
|
37
|
-
export interface ChatTimings {
|
|
38
|
-
/** 模型用时:全部步骤时长之和(毫秒)。 */
|
|
39
|
-
modelMs?: number;
|
|
40
|
-
/** 首 token 平均(TTFT):以首步骤时长近似(毫秒)。 */
|
|
41
|
-
ttftMs?: number;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** 从 chat 树的节点缓存解析步骤计时(location.step.start/end)。 */
|
|
45
|
-
function computeTimings(valuesCache: unknown): ChatTimings {
|
|
46
|
-
if (!Array.isArray(valuesCache)) return {};
|
|
47
|
-
let modelMs = 0;
|
|
48
|
-
let ttftSum = 0;
|
|
49
|
-
let ttftCount = 0;
|
|
50
|
-
for (const node of valuesCache) {
|
|
51
|
-
const n = node as { kind?: string; location?: { step?: { start?: { time?: number }; end?: { time?: number } } } };
|
|
52
|
-
if (n?.kind !== 'assistant-step') continue;
|
|
53
|
-
const start = n.location?.step?.start?.time;
|
|
54
|
-
const end = n.location?.step?.end?.time;
|
|
55
|
-
if (start !== undefined && end !== undefined && end > start) {
|
|
56
|
-
modelMs += end - start;
|
|
57
|
-
ttftSum += end - start;
|
|
58
|
-
ttftCount += 1;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
return { modelMs: modelMs > 0 ? modelMs : undefined, ttftMs: ttftCount > 0 ? ttftSum / ttftCount : undefined };
|
|
62
|
-
}
|
|
63
|
-
|
|
64
38
|
export function CodinputBar(props: BarProps): JSX.Element | null {
|
|
65
|
-
const { variant, blocked, disabled, placeholder, sessionId, useInput, inputActions, useProjection, useChat } = props;
|
|
39
|
+
const { variant, blocked, disabled, placeholder, sessionId, useInput, inputActions, useProjection, useChat, useSession } = props;
|
|
66
40
|
const state = useInput((s) => s);
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
const
|
|
41
|
+
// 官方 StatsPills 同款数据源:chat store 的 legacy 节点数组(选择器只取
|
|
42
|
+
// 数组引用,派生换算在 stats.ts 的 useSessionStats 里做)。
|
|
43
|
+
const chatNodes = useChat ? useChat((s: unknown) => (s as { legacy?: { nodes?: unknown } })?.legacy?.nodes) : undefined;
|
|
44
|
+
// 会话运行中(官方 useSession s.running):空草稿时主按钮变停止。
|
|
45
|
+
const running = useSession ? useSession((s: unknown) => (s as { running?: boolean } | undefined)?.running) === true : false;
|
|
70
46
|
const sideCarried = useSidebarInputCarried(sessionId);
|
|
71
47
|
const [mode, setMode] = useState<CodinputPrefs['mode']>(() => loadPrefs().mode);
|
|
72
48
|
useEffect(() => subscribePrefs(() => setMode(loadPrefs().mode)), []);
|
|
@@ -81,9 +57,11 @@ export function CodinputBar(props: BarProps): JSX.Element | null {
|
|
|
81
57
|
actions={inputActions}
|
|
82
58
|
useProjection={useProjection}
|
|
83
59
|
placeholder={placeholder}
|
|
84
|
-
disabled={disabled === true
|
|
60
|
+
disabled={disabled === true}
|
|
61
|
+
blocked={blocked !== undefined}
|
|
62
|
+
running={running}
|
|
85
63
|
hero={variant === 'hero'}
|
|
86
|
-
|
|
64
|
+
chatNodes={chatNodes}
|
|
87
65
|
float={floating}
|
|
88
66
|
onDragZonePointerDown={beginFloatDrag}
|
|
89
67
|
/>
|
|
@@ -17,7 +17,7 @@ import type {
|
|
|
17
17
|
} from '../host-types';
|
|
18
18
|
import { stash } from '../services';
|
|
19
19
|
import { loadPrefs, savePrefs } from '../prefs';
|
|
20
|
-
import { addFilesToDraft, dragHasFiles } from '../attachments';
|
|
20
|
+
import { addFilesToDraft, bindSessionFilePicker, dragHasFiles } from '../attachments';
|
|
21
21
|
import { diagError, diagSession, diagWindow } from '../diag';
|
|
22
22
|
import { IconEdit, IconEye } from '../icons';
|
|
23
23
|
import { t, useT } from '../i18n';
|
|
@@ -37,10 +37,15 @@ export interface CodinputSurfaceProps {
|
|
|
37
37
|
/** 宿主 bar owner props 透传:占位文案与禁用。 */
|
|
38
38
|
placeholder?: string;
|
|
39
39
|
disabled?: boolean;
|
|
40
|
+
/** 会话级阻塞(宿主 blocked):编辑器与发送锁死,但停止钮仍可点(官方同款)。 */
|
|
41
|
+
blocked?: boolean;
|
|
42
|
+
/** 会话运行中(useSession s.running):空草稿时主按钮变停止(官方同款)。 */
|
|
43
|
+
running?: boolean;
|
|
40
44
|
/** 官方 bar variant:hero(新会话,无对话记录)不渲染数据行。 */
|
|
41
45
|
hero?: boolean;
|
|
42
46
|
/** chat 树解析的轮次计时(模型用时 / TTFT),数据行详情面板用。 */
|
|
43
|
-
|
|
47
|
+
/** chat legacy 节点数组(sessionStats 投影缺席时派生轮次/decode 计时)。 */
|
|
48
|
+
chatNodes?: unknown;
|
|
44
49
|
/** 侧栏形态(视觉略紧凑)。 */
|
|
45
50
|
side?: boolean;
|
|
46
51
|
/** 悬浮形态(卡片在 FloatingShell 内 portal 渲染;拖动由 CodinputBar 的会话管理)。 */
|
|
@@ -56,7 +61,7 @@ const DOMPurify = ((DOMPurifyModule as unknown as { default?: unknown }).default
|
|
|
56
61
|
DOMPurifyModule) as unknown as { sanitize(html: string): string };
|
|
57
62
|
|
|
58
63
|
export function CodinputSurface(props: CodinputSurfaceProps): JSX.Element {
|
|
59
|
-
const { sessionId, state, actions, useProjection, placeholder, disabled, side, hero,
|
|
64
|
+
const { sessionId, state, actions, useProjection, placeholder, disabled, blocked, running, side, hero, chatNodes, float, onDragZonePointerDown } = props;
|
|
60
65
|
// 语言切换时整棵表面重渲染(文案都走 i18n 的 t())。
|
|
61
66
|
useT();
|
|
62
67
|
|
|
@@ -157,6 +162,14 @@ export function CodinputSurface(props: CodinputSurfaceProps): JSX.Element {
|
|
|
157
162
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
158
163
|
}, [sessionId, svcEpoch]);
|
|
159
164
|
|
|
165
|
+
// 文件拾取绑定:官方 InputBar 挂载时把 shell 的 filePicker 绑给自己,卸载
|
|
166
|
+
// 解绑——slash「添加 · 文件」行 available = shell.canPickFiles,接管期间不
|
|
167
|
+
// 绑该行就消失。接管面挂载即绑、卸载即解,open 走本模块的附件入草稿链路。
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
if (!sessionId) return;
|
|
170
|
+
return bindSessionFilePicker(sessionId);
|
|
171
|
+
}, [sessionId]);
|
|
172
|
+
|
|
160
173
|
// popup 拾取后的 composer 焦点归还(官方 overlay wiring 的等价绑定)。
|
|
161
174
|
// alpha 的 commandUi 未提供 bindComposerFocus——按需可用。
|
|
162
175
|
useEffect(() => {
|
|
@@ -187,6 +200,21 @@ export function CodinputSurface(props: CodinputSurfaceProps): JSX.Element {
|
|
|
187
200
|
caretRef.current = caret;
|
|
188
201
|
};
|
|
189
202
|
|
|
203
|
+
// 官方 stop 同链路:sessions.scope(sid).get('conversation').cancel()。
|
|
204
|
+
const stopSession = (): void => {
|
|
205
|
+
if (!sessionId) return;
|
|
206
|
+
try {
|
|
207
|
+
const scope = stash.sessions?.scope(sessionId) as
|
|
208
|
+
| { get?: (name: string) => { cancel?: () => Promise<unknown> } }
|
|
209
|
+
| undefined;
|
|
210
|
+
const conversation = scope?.get?.('conversation');
|
|
211
|
+
void conversation?.cancel?.()?.catch?.(() => {});
|
|
212
|
+
} catch {
|
|
213
|
+
/* 会话作用域缺席:静默(下一拍 running 投影自会收敛) */
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
const onStop = stopSession;
|
|
217
|
+
|
|
190
218
|
const sendGesture = (): void => {
|
|
191
219
|
if (!actions) return;
|
|
192
220
|
const st = state;
|
|
@@ -200,10 +228,14 @@ export function CodinputSurface(props: CodinputSurfaceProps): JSX.Element {
|
|
|
200
228
|
};
|
|
201
229
|
|
|
202
230
|
// 官方同语义:草稿与附件皆空时发送禁用(空草稿手势仍可经键盘 Steer)。
|
|
231
|
+
const inert = disabled === true || blocked === true;
|
|
203
232
|
const draftEmpty = (state?.draft ?? '').trim() === '';
|
|
204
233
|
const noAttachments = (state?.attachmentIds.length ?? 0) === 0;
|
|
234
|
+
// 官方 primaryStops:运行中且草稿与附件皆空 → 主按钮变停止;停止形态不随
|
|
235
|
+
// 禁用锁死(官方 primaryDisabled 在 stops 分支只看 stop 是否可用)。
|
|
236
|
+
const primaryStops = running === true && draftEmpty && noAttachments;
|
|
205
237
|
const sendDisabled =
|
|
206
|
-
|
|
238
|
+
inert ||
|
|
207
239
|
actions === undefined ||
|
|
208
240
|
(state !== undefined && state.phase === 'submitting') ||
|
|
209
241
|
(draftEmpty && noAttachments);
|
|
@@ -313,7 +345,7 @@ export function CodinputSurface(props: CodinputSurfaceProps): JSX.Element {
|
|
|
313
345
|
placeholder={placeholderText}
|
|
314
346
|
viewRef={viewRef}
|
|
315
347
|
onCaretMoved={caretMoved}
|
|
316
|
-
disabled={
|
|
348
|
+
disabled={inert}
|
|
317
349
|
autoFocus={true}
|
|
318
350
|
solo={!previewOn}
|
|
319
351
|
stateKey={sessionId ?? 'hero'}
|
|
@@ -333,7 +365,9 @@ export function CodinputSurface(props: CodinputSurfaceProps): JSX.Element {
|
|
|
333
365
|
triggers={triggers}
|
|
334
366
|
caretRef={caretRef}
|
|
335
367
|
useProjection={useProjection}
|
|
336
|
-
disabled={
|
|
368
|
+
disabled={inert}
|
|
369
|
+
primaryStops={primaryStops}
|
|
370
|
+
onStop={onStop}
|
|
337
371
|
onNotify={notifyFor(sessionId)}
|
|
338
372
|
onSend={sendGesture}
|
|
339
373
|
sendDisabled={sendDisabled}
|
|
@@ -341,7 +375,7 @@ export function CodinputSurface(props: CodinputSurfaceProps): JSX.Element {
|
|
|
341
375
|
|
|
342
376
|
{/* 数据行:悬浮形态收进卡片(工具行下方,随卡片圆角一体)。 */}
|
|
343
377
|
{float && hero !== true ? (
|
|
344
|
-
<StatsRow useProjection={useProjection} sessionId={sessionId}
|
|
378
|
+
<StatsRow useProjection={useProjection} sessionId={sessionId} chatNodes={chatNodes} />
|
|
345
379
|
) : null}
|
|
346
380
|
|
|
347
381
|
{/* 官方 MenuView 锚定:主模式以卡片顶上方弹出;侧栏/悬浮形态由
|
|
@@ -351,7 +385,7 @@ export function CodinputSurface(props: CodinputSurfaceProps): JSX.Element {
|
|
|
351
385
|
|
|
352
386
|
{/* 数据行:主输入/侧栏渲染在卡片下方(官方 hero 新会话态无 stats)。 */}
|
|
353
387
|
{!float && hero !== true ? (
|
|
354
|
-
<StatsRow useProjection={useProjection} sessionId={sessionId}
|
|
388
|
+
<StatsRow useProjection={useProjection} sessionId={sessionId} chatNodes={chatNodes} />
|
|
355
389
|
) : null}
|
|
356
390
|
|
|
357
391
|
{popup ? <PopupSelectView popup={popup} /> : null}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 原图预览(官方 primitives ImageLightbox 逐值复刻):点击附件图片缩略图
|
|
3
|
+
* 打开;Esc、背板按下或右上角关闭钮关闭;挂载聚焦关闭钮、Tab 圈住焦点、
|
|
4
|
+
* 卸载还原 opener 焦点。经 body portal 渲染——opener 处于 transform/filter
|
|
5
|
+
* 祖先内时固定背板会被困在该祖先盒里,盖不满视口(官方同理由)。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useEffect, useRef, type JSX } from 'react';
|
|
9
|
+
import { createPortal } from 'react-dom';
|
|
10
|
+
|
|
11
|
+
export interface ImageLightboxLabels {
|
|
12
|
+
/** 预览对话框的可访问名。 */
|
|
13
|
+
dialog: string;
|
|
14
|
+
/** 关闭控件的可访问名。 */
|
|
15
|
+
close: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 官方 IconCloseOutlineRegular(1px 描边 ×,16 视框,MIT,见 LICENSE 归属段)。 */
|
|
19
|
+
function CloseOutlineGlyph({ size = 16 }: { size?: number }): JSX.Element {
|
|
20
|
+
return (
|
|
21
|
+
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true" strokeWidth={1}>
|
|
22
|
+
<path d="M2.5 2.5L13.5 13.5" stroke="currentColor" />
|
|
23
|
+
<path d="M13.5 2.5L2.5 13.5" stroke="currentColor" />
|
|
24
|
+
</svg>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function ImageLightbox(props: { src: string; alt: string; labels: ImageLightboxLabels; onClose(): void }): JSX.Element {
|
|
29
|
+
const { src, alt, labels, onClose } = props;
|
|
30
|
+
const closeRef = useRef<HTMLButtonElement | null>(null);
|
|
31
|
+
const restoreRef = useRef<HTMLElement | null>(null);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
restoreRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
34
|
+
closeRef.current?.focus();
|
|
35
|
+
const onKeyDown = (event: KeyboardEvent): void => {
|
|
36
|
+
if (event.key === 'Escape') {
|
|
37
|
+
event.stopPropagation();
|
|
38
|
+
onClose();
|
|
39
|
+
}
|
|
40
|
+
if (event.key === 'Tab') {
|
|
41
|
+
event.preventDefault();
|
|
42
|
+
closeRef.current?.focus();
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
window.addEventListener('keydown', onKeyDown, true);
|
|
46
|
+
return () => {
|
|
47
|
+
window.removeEventListener('keydown', onKeyDown, true);
|
|
48
|
+
restoreRef.current?.focus();
|
|
49
|
+
};
|
|
50
|
+
}, [onClose]);
|
|
51
|
+
return createPortal(
|
|
52
|
+
<div className="dci-lightbox" role="dialog" aria-modal="true" aria-label={labels.dialog}>
|
|
53
|
+
<div className="dci-lightbox-mask" aria-hidden="true" onMouseDown={onClose} />
|
|
54
|
+
<img className="dci-lightbox-image" src={src} alt={alt} />
|
|
55
|
+
<button ref={closeRef} type="button" className="dci-lightbox-close" aria-label={labels.close} onClick={onClose}>
|
|
56
|
+
<CloseOutlineGlyph />
|
|
57
|
+
</button>
|
|
58
|
+
</div>,
|
|
59
|
+
document.body,
|
|
60
|
+
);
|
|
61
|
+
}
|