dsh-codinput 0.2.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-codinput",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "DSH WebUI 插件:把聊天输入框变成代码编辑器一样的输入风格(CodeMirror 6 接管 conversation.composer.bar)。",
5
5
  "license": "MIT",
6
6
  "author": "Witherwithwinter",
@@ -1,18 +1,23 @@
1
1
  /**
2
2
  * 工具行:结构与样式逐条对齐官方 InputBar 工具行——
3
3
  * 左侧 .dci-tools:指令(plus)、权限(shield 触发器);
4
- * 右侧 .dci-trailing:模型与思考强度(触发器)、发送(.primary 圆钮)。
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
- <ModelMenu
79
- sessionId={sessionId}
80
- onNotify={onNotify}
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
+ }
@@ -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 ----------
@@ -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 () => {
@@ -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
  });
@@ -79,6 +79,16 @@ const CSS = `
79
79
  .dci-tools { align-items: center; min-width: 0; gap: 12px; display: flex; }
80
80
  .dci-trailing { align-items: center; flex: none; min-width: 0; gap: 12px; margin-left: auto; display: flex; }
81
81
 
82
+ /* ===== 语音输入座位(官方 .standardControls / .activity / .activityExpanded)=====
83
+ 按钮本体是官方 activity 槽位条目组件(Gyyz9a_* 样式由语音插件自带注入);
84
+ 这里只负责座位:展开时 capture 行接管整行、左侧工具与模型控件让位。 */
85
+ .dci-standard-controls { align-items: center; min-width: 0; gap: 12px; display: flex; }
86
+ .dci-voice-seat { flex: none; align-items: center; display: flex; }
87
+ .dci-voice-seat:empty { display: none; }
88
+ .dci-row[data-voice-active] .dci-tools, .dci-row[data-voice-active] .dci-standard-controls { display: none; }
89
+ .dci-row[data-voice-active] .dci-trailing { flex: 1; margin-left: 0; }
90
+ .dci-row[data-voice-active] .dci-voice-seat { flex: 1; min-width: 0; }
91
+
82
92
  /* 圆形图标按钮(官方 .add)*/
83
93
  .dci-add { background: var(--dsw-specific-selector, color-mix(in srgb, currentColor 8%, transparent));
84
94
  width: 28px; height: 28px; color: var(--dsw-alias-label-primary, currentColor); cursor: pointer; border: none;