@xcanwin/manyoyo 6.2.8 → 6.2.10

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.
@@ -391,10 +391,15 @@ textarea:focus-visible {
391
391
  color: var(--muted);
392
392
  }
393
393
 
394
+ .text-block[hidden] {
395
+ display: none;
396
+ }
397
+
394
398
  .form-grid input,
395
399
  .form-grid select,
396
400
  .text-block textarea,
397
- .text-block input {
401
+ .text-block input,
402
+ .text-block select {
398
403
  border: 1px solid var(--line);
399
404
  border-radius: 10px;
400
405
  padding: 9px 11px;
@@ -121,7 +121,7 @@
121
121
  <button type="button" id="activityAgentBtn" class="secondary is-active">Agent对话</button>
122
122
  <button type="button" id="activityCommandBtn" class="secondary">系统命令</button>
123
123
  <button type="button" id="agentTemplateBtn" class="secondary">CLI · —</button>
124
- <span id="activityModelChip" class="composer-options-info" aria-live="polite">模型 · —</span>
124
+ <button type="button" id="activityModelChip" class="secondary">模型 · —</button>
125
125
  </div>
126
126
  </div>
127
127
  <button type="submit" id="sendBtn">发送</button>
@@ -288,6 +288,31 @@
288
288
  </section>
289
289
  </div>
290
290
 
291
+ <div id="modelModal" class="modal-backdrop" hidden>
292
+ <section class="modal" role="dialog" aria-modal="true" aria-labelledby="modelModalTitle">
293
+ <header class="modal-header">
294
+ <h2 id="modelModalTitle">选择模型</h2>
295
+ <button type="button" id="modelModalCancelBtn" class="secondary">关闭</button>
296
+ </header>
297
+ <div class="modal-body">
298
+ <div id="modelModalLoading" class="modal-tip" hidden>正在获取模型列表…</div>
299
+ <label class="text-block">模型
300
+ <select id="modelSelect">
301
+ <option value="">跟随默认(不传 --model)</option>
302
+ <option value="__custom__">自定义…</option>
303
+ </select>
304
+ </label>
305
+ <label class="text-block" id="modelCustomGroup" hidden>自定义模型名称
306
+ <input type="text" id="modelCustomInput" placeholder="例如 gemini-2.5-pro">
307
+ </label>
308
+ <div id="modelModalError" class="modal-error" hidden></div>
309
+ </div>
310
+ <footer class="modal-footer">
311
+ <button type="button" id="modelModalSaveBtn">保存</button>
312
+ </footer>
313
+ </section>
314
+ </div>
315
+
291
316
  <div id="externalLinkModal" class="modal-backdrop" hidden>
292
317
  <section class="modal" role="dialog" aria-modal="true" aria-labelledby="externalLinkTitle">
293
318
  <header class="modal-header">
@@ -53,6 +53,10 @@
53
53
  createModalOpen: false,
54
54
  agentTemplateModalOpen: false,
55
55
  externalLinkModalOpen: false,
56
+ modelModalOpen: false,
57
+ modelModalSaving: false,
58
+ modelModalLoading: false,
59
+ modelModalCatalog: [],
56
60
  configLoading: false,
57
61
  configSaving: false,
58
62
  configSaveMessage: '',
@@ -216,6 +220,14 @@
216
220
  const agentTemplateCancelBtn = document.getElementById('agentTemplateCancelBtn');
217
221
  const agentTemplateResetBtn = document.getElementById('agentTemplateResetBtn');
218
222
  const agentTemplateSaveBtn = document.getElementById('agentTemplateSaveBtn');
223
+ const modelModal = document.getElementById('modelModal');
224
+ const modelModalLoading = document.getElementById('modelModalLoading');
225
+ const modelSelect = document.getElementById('modelSelect');
226
+ const modelCustomGroup = document.getElementById('modelCustomGroup');
227
+ const modelCustomInput = document.getElementById('modelCustomInput');
228
+ const modelModalError = document.getElementById('modelModalError');
229
+ const modelModalCancelBtn = document.getElementById('modelModalCancelBtn');
230
+ const modelModalSaveBtn = document.getElementById('modelModalSaveBtn');
219
231
  const externalLinkModal = document.getElementById('externalLinkModal');
220
232
  const externalLinkUrl = document.getElementById('externalLinkUrl');
221
233
  const externalLinkCancelBtn = document.getElementById('externalLinkCancelBtn');
@@ -1524,7 +1536,120 @@
1524
1536
  if (!state.active) {
1525
1537
  return '—';
1526
1538
  }
1527
- return '自动';
1539
+ const activeSession = getActiveSession();
1540
+ const detail = state.sessionDetail && state.active === (state.sessionDetail.name || state.active) ? state.sessionDetail : null;
1541
+ const model = String(
1542
+ (detail && detail.model)
1543
+ || (activeSession && activeSession.model)
1544
+ || ''
1545
+ ).trim();
1546
+ return model || '自动';
1547
+ }
1548
+
1549
+ const MODEL_CUSTOM_OPTION_VALUE = '__custom__';
1550
+
1551
+ function populateModelSelect(models, selectedModel) {
1552
+ if (!modelSelect) {
1553
+ return;
1554
+ }
1555
+ modelSelect.innerHTML = '';
1556
+ const followOption = document.createElement('option');
1557
+ followOption.value = '';
1558
+ followOption.textContent = '跟随默认(不传 --model)';
1559
+ modelSelect.appendChild(followOption);
1560
+
1561
+ const knownValues = new Set();
1562
+ (Array.isArray(models) ? models : []).forEach(function (model) {
1563
+ if (!model || !model.value) {
1564
+ return;
1565
+ }
1566
+ knownValues.add(model.value);
1567
+ const option = document.createElement('option');
1568
+ option.value = model.value;
1569
+ option.textContent = model.description ? `${model.label} · ${model.description}` : model.label;
1570
+ modelSelect.appendChild(option);
1571
+ });
1572
+
1573
+ const customOption = document.createElement('option');
1574
+ customOption.value = MODEL_CUSTOM_OPTION_VALUE;
1575
+ customOption.textContent = '自定义…';
1576
+ modelSelect.appendChild(customOption);
1577
+
1578
+ if (selectedModel && knownValues.has(selectedModel)) {
1579
+ modelSelect.value = selectedModel;
1580
+ if (modelCustomGroup) modelCustomGroup.hidden = true;
1581
+ } else if (selectedModel) {
1582
+ modelSelect.value = MODEL_CUSTOM_OPTION_VALUE;
1583
+ if (modelCustomInput) modelCustomInput.value = selectedModel;
1584
+ if (modelCustomGroup) modelCustomGroup.hidden = false;
1585
+ } else {
1586
+ modelSelect.value = '';
1587
+ if (modelCustomGroup) modelCustomGroup.hidden = true;
1588
+ }
1589
+ }
1590
+
1591
+ function showModelModalError(message) {
1592
+ if (!modelModalError) return;
1593
+ modelModalError.textContent = message || '';
1594
+ modelModalError.hidden = !message;
1595
+ }
1596
+
1597
+ async function openModelModal() {
1598
+ if (!state.active || state.modelModalSaving) {
1599
+ return;
1600
+ }
1601
+ const detail = await ensureActiveSessionDetail();
1602
+ if (!detail) {
1603
+ alert(state.sessionDetailError || '当前会话详情暂时不可用');
1604
+ return;
1605
+ }
1606
+ state.modelModalOpen = true;
1607
+ state.modelModalLoading = true;
1608
+ state.modelModalCatalog = [];
1609
+ showModelModalError('');
1610
+ populateModelSelect([], detail.model || '');
1611
+ syncUi();
1612
+ try {
1613
+ const data = await api('/api/sessions/' + encodeURIComponent(state.active) + '/models');
1614
+ state.modelModalCatalog = Array.isArray(data && data.models) ? data.models : [];
1615
+ } catch (e) {
1616
+ state.modelModalCatalog = [];
1617
+ } finally {
1618
+ state.modelModalLoading = false;
1619
+ populateModelSelect(state.modelModalCatalog, detail.model || '');
1620
+ syncUi();
1621
+ }
1622
+ }
1623
+
1624
+ function closeModelModal() {
1625
+ state.modelModalOpen = false;
1626
+ showModelModalError('');
1627
+ }
1628
+
1629
+ async function saveModelModal() {
1630
+ if (!state.active || state.modelModalSaving) {
1631
+ return;
1632
+ }
1633
+ const rawValue = modelSelect && modelSelect.value === MODEL_CUSTOM_OPTION_VALUE
1634
+ ? (modelCustomInput ? modelCustomInput.value : '')
1635
+ : (modelSelect ? modelSelect.value : '');
1636
+ state.modelModalSaving = true;
1637
+ showModelModalError('');
1638
+ syncUi();
1639
+ try {
1640
+ await api('/api/sessions/' + encodeURIComponent(state.active) + '/model', {
1641
+ method: 'POST',
1642
+ body: JSON.stringify({ model: String(rawValue || '').trim() })
1643
+ });
1644
+ await refreshSessionsSilent({ preferredName: state.active });
1645
+ await loadSessionDetailForSession(state.active);
1646
+ closeModelModal();
1647
+ } catch (e) {
1648
+ showModelModalError(e && e.message ? e.message : '保存失败');
1649
+ } finally {
1650
+ state.modelModalSaving = false;
1651
+ syncUi();
1652
+ }
1528
1653
  }
1529
1654
 
1530
1655
  function buildMessageMetaLines(message) {
@@ -2083,6 +2208,13 @@
2083
2208
  { label: '最近 resume', value: lastResumeText },
2084
2209
  { label: '最近结果', value: detail.lastResumeOk == null ? '暂无' : (detail.lastResumeOk ? '成功' : '失败'), tone: detail.lastResumeOk == null ? 'info' : (detail.lastResumeOk ? 'ok' : 'danger') }
2085
2210
  ]);
