dsh-codinput 0.3.0 → 0.3.2
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 +40 -0
- package/NOTICE +19 -0
- package/README.en.md +6 -12
- package/README.md +6 -12
- package/assets/icon.png +0 -0
- package/assets/icon.svg +1 -1
- package/assets/preview-float.webp +0 -0
- package/assets/preview-normal.webp +0 -0
- package/assets/preview-side.webp +0 -0
- package/assets/preview-split.webp +0 -0
- package/lib/client.js +44 -35
- package/package.json +1 -1
- package/src/client/components/CodinputSurface.tsx +31 -1
- package/src/client/components/ModelLabel.tsx +57 -0
- package/src/client/components/ModelMenu.tsx +90 -30
- package/src/client/components/VoiceActivity.tsx +5 -62
- package/src/client/components/slotEntry.tsx +75 -0
- package/src/client/host-types.ts +2 -0
- package/src/client/icons.tsx +11 -2
- package/src/client/index.tsx +6 -1
- package/src/client/modeldir.ts +2 -0
- package/src/client/sidebar.tsx +36 -3
- package/src/client/styles.ts +13 -4
- package/assets/en/preview-ball-detail.png +0 -0
- package/assets/en/preview-float.png +0 -0
- package/assets/en/preview-normal.png +0 -0
- package/assets/en/preview-side.png +0 -0
- package/assets/en/preview-split.png +0 -0
- 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/package.json
CHANGED
|
@@ -73,6 +73,7 @@ export function CodinputSurface(props: CodinputSurfaceProps): JSX.Element {
|
|
|
73
73
|
const cardRef = useRef<HTMLDivElement | null>(null);
|
|
74
74
|
const viewRef = useRef<EditorView | null>(null);
|
|
75
75
|
const caretRef = useRef<number | null>(null);
|
|
76
|
+
const previewRef = useRef<HTMLDivElement | null>(null);
|
|
76
77
|
|
|
77
78
|
// 高度调节:仅顶边把手,1:1 跟手(顶边上移多少、编辑区就高多少),松手持久化。
|
|
78
79
|
// 宽度不做自有调节——沿用官方聊天宽度调节器(wSkVaW_widthHandle):
|
|
@@ -245,6 +246,34 @@ export function CodinputSurface(props: CodinputSurfaceProps): JSX.Element {
|
|
|
245
246
|
[state?.draft, previewOn],
|
|
246
247
|
);
|
|
247
248
|
|
|
249
|
+
// ---- 分屏同步滚动:两侧按滚动比例互随(内容高度不同,比例映射) ----
|
|
250
|
+
// scroll 不冒泡但走捕获相位,挂在 body 上的 onScrollCapture 能收到两栏的
|
|
251
|
+
// 滚动,且 CM 视图随会话重建(scrollDOM 换新)也无需重挂监听;epsilon 防
|
|
252
|
+
// 两侧事件互馈成回环。预览栏不显示滚动条(编辑栏为主),滚轮仍可反向带动。
|
|
253
|
+
const syncSplitScroll = (event: React.SyntheticEvent<HTMLDivElement>): void => {
|
|
254
|
+
if (!editOn || !previewOn) return;
|
|
255
|
+
const src = event.target as HTMLElement;
|
|
256
|
+
const editorScroll = viewRef.current?.scrollDOM;
|
|
257
|
+
const previewScroll = previewRef.current;
|
|
258
|
+
if (!editorScroll || !previewScroll) return;
|
|
259
|
+
const dst = src === editorScroll ? previewScroll : src === previewScroll ? editorScroll : null;
|
|
260
|
+
if (!dst) return;
|
|
261
|
+
const srcMax = src.scrollHeight - src.clientHeight;
|
|
262
|
+
if (srcMax <= 0) return;
|
|
263
|
+
const top = (src.scrollTop / srcMax) * (dst.scrollHeight - dst.clientHeight);
|
|
264
|
+
if (Math.abs(dst.scrollTop - top) > 1) dst.scrollTop = top;
|
|
265
|
+
};
|
|
266
|
+
// 进入分屏 / 草稿更新 / 切会话后,预览按编辑器当前比例对齐。
|
|
267
|
+
useEffect(() => {
|
|
268
|
+
if (!editOn || !previewOn) return;
|
|
269
|
+
const editorScroll = viewRef.current?.scrollDOM;
|
|
270
|
+
const previewScroll = previewRef.current;
|
|
271
|
+
if (!editorScroll || !previewScroll) return;
|
|
272
|
+
const editorMax = editorScroll.scrollHeight - editorScroll.clientHeight;
|
|
273
|
+
if (editorMax <= 0) return;
|
|
274
|
+
previewScroll.scrollTop = (editorScroll.scrollTop / editorMax) * (previewScroll.scrollHeight - previewScroll.clientHeight);
|
|
275
|
+
}, [editOn, previewOn, html, sessionId]);
|
|
276
|
+
|
|
248
277
|
const toggleEdit = (): void => {
|
|
249
278
|
if (!editOn && previewOn) setEditOn(true);
|
|
250
279
|
else if (editOn && previewOn) setEditOn(false);
|
|
@@ -329,6 +358,7 @@ export function CodinputSurface(props: CodinputSurfaceProps): JSX.Element {
|
|
|
329
358
|
<div
|
|
330
359
|
className="dci-body"
|
|
331
360
|
data-split={editOn && previewOn}
|
|
361
|
+
onScrollCapture={syncSplitScroll}
|
|
332
362
|
style={
|
|
333
363
|
!side && !float && bodyHeight !== null
|
|
334
364
|
? ({ '--dci-body-h': `${bodyHeight}px`, '--dci-body-max': `${bodyHeight}px` } as React.CSSProperties)
|
|
@@ -352,7 +382,7 @@ export function CodinputSurface(props: CodinputSurfaceProps): JSX.Element {
|
|
|
352
382
|
/>
|
|
353
383
|
) : null}
|
|
354
384
|
{previewOn ? (
|
|
355
|
-
<div className="dci-preview" data-solo={!editOn}>
|
|
385
|
+
<div className="dci-preview" data-solo={!editOn} ref={previewRef}>
|
|
356
386
|
{html ? <div dangerouslySetInnerHTML={{ __html: html }} /> : <span style={{ opacity: 0.4 }}>{t("preview.empty")}</span>}
|
|
357
387
|
</div>
|
|
358
388
|
) : null}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 模型名标签:渲染官方 model-selection 在 `conversation.input.model.label`
|
|
3
|
+
* 子槽位(0.2.1 起)的条目组件本体——桌面版宿主挂载
|
|
4
|
+
* dsh-client-ui-deepseek-model-label-1017 后,官方给 deepseek-flash 的
|
|
5
|
+
* 目录名「DeepSeek-V4.1-Flash」后面追加一枚 info 色发布徽章「1017」
|
|
6
|
+
* (Tag 原语,官方插件的样式注入自带)。web 宿主(0.1.x)没有该插件、
|
|
7
|
+
* 槽位键也未声明,条目为空 → 回退纯名,与官方两个宿主的表现各自一致。
|
|
8
|
+
*
|
|
9
|
+
* 渲染公式复刻官方 ModelSelect.renderModelLabel:外包
|
|
10
|
+
* span[data-provider-id][data-model-id],条目 props = {provider, model,
|
|
11
|
+
* placement, selected, t = locale.bind(entry.locale)};无条目或条目崩溃
|
|
12
|
+
* 一律回退 fallback(官方 renderSlot 的 fallback 同参)。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createElement, useMemo } from 'react';
|
|
16
|
+
import { EntryBoundary, firstComponentEntry, translatorFor, useSlotEntries } from './slotEntry';
|
|
17
|
+
|
|
18
|
+
const LABEL_SLOT = 'conversation.input.model.label';
|
|
19
|
+
|
|
20
|
+
export type ModelLabelPlacement = 'trigger' | 'menu' | 'option';
|
|
21
|
+
|
|
22
|
+
export interface ModelLabelProps {
|
|
23
|
+
provider: { id: string; name: string };
|
|
24
|
+
model: { id: string; name: string };
|
|
25
|
+
placement: ModelLabelPlacement;
|
|
26
|
+
selected: boolean;
|
|
27
|
+
/** 无官方条目 / 条目崩溃时的回退显示(通常是目录名)。 */
|
|
28
|
+
fallback: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function ModelLabel(props: ModelLabelProps): JSX.Element {
|
|
32
|
+
const { provider, model, placement, selected, fallback } = props;
|
|
33
|
+
const entries = useSlotEntries(LABEL_SLOT);
|
|
34
|
+
const entry = useMemo(() => firstComponentEntry(entries), [entries]);
|
|
35
|
+
// 徽章文案在官方插件自己的词典里(ns = deepseek-model-label-1017);
|
|
36
|
+
// 条目没带 locale 就没有可用的 t,按无条目回退。
|
|
37
|
+
const translator = useMemo(
|
|
38
|
+
() => (entry && entry.locale ? translatorFor(entry) : null),
|
|
39
|
+
[entry],
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
let label: React.ReactNode = fallback;
|
|
43
|
+
if (entry && translator) {
|
|
44
|
+
label = createElement(entry.component as React.ComponentType<Record<string, unknown>>, {
|
|
45
|
+
provider,
|
|
46
|
+
model,
|
|
47
|
+
placement,
|
|
48
|
+
selected,
|
|
49
|
+
t: translator,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return (
|
|
53
|
+
<span data-provider-id={provider.id} data-model-id={model.id}>
|
|
54
|
+
<EntryBoundary fallback={fallback}>{label}</EntryBoundary>
|
|
55
|
+
</span>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 模型与思考强度菜单:官方
|
|
2
|
+
* 模型与思考强度菜单:官方 ModelSelect 座位(conversation.input.model 槽位)
|
|
3
|
+
* 的触发器与两级 cell 菜单复刻。
|
|
3
4
|
* 数据来自官方 per-session 目录(ctx.modelDirectories.directoryFor(id).store:
|
|
4
5
|
* { current, groups, status, error }),写入走官方 select(selectModel RPC)。
|
|
5
6
|
* 服务缺席时触发器禁用。
|
|
7
|
+
*
|
|
8
|
+
* 0.2.1 同步:模型名的显示名经官方 `conversation.input.model.label` 子槽位
|
|
9
|
+
* 渲染(ModelLabel)——桌面版宿主的 deepseek-model-label-1017 插件会给
|
|
10
|
+
* deepseek-flash 追加「1017」发布徽章,web 宿主无条目回退纯名;思考强度
|
|
11
|
+
* 显示对齐官方公式(selection 未显式指定时回落模型 defaultEffort,模型无
|
|
12
|
+
* reasoning 元数据时显示会话 retainedEffort,均无才显示 Default),推理
|
|
13
|
+
* 等级菜单在模型未声明默认强度时补官方的 Default(跟随提供方默认)行。
|
|
6
14
|
*/
|
|
7
15
|
|
|
8
16
|
import { useEffect, useRef, useState } from 'react';
|
|
9
|
-
import type {
|
|
17
|
+
import type { ModelCatalogModel, ModelProviderGroup, ModelSelection } from '../host-types';
|
|
10
18
|
import { getSessionDirectory, type DirectorySnapshot } from '../modeldir';
|
|
11
19
|
import { stash } from '../services';
|
|
12
20
|
import { IconChevronOfficial } from '../icons';
|
|
21
|
+
import { ModelLabel } from './ModelLabel';
|
|
13
22
|
import { MenuCheck, MenuSurface } from './MenuSurface';
|
|
14
23
|
import { useSnapshot } from './useSnapshot';
|
|
15
24
|
import { t } from '../i18n';
|
|
@@ -41,13 +50,24 @@ function modelLabel(selection: ModelSelection | null, groups: readonly ModelProv
|
|
|
41
50
|
return findCurrent(selection, groups)?.model.name ?? selection.model;
|
|
42
51
|
}
|
|
43
52
|
|
|
44
|
-
/**
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
53
|
+
/**
|
|
54
|
+
* 思考强度显示(官方 ModelSelect 的 effortLabel 公式):目录里找不到当前
|
|
55
|
+
* 模型 → 无强度段;模型无 reasoning 元数据 → 会话 retainedEffort(官方
|
|
56
|
+
* 已折成显示名);selection 未显式指定 → 模型 defaultEffort 的名字;再
|
|
57
|
+
* 无才显示 Default(effort.providerDefault,官方中英同为「Default」)。
|
|
58
|
+
*/
|
|
59
|
+
function effortLabel(
|
|
60
|
+
selection: ModelSelection | null,
|
|
61
|
+
groups: readonly ModelProviderGroup[],
|
|
62
|
+
snapshot: DirectorySnapshot | undefined,
|
|
63
|
+
): string | undefined {
|
|
64
|
+
const found = findCurrent(selection, groups);
|
|
65
|
+
if (!found) return undefined;
|
|
66
|
+
const reasoning = found.model.reasoning;
|
|
67
|
+
if (!reasoning) return snapshot?.retainedEffort;
|
|
68
|
+
const effective = selection?.reasoningEffort ?? reasoning.defaultEffort;
|
|
69
|
+
if (effective === undefined) return 'Default';
|
|
70
|
+
return reasoning.efforts.find((level) => level.id === effective)?.name ?? effective;
|
|
51
71
|
}
|
|
52
72
|
|
|
53
73
|
export function ModelMenu(props: ModelMenuProps): JSX.Element {
|
|
@@ -97,8 +117,20 @@ export function ModelMenu(props: ModelMenuProps): JSX.Element {
|
|
|
97
117
|
|
|
98
118
|
const current = snap?.current ?? null;
|
|
99
119
|
const groups = snap?.groups ?? [];
|
|
120
|
+
const found = findCurrent(current, groups);
|
|
100
121
|
const currentModelName = modelLabel(current, groups);
|
|
101
|
-
const currentEffort = effortLabel(current, groups);
|
|
122
|
+
const currentEffort = effortLabel(current, groups, snap);
|
|
123
|
+
const triggerTitle = currentEffort === undefined ? currentModelName : `${currentModelName} · ${currentEffort}`;
|
|
124
|
+
/** 官方 renderModelLabel:触发器/根菜单 cell 位置的当前模型显示名。 */
|
|
125
|
+
const renderCurrentName = (placement: 'trigger' | 'menu', target: NonNullable<typeof found>): JSX.Element => (
|
|
126
|
+
<ModelLabel
|
|
127
|
+
provider={{ id: target.group.id, name: target.group.name }}
|
|
128
|
+
model={target.model}
|
|
129
|
+
placement={placement}
|
|
130
|
+
selected={true}
|
|
131
|
+
fallback={currentModelName}
|
|
132
|
+
/>
|
|
133
|
+
);
|
|
102
134
|
|
|
103
135
|
const pick = async (group: ModelProviderGroup, model: ModelCatalogModel, effortId: string | undefined): Promise<void> => {
|
|
104
136
|
if (!directory || busy) return;
|
|
@@ -127,15 +159,18 @@ export function ModelMenu(props: ModelMenuProps): JSX.Element {
|
|
|
127
159
|
ref={triggerRef}
|
|
128
160
|
type="button"
|
|
129
161
|
className="dci-trigger"
|
|
162
|
+
data-model=""
|
|
130
163
|
style={{ maxWidth: 'min(360px, 45cqw)' }}
|
|
131
164
|
data-open={open}
|
|
132
165
|
onMouseDown={(e) => e.preventDefault()}
|
|
133
166
|
disabled={!directory}
|
|
134
167
|
onClick={() => (open ? close() : openMenu())}
|
|
135
|
-
aria-
|
|
136
|
-
|
|
168
|
+
aria-haspopup="menu"
|
|
169
|
+
aria-expanded={open}
|
|
170
|
+
aria-label={t("tool.model", { name: triggerTitle })}
|
|
171
|
+
title={t("tool.model", { name: triggerTitle })}
|
|
137
172
|
>
|
|
138
|
-
<span className="dci-triggerLabel">{currentModelName}</span>
|
|
173
|
+
<span className="dci-triggerLabel">{found ? renderCurrentName('trigger', found) : currentModelName}</span>
|
|
139
174
|
{currentEffort ? <span className="dci-triggerEffort">{currentEffort}</span> : null}
|
|
140
175
|
<span className="dci-chev" data-open={open}>
|
|
141
176
|
<IconChevronOfficial size={14} />
|
|
@@ -149,18 +184,22 @@ export function ModelMenu(props: ModelMenuProps): JSX.Element {
|
|
|
149
184
|
<>
|
|
150
185
|
<button type="button" className="dci-cell" onClick={() => setView('models')}>
|
|
151
186
|
<span className="dci-cellLabel">{t("menu.model")}</span>
|
|
152
|
-
<span className="dci-cellValue">
|
|
153
|
-
|
|
154
|
-
<IconChevronOfficial size={14} />
|
|
187
|
+
<span className="dci-cellValue">
|
|
188
|
+
{found ? renderCurrentName('menu', found) : currentModelName}
|
|
155
189
|
</span>
|
|
156
|
-
</button>
|
|
157
|
-
<button type="button" className="dci-cell" onClick={() => setView('efforts')}>
|
|
158
|
-
<span className="dci-cellLabel">{t("menu.reasoning")}</span>
|
|
159
|
-
<span className="dci-cellValue">{currentEffort}</span>
|
|
160
190
|
<span className="dci-cellChevron" style={{ transform: 'rotate(-90deg)' }} aria-hidden="true">
|
|
161
191
|
<IconChevronOfficial size={14} />
|
|
162
192
|
</span>
|
|
163
193
|
</button>
|
|
194
|
+
{found?.model.reasoning ? (
|
|
195
|
+
<button type="button" className="dci-cell" onClick={() => setView('efforts')}>
|
|
196
|
+
<span className="dci-cellLabel">{t("menu.reasoning")}</span>
|
|
197
|
+
<span className="dci-cellValue">{currentEffort}</span>
|
|
198
|
+
<span className="dci-cellChevron" style={{ transform: 'rotate(-90deg)' }} aria-hidden="true">
|
|
199
|
+
<IconChevronOfficial size={14} />
|
|
200
|
+
</span>
|
|
201
|
+
</button>
|
|
202
|
+
) : null}
|
|
164
203
|
</>
|
|
165
204
|
) : null}
|
|
166
205
|
|
|
@@ -181,7 +220,15 @@ export function ModelMenu(props: ModelMenuProps): JSX.Element {
|
|
|
181
220
|
title={model.description}
|
|
182
221
|
onClick={() => void pick(group, model, model.reasoning?.defaultEffort)}
|
|
183
222
|
>
|
|
184
|
-
<span className="dci-itemLabel">
|
|
223
|
+
<span className="dci-itemLabel">
|
|
224
|
+
<ModelLabel
|
|
225
|
+
provider={{ id: group.id, name: group.name }}
|
|
226
|
+
model={model}
|
|
227
|
+
placement="option"
|
|
228
|
+
selected={active}
|
|
229
|
+
fallback={model.name}
|
|
230
|
+
/>
|
|
231
|
+
</span>
|
|
185
232
|
{active ? (
|
|
186
233
|
<span className="dci-check" aria-hidden="true">
|
|
187
234
|
<MenuCheck size={16} />
|
|
@@ -200,21 +247,34 @@ export function ModelMenu(props: ModelMenuProps): JSX.Element {
|
|
|
200
247
|
<>
|
|
201
248
|
<div className="dci-menuLabel">{t("menu.reasoning")}</div>
|
|
202
249
|
{(() => {
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
250
|
+
const reasoning = found?.model.reasoning;
|
|
251
|
+
if (!reasoning) return <div className="dci-empty">{t("menu.reasoningNone")}</div>;
|
|
252
|
+
const effectiveEffort = current?.reasoningEffort ?? reasoning.defaultEffort;
|
|
253
|
+
// 官方 effortChoices:模型未声明默认强度时补一行 Default
|
|
254
|
+
// (写回时省略 reasoningEffort = 跟随提供方默认)。
|
|
255
|
+
const rows: { key: string; effort: string | undefined; label: string }[] = [
|
|
256
|
+
...(reasoning.defaultEffort === undefined
|
|
257
|
+
? [{ key: 'provider-default', effort: undefined as string | undefined, label: 'Default' }]
|
|
258
|
+
: []),
|
|
259
|
+
...reasoning.efforts.map((eff) => ({
|
|
260
|
+
key: `effort:${eff.id}`,
|
|
261
|
+
effort: eff.id,
|
|
262
|
+
label: eff.name,
|
|
263
|
+
})),
|
|
264
|
+
];
|
|
265
|
+
return rows.map((row) => {
|
|
266
|
+
const active = effectiveEffort === row.effort;
|
|
267
|
+
const named = row.effort === undefined ? undefined : reasoning.efforts.find((e) => e.id === row.effort);
|
|
208
268
|
return (
|
|
209
269
|
<button
|
|
210
|
-
key={
|
|
270
|
+
key={row.key}
|
|
211
271
|
type="button"
|
|
212
272
|
className="dci-item"
|
|
213
273
|
disabled={busy}
|
|
214
|
-
title={
|
|
215
|
-
onClick={() => found && void pick(found.group, found.model,
|
|
274
|
+
title={named?.description}
|
|
275
|
+
onClick={() => found && void pick(found.group, found.model, row.effort)}
|
|
216
276
|
>
|
|
217
|
-
<span className="dci-itemLabel">{
|
|
277
|
+
<span className="dci-itemLabel">{row.label}</span>
|
|
218
278
|
{active ? (
|
|
219
279
|
<span className="dci-check" aria-hidden="true">
|
|
220
280
|
<MenuCheck size={16} />
|
|
@@ -17,51 +17,21 @@
|
|
|
17
17
|
* locale 缺席)一律不渲染,绝不半组装。
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { createElement,
|
|
20
|
+
import { createElement, useMemo, useSyncExternalStore } from 'react';
|
|
21
21
|
import type { HostObservable, SlotEntryLike } from '../host-types';
|
|
22
|
-
import { stash } from '../services';
|
|
23
|
-
import { bindForeign } from '../i18n';
|
|
24
22
|
import { diagError } from '../diag';
|
|
23
|
+
import { EntryBoundary, firstComponentEntry, translatorFor, useSlotEntries } from './slotEntry';
|
|
25
24
|
|
|
26
25
|
const ACTIVITY_SLOT = 'conversation.input.activity';
|
|
27
26
|
const VOICE_NS_FALLBACK = 'voice-input';
|
|
28
27
|
|
|
29
|
-
type Translator = (key: string, params?: Record<string, string | number>) => string;
|
|
30
28
|
type ReadinessSelector = (value: unknown) => unknown;
|
|
31
29
|
type ReadinessHook = (selector: ReadinessSelector) => unknown;
|
|
32
30
|
|
|
33
|
-
const EMPTY_ENTRIES: readonly unknown[] = [];
|
|
34
|
-
|
|
35
31
|
/** observableHook 同款缓存轴:钩子身份按 readiness store 稳定,不逐渲染重订。 */
|
|
36
32
|
const readinessHookCache = new WeakMap<object, ReadinessHook>();
|
|
37
33
|
/** 官方 renderer 的 inject 缓存轴:条目 × 会话绑定。 */
|
|
38
34
|
const injectCache = new WeakMap<object, Map<string, Record<string, unknown> | null>>();
|
|
39
|
-
const translatorCache = new Map<string, Translator | null>();
|
|
40
|
-
|
|
41
|
-
/** 槽位条目快照:entries 引用在变更之间稳定(官方契约),直接作 uSES 源。 */
|
|
42
|
-
function useSlotEntries(key: string): readonly unknown[] {
|
|
43
|
-
const slots = stash.slots;
|
|
44
|
-
const subscribe = useCallback(
|
|
45
|
-
(fn: () => void) => {
|
|
46
|
-
if (typeof slots?.subscribe !== 'function') return () => {};
|
|
47
|
-
try {
|
|
48
|
-
return slots.subscribe(key, fn) ?? (() => {});
|
|
49
|
-
} catch {
|
|
50
|
-
return () => {};
|
|
51
|
-
}
|
|
52
|
-
},
|
|
53
|
-
[slots, key],
|
|
54
|
-
);
|
|
55
|
-
const getSnapshot = useCallback(() => {
|
|
56
|
-
if (typeof slots?.entries !== 'function') return EMPTY_ENTRIES;
|
|
57
|
-
try {
|
|
58
|
-
return slots.entries(key) ?? EMPTY_ENTRIES;
|
|
59
|
-
} catch {
|
|
60
|
-
return EMPTY_ENTRIES;
|
|
61
|
-
}
|
|
62
|
-
}, [slots, key]);
|
|
63
|
-
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
64
|
-
}
|
|
65
35
|
|
|
66
36
|
/** 调条目 inject 工厂取 props 面(session 槽位以 sessionId 调用,官方同参)。 */
|
|
67
37
|
function resolveInjected(entry: SlotEntryLike, sessionId: string): Record<string, unknown> | null {
|
|
@@ -84,13 +54,6 @@ function resolveInjected(entry: SlotEntryLike, sessionId: string): Record<string
|
|
|
84
54
|
return perSession.get(sessionId) ?? null;
|
|
85
55
|
}
|
|
86
56
|
|
|
87
|
-
/** 官方 renderer 的 locale 座位:locale.bind(entry.locale),按 ns 缓存。 */
|
|
88
|
-
function translatorFor(entry: SlotEntryLike): Translator | null {
|
|
89
|
-
const ns = entry.locale ?? VOICE_NS_FALLBACK;
|
|
90
|
-
if (!translatorCache.has(ns)) translatorCache.set(ns, bindForeign(ns));
|
|
91
|
-
return translatorCache.get(ns) ?? null;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
57
|
/** hooks.speechReadiness(快照 store)→ use<Name> 选择器钩子(observableHook 同款)。 */
|
|
95
58
|
function readinessHookFor(source: unknown): ReadinessHook | null {
|
|
96
59
|
const face = source as HostObservable<unknown> | undefined;
|
|
@@ -108,20 +71,6 @@ function readinessHookFor(source: unknown): ReadinessHook | null {
|
|
|
108
71
|
return hook;
|
|
109
72
|
}
|
|
110
73
|
|
|
111
|
-
/** 条目崩溃只丢语音钮,不连坐 Codinput 的 bar 条目(bar 被废黜 = 全部输入消失)。 */
|
|
112
|
-
class VoiceBoundary extends Component<{ children?: React.ReactNode }, { failed: boolean }> {
|
|
113
|
-
state = { failed: false };
|
|
114
|
-
static getDerivedStateFromError(): { failed: boolean } {
|
|
115
|
-
return { failed: true };
|
|
116
|
-
}
|
|
117
|
-
componentDidCatch(error: unknown): void {
|
|
118
|
-
diagError('voice.entry', error);
|
|
119
|
-
}
|
|
120
|
-
render(): React.ReactNode {
|
|
121
|
-
return this.state.failed ? null : this.props.children;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
74
|
export interface VoiceActivityProps {
|
|
126
75
|
sessionId: string | undefined;
|
|
127
76
|
/** 官方输入机动作面(bar 槽位 standard props 里的 inputActions 原样透传)。 */
|
|
@@ -137,19 +86,13 @@ export function VoiceActivity(props: VoiceActivityProps): JSX.Element | null {
|
|
|
137
86
|
const entries = useSlotEntries(ACTIVITY_SLOT);
|
|
138
87
|
|
|
139
88
|
// single 槽位赢家 = 优先级最低者 = entries 首个(register 即按优先级排序)。
|
|
140
|
-
const entry = useMemo(()
|
|
141
|
-
for (const candidate of entries) {
|
|
142
|
-
const record = candidate as SlotEntryLike | null | undefined;
|
|
143
|
-
if (record && typeof record === 'object' && typeof record.component === 'function') return record;
|
|
144
|
-
}
|
|
145
|
-
return null;
|
|
146
|
-
}, [entries]);
|
|
89
|
+
const entry = useMemo(() => firstComponentEntry(entries), [entries]);
|
|
147
90
|
|
|
148
91
|
const injected = useMemo(
|
|
149
92
|
() => (entry && sessionId ? resolveInjected(entry, sessionId) : null),
|
|
150
93
|
[entry, sessionId],
|
|
151
94
|
);
|
|
152
|
-
const translator = useMemo(() => (entry ? translatorFor(entry) : null), [entry]);
|
|
95
|
+
const translator = useMemo(() => (entry ? translatorFor(entry, VOICE_NS_FALLBACK) : null), [entry]);
|
|
153
96
|
const readinessSource = (injected?.['hooks'] as { speechReadiness?: unknown } | undefined)?.speechReadiness;
|
|
154
97
|
const useReadiness = useMemo(() => readinessHookFor(readinessSource), [readinessSource]);
|
|
155
98
|
|
|
@@ -158,7 +101,7 @@ export function VoiceActivity(props: VoiceActivityProps): JSX.Element | null {
|
|
|
158
101
|
// hooks 子表已折成 use* 钩子(bindInjectSources 同款),其余 props 原样下传。
|
|
159
102
|
const { hooks: _hooks, ...actions } = injected;
|
|
160
103
|
void _hooks;
|
|
161
|
-
return createElement(
|
|
104
|
+
return createElement(EntryBoundary, {
|
|
162
105
|
children: createElement(entry.component as React.ComponentType<Record<string, unknown>>, {
|
|
163
106
|
sessionId,
|
|
164
107
|
inputActions,
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 槽位条目渲染的共享件:检视任意槽位的条目快照(检测即兼容)、按条目 locale
|
|
3
|
+
* 绑定官方词典、以及条目崩溃兜底边界。语音输入(VoiceActivity)与模型名
|
|
4
|
+
* 标签(ModelLabel)共用同一套合成公式,抽到这里避免两份漂移。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useCallback, useSyncExternalStore, Component } from 'react';
|
|
8
|
+
import type { SlotEntryLike } from '../host-types';
|
|
9
|
+
import { bindForeign } from '../i18n';
|
|
10
|
+
import { diagError } from '../diag';
|
|
11
|
+
import { stash } from '../services';
|
|
12
|
+
|
|
13
|
+
export type Translator = (key: string, params?: Record<string, string | number>) => string;
|
|
14
|
+
|
|
15
|
+
const EMPTY_ENTRIES: readonly unknown[] = [];
|
|
16
|
+
|
|
17
|
+
/** 槽位条目快照:entries 引用在变更之间稳定(官方契约),直接作 uSES 源。 */
|
|
18
|
+
export function useSlotEntries(key: string): readonly unknown[] {
|
|
19
|
+
// 服务缺席时退化为恒空快照;slots 就绪后 deps 变化自动重订。
|
|
20
|
+
const slots = stash.slots;
|
|
21
|
+
const subscribe = useCallback(
|
|
22
|
+
(fn: () => void) => {
|
|
23
|
+
if (typeof slots?.subscribe !== 'function') return () => {};
|
|
24
|
+
try {
|
|
25
|
+
return slots.subscribe(key, fn) ?? (() => {});
|
|
26
|
+
} catch {
|
|
27
|
+
return () => {};
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
[slots, key],
|
|
31
|
+
);
|
|
32
|
+
const getSnapshot = useCallback(() => {
|
|
33
|
+
if (typeof slots?.entries !== 'function') return EMPTY_ENTRIES;
|
|
34
|
+
try {
|
|
35
|
+
return slots.entries(key) ?? EMPTY_ENTRIES;
|
|
36
|
+
} catch {
|
|
37
|
+
// 槽位键未声明(如 web 0.1.x 没有 model.label 子槽位)按无条目处理。
|
|
38
|
+
return EMPTY_ENTRIES;
|
|
39
|
+
}
|
|
40
|
+
}, [slots, key]);
|
|
41
|
+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** single 槽位赢家 = entries 首个带组件的条目(register 即按优先级排序)。 */
|
|
45
|
+
export function firstComponentEntry(entries: readonly unknown[]): SlotEntryLike | null {
|
|
46
|
+
for (const candidate of entries) {
|
|
47
|
+
const record = candidate as SlotEntryLike | null | undefined;
|
|
48
|
+
if (record && typeof record === 'object' && typeof record.component === 'function') return record;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 官方 renderer 的 locale 座位:locale.bind(entry.locale),按 ns 缓存。 */
|
|
54
|
+
export function translatorFor(entry: SlotEntryLike, fallbackNs?: string): Translator | null {
|
|
55
|
+
const ns = entry.locale ?? fallbackNs;
|
|
56
|
+
if (!ns) return null;
|
|
57
|
+
return bindForeign(ns);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 条目崩溃只丢本座位,不连坐 Codinput 的 bar 条目(被废黜 = 输入面消失)。 */
|
|
61
|
+
export class EntryBoundary extends Component<
|
|
62
|
+
{ children?: React.ReactNode; fallback?: React.ReactNode },
|
|
63
|
+
{ failed: boolean }
|
|
64
|
+
> {
|
|
65
|
+
state = { failed: false };
|
|
66
|
+
static getDerivedStateFromError(): { failed: boolean } {
|
|
67
|
+
return { failed: true };
|
|
68
|
+
}
|
|
69
|
+
componentDidCatch(error: unknown): void {
|
|
70
|
+
diagError('slot.entry', error);
|
|
71
|
+
}
|
|
72
|
+
render(): React.ReactNode {
|
|
73
|
+
return this.state.failed ? this.props.fallback ?? null : this.props.children;
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/client/host-types.ts
CHANGED
|
@@ -345,6 +345,8 @@ export interface SidebarTabInfo {
|
|
|
345
345
|
readonly tab: {
|
|
346
346
|
readonly visible: boolean;
|
|
347
347
|
readonly id?: string;
|
|
348
|
+
/** 打开时捕获的标签名(dockkit tab 记录;chip 标题组件显示它)。 */
|
|
349
|
+
readonly title?: string;
|
|
348
350
|
/** tab 域动作面(occurrence held):close() 关闭本标签。 */
|
|
349
351
|
readonly actions?: { readonly close?: () => void };
|
|
350
352
|
};
|
package/src/client/icons.tsx
CHANGED
|
@@ -7,7 +7,7 @@ interface IconProps {
|
|
|
7
7
|
className?: string;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
function svg(paths: JSX.Element, size: number | undefined, className: string | undefined): JSX.Element {
|
|
10
|
+
function svg(paths: JSX.Element, size: number | undefined, className: string | undefined, stroke = 'currentColor'): JSX.Element {
|
|
11
11
|
return (
|
|
12
12
|
<svg
|
|
13
13
|
xmlns="http://www.w3.org/2000/svg"
|
|
@@ -15,7 +15,7 @@ function svg(paths: JSX.Element, size: number | undefined, className: string | u
|
|
|
15
15
|
height={size ?? 16}
|
|
16
16
|
viewBox="0 0 24 24"
|
|
17
17
|
fill="none"
|
|
18
|
-
stroke=
|
|
18
|
+
stroke={stroke}
|
|
19
19
|
strokeWidth={2}
|
|
20
20
|
strokeLinecap="round"
|
|
21
21
|
strokeLinejoin="round"
|
|
@@ -47,6 +47,15 @@ export function IconBrand({ size, className }: IconProps): JSX.Element {
|
|
|
47
47
|
return svg(BRAND_MARK, size, className);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* 品牌彩色标:同一机型标固定鲸鱼蓝(assets/icon.svg 同源 #3964FE),不随
|
|
52
|
+
* currentColor——侧栏标签 chip 与指南页条目用(官方在该两处画的是彩色
|
|
53
|
+
* artwork 同位:FileTypeIcon 文件夹黄、GuideArtworkFiles 彩绘)。
|
|
54
|
+
*/
|
|
55
|
+
export function IconBrandLogo({ size, className }: IconProps): JSX.Element {
|
|
56
|
+
return svg(BRAND_MARK, size, className, '#3964FE');
|
|
57
|
+
}
|
|
58
|
+
|
|
50
59
|
export function IconEdit({ size, className }: IconProps): JSX.Element {
|
|
51
60
|
return svg(
|
|
52
61
|
<>
|
package/src/client/index.tsx
CHANGED
|
@@ -17,7 +17,7 @@ import { diagError, diagWindow, runGuarded } from './diag';
|
|
|
17
17
|
import { loadPrefs, subscribePrefs } from './prefs';
|
|
18
18
|
import { isAnySidebarInputCarried, subscribeSidebarInput } from './sideinput';
|
|
19
19
|
import { barRegistration, CodinputBar } from './components/CodinputBar';
|
|
20
|
-
import { sidebarBodyRegistration, sidebarTabDefinition } from './sidebar';
|
|
20
|
+
import { sidebarBodyRegistration, sidebarTabDefinition, sidebarTitleRegistration } from './sidebar';
|
|
21
21
|
import { settingsRegistration } from './settings';
|
|
22
22
|
import { registerCodinputCommand } from './command';
|
|
23
23
|
|
|
@@ -135,6 +135,11 @@ export function apply(ctx: ClientContext): void {
|
|
|
135
135
|
tryRegister(() => {
|
|
136
136
|
ctx.slots.register(body.options, body.component);
|
|
137
137
|
}, 'sidebar.body');
|
|
138
|
+
// chip 标题(品牌标 + 标签名):官方 FilesTitle 同构的 keyed 注册。
|
|
139
|
+
const title = sidebarTitleRegistration();
|
|
140
|
+
tryRegister(() => {
|
|
141
|
+
ctx.slots.register(title.options, title.component);
|
|
142
|
+
}, 'sidebar.title');
|
|
138
143
|
});
|
|
139
144
|
});
|
|
140
145
|
|
package/src/client/modeldir.ts
CHANGED
|
@@ -19,6 +19,8 @@ export interface DirectorySnapshot {
|
|
|
19
19
|
failures: readonly { id: string; name: string; message: string }[];
|
|
20
20
|
status: 'idle' | 'loading' | 'ready' | 'selecting' | 'error';
|
|
21
21
|
error: string | null;
|
|
22
|
+
/** 官方 syncInputs 附带:当前强度折算成的显示名(0.2.x 座位触发器在模型无 reasoning 元数据时显示它)。 */
|
|
23
|
+
retainedEffort?: string;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export interface SessionDirectory {
|
package/src/client/sidebar.tsx
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 侧边模式:官方 Sidebar(dockkit)标签页承载,入口与标签芯片同名
|
|
3
|
-
* 「Codinput
|
|
4
|
-
* sidebar.right.pane.tab(keyed,key = 定义 id
|
|
5
|
-
*
|
|
3
|
+
* 「Codinput」。三段式注册——sidebarRightTabs.register(类型定义,guide
|
|
4
|
+
* 条目带品牌标)+ sidebar.right.pane.tab(keyed,key = 定义 id,body)+
|
|
5
|
+
* sidebar.right.pane.tab.title(keyed 同 key,chip 标题:品牌标 + tab.title,
|
|
6
|
+
* 官方 FilesTitle 同构——不注册时 chip 退化为打开时捕获的纯文本)。
|
|
7
|
+
* 停靠、分栏、全屏、浮动由官方 dockkit 承担;侧栏与主输入读写同一台
|
|
8
|
+
* 输入机,草稿天然同源。
|
|
6
9
|
*
|
|
7
10
|
* 唯一输入组件约定:body **挂载**期间经 sideinput 总线声明承载本会话
|
|
8
11
|
* 输入(形态 = side),主输入遮蔽条目随之隐藏——收起侧栏不让位(可见性
|
|
@@ -18,6 +21,7 @@ import { claimSidebarInput, subscribeSidebarTabClose, useSidebarCarryOwner, type
|
|
|
18
21
|
import { loadPrefs, savePrefs, subscribePrefs } from './prefs';
|
|
19
22
|
import { diagError, diagWindow } from './diag';
|
|
20
23
|
import { CodinputSurface } from './components/CodinputSurface';
|
|
24
|
+
import { IconBrandLogo } from './icons';
|
|
21
25
|
import { t, useT } from './i18n';
|
|
22
26
|
|
|
23
27
|
export const SIDEBAR_TAB_ID = 'dsh-codinput';
|
|
@@ -39,6 +43,9 @@ export function sidebarTabDefinition(): Record<string, unknown> {
|
|
|
39
43
|
order: 0,
|
|
40
44
|
title: () => 'Codinput',
|
|
41
45
|
description: () => t('side.guideDesc'),
|
|
46
|
+
// 指南页条目图标:官方 EntryBox 渲染 <Icon size={22|26}/>,缺席
|
|
47
|
+
// 退灰色立方体(placeholderInk)——给品牌彩色标。
|
|
48
|
+
icon: IconBrandLogo,
|
|
42
49
|
},
|
|
43
50
|
],
|
|
44
51
|
};
|
|
@@ -56,6 +63,32 @@ export function sidebarBodyRegistration(): { options: SlotRegisterOptions; compo
|
|
|
56
63
|
};
|
|
57
64
|
}
|
|
58
65
|
|
|
66
|
+
/**
|
|
67
|
+
* 第三段:标签 chip 标题(keyed 同 key)。官方同位组件(FilesTitle/
|
|
68
|
+
* GuideTitle)= 彩色图标 + useTabInfo().tab.title;title 座位声明了
|
|
69
|
+
* tabInfo 注入 hook,宿主按 use<Name> 约定下发。缺席时退回静态名。
|
|
70
|
+
*/
|
|
71
|
+
export function sidebarTitleRegistration(): { options: SlotRegisterOptions; component: unknown } {
|
|
72
|
+
return {
|
|
73
|
+
options: {
|
|
74
|
+
name: 'sidebar.right.pane.tab.title',
|
|
75
|
+
key: SIDEBAR_TAB_ID,
|
|
76
|
+
registrant: 'dsh-codinput',
|
|
77
|
+
},
|
|
78
|
+
component: SidebarCodinputTitle,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function SidebarCodinputTitle(props: { useTabInfo?: () => SidebarTabInfo }): JSX.Element {
|
|
83
|
+
const title = props.useTabInfo?.().tab.title ?? 'Codinput';
|
|
84
|
+
return (
|
|
85
|
+
<>
|
|
86
|
+
<IconBrandLogo size={16} />
|
|
87
|
+
{title}
|
|
88
|
+
</>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
59
92
|
/**
|
|
60
93
|
* session 槽位标准 props 直达官方输入机。useTabInfo 是槽位声明的注入
|
|
61
94
|
* hook(宿主按 use<Name> 约定随 props 下发,缺席说明宿主形状漂移——
|