dsh-codinput 0.2.0 → 0.3.1
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 +37 -0
- package/NOTICE +19 -0
- package/README.en.md +10 -14
- package/README.md +10 -14
- 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 +50 -34
- package/package.json +1 -1
- package/src/client/components/CodinputSurface.tsx +31 -1
- package/src/client/components/Toolbar.tsx +24 -6
- package/src/client/components/VoiceActivity.tsx +172 -0
- package/src/client/host-types.ts +18 -0
- package/src/client/i18n.ts +21 -0
- package/src/client/services.ts +5 -0
- package/src/client/styles.ts +18 -2
- 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}
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 工具行:结构与样式逐条对齐官方 InputBar 工具行——
|
|
3
3
|
* 左侧 .dci-tools:指令(plus)、权限(shield 触发器);
|
|
4
|
-
* 右侧 .dci-trailing
|
|
4
|
+
* 右侧 .dci-trailing:模型与思考强度(触发器)、语音输入(官方 activity 槽位
|
|
5
|
+
* 条目,桌面版宿主才有)、发送(.primary 圆钮)。
|
|
5
6
|
* 按钮沿用官方 keepFocus 语义(mousedown 不夺走编辑器焦点)。
|
|
6
7
|
* 附件无独立按钮(官方已并入 slash「添加 · 文件」),粘贴/拖拽路径保留。
|
|
7
8
|
* 0.1.7-rc.2 起官方上下文小圈不再在工具行(挪进卡片下方 dock 行,见 StatsRow)。
|
|
9
|
+
* 语音展开时隐藏 tools 与模型(官方 activity 同款:capture 行接管整行,
|
|
10
|
+
* 发送/停止保持可见)。
|
|
8
11
|
*/
|
|
9
12
|
|
|
13
|
+
import { useCallback, useState } from 'react';
|
|
10
14
|
import type { DraftAttachmentId, InputActions, InputState, InputTriggerController, UseProjection } from '../host-types';
|
|
11
15
|
import { openCommandMenu } from '../triggers';
|
|
12
16
|
import { t, useT } from '../i18n';
|
|
13
17
|
import { IconPlusOfficial, IconSendOfficial, IconStopOfficial } from '../icons';
|
|
14
18
|
import { ModelMenu } from './ModelMenu';
|
|
15
19
|
import { PermissionMenu } from './PermissionMenu';
|
|
20
|
+
import { VoiceActivity } from './VoiceActivity';
|
|
16
21
|
|
|
17
22
|
export interface ToolbarProps {
|
|
18
23
|
sessionId: string | undefined;
|
|
@@ -44,6 +49,9 @@ export function Toolbar(props: ToolbarProps): JSX.Element {
|
|
|
44
49
|
useT();
|
|
45
50
|
|
|
46
51
|
const attachments: readonly DraftAttachmentId[] = state?.attachmentIds ?? [];
|
|
52
|
+
// 官方 activity 语义:语音展开时本行左侧工具与模型控件隐藏、capture 行伸展。
|
|
53
|
+
const [voiceActive, setVoiceActive] = useState(false);
|
|
54
|
+
const onVoiceActiveChange = useCallback((active: boolean): void => setVoiceActive(active), []);
|
|
47
55
|
|
|
48
56
|
const onCommandClick = (): void => {
|
|
49
57
|
if (!triggers) return;
|
|
@@ -51,7 +59,7 @@ export function Toolbar(props: ToolbarProps): JSX.Element {
|
|
|
51
59
|
};
|
|
52
60
|
|
|
53
61
|
return (
|
|
54
|
-
<div className="dci-row">
|
|
62
|
+
<div className="dci-row" data-voice-active={voiceActive || undefined}>
|
|
55
63
|
<div className="dci-tools">
|
|
56
64
|
<button
|
|
57
65
|
type="button"
|
|
@@ -75,10 +83,20 @@ export function Toolbar(props: ToolbarProps): JSX.Element {
|
|
|
75
83
|
/>
|
|
76
84
|
</div>
|
|
77
85
|
<div className="dci-trailing">
|
|
78
|
-
<
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
86
|
+
<div className="dci-standard-controls">
|
|
87
|
+
<ModelMenu
|
|
88
|
+
sessionId={sessionId}
|
|
89
|
+
onNotify={onNotify}
|
|
90
|
+
/>
|
|
91
|
+
</div>
|
|
92
|
+
<div className="dci-voice-seat">
|
|
93
|
+
<VoiceActivity
|
|
94
|
+
sessionId={sessionId}
|
|
95
|
+
inputActions={actions}
|
|
96
|
+
locked={disabled === true}
|
|
97
|
+
onActiveChange={onVoiceActiveChange}
|
|
98
|
+
/>
|
|
99
|
+
</div>
|
|
82
100
|
<button
|
|
83
101
|
type="button"
|
|
84
102
|
className="dci-send"
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 语音输入按钮:渲染官方 voice-input 插件注册在 conversation.input.activity
|
|
3
|
+
* 槽位的**条目组件本体**(麦克风触发钮、录音波形、转写、未就绪引导弹窗全部
|
|
4
|
+
* 是官方代码,其闭包自带 primitives 与样式注入)。
|
|
5
|
+
*
|
|
6
|
+
* 检测即兼容:官方桌面版(0.2.0-rc.2 起)挂载 experimental-client-ui-voice-input
|
|
7
|
+
* 后,原生输入框工具行右侧(模型选择器与发送钮之间)出现麦克风钮;web 宿主
|
|
8
|
+
* (0.1.x)没有该插件、槽位无条目,官方输入框也没有按钮——Codinput 同样不
|
|
9
|
+
* 渲染。条目出现/消失经 slots.subscribe 响应式跟随(用户开关语音插件即时生效)。
|
|
10
|
+
*
|
|
11
|
+
* props 组装复刻官方 renderer 的合成公式:
|
|
12
|
+
* {...standard(sessionId/inputActions), ...entry.inject()(其 hooks 子表的
|
|
13
|
+
* speechReadiness 按 standardHookPropName 变成 useSpeechReadiness 选择器钩子),
|
|
14
|
+
* ...ownerProps(locked/onActiveChange), t = locale.bind(entry.locale)}
|
|
15
|
+
* inject 结果按(条目 × 会话)缓存——官方 renderer 同款缓存轴,避免每次渲染
|
|
16
|
+
* 重建对象让钩子身份漂移。宿主未就绪(slots 无检视面 / inject 不可调 /
|
|
17
|
+
* locale 缺席)一律不渲染,绝不半组装。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createElement, useCallback, useMemo, useSyncExternalStore, Component } from 'react';
|
|
21
|
+
import type { HostObservable, SlotEntryLike } from '../host-types';
|
|
22
|
+
import { stash } from '../services';
|
|
23
|
+
import { bindForeign } from '../i18n';
|
|
24
|
+
import { diagError } from '../diag';
|
|
25
|
+
|
|
26
|
+
const ACTIVITY_SLOT = 'conversation.input.activity';
|
|
27
|
+
const VOICE_NS_FALLBACK = 'voice-input';
|
|
28
|
+
|
|
29
|
+
type Translator = (key: string, params?: Record<string, string | number>) => string;
|
|
30
|
+
type ReadinessSelector = (value: unknown) => unknown;
|
|
31
|
+
type ReadinessHook = (selector: ReadinessSelector) => unknown;
|
|
32
|
+
|
|
33
|
+
const EMPTY_ENTRIES: readonly unknown[] = [];
|
|
34
|
+
|
|
35
|
+
/** observableHook 同款缓存轴:钩子身份按 readiness store 稳定,不逐渲染重订。 */
|
|
36
|
+
const readinessHookCache = new WeakMap<object, ReadinessHook>();
|
|
37
|
+
/** 官方 renderer 的 inject 缓存轴:条目 × 会话绑定。 */
|
|
38
|
+
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
|
+
|
|
66
|
+
/** 调条目 inject 工厂取 props 面(session 槽位以 sessionId 调用,官方同参)。 */
|
|
67
|
+
function resolveInjected(entry: SlotEntryLike, sessionId: string): Record<string, unknown> | null {
|
|
68
|
+
if (typeof entry.inject !== 'function') return null;
|
|
69
|
+
let perSession = injectCache.get(entry);
|
|
70
|
+
if (!perSession) {
|
|
71
|
+
perSession = new Map();
|
|
72
|
+
injectCache.set(entry, perSession);
|
|
73
|
+
}
|
|
74
|
+
if (!perSession.has(sessionId)) {
|
|
75
|
+
let face: Record<string, unknown> | null = null;
|
|
76
|
+
try {
|
|
77
|
+
face = (entry.inject as (key: string) => Record<string, unknown>)(sessionId) ?? null;
|
|
78
|
+
} catch (error) {
|
|
79
|
+
diagError('voice.inject', error);
|
|
80
|
+
face = null;
|
|
81
|
+
}
|
|
82
|
+
perSession.set(sessionId, face);
|
|
83
|
+
}
|
|
84
|
+
return perSession.get(sessionId) ?? null;
|
|
85
|
+
}
|
|
86
|
+
|
|
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
|
+
/** hooks.speechReadiness(快照 store)→ use<Name> 选择器钩子(observableHook 同款)。 */
|
|
95
|
+
function readinessHookFor(source: unknown): ReadinessHook | null {
|
|
96
|
+
const face = source as HostObservable<unknown> | undefined;
|
|
97
|
+
if (!face || typeof face.subscribe !== 'function' || typeof face.getSnapshot !== 'function') return null;
|
|
98
|
+
let hook = readinessHookCache.get(face);
|
|
99
|
+
if (!hook) {
|
|
100
|
+
hook = (selector: ReadinessSelector): unknown =>
|
|
101
|
+
useSyncExternalStore(
|
|
102
|
+
(fn: () => void) => face.subscribe(fn) ?? (() => {}),
|
|
103
|
+
() => selector(face.getSnapshot()),
|
|
104
|
+
() => selector(face.getSnapshot()),
|
|
105
|
+
);
|
|
106
|
+
readinessHookCache.set(face, hook);
|
|
107
|
+
}
|
|
108
|
+
return hook;
|
|
109
|
+
}
|
|
110
|
+
|
|
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
|
+
export interface VoiceActivityProps {
|
|
126
|
+
sessionId: string | undefined;
|
|
127
|
+
/** 官方输入机动作面(bar 槽位 standard props 里的 inputActions 原样透传)。 */
|
|
128
|
+
inputActions: unknown;
|
|
129
|
+
/** 输入锁定(官方 ownerProps.locked:submitting/inert 时触发钮禁用)。 */
|
|
130
|
+
locked: boolean;
|
|
131
|
+
/** 展开/收起上报(官方 ownerProps.onActiveChange:录音时宿主隐藏其余工具)。 */
|
|
132
|
+
onActiveChange(active: boolean): void;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function VoiceActivity(props: VoiceActivityProps): JSX.Element | null {
|
|
136
|
+
const { sessionId, inputActions, locked, onActiveChange } = props;
|
|
137
|
+
const entries = useSlotEntries(ACTIVITY_SLOT);
|
|
138
|
+
|
|
139
|
+
// single 槽位赢家 = 优先级最低者 = entries 首个(register 即按优先级排序)。
|
|
140
|
+
const entry = useMemo((): SlotEntryLike | null => {
|
|
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]);
|
|
147
|
+
|
|
148
|
+
const injected = useMemo(
|
|
149
|
+
() => (entry && sessionId ? resolveInjected(entry, sessionId) : null),
|
|
150
|
+
[entry, sessionId],
|
|
151
|
+
);
|
|
152
|
+
const translator = useMemo(() => (entry ? translatorFor(entry) : null), [entry]);
|
|
153
|
+
const readinessSource = (injected?.['hooks'] as { speechReadiness?: unknown } | undefined)?.speechReadiness;
|
|
154
|
+
const useReadiness = useMemo(() => readinessHookFor(readinessSource), [readinessSource]);
|
|
155
|
+
|
|
156
|
+
if (!entry || !sessionId || !injected || !translator || !useReadiness) return null;
|
|
157
|
+
|
|
158
|
+
// hooks 子表已折成 use* 钩子(bindInjectSources 同款),其余 props 原样下传。
|
|
159
|
+
const { hooks: _hooks, ...actions } = injected;
|
|
160
|
+
void _hooks;
|
|
161
|
+
return createElement(VoiceBoundary, {
|
|
162
|
+
children: createElement(entry.component as React.ComponentType<Record<string, unknown>>, {
|
|
163
|
+
sessionId,
|
|
164
|
+
inputActions,
|
|
165
|
+
locked,
|
|
166
|
+
onActiveChange,
|
|
167
|
+
...actions,
|
|
168
|
+
useSpeechReadiness: useReadiness,
|
|
169
|
+
t: translator,
|
|
170
|
+
}),
|
|
171
|
+
});
|
|
172
|
+
}
|
package/src/client/host-types.ts
CHANGED
|
@@ -44,6 +44,24 @@ export interface SlotsService {
|
|
|
44
44
|
register(options: SlotRegisterOptions, component: unknown): () => void;
|
|
45
45
|
/** 锚定槽位:当该槽被声明时运行工厂;工厂 yield 掉的可清理返回值统一登记生命周期。 */
|
|
46
46
|
inject(slotName: string, factory: () => Generator<unknown> | (() => unknown)): void;
|
|
47
|
+
/**
|
|
48
|
+
* 槽位检视面(0.2.x 起的 SlotCore 公开方法,鸭子探测):entries 返回条目
|
|
49
|
+
* 快照(引用在变更之间稳定,可作 uSES getSnapshot 源;未声明槽返回稳定
|
|
50
|
+
* 空数组);subscribe 订阅该槽条目变更。
|
|
51
|
+
*/
|
|
52
|
+
entries?(key: string): readonly unknown[];
|
|
53
|
+
subscribe?(key: string, fn: () => void): () => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 槽位条目记录(SlotCore.register 产物)里我们消费的切面。 */
|
|
57
|
+
export interface SlotEntryLike {
|
|
58
|
+
/** 条目 React 组件(官方 renderer 以 {...kit,...injected,...ownerProps} 渲染)。 */
|
|
59
|
+
readonly component?: unknown;
|
|
60
|
+
/** 注入工厂:session 槽位以 binding.key(sessionId)调用,返回 props 面。 */
|
|
61
|
+
readonly inject?: (...args: unknown[]) => Record<string, unknown>;
|
|
62
|
+
/** 条目词典命名空间(locale.bind(ns) 即官方传给组件的 t)。 */
|
|
63
|
+
readonly locale?: string;
|
|
64
|
+
readonly registrant?: string;
|
|
47
65
|
}
|
|
48
66
|
|
|
49
67
|
// ---------- input machine ----------
|
package/src/client/i18n.ts
CHANGED
|
@@ -232,9 +232,11 @@ interface LocaleFaceLike {
|
|
|
232
232
|
getSnapshot?(): { active?: string };
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
let hostFace: LocaleFaceLike | null = null;
|
|
235
236
|
let bound: ((key: string, params?: TranslateParams) => string) | null = null;
|
|
236
237
|
let activeLocale = 'zh';
|
|
237
238
|
const listeners = new Set<() => void>();
|
|
239
|
+
const foreignBinds = new Map<string, ((key: string, params?: TranslateParams) => string) | null>();
|
|
238
240
|
|
|
239
241
|
function emit(): void {
|
|
240
242
|
for (const fn of listeners) fn();
|
|
@@ -257,6 +259,7 @@ function builtinDict(): Dict {
|
|
|
257
259
|
export function installI18n(locale: unknown): void {
|
|
258
260
|
const face = locale as LocaleFaceLike | undefined;
|
|
259
261
|
if (!face) return;
|
|
262
|
+
hostFace = face;
|
|
260
263
|
try {
|
|
261
264
|
face.register?.(NS, 'zh', ZH);
|
|
262
265
|
face.register?.(NS, 'en', EN);
|
|
@@ -282,6 +285,24 @@ export function installI18n(locale: unknown): void {
|
|
|
282
285
|
}
|
|
283
286
|
}
|
|
284
287
|
|
|
288
|
+
/**
|
|
289
|
+
* 绑定其他命名空间的宿主词典(如语音插件注册的 voice-input——渲染官方条目
|
|
290
|
+
* 组件时,官方 renderer 传的就是 locale.bind(entry.locale))。返回引用稳定
|
|
291
|
+
* 的翻译函数;宿主 face 未就绪或 bind 失败返回 null。结果按 ns 缓存。
|
|
292
|
+
*/
|
|
293
|
+
export function bindForeign(ns: string): ((key: string, params?: TranslateParams) => string) | null {
|
|
294
|
+
if (!foreignBinds.has(ns)) {
|
|
295
|
+
let bound: ((key: string, params?: TranslateParams) => string) | null = null;
|
|
296
|
+
try {
|
|
297
|
+
bound = hostFace?.bind?.(ns) ?? null;
|
|
298
|
+
} catch {
|
|
299
|
+
bound = null;
|
|
300
|
+
}
|
|
301
|
+
foreignBinds.set(ns, bound);
|
|
302
|
+
}
|
|
303
|
+
return foreignBinds.get(ns) ?? null;
|
|
304
|
+
}
|
|
305
|
+
|
|
285
306
|
export function subscribeI18n(fn: () => void): () => void {
|
|
286
307
|
listeners.add(fn);
|
|
287
308
|
return () => {
|
package/src/client/services.ts
CHANGED
|
@@ -10,11 +10,13 @@ import type {
|
|
|
10
10
|
ModelDirectoriesFace,
|
|
11
11
|
SessionsFace,
|
|
12
12
|
SidebarRightFace,
|
|
13
|
+
SlotsService,
|
|
13
14
|
} from './host-types';
|
|
14
15
|
import { diagError, runGuarded } from './diag';
|
|
15
16
|
import { installI18n } from './i18n';
|
|
16
17
|
|
|
17
18
|
export interface ServiceStash {
|
|
19
|
+
slots?: SlotsService;
|
|
18
20
|
conversation?: ConversationFace;
|
|
19
21
|
sessions?: SessionsFace;
|
|
20
22
|
commandUi?: CommandUiFace;
|
|
@@ -33,8 +35,11 @@ export function installServices(ctx: ClientContext): void {
|
|
|
33
35
|
});
|
|
34
36
|
|
|
35
37
|
// sessions / commandUi:被消费服务自身的 static inject 链由宿主装配。
|
|
38
|
+
// slots 一并取入 stash:语音按钮要检视 conversation.input.activity 槽位
|
|
39
|
+
// 条目(见 components/VoiceActivity)。
|
|
36
40
|
runGuarded('stash.services', () => {
|
|
37
41
|
ctx.inject(['slots', 'sessions', 'commandUi'], (scope: Record<string, unknown>) => {
|
|
42
|
+
stash.slots = scope['slots'] as SlotsService;
|
|
38
43
|
stash.sessions = scope['sessions'] as SessionsFace;
|
|
39
44
|
stash.commandUi = scope['commandUi'] as CommandUiFace;
|
|
40
45
|
});
|
package/src/client/styles.ts
CHANGED
|
@@ -50,8 +50,14 @@ const CSS = `
|
|
|
50
50
|
.dci-body { position: relative; display: flex; gap: 0; min-height: 60px; max-height: var(--dci-body-max, 46vh); overflow: hidden; }
|
|
51
51
|
.dci-body[style*="--dci-body-h"] { height: var(--dci-body-h); }
|
|
52
52
|
/* 分屏:grid 等宽两栏(fr 计入 padding/border,两栏边界严格居中;flex
|
|
53
|
-
* 会把预览栏的横向 padding
|
|
54
|
-
|
|
53
|
+
* 会把预览栏的横向 padding 泄漏进空间分配,做不到对半)。行高必须
|
|
54
|
+
* minmax(0,1fr)+两栏 min-height:0:隐式 auto 行随内容撑高、不受 max-height
|
|
55
|
+
* 钳制,内容一高整行溢出被 overflow:hidden 裁掉,两栏都出不了滚动条。 */
|
|
56
|
+
.dci-body[data-split="true"] { display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: minmax(0, 1fr); }
|
|
57
|
+
.dci-body[data-split="true"] > * { min-height: 0; }
|
|
58
|
+
/* 分屏同步滚动:只留编辑栏一个滚动条,预览栏藏起但滚轮仍可反向带动编辑器。 */
|
|
59
|
+
.dci-body[data-split="true"] .dci-preview { scrollbar-width: none; }
|
|
60
|
+
.dci-body[data-split="true"] .dci-preview::-webkit-scrollbar { display: none; }
|
|
55
61
|
.dci-body[data-split="true"] .dci-editor-wrap { border-right: 1px solid var(--dsw-alias-border-l2, color-mix(in srgb, currentColor 12%, transparent)); }
|
|
56
62
|
.dci-editor-wrap { position: relative; flex: 1 1 100%; min-width: 0; box-sizing: border-box; display: flex; flex-direction: column; }
|
|
57
63
|
.dci-editor-wrap[data-solo="true"] { flex-basis: 100%; }
|
|
@@ -79,6 +85,16 @@ const CSS = `
|
|
|
79
85
|
.dci-tools { align-items: center; min-width: 0; gap: 12px; display: flex; }
|
|
80
86
|
.dci-trailing { align-items: center; flex: none; min-width: 0; gap: 12px; margin-left: auto; display: flex; }
|
|
81
87
|
|
|
88
|
+
/* ===== 语音输入座位(官方 .standardControls / .activity / .activityExpanded)=====
|
|
89
|
+
按钮本体是官方 activity 槽位条目组件(Gyyz9a_* 样式由语音插件自带注入);
|
|
90
|
+
这里只负责座位:展开时 capture 行接管整行、左侧工具与模型控件让位。 */
|
|
91
|
+
.dci-standard-controls { align-items: center; min-width: 0; gap: 12px; display: flex; }
|
|
92
|
+
.dci-voice-seat { flex: none; align-items: center; display: flex; }
|
|
93
|
+
.dci-voice-seat:empty { display: none; }
|
|
94
|
+
.dci-row[data-voice-active] .dci-tools, .dci-row[data-voice-active] .dci-standard-controls { display: none; }
|
|
95
|
+
.dci-row[data-voice-active] .dci-trailing { flex: 1; margin-left: 0; }
|
|
96
|
+
.dci-row[data-voice-active] .dci-voice-seat { flex: 1; min-width: 0; }
|
|
97
|
+
|
|
82
98
|
/* 圆形图标按钮(官方 .add)*/
|
|
83
99
|
.dci-add { background: var(--dsw-specific-selector, color-mix(in srgb, currentColor 8%, transparent));
|
|
84
100
|
width: 28px; height: 28px; color: var(--dsw-alias-label-primary, currentColor); cursor: pointer; border: none;
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/assets/preview-float.png
DELETED
|
Binary file
|
|
Binary file
|
package/assets/preview-side.png
DELETED
|
Binary file
|
package/assets/preview-split.png
DELETED
|
Binary file
|