2211
+ renderKeyValueCard(detailSummary, '用量统计', detail.usageTotal ? [
2212
+ { label: '累计输入 tokens', value: String(detail.usageTotal.inputTokens) },
2213
+ { label: '累计输出 tokens', value: String(detail.usageTotal.outputTokens) },
2214
+ { label: '累计花费', value: typeof detail.usageTotal.costUsd === 'number' ? `$${detail.usageTotal.costUsd.toFixed(4)}` : '暂不支持' }
2215
+ ] : [
2216
+ { label: '状态', value: '暂无数据(当前 Agent 程序不支持用量统计,或还未执行过对话)', tone: 'info' }
2217
+ ]);
2086
2218
  renderKeyValueCard(detailSummary, '最近活动', [
2087
2219
  { label: '最近角色', value: latestRoleLabel },
2088
2220
  { label: '最近时间', value: latestTimestampText },
@@ -2210,7 +2342,7 @@
2210
2342
  if (activityModelChip) {
2211
2343
  activityModelChip.textContent = `模型 · ${resolveToolbarModelLabel()}`;
2212
2344
  activityModelChip.title = state.active
2213
- ? '当前版本暂不单独配置模型,默认跟随 CLI 或容器内配置'
2345
+ ? '点击选择本会话使用的模型'
2214
2346
  : '请先选择会话';
2215
2347
  }
2216
2348
  if (viewActivityBtn) viewActivityBtn.classList.toggle('is-active', activityTab);
@@ -2315,6 +2447,27 @@
2315
2447
  if (externalLinkModal) {
2316
2448
  externalLinkModal.hidden = !state.externalLinkModalOpen;
2317
2449
  }
2450
+ if (modelModal) {
2451
+ modelModal.hidden = !state.modelModalOpen;
2452
+ }
2453
+ if (modelModalLoading) {
2454
+ modelModalLoading.hidden = !state.modelModalLoading;
2455
+ }
2456
+ if (modelSelect) {
2457
+ modelSelect.disabled = state.modelModalSaving || state.modelModalLoading;
2458
+ }
2459
+ if (modelCustomInput) {
2460
+ modelCustomInput.disabled = state.modelModalSaving || state.modelModalLoading;
2461
+ }
2462
+ if (modelModalSaveBtn) {
2463
+ modelModalSaveBtn.disabled = state.modelModalSaving || state.modelModalLoading;
2464
+ }
2465
+ if (modelModalCancelBtn) {
2466
+ modelModalCancelBtn.disabled = state.modelModalSaving;
2467
+ }
2468
+ if (activityModelChip) {
2469
+ activityModelChip.disabled = !state.active;
2470
+ }
2318
2471
  if (agentTemplateSaveBtn) {
2319
2472
  agentTemplateSaveBtn.disabled = state.agentTemplateSaving || !state.active;
2320
2473
  }
@@ -2338,7 +2491,7 @@
2338
2491
  }
2339
2492
  document.body.classList.toggle(
2340
2493
  'modal-open',
2341
- state.configModalOpen || state.createModalOpen || state.directoryPicker.open || state.agentTemplateModalOpen || state.externalLinkModalOpen || state.cloneModal.open
2494
+ state.configModalOpen || state.createModalOpen || state.directoryPicker.open || state.agentTemplateModalOpen || state.externalLinkModalOpen || state.cloneModal.open || state.modelModalOpen
2342
2495
  );
2343
2496
  if (composer) {
2344
2497
  composer.hidden = !activityTab;
@@ -4231,6 +4384,45 @@
4231
4384
  });
