@onefl666/dsh-great-plan 0.0.0-stage → 0.6.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/lib/client.js ADDED
@@ -0,0 +1,366 @@
1
+ window.__ModuleLoader__.load({
2
+ id: '@onefl666/dsh-great-plan',
3
+ factory(require) {
4
+ const NS = 'great-plan';
5
+
6
+ const zh = {
7
+ 'trigger.label': '执行选项',
8
+ 'trigger.aria': '执行选项:选择模型、思考强度和执行方式',
9
+ 'panel.title': '执行选项',
10
+ 'panel.subtitle': '选择模型与计划的执行方式',
11
+ 'panel.back': '返回',
12
+ 'panel.close': '关闭',
13
+ 'panel.model': '模型',
14
+ 'panel.effort': '思考强度',
15
+ 'panel.modelHint': '当前会话使用的模型',
16
+ 'panel.effortHint': '所选模型的推理等级',
17
+ 'panel.loading': '加载中…',
18
+ 'panel.selectModel': '选择模型',
19
+ 'panel.selectModelFirst': '请先选择模型。',
20
+ 'panel.providerDefault': '提供商默认',
21
+ 'panel.retry': '重试',
22
+ 'panel.modelEmpty': '没有可用模型。',
23
+ 'panel.noEffort': '当前模型未提供思考强度。',
24
+ 'panel.modelError': '模型目录加载失败:{message}',
25
+ 'panel.subagentUnavailable': '子会话不支持切换模型。',
26
+ 'panel.applyNote': '模型选择会立即应用到当前会话。',
27
+ 'panel.revise': '返回修改',
28
+ 'panel.searchModels': '搜索模型',
29
+ 'panel.noMatches': '没有匹配的模型。',
30
+ 'panel.modelDirectoryLoading': '模型目录加载中,暂时无法创建新会话。',
31
+ 'panel.modelDirectoryError': '模型目录不可用,将使用默认模型。',
32
+ 'panel.actions.execute.title': '执行计划',
33
+ 'panel.actions.execute.description': '在当前会话中按所选模型继续执行',
34
+ 'panel.actions.compact.title': '压缩上下文并执行',
35
+ 'panel.actions.compact.description': '先压缩较早历史,再执行计划',
36
+ 'panel.actions.clear.title': '清空上下文并执行…',
37
+ 'panel.actions.clear.description': '停止当前回合,在新会话中执行计划',
38
+ 'compact.failed': '压缩未完成:{message}',
39
+ 'compact.failedHint': '可以重试压缩、仍然执行计划,或取消本次执行。',
40
+ 'compact.retry': '重试压缩',
41
+ 'compact.retrying': '正在重试压缩…',
42
+ 'compact.proceed': '仍然执行',
43
+ 'compact.cancel': '取消',
44
+ 'compact.success': '压缩完成,正在执行…',
45
+ 'compact.error.usage': '用法:/great-plan-compact(不接受参数)',
46
+ 'compact.error.planStateUnavailable': '当前组合无法读取 plan 状态。',
47
+ 'compact.error.planInactive': 'plan 模式未激活,没有可压缩的内容。',
48
+ 'compact.error.backendUnavailable': '当前部署没有可用的压缩后端。',
49
+ 'compact.error.noReview': '没有待审核的计划可供压缩。',
50
+ 'compact.error.empty': '待审计划之前没有可压缩的历史记录。',
51
+ 'compact.error.busy': '另一个压缩正在进行,暂时无法压缩。',
52
+ 'compact.error.changed': '压缩期间所选历史发生变化。',
53
+ 'compact.error.summary': '压缩未能生成有用的摘要。',
54
+ 'compact.error.commit': '压缩未正常完成。',
55
+ 'compact.error.persistence': '压缩完成,但会话保存失败。',
56
+ 'compact.error.cancelled': '压缩已取消。',
57
+ 'compact.error.unavailable': '当前会话不支持 /great-plan-compact 命令。',
58
+ 'clear.title': '清空上下文并执行',
59
+ 'clear.warning': '此操作会停止当前回合,并在同一工作区创建新会话执行计划。当前会话会保留,不会归档。',
60
+ 'clear.preset': '新会话 Agent 模式',
61
+ 'clear.titleLabel': '新会话标题',
62
+ 'clear.autoTitle': '将自动生成标题',
63
+ 'clear.model': '模型与思考强度',
64
+ 'clear.modelHint': '返回上级可修改',
65
+ 'clear.busy': '正在创建新会话…',
66
+ 'clear.confirm': '创建新会话并执行',
67
+ 'clear.kept': '旧会话会保留,可在列表中重新打开。',
68
+ 'clear.titleSuffix': ' · 执行计划',
69
+ 'preview.title': '首条消息预览',
70
+ 'preview.plan': '已批准的计划',
71
+ 'preview.viewPlan': '查看完整计划',
72
+ 'preview.hidePlan': '收起完整计划',
73
+ 'preview.blocks': '首条消息将包含',
74
+ 'preview.block.intro': '执行说明',
75
+ 'preview.block.budget': 'token 预算提示',
76
+ 'preview.block.runtime': '运行环境',
77
+ 'preview.block.instructions': '原始用户指令',
78
+ 'preview.block.plan': '已批准的计划',
79
+ 'preview.block.approved': '执行提示',
80
+ 'preview.included': '将携带',
81
+ 'preview.excluded': '不携带',
82
+ 'preview.instructionCount': '{count} 条 · {chars} 字符',
83
+ 'preview.loadingInstructions': '正在读取计划模式指令…',
84
+ 'preview.instructionsUnavailable': '未找到计划模式指令,将只发送计划。',
85
+ 'preview.noInstructions': '本次不携带原始用户指令。',
86
+ 'preview.oversized': '首条消息较长({chars} 字符),可能接近模型的上下文上限。',
87
+ 'preview.total': '合计 {chars} 字符',
88
+ 'preview.approxTokens': '约 {tokens} tokens',
89
+ 'preview.runtimeHint': '运行环境在执行时重新读取,预览中的时间可能与实际不同。',
90
+ 'preview.minimalSwitchHint': '仅极简模式会携带运行环境。',
91
+ 'preview.switches': '本次执行开关',
92
+ 'preview.switchBudget': '携带 token 预算提示',
93
+ 'preview.switchInstructions': '携带原始用户指令',
94
+ 'preview.switchMinimal': '携带运行环境',
95
+ 'preset.title': '选择 Agent 模式',
96
+ 'preset.loading': '正在加载 Agent 模式…',
97
+ 'preset.unavailable': '当前部署没有可选的 Agent 模式,将使用默认模式。',
98
+ 'preset.error': 'Agent 模式加载失败:{message}',
99
+ 'preset.retry': '重试',
100
+ 'preset.default': '默认模式',
101
+ 'preset.standard': '标准模式',
102
+ 'preset.ptc': 'PTC 模式',
103
+ 'preset.minimal': '极简模式',
104
+ 'preset.cordis': '创建者模式',
105
+ 'preset.standardDescription': '使用代码、文件和信息完成任务。',
106
+ 'preset.ptcDescription': '适合批量调用工具并整理结果。',
107
+ 'preset.minimalDescription': 'Agent 使用终端和图片读取工具,并支持上下文压缩,适合测试和对比其基础表现。',
108
+ 'preset.cordisDescription': '创建插件或 Agent 预设。',
109
+ 'model.accountProvider': 'DeepSeek 账户',
110
+ 'error.reviewGone': '计划审核已结束,无法继续操作。',
111
+ 'error.approveFailed': '执行计划失败:{message}',
112
+ 'error.selectModelFailed': '应用模型选择失败:{message}',
113
+ 'error.freshFailed': '新会话执行失败:{message}',
114
+ 'error.emptyPlan': '计划内容为空,无法执行。',
115
+ 'error.createSessionFailed': '创建新会话失败:{message}',
116
+ 'error.modelApplyFailed': '应用模型选择失败:{message}',
117
+ 'error.startSessionFailed': '在新会话中开始执行失败:{message}',
118
+ 'error.openSessionFailed': '新会话已创建,但无法打开:{message}',
119
+ 'error.dismissFailed': '无法返回修改,请重试:{message}',
120
+ 'error.cleanupFailed': '未能清理失败执行创建的新会话:{message}',
121
+ 'settings.nav': '卓越计划',
122
+ 'settings.heading': '计划与执行',
123
+ 'settings.brainstorm.title': '注入头脑风暴提示词',
124
+ 'settings.brainstorm.description': 'plan 模式下,当需求存在会改变方案的歧义时,先逐轮提出一个简短问题,再给出建议与权衡。关闭后仍保留证据链与执行交接提示。',
125
+ 'settings.budget.title': '注入 token 预算无上限提示词',
126
+ 'settings.budget.description': 'plan 模式与新会话执行时,提示模型 token 预算无上限,不要为节省用量而减少调研、工具调用或必要提问;最终计划与总结仍保持简洁。',
127
+ 'settings.originalInstructions.title': '携带原始用户指令',
128
+ 'settings.originalInstructions.description': '「清空上下文并执行」时,把本次 plan 模式期间用户发送的指令按时间顺序带入新会话,与已批准的计划一起作为首条消息。',
129
+ 'settings.minimalContext.title': '极简模式下附带运行环境',
130
+ 'settings.minimalContext.description': '新会话选择极简模式时,在首条消息中附带工作区目录、系统平台和当前时间/时区,并注明旧上下文已清空。',
131
+ 'settings.loading': '正在读取设置…',
132
+ 'settings.unavailable': '当前部署未提供该设置项。',
133
+ 'settings.readOnly': '当前配置不可写入。',
134
+ 'settings.saveFailed': '保存失败,请重试。',
135
+ 'settings.reset': '恢复默认',
136
+ 'settings.resetAria': '将「{title}」恢复为默认开启',
137
+ };
138
+
139
+ const en = {
140
+ 'trigger.label': 'Execution options',
141
+ 'trigger.aria': 'Execution options: choose the model, reasoning effort, and execution method',
142
+ 'panel.title': 'Execution options',
143
+ 'panel.subtitle': 'Choose the model and how to execute the plan',
144
+ 'panel.back': 'Back',
145
+ 'panel.close': 'Close',
146
+ 'panel.model': 'Model',
147
+ 'panel.effort': 'Reasoning effort',
148
+ 'panel.modelHint': 'Model used by this session',
149
+ 'panel.effortHint': 'Reasoning level for the selected model',
150
+ 'panel.loading': 'Loading…',
151
+ 'panel.selectModel': 'Select model',
152
+ 'panel.selectModelFirst': 'Select a model first.',
153
+ 'panel.providerDefault': 'Provider default',
154
+ 'panel.retry': 'Retry',
155
+ 'panel.modelEmpty': 'No models available.',
156
+ 'panel.noEffort': 'This model provides no reasoning levels.',
157
+ 'panel.modelError': 'Model catalog failed to load: {message}',
158
+ 'panel.subagentUnavailable': 'Model switching is unavailable in subagent sessions.',
159
+ 'panel.applyNote': 'Model changes apply to this session immediately.',
160
+ 'panel.revise': 'Back to editing',
161
+ 'panel.searchModels': 'Search models',
162
+ 'panel.noMatches': 'No matching models.',
163
+ 'panel.modelDirectoryLoading': 'The model catalog is still loading; a fresh session cannot start yet.',
164
+ 'panel.modelDirectoryError': 'The model catalog is unavailable; the default model will be used.',
165
+ 'panel.actions.execute.title': 'Execute plan',
166
+ 'panel.actions.execute.description': 'Continue in this session with the selected model',
167
+ 'panel.actions.compact.title': 'Compact and execute',
168
+ 'panel.actions.compact.description': 'Compact older history, then execute the plan',
169
+ 'panel.actions.clear.title': 'Start fresh and execute…',
170
+ 'panel.actions.clear.description': 'Stop this turn and execute the plan in a new session',
171
+ 'compact.failed': 'Compaction did not complete: {message}',
172
+ 'compact.failedHint': 'You can retry compaction, execute the plan anyway, or cancel this run.',
173
+ 'compact.retry': 'Retry compaction',
174
+ 'compact.retrying': 'Retrying compaction…',
175
+ 'compact.proceed': 'Execute anyway',
176
+ 'compact.cancel': 'Cancel',
177
+ 'compact.success': 'Compaction complete; executing…',
178
+ 'compact.error.usage': 'Usage: /great-plan-compact (no arguments)',
179
+ 'compact.error.planStateUnavailable': 'Plan state is unavailable in this composition.',
180
+ 'compact.error.planInactive': 'Plan mode is not active; nothing to compact.',
181
+ 'compact.error.backendUnavailable': 'Compaction is unavailable because no compaction backend is composed.',
182
+ 'compact.error.noReview': 'There is no pending plan review to compact.',
183
+ 'compact.error.empty': 'No compactable history exists before the pending plan.',
184
+ 'compact.error.busy': 'Compaction is unavailable because another compaction is running.',
185
+ 'compact.error.changed': 'The history selected for compaction changed during the attempt.',
186
+ 'compact.error.summary': 'Compaction could not produce a useful summary.',
187
+ 'compact.error.commit': 'Compaction did not finish cleanly.',
188
+ 'compact.error.persistence': 'Compaction finished, but the session could not be saved.',
189
+ 'compact.error.cancelled': 'Compaction was cancelled.',
190
+ 'compact.error.unavailable': 'The /great-plan-compact command is unavailable.',
191
+ 'clear.title': 'Start fresh and execute',
192
+ 'clear.warning': 'This stops the current turn and creates a new session in the same workspace to execute the plan. The current session is kept and is not archived.',
193
+ 'clear.preset': 'New session agent preset',
194
+ 'clear.titleLabel': 'New session title',
195
+ 'clear.autoTitle': 'A title will be generated',
196
+ 'clear.model': 'Model and reasoning effort',
197
+ 'clear.modelHint': 'Go back to change',
198
+ 'clear.busy': 'Creating the new session…',
199
+ 'clear.confirm': 'Create session and execute',
200
+ 'clear.kept': 'The previous session is kept and can be reopened from the list.',
201
+ 'clear.titleSuffix': ' · Execute plan',
202
+ 'preview.title': 'First-message preview',
203
+ 'preview.plan': 'Approved plan',
204
+ 'preview.viewPlan': 'View full plan',
205
+ 'preview.hidePlan': 'Hide full plan',
206
+ 'preview.blocks': 'First message will include',
207
+ 'preview.block.intro': 'Execution intro',
208
+ 'preview.block.budget': 'Token budget reminder',
209
+ 'preview.block.runtime': 'Runtime context',
210
+ 'preview.block.instructions': 'Original user instructions',
211
+ 'preview.block.plan': 'Approved plan',
212
+ 'preview.block.approved': 'Execution cue',
213
+ 'preview.included': 'Included',
214
+ 'preview.excluded': 'Excluded',
215
+ 'preview.instructionCount': '{count} items · {chars} chars',
216
+ 'preview.loadingInstructions': 'Reading plan-mode instructions…',
217
+ 'preview.instructionsUnavailable': 'No plan-mode instructions were found; only the plan will be sent.',
218
+ 'preview.noInstructions': 'Original user instructions are excluded for this run.',
219
+ 'preview.oversized': 'The first message is long ({chars} characters) and may approach the model\'s context limit.',
220
+ 'preview.total': 'Total: {chars} characters',
221
+ 'preview.approxTokens': 'About {tokens} tokens',
222
+ 'preview.runtimeHint': 'Runtime context is read again at execution time, so the previewed time may differ.',
223
+ 'preview.minimalSwitchHint': 'Runtime context is included only with the Minimal preset.',
224
+ 'preview.switches': 'This-run switches',
225
+ 'preview.switchBudget': 'Include token budget reminder',
226
+ 'preview.switchInstructions': 'Include original user instructions',
227
+ 'preview.switchMinimal': 'Include runtime context',
228
+ 'preset.title': 'Choose an agent preset',
229
+ 'preset.loading': 'Loading agent presets…',
230
+ 'preset.unavailable': 'No agent presets are available in this deployment; the default preset will be used.',
231
+ 'preset.error': 'Agent presets failed to load: {message}',
232
+ 'preset.retry': 'Retry',
233
+ 'preset.default': 'Default preset',
234
+ 'preset.standard': 'Standard',
235
+ 'preset.ptc': 'PTC',
236
+ 'preset.minimal': 'Minimal mode',
237
+ 'preset.cordis': 'Creator',
238
+ 'preset.standardDescription': 'Work with code, files, and information.',
239
+ 'preset.ptcDescription': 'Call tools in batches and organize the results.',
240
+ 'preset.minimalDescription': 'The agent uses a terminal and image reading, with context compaction. Useful for testing and comparing its basic performance.',
241
+ 'preset.cordisDescription': 'Create plugins or agent presets.',
242
+ 'model.accountProvider': 'DeepSeek Account',
243
+ 'error.reviewGone': 'The plan review has ended.',
244
+ 'error.approveFailed': 'Executing the plan failed: {message}',
245
+ 'error.selectModelFailed': 'Applying the model selection failed: {message}',
246
+ 'error.freshFailed': 'The fresh-session execution failed: {message}',
247
+ 'error.emptyPlan': 'The reviewed plan is empty.',
248
+ 'error.createSessionFailed': 'Creating the new session failed: {message}',
249
+ 'error.modelApplyFailed': 'Applying the model selection failed: {message}',
250
+ 'error.startSessionFailed': 'Starting execution in the new session failed: {message}',
251
+ 'error.openSessionFailed': 'The new session was created but could not be opened: {message}',
252
+ 'error.dismissFailed': 'Could not return to editing: {message}',
253
+ 'error.cleanupFailed': 'The unused new session could not be cleaned up: {message}',
254
+ 'settings.nav': 'Great Plan',
255
+ 'settings.heading': 'Planning and execution',
256
+ 'settings.brainstorm.title': 'Inject brainstorming prompt',
257
+ 'settings.brainstorm.description': 'In plan mode, ask one short question at a time when ambiguity could change the approach, then summarize recommendations and tradeoffs. Turning this off keeps the evidence chain and execution handoff guidance.',
258
+ 'settings.budget.title': 'Inject unlimited token budget prompt',
259
+ 'settings.budget.description': 'In plan mode and fresh-session execution, tell the model its token budget is unlimited and not to cut investigation, tool calls, or necessary questions to save tokens. Plans and summaries stay concise.',
260
+ 'settings.originalInstructions.title': 'Include original user instructions',
261
+ 'settings.originalInstructions.description': 'When starting fresh, carry the instructions the user sent during the current plan-mode session, in chronological order, into the new session\'s first message alongside the approved plan.',
262
+ 'settings.minimalContext.title': 'Include runtime context in Minimal mode',
263
+ 'settings.minimalContext.description': 'When the new session uses Minimal mode, include the workspace directory, system platform, and current time/time zone in its first message, and note that the previous context was cleared.',
264
+ 'settings.loading': 'Loading settings…',
265
+ 'settings.unavailable': 'This preference is unavailable in the current deployment.',
266
+ 'settings.readOnly': 'This preference is read-only in the current configuration.',
267
+ 'settings.saveFailed': 'Could not save. Please try again.',
268
+ 'settings.reset': 'Reset to default',
269
+ 'settings.resetAria': 'Reset "{title}" to its default (on)',
270
+ };
271
+
272
+ /**
273
+ * Client chunk load waves.
274
+ *
275
+ * The first wave carries the primitives (`ui`) and the stateless actions, the
276
+ * second the configuration/preset surfaces, and the final wave the panel that
277
+ * composes them. Loading is parallel inside one wave and sequential across
278
+ * waves. `testSurface.loadChunks` exposes the grouping to the wiring tests
279
+ * only; the DSH Loader consumes just `inject` and `apply`.
280
+ */
281
+ const CHUNK_WAVES = [
282
+ ['./client.ui.js', './client.actions.js'],
283
+ ['./client.settings.js', './client.model.js', './client.preset.js'],
284
+ ['./client.panel.js'],
285
+ ];
286
+
287
+ /** Load every chunk in wave order, parallel inside one wave. */
288
+ async function loadChunks(load) {
289
+ const chunks = {};
290
+ for (const wave of CHUNK_WAVES) {
291
+ const values = await Promise.all(wave.map((spec) => load(spec)));
292
+ for (let index = 0; index < wave.length; index += 1) chunks[wave[index]] = values[index];
293
+ }
294
+ return chunks;
295
+ }
296
+
297
+ return {
298
+ inject: [
299
+ 'slots',
300
+ 'locale',
301
+ 'sessions',
302
+ 'modelDirectories',
303
+ 'remote',
304
+ 'remote.session',
305
+ 'remote.commands',
306
+ 'remote.agentPresets',
307
+ 'uiWorkspace',
308
+ ],
309
+ // Test-only surface: the DSH Loader consumes `inject` and `apply` only.
310
+ testSurface: {
311
+ dictionaries: { zh, en },
312
+ loadChunks: () => CHUNK_WAVES.map((wave) => [...wave]),
313
+ },
314
+ async apply(ctx) {
315
+ // Three waves: primitives + stateless actions, then the
316
+ // configuration/preset surfaces, then the panel that composes them.
317
+ const chunks = await loadChunks((spec) => require.async(spec));
318
+ const ui = chunks['./client.ui.js'];
319
+ const settings = chunks['./client.settings.js'];
320
+ const preset = chunks['./client.preset.js'];
321
+ const actions = chunks['./client.actions.js'];
322
+ const panel = chunks['./client.panel.js'];
323
+
324
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'great-plan: dictionaries');
325
+ ctx.effect(() => ui.installStyles(), 'great-plan: styles');
326
+
327
+ const presetStore = preset.createPresetStore(ctx);
328
+ const t = ctx.locale.bind(NS);
329
+
330
+ // `configForms` is optional (not in the plugin-level inject list): the
331
+ // plan-review entry must load without the settings capability. The
332
+ // latest injected mirror backs both the Settings page and the
333
+ // fresh-session prompt switches; without it every switch defaults on.
334
+ let configForms;
335
+ const readFreshPromptSwitches = () => settings.readFreshPromptSwitches(configForms);
336
+
337
+ ctx.slots.inject('conversation.plan-review.actions', () => ctx.slots.register({
338
+ name: 'conversation.plan-review.actions',
339
+ id: 'great-plan-execute-options',
340
+ order: -2,
341
+ locale: NS,
342
+ inject: (sessionId) => ({
343
+ directory: ctx.modelDirectories.directoryFor(sessionId),
344
+ actions: actions.createActions(ctx, sessionId, readFreshPromptSwitches),
345
+ presetStore,
346
+ modelAvailable: ctx.sessions.subagentAddress(sessionId) === undefined,
347
+ readPromptSwitches: readFreshPromptSwitches,
348
+ }),
349
+ }, panel.ExecutionTrigger));
350
+
351
+ ctx.inject(['configForms'], (scope) => {
352
+ configForms = scope.get('configForms');
353
+ if (configForms === undefined || configForms === null) return;
354
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
355
+ name: 'settings.section',
356
+ id: 'great-plan',
357
+ order: 25,
358
+ label: () => t('settings.nav'),
359
+ locale: NS,
360
+ inject: () => ({ form: configForms.get('great-plan') }),
361
+ }, settings.GreatPlanSection));
362
+ });
363
+ },
364
+ };
365
+ },
366
+ });
@@ -0,0 +1,232 @@
1
+ window.__ModuleLoader__.load({
2
+ id: '@onefl666/dsh-great-plan',
3
+ chunk: 'client.model.js',
4
+ factory(require) {
5
+ const React = require('react');
6
+ const h = React.createElement;
7
+ const ui = require('@onefl666/dsh-great-plan/client.ui.js');
8
+
9
+ function useDirectoryState(directory) {
10
+ return React.useSyncExternalStore(
11
+ (listener) => directory.store.subscribe(listener),
12
+ () => directory.store.getSnapshot(),
13
+ () => directory.store.getSnapshot(),
14
+ );
15
+ }
16
+
17
+ function currentModelOf(state) {
18
+ const current = state.current;
19
+ if (current === null || current === undefined) return undefined;
20
+ for (const group of state.groups ?? []) {
21
+ if (group.id !== current.provider) continue;
22
+ const model = (group.models ?? []).find((candidate) => candidate.id === current.model);
23
+ if (model !== undefined) return { group, model };
24
+ }
25
+ return undefined;
26
+ }
27
+
28
+ function reasoningOf(state) {
29
+ return currentModelOf(state)?.model?.reasoning;
30
+ }
31
+
32
+ function modelNameOf(state, t) {
33
+ const current = state.current;
34
+ if (current === null || current === undefined) {
35
+ return state.status === 'loading' ? t('panel.loading') : t('panel.selectModel');
36
+ }
37
+ return currentModelOf(state)?.model?.name ?? current.model;
38
+ }
39
+
40
+ function effortLabelOf(state, t) {
41
+ const current = state.current;
42
+ if (current === null || current === undefined) return undefined;
43
+ const reasoning = reasoningOf(state);
44
+ const effort = current.reasoningEffort ?? reasoning?.defaultEffort;
45
+ if (effort === undefined) return reasoning === undefined ? undefined : t('panel.providerDefault');
46
+ return reasoning?.efforts?.find((level) => level.id === effort)?.name ?? effort;
47
+ }
48
+
49
+ function summaryOf(state, t) {
50
+ const name = modelNameOf(state, t);
51
+ const effort = effortLabelOf(state, t);
52
+ return effort === undefined ? name : `${name} · ${effort}`;
53
+ }
54
+
55
+ function toModelSelection(current) {
56
+ if (current === null || current === undefined) return undefined;
57
+ return {
58
+ provider: current.provider,
59
+ model: current.model,
60
+ ...current.reasoningEffort === undefined ? {} : { reasoningEffort: current.reasoningEffort },
61
+ };
62
+ }
63
+
64
+ function providerRank(id) {
65
+ if (id === 'deepseek-account') return 0;
66
+ if (id === 'deepseek-official') return 1;
67
+ return 2;
68
+ }
69
+
70
+ function orderedGroups(groups) {
71
+ return [...groups].sort((left, right) => providerRank(left.id) - providerRank(right.id));
72
+ }
73
+
74
+ function providerName(group, t) {
75
+ if (group.id === 'deepseek-account') return t('model.accountProvider');
76
+ return group.name ?? group.id;
77
+ }
78
+
79
+ /** Case-insensitive substring match against the trimmed model-search query. */
80
+ function matchesQuery(value, needle) {
81
+ return typeof value === 'string' && value.toLowerCase().includes(needle);
82
+ }
83
+
84
+ /**
85
+ * Filter ordered model groups by a free-text query.
86
+ *
87
+ * A missing, blank, or whitespace-only query returns the input array itself
88
+ * (same reference) so the unfiltered catalog keeps working without a copy.
89
+ * Otherwise the query is trimmed and matched case-insensitively against the
90
+ * provider id/display name and the model id/name: a provider match keeps all
91
+ * of its models, a model match keeps only the matching models, and groups
92
+ * left without models are dropped. Ordering is preserved.
93
+ */
94
+ function filterModelGroups(groups, query) {
95
+ if (typeof query !== 'string' || query.trim() === '') return groups;
96
+ const needle = query.trim().toLowerCase();
97
+ const filtered = [];
98
+ for (const group of groups) {
99
+ const models = group.models ?? [];
100
+ if (matchesQuery(group.id, needle) || matchesQuery(group.name, needle)) {
101
+ if (models.length > 0) filtered.push(group);
102
+ continue;
103
+ }
104
+ const matching = models.filter((model) => (
105
+ matchesQuery(model?.id, needle) || matchesQuery(model?.name, needle)
106
+ ));
107
+ if (matching.length > 0) filtered.push({ ...group, models: matching });
108
+ }
109
+ return filtered;
110
+ }
111
+
112
+ function ModelRows({ state, t, disabled, onOpenModel, onOpenEffort, onRetry }) {
113
+ const reasoning = reasoningOf(state);
114
+ const busy = disabled || state.status === 'selecting';
115
+ return h('div', null,
116
+ h(ui.SelectRow, {
117
+ label: t('panel.model'),
118
+ value: modelNameOf(state, t),
119
+ description: t('panel.modelHint'),
120
+ disabled: busy,
121
+ onClick: onOpenModel,
122
+ }),
123
+ reasoning === undefined ? null : h(ui.SelectRow, {
124
+ label: t('panel.effort'),
125
+ value: effortLabelOf(state, t) ?? t('panel.providerDefault'),
126
+ description: t('panel.effortHint'),
127
+ disabled: busy,
128
+ onClick: onOpenEffort,
129
+ }),
130
+ state.status === 'error' ? h('div', null,
131
+ h(ui.ErrorText, null, t('panel.modelError', { message: state.error ?? '' })),
132
+ h(ui.Button, { variant: 'ghost', onClick: onRetry }, t('panel.retry')),
133
+ ) : null,
134
+ );
135
+ }
136
+
137
+ function ModelListView({ state, t, disabled, onPick, onRetry }) {
138
+ const [query, setQuery] = React.useState('');
139
+ const groups = orderedGroups(state.groups ?? []);
140
+ const busy = disabled || state.status === 'selecting';
141
+ if (groups.length === 0) {
142
+ if (state.status === 'loading' || state.status === 'idle') {
143
+ return h(ui.Note, null, h(ui.Loading, null, t('panel.loading')));
144
+ }
145
+ if (state.status === 'error') {
146
+ return h('div', null,
147
+ h(ui.ErrorText, null, t('panel.modelError', { message: state.error ?? '' })),
148
+ h(ui.Button, { variant: 'ghost', onClick: onRetry }, t('panel.retry')),
149
+ );
150
+ }
151
+ return h(ui.Note, null, t('panel.modelEmpty'));
152
+ }
153
+ const visible = filterModelGroups(groups, query);
154
+ return h('div', null,
155
+ h('input', {
156
+ type: 'search',
157
+ className: 'gp-search',
158
+ value: query,
159
+ placeholder: t('panel.searchModels'),
160
+ 'aria-label': t('panel.searchModels'),
161
+ onChange: (event) => {
162
+ setQuery(event?.target?.value ?? '');
163
+ },
164
+ }),
165
+ state.status === 'error' ? h(ui.ErrorText, null, t('panel.modelError', { message: state.error ?? '' })) : null,
166
+ visible.length === 0
167
+ ? h(ui.Note, null, t('panel.noMatches'))
168
+ : h('div', { className: 'gp-list', role: 'radiogroup', 'aria-label': t('panel.model') },
169
+ visible.map((group) => h(React.Fragment, { key: group.id },
170
+ h(ui.GroupHeading, { role: 'presentation' }, providerName(group, t)),
171
+ (group.models ?? []).map((model) => {
172
+ const selected = state.current?.provider === group.id && state.current?.model === model.id;
173
+ return h(ui.ListRow, {
174
+ key: `${group.id}/${model.id}`,
175
+ title: model.name ?? model.id,
176
+ role: 'radio',
177
+ checked: selected,
178
+ selected,
179
+ disabled: busy,
180
+ onClick: () => onPick(group, model),
181
+ });
182
+ }),
183
+ )),
184
+ ),
185
+ state.status === 'error' ? h(ui.Button, { variant: 'ghost', onClick: onRetry }, t('panel.retry')) : null,
186
+ );
187
+ }
188
+
189
+ function EffortListView({ state, t, disabled, onPick }) {
190
+ const current = state.current;
191
+ if (current === null || current === undefined) return h(ui.Note, null, t('panel.selectModelFirst'));
192
+ const reasoning = reasoningOf(state);
193
+ if (reasoning === undefined) return h(ui.Note, null, t('panel.noEffort'));
194
+ const selectedEffort = current.reasoningEffort;
195
+ const options = [{ key: 'provider-default', id: undefined, name: t('panel.providerDefault') }];
196
+ for (const effort of reasoning.efforts ?? []) {
197
+ options.push({ key: `effort:${effort.id}`, id: effort.id, name: effort.name });
198
+ }
199
+ const busy = disabled || state.status === 'selecting';
200
+ return h('div', { className: 'gp-list', role: 'radiogroup', 'aria-label': t('panel.effort') },
201
+ options.map((option) => {
202
+ const selected = selectedEffort === option.id;
203
+ return h(ui.ListRow, {
204
+ key: option.key,
205
+ title: option.name,
206
+ role: 'radio',
207
+ checked: selected,
208
+ selected,
209
+ disabled: busy,
210
+ onClick: () => onPick(option.id),
211
+ });
212
+ }),
213
+ );
214
+ }
215
+
216
+ return {
217
+ useDirectoryState,
218
+ currentModelOf,
219
+ reasoningOf,
220
+ modelNameOf,
221
+ effortLabelOf,
222
+ summaryOf,
223
+ toModelSelection,
224
+ orderedGroups,
225
+ providerName,
226
+ filterModelGroups,
227
+ ModelRows,
228
+ ModelListView,
229
+ EffortListView,
230
+ };
231
+ },
232
+ });