4232
4385
  }
4233
4386
 
4387
+ if (activityModelChip) {
4388
+ activityModelChip.addEventListener('click', function () {
4389
+ closeComposerOptionsMenu();
4390
+ openModelModal().catch(function (e) {
4391
+ alert(e && e.message ? e.message : '加载模型信息失败');
4392
+ });
4393
+ });
4394
+ }
4395
+
4396
+ if (modelModalCancelBtn) {
4397
+ modelModalCancelBtn.addEventListener('click', function () {
4398
+ closeModelModal();
4399
+ syncUi();
4400
+ });
4401
+ }
4402
+
4403
+ if (modelModalSaveBtn) {
4404
+ modelModalSaveBtn.addEventListener('click', function () {
4405
+ saveModelModal();
4406
+ });
4407
+ }
4408
+
4409
+ if (modelSelect) {
4410
+ modelSelect.addEventListener('change', function () {
4411
+ if (modelCustomGroup) {
4412
+ modelCustomGroup.hidden = modelSelect.value !== MODEL_CUSTOM_OPTION_VALUE;
4413
+ }
4414
+ });
4415
+ }
4416
+
4417
+ if (modelModal) {
4418
+ modelModal.addEventListener('click', function (event) {
4419
+ if (event.target === modelModal && !state.modelModalSaving) {
4420
+ closeModelModal();
4421
+ syncUi();
4422
+ }
4423
+ });
4424
+ }
4425
+
4234
4426
  if (configCancelBtn) {
4235
4427
  configCancelBtn.addEventListener('click', function () {
4236
4428
  closeConfigModal();
@@ -4757,6 +4949,10 @@
4757
4949
  closeAgentTemplateModal();
4758
4950
  syncUi();
4759
4951
  }
4952
+ if (event.key === 'Escape' && state.modelModalOpen && !state.modelModalSaving) {
4953
+ closeModelModal();
4954
+ syncUi();
4955
+ }
4760
4956
  if (event.key === 'Escape' && state.externalLinkModalOpen) {
4761
4957
  closeExternalLinkModalView();
4762
4958
  syncUi();
package/lib/web/server.js CHANGED
@@ -189,7 +189,26 @@ function createEmptyWebAgentSession(agentId, agentName) {
189
189
  lastResumeAt: null,
190
190
  lastResumeOk: null,
191
191
  lastResumeError: '',
192
- engineSessionId: ''
192
+ engineSessionId: '',
193
+ usageTotal: null,
194
+ model: ''
195
+ };
196
+ }
197
+
198
+ function normalizeWebAgentUsageTotal(value) {
199
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
200
+ return null;
201
+ }
202
+ if (typeof value.inputTokens !== 'number' || typeof value.outputTokens !== 'number') {
203
+ return null;
204
+ }
205
+ if (typeof value.costUsd !== 'number' && value.costUsd !== null) {
206
+ return null;
207
+ }
208
+ return {
209
+ inputTokens: value.inputTokens,
210
+ outputTokens: value.outputTokens,
211
+ costUsd: typeof value.costUsd === 'number' ? value.costUsd : null
193
212
  };
194
213
  }
195
214
 
@@ -218,7 +237,9 @@ function normalizeWebAgentSessionRecord(agentId, rawAgent) {
218
237
  lastResumeAt: typeof source.lastResumeAt === 'string' ? source.lastResumeAt : null,
219
238
  lastResumeOk: typeof source.lastResumeOk === 'boolean' ? source.lastResumeOk : null,
220
239
  lastResumeError: typeof source.lastResumeError === 'string' ? source.lastResumeError : '',
221
- engineSessionId: typeof source.engineSessionId === 'string' ? source.engineSessionId : ''
240
+ engineSessionId: typeof source.engineSessionId === 'string' ? source.engineSessionId : '',
241
+ usageTotal: normalizeWebAgentUsageTotal(source.usageTotal),
242
+ model: normalizeStoredAgentModelValue(source.model)
222
243
  };
223
244
  }
224
245
 
@@ -756,6 +777,7 @@ function renderAgentPromptCommand(template, prompt) {
756
777
  function buildCodexAgentExecCommand(template, prompt, options = {}) {
757
778
  const templateText = normalizeAgentPromptCommandTemplate(template, 'agentPromptCommand');
758
779
  const sessionId = options && typeof options.sessionId === 'string' ? options.sessionId.trim() : '';
780
+ const model = options && typeof options.model === 'string' ? options.model.trim() : '';
759
781
  const execMatch = templateText.match(
760
782
  /^((?:(?:[A-Za-z_][A-Za-z0-9_]*=)(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s]+)\s+)*)codex\s+exec\b/
761
783
  );
@@ -765,12 +787,14 @@ function buildCodexAgentExecCommand(template, prompt, options = {}) {
765
787
  const suffix = templateText.slice(execMatch[0].length);
766
788
  const hasJson = /(?:^|\s)--json(?:\s|$)/.test(suffix);
767
789
  const injectedFlags = hasJson ? '' : ' --json';
790
+ const hasModel = /(?:^|\s)(?:-m|--model)(?:\s|$)/.test(suffix);
791
+ const modelFlag = model && !hasModel ? ` --model ${model}` : '';
768
792
  if (sessionId) {
769
793
  const promptIndex = suffix.indexOf('{prompt}');
770
794
  const resumeSuffix = `${suffix.slice(0, promptIndex)}${quoteBashSingleValue(sessionId)} ${suffix.slice(promptIndex)}`;
771
- codexTemplate = `${prefix}codex exec resume${injectedFlags}${resumeSuffix}`;
795
+ codexTemplate = `${prefix}codex exec resume${injectedFlags}${modelFlag}${resumeSuffix}`;
772
796
  } else {
773
- codexTemplate = `${prefix}codex exec${injectedFlags}${suffix}`;
797
+ codexTemplate = `${prefix}codex exec${injectedFlags}${modelFlag}${suffix}`;
774
798
  }
775
799
  }
776
800
  return codexTemplate === templateText
@@ -805,6 +829,10 @@ function buildClaudeAgentExecCommand(template, prompt, options = {}) {
805
829
  if (sessionId) {
806
830
  flagSpecs.push({ flag: `-r ${sessionId}`, pattern: /(?:^|\s)-r(?:\s|$)/ });
807
831
  }
832
+ const model = options && typeof options.model === 'string' ? options.model.trim() : '';
833
+ if (model) {
834
+ flagSpecs.push({ flag: `--model ${model}`, pattern: /(?:^|\s)--model(?:\s|$)/ });
835
+ }
808
836
  const claudeTemplate = prependAgentFlags(
809
837
  templateText,
810
838
  /^(((?:(?:[A-Za-z_][A-Za-z0-9_]*=)(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s]+)\s+)*)claude\b)(.*)$/,
@@ -815,14 +843,19 @@ function buildClaudeAgentExecCommand(template, prompt, options = {}) {
815
843
  : renderAgentPromptCommand(claudeTemplate, prompt);
816
844
  }
817
845
 
818
- function buildGeminiAgentExecCommand(template, prompt) {
846
+ function buildGeminiAgentExecCommand(template, prompt, options = {}) {
819
847
  const templateText = normalizeAgentPromptCommandTemplate(template, 'agentPromptCommand');
848
+ const flagSpecs = [
849
+ { flag: '--output-format stream-json', pattern: /(?:^|\s)--output-format(?:\s|$)/ }
850
+ ];
851
+ const model = options && typeof options.model === 'string' ? options.model.trim() : '';
852
+ if (model) {
853
+ flagSpecs.push({ flag: `--model ${model}`, pattern: /(?:^|\s)(?:-m|--model)(?:\s|$)/ });
854
+ }
820
855
  const geminiTemplate = prependAgentFlags(
821
856
  templateText,
822
857
  /^(((?:(?:[A-Za-z_][A-Za-z0-9_]*=)(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s]+)\s+)*)gemini\b)(.*)$/,
823
- [
824
- { flag: '--output-format stream-json', pattern: /(?:^|\s)--output-format(?:\s|$)/ }
825
- ]
858
+ flagSpecs
826
859
  );
827
860
  return geminiTemplate === templateText
828
861
  ? renderAgentPromptCommand(templateText, prompt)
@@ -841,6 +874,10 @@ function buildOpenCodeAgentExecCommand(template, prompt, options = {}) {
841
874
  pattern: /(?:^|\s)(?:--session|-s)(?:\s|$)/
842
875
  });
843
876
  }
877
+ const model = options && typeof options.model === 'string' ? options.model.trim() : '';
878
+ if (model) {
879
+ flagSpecs.push({ flag: `--model ${model}`, pattern: /(?:^|\s)(?:-m|--model)(?:\s|$)/ });
880
+ }
844
881
  const opencodeTemplate = prependAgentFlags(
845
882
  templateText,
846
883
  /^(((?:(?:[A-Za-z_][A-Za-z0-9_]*=)(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s]+)\s+)*)opencode\b.*?\s+run\b)(.*)$/,
@@ -856,7 +893,7 @@ function buildWebAgentExecCommand(template, prompt, agentProgram, options = {})
856
893
  case 'claude':
857
894
  return buildClaudeAgentExecCommand(template, prompt, options);
858
895
  case 'gemini':
859
- return buildGeminiAgentExecCommand(template, prompt);
896
+ return buildGeminiAgentExecCommand(template, prompt, options);
860
897
  case 'codex':
861
898
  return buildCodexAgentExecCommand(template, prompt, options);
862
899
  case 'opencode':
@@ -1039,6 +1076,242 @@ function extractEngineSessionId(agentProgram, text) {
1039
1076
  return '';
1040
1077
  }
1041
1078
 
1079
+ function extractClaudeTurnUsage(text) {
1080
+ let result = null;
1081
+ for (const rawLine of String(text || '').split('\n')) {
1082
+ const payload = parseJsonObjectLine(rawLine);
1083
+ if (!payload || payload.type !== 'result') {
1084
+ continue;
1085
+ }
1086
+ const usage = toPlainObject(payload.usage);
1087
+ result = {
1088
+ inputTokens: typeof usage.input_tokens === 'number' ? usage.input_tokens : null,
1089
+ outputTokens: typeof usage.output_tokens === 'number' ? usage.output_tokens : null,
1090
+ costUsd: typeof payload.total_cost_usd === 'number' ? payload.total_cost_usd : null
1091
+ };
1092
+ }
1093
+ return result;
1094
+ }
1095
+
1096
+ function extractCodexTurnUsage(text) {
1097
+ let result = null;
1098
+ for (const rawLine of String(text || '').split('\n')) {
1099
+ const payload = parseJsonObjectLine(rawLine);
1100
+ if (!payload || payload.type !== 'turn.completed') {
1101
+ continue;
1102
+ }
1103
+ const usage = toPlainObject(payload.usage);
1104
+ result = {
1105
+ inputTokens: typeof usage.input_tokens === 'number' ? usage.input_tokens : null,
1106
+ outputTokens: typeof usage.output_tokens === 'number' ? usage.output_tokens : null,
1107
+ costUsd: null
1108
+ };
1109
+ }
1110
+ return result;
1111
+ }
1112
+
1113
+ function extractOpenCodeTurnUsage(text) {
1114
+ let result = null;
1115
+ for (const rawLine of String(text || '').split('\n')) {
1116
+ const payload = parseJsonObjectLine(rawLine);
1117
+ if (!payload || payload.type !== 'step_finish') {
1118
+ continue;
1119
+ }
1120
+ const part = toPlainObject(payload.part);
1121
+ const tokens = toPlainObject(part.tokens);
1122
+ result = {
1123
+ inputTokens: typeof tokens.input === 'number' ? tokens.input : null,
1124
+ outputTokens: typeof tokens.output === 'number' ? tokens.output : null,
1125
+ costUsd: typeof part.cost === 'number' ? part.cost : null
1126
+ };
1127
+ }
1128
+ return result;
1129
+ }
1130
+
1131
+ // Gemini 的非交互 stream-json 输出目前没有已确认的 usage/token 字段可解析,故不返回数据(而非猜测字段名)。
1132
+ function extractTurnUsage(agentProgram, text) {
1133
+ if (agentProgram === 'claude') {
1134
+ return extractClaudeTurnUsage(text);
1135
+ }
1136
+ if (agentProgram === 'codex') {
1137
+ return extractCodexTurnUsage(text);
1138
+ }
1139
+ if (agentProgram === 'opencode') {
1140
+ return extractOpenCodeTurnUsage(text);
1141
+ }
1142
+ return null;
1143
+ }
1144
+
1145
+ function accumulateUsageTotal(baseline, turnUsage) {
1146
+ const base = baseline && typeof baseline === 'object'
1147
+ ? baseline
1148
+ : { inputTokens: 0, outputTokens: 0, costUsd: null };
1149
+ const baseCost = typeof base.costUsd === 'number' ? base.costUsd : null;
1150
+ const turnCost = typeof turnUsage.costUsd === 'number' ? turnUsage.costUsd : null;
1151
+ return {
1152
+ inputTokens: (base.inputTokens || 0) + (turnUsage.inputTokens || 0),
1153
+ outputTokens: (base.outputTokens || 0) + (turnUsage.outputTokens || 0),
1154
+ // 引擎从未上报过费用(如 Codex)时保持 null,不要伪装成"花费为 0"
1155
+ costUsd: baseCost === null && turnCost === null ? null : (baseCost || 0) + (turnCost || 0)
1156
+ };
1157
+ }
1158
+
1159
+ // 起一个短命的容器内子进程,通过对应协议问一次元信息(初始化握手 / model 列表),拿到结果就杀掉进程——
1160
+ // 不是长驻会话,不会产生真实对话轮次或费用。
1161
+ function runAgentProtocolProcess(ctx, containerName, command, options = {}) {
1162
+ const stdinLines = Array.isArray(options.stdinLines) ? options.stdinLines : [];
1163
+ const onLine = typeof options.onLine === 'function' ? options.onLine : null;
1164
+ const timeoutMs = typeof options.timeoutMs === 'number' ? options.timeoutMs : 8000;
1165
+ return new Promise(resolve => {
1166
+ let settled = false;
1167
+ let pending = '';
1168
+ let stdoutAll = '';
1169
+ let child;
1170
+ try {
1171
+ child = spawn(ctx.dockerCmd, ['exec', '-i', containerName, '/bin/bash', '-lc', command], { stdio: ['pipe', 'pipe', 'pipe'] });
1172
+ } catch (e) {
1173
+ resolve({ result: null, stdout: '' });
1174
+ return;
1175
+ }
1176
+ const timer = setTimeout(() => finish(null), timeoutMs);
1177
+ function finish(result) {
1178
+ if (settled) return;
1179
+ settled = true;
1180
+ clearTimeout(timer);
1181
+ try { child.kill('SIGKILL'); } catch (e) { /* 进程可能已退出 */ }
1182
+ resolve({ result, stdout: stdoutAll });
1183
+ }
1184
+ child.on('error', () => finish(null));
1185
+ child.on('close', () => finish(null));
1186
+ child.stdout.on('data', chunk => {
1187
+ const text = chunk.toString('utf-8');
1188
+ stdoutAll += text;
1189
+ if (!onLine) {
1190
+ return;
1191
+ }
1192
+ pending += text;
1193
+ let newlineIndex = pending.indexOf('\n');
1194
+ while (newlineIndex !== -1) {
1195
+ const line = pending.slice(0, newlineIndex);
1196
+ pending = pending.slice(newlineIndex + 1);
1197
+ const trimmed = line.trim();
1198
+ if (trimmed) {
1199
+ const payload = parseJsonObjectLine(trimmed);
1200
+ if (payload) {
1201
+ const matched = onLine(payload);
1202
+ if (matched !== undefined) {
1203
+ finish(matched);
1204
+ return;
1205
+ }
1206
+ }
1207
+ }
1208
+ newlineIndex = pending.indexOf('\n');
1209
+ }
1210
+ });
1211
+ try {
1212
+ stdinLines.forEach(line => child.stdin.write(`${line}\n`));
1213
+ if (!stdinLines.length) {
1214
+ child.stdin.end();
1215
+ }
1216
+ } catch (e) { /* 进程可能已提前退出 */ }
1217
+ });
1218
+ }
1219
+
1220
+ function normalizeAgentModelCatalogEntry(item) {
1221
+ const value = pickFirstString(item && item.value, item && item.id, item && item.model);
1222
+ if (!value) {
1223
+ return null;
1224
+ }
1225
+ return {
1226
+ value,
1227
+ label: pickFirstString(item && item.displayName, value),
1228
+ description: pickFirstString(item && item.description)
1229
+ };
1230
+ }
1231
+
1232
+ function extractClaudeModelsFromControlResponse(payload) {
1233
+ if (!payload || payload.type !== 'control_response') {
1234
+ return undefined;
1235
+ }
1236
+ const response = toPlainObject(payload.response);
1237
+ if (response.request_id !== 'manyoyo-model-catalog') {
1238
+ return undefined;
1239
+ }
1240
+ const inner = toPlainObject(response.response);
1241
+ const models = Array.isArray(inner.models) ? inner.models : [];
1242
+ return models.map(normalizeAgentModelCatalogEntry).filter(Boolean);
1243
+ }
1244
+
1245
+ function extractCodexModelsFromListResponse(payload) {
1246
+ if (!payload || payload.id !== 'manyoyo-model-list' || !payload.result) {
1247
+ return undefined;
1248
+ }
1249
+ const data = Array.isArray(payload.result.data) ? payload.result.data : [];
1250
+ return data.map(normalizeAgentModelCatalogEntry).filter(Boolean);
1251
+ }
1252
+
1253
+ function extractOpenCodeModelsFromOutput(text) {
1254
+ return String(text || '')
1255
+ .split('\n')
1256
+ .map(line => line.trim())
1257
+ .filter(Boolean)
1258
+ .map(value => ({ value, label: value, description: '' }));
1259
+ }
1260
+
1261
+ async function fetchAgentModelCatalog(ctx, containerName, agentProgram) {
1262
+ if (agentProgram === 'claude') {
1263
+ const requestLine = JSON.stringify({
1264
+ type: 'control_request',
1265
+ request_id: 'manyoyo-model-catalog',
1266
+ request: { subtype: 'initialize' }
1267
+ });
1268
+ const { result } = await runAgentProtocolProcess(
1269
+ ctx,
1270
+ containerName,
1271
+ 'claude -p --input-format stream-json --output-format stream-json --verbose',
1272
+ { stdinLines: [requestLine], onLine: extractClaudeModelsFromControlResponse }
1273
+ );
1274
+ return Array.isArray(result) ? result : [];
1275
+ }
1276
+ if (agentProgram === 'codex') {
1277
+ const stdinLines = [
1278
+ JSON.stringify({ jsonrpc: '2.0', id: 'manyoyo-init', method: 'initialize', params: { clientInfo: { name: 'manyoyo', version: '1.0.0' } } }),
1279
+ JSON.stringify({ jsonrpc: '2.0', id: 'manyoyo-model-list', method: 'model/list', params: {} })
1280
+ ];
1281
+ const { result } = await runAgentProtocolProcess(
1282
+ ctx,
1283
+ containerName,
1284
+ 'codex app-server',
1285
+ { stdinLines, onLine: extractCodexModelsFromListResponse }
1286
+ );
1287
+ return Array.isArray(result) ? result : [];
1288
+ }
1289
+ if (agentProgram === 'opencode') {
1290
+ const { stdout } = await runAgentProtocolProcess(ctx, containerName, 'opencode models', {});
1291
+ return extractOpenCodeModelsFromOutput(stdout);
1292
+ }
1293
+ // Gemini 的 --acp 模式在认证前就会挂起重试,没有可用的“认证前”探测窗口,暂不支持目录抓取。
1294
+ return [];
1295
+ }
1296
+
1297
+ const SAFE_AGENT_MODEL_VALUE_PATTERN = /^[A-Za-z0-9._\-[\]/]+$/;
1298
+
1299
+ function normalizeAgentModelValueInput(value) {
1300
+ const trimmed = String(value || '').trim();
1301
+ if (!trimmed) {
1302
+ return '';
1303
+ }
1304
+ if (!SAFE_AGENT_MODEL_VALUE_PATTERN.test(trimmed)) {
1305
+ throw new Error(`模型名称包含非法字符: ${trimmed}`);
1306
+ }
1307
+ return trimmed;
1308
+ }
1309
+
1310
+ function normalizeStoredAgentModelValue(value) {
1311
+ const trimmed = typeof value === 'string' ? value.trim() : '';
1312
+ return SAFE_AGENT_MODEL_VALUE_PATTERN.test(trimmed) ? trimmed : '';
1313
+ }
1314
+
1042
1315
  function getAgentRuntimeMeta(template) {
1043
1316
  const normalizedTemplate = normalizeAgentPromptCommandTemplate(template, 'agentPromptCommand');
1044
1317
  const agentProgram = resolveAgentProgram(normalizedTemplate);
@@ -1744,7 +2017,8 @@ async function prepareWebAgentExecution(ctx, state, sessionRef, prompt) {
1744
2017
  ? prompt
1745
2018
  : buildAgentPromptWithHistory(agentSession, prompt);
1746
2019
  const command = buildWebAgentExecCommand(effectiveTemplate, effectivePrompt, agentMeta.agentProgram, {
1747
- sessionId: sessionIdForRun
2020
+ sessionId: sessionIdForRun,
2021
+ model: agentSession.model || ''
1748
2022
  });
1749
2023
  const contextMode = resumeSucceeded ? 'resume' : (hasPriorConversation ? 'history-injected' : 'first-turn');
1750
2024
 
@@ -1762,13 +2036,15 @@ async function prepareWebAgentExecution(ctx, state, sessionRef, prompt) {
1762
2036
  }
1763
2037
 
1764
2038
  function finalizeWebAgentExecution(state, sessionRef, agentSession, agentMeta, meta, result) {
2039
+ const turnUsage = extractTurnUsage(agentMeta.agentProgram, result.stdout);
1765
2040
  appendWebSessionMessage(state.webHistoryDir, sessionRef, 'assistant', result.output, {
1766
2041
  exitCode: result.exitCode,
1767
2042
  mode: 'agent',
1768
2043
  contextMode: meta.contextMode,
1769
2044
  resumeAttempted: meta.resumeAttempted,
1770
2045
  resumeSucceeded: meta.resumeSucceeded,
1771
- interrupted: result.interrupted === true
2046
+ interrupted: result.interrupted === true,
2047
+ ...(turnUsage ? { usage: turnUsage } : {})
1772
2048
  });
1773
2049
  const patch = {
1774
2050
  lastResumeAt: meta.resumeAttempted ? new Date().toISOString() : (agentSession.lastResumeAt || null),
@@ -1779,6 +2055,9 @@ function finalizeWebAgentExecution(state, sessionRef, agentSession, agentMeta, m
1779
2055
  if (engineSessionId) {
1780
2056
  patch.engineSessionId = engineSessionId;
1781
2057
  }
2058
+ if (turnUsage) {
2059
+ patch.usageTotal = accumulateUsageTotal(agentSession.usageTotal, turnUsage);
2060
+ }
1782
2061
  patchWebAgentSessionState(state.webHistoryDir, sessionRef, patch);
1783
2062
  }
1784
2063
 
@@ -3541,6 +3820,7 @@ function buildSessionSummary(ctx, state, containerMap, sessionRef) {
3541
3820
  agentEnabled: isAgentPromptCommandEnabled(effectiveAgentPromptCommand),
3542
3821
  agentProgram: effectiveAgentProgram || '',
3543
3822
  resumeSupported: effectiveResumeSupported,
3823
+ model: agentSession.model || '',
3544
3824
  hostPath: applied.hostPath || '',
3545
3825
  containerPath: applied.containerPath || ''
3546
3826
  };
@@ -3626,6 +3906,8 @@ function buildSessionDetail(ctx, state, containerMap, name) {
3626
3906
  lastResumeAt: agentSession.lastResumeAt || null,
3627
3907
  lastResumeOk: typeof agentSession.lastResumeOk === 'boolean' ? agentSession.lastResumeOk : null,
3628
3908
  lastResumeError: agentSession.lastResumeError || '',
3909
+ usageTotal: agentSession.usageTotal || null,
3910
+ model: agentSession.model || '',
3629
3911
  applied
3630
3912
  };
3631
3913
  }
@@ -4543,6 +4825,51 @@ async function handleWebApi(req, res, pathname, ctx, state) {
4543
4825
  sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), detail });
4544
4826
  }
4545
4827
  },
4828
+ {
4829
+ method: 'GET',
4830
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/models$/),
4831
+ handler: async match => {
4832
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
4833
+ if (!sessionRef) {
4834
+ return;
4835
+ }
4836
+ const containerMap = listWebManyoyoContainers(ctx);
4837
+ const containerInfo = containerMap[sessionRef.containerName] || {};
4838
+ const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
4839
+ const effectiveTemplate = resolveEffectiveAgentPromptCommandForSession(
4840
+ history,
4841
+ sessionRef.agentId,
4842
+ containerInfo.defaultCommand
4843
+ );
4844
+ const agentProgram = resolveAgentProgram(effectiveTemplate);
4845
+ const models = await fetchAgentModelCatalog(ctx, sessionRef.containerName, agentProgram);
4846
+ sendJson(res, 200, {
4847
+ name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
4848
+ agentProgram,
4849
+ models
4850
+ });
4851
+ }
4852
+ },
4853
+ {
4854
+ method: 'POST',
4855
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/model$/),
4856
+ handler: async match => {
4857
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
4858
+ if (!sessionRef) {
4859
+ return;
4860
+ }
4861
+ const payload = await readJsonBody(req);
4862
+ let model;
4863
+ try {
4864
+ model = normalizeAgentModelValueInput(payload.model);
4865
+ } catch (e) {
4866
+ sendJson(res, 400, { error: e.message || String(e) });
4867
+ return;
4868
+ }
4869
+ patchWebAgentSessionState(state.webHistoryDir, sessionRef, { model });
4870
+ sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), model });
4871
+ }
4872
+ },
4546
4873
  {
4547
4874
  method: 'GET',
4548
4875
  match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/audit$/),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xcanwin/manyoyo",
3
- "version": "6.2.8",
3
+ "version": "6.2.10",
4
4
  "imageVersion": "1.9.1-common",
5
5
  "playwrightCliVersion": "0.1.18",
6
6
  "description": "AI Agent CLI Security Sandbox for Docker and Podman",