codexmate 0.0.37 → 0.0.38

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.
@@ -0,0 +1,68 @@
1
+ function parseAnalyticsExportArgs(args = []) {
2
+ const options = {
3
+ format: 'csv',
4
+ source: 'all',
5
+ output: ''
6
+ };
7
+ const errors = [];
8
+ for (let index = 0; index < args.length; index += 1) {
9
+ const token = String(args[index] || '');
10
+ const readValue = (flag) => {
11
+ if (token.startsWith(`${flag}=`)) {
12
+ return token.slice(flag.length + 1);
13
+ }
14
+ const value = args[index + 1];
15
+ index += 1;
16
+ return value;
17
+ };
18
+ if (token === '--format' || token.startsWith('--format=')) {
19
+ options.format = String(readValue('--format') || '').trim().toLowerCase();
20
+ continue;
21
+ }
22
+ if (token === '--from' || token.startsWith('--from=')) {
23
+ options.from = String(readValue('--from') || '').trim();
24
+ continue;
25
+ }
26
+ if (token === '--to' || token.startsWith('--to=')) {
27
+ options.to = String(readValue('--to') || '').trim();
28
+ continue;
29
+ }
30
+ if (token === '--model' || token.startsWith('--model=')) {
31
+ options.model = String(readValue('--model') || '').trim();
32
+ continue;
33
+ }
34
+ if (token === '--source' || token.startsWith('--source=')) {
35
+ options.source = String(readValue('--source') || '').trim().toLowerCase();
36
+ continue;
37
+ }
38
+ if (token === '--output' || token === '-o' || token.startsWith('--output=')) {
39
+ options.output = String(readValue(token === '-o' ? '-o' : '--output') || '').trim();
40
+ continue;
41
+ }
42
+ if (token === '--force-refresh') {
43
+ options.forceRefresh = true;
44
+ continue;
45
+ }
46
+ if (token === '--help' || token === '-h') {
47
+ options.help = true;
48
+ continue;
49
+ }
50
+ if (token) {
51
+ errors.push(`未知参数 ${token}`);
52
+ }
53
+ }
54
+ if (options.format !== 'csv' && options.format !== 'json') {
55
+ errors.push('--format 必须是 csv 或 json');
56
+ }
57
+ if (options.source && !['codex', 'claude', 'gemini', 'codebuddy', 'all'].includes(options.source)) {
58
+ errors.push('--source 必须是 codex、claude、gemini、codebuddy 或 all');
59
+ }
60
+ return {
61
+ options,
62
+ error: errors.join(';')
63
+ };
64
+ }
65
+
66
+ module.exports = {
67
+ parseAnalyticsExportArgs
68
+ };
@@ -113,6 +113,192 @@ async function listSessionUsageCore(params = {}, deps = {}) {
113
113
  return normalizedSessions.filter(Boolean);
114
114
  }
115
115
 
116
+ function readNonNegativeInteger(value) {
117
+ const numeric = Number(value);
118
+ if (!Number.isFinite(numeric) || numeric < 0) {
119
+ return 0;
120
+ }
121
+ return Math.floor(numeric);
122
+ }
123
+
124
+ function parseUsageExportDate(value, boundary) {
125
+ if (value === undefined || value === null || value === '') {
126
+ return null;
127
+ }
128
+ if (value instanceof Date) {
129
+ const time = value.getTime();
130
+ return Number.isFinite(time) ? time : NaN;
131
+ }
132
+ const raw = String(value).trim();
133
+ if (!raw) {
134
+ return null;
135
+ }
136
+ const dateOnly = raw.match(/^(\d{4})-(\d{2})-(\d{2})$/);
137
+ if (dateOnly) {
138
+ const year = Number(dateOnly[1]);
139
+ const month = Number(dateOnly[2]) - 1;
140
+ const day = Number(dateOnly[3]);
141
+ const start = Date.UTC(year, month, day);
142
+ const normalized = new Date(start);
143
+ if (!Number.isFinite(start)
144
+ || normalized.getUTCFullYear() !== year
145
+ || normalized.getUTCMonth() !== month
146
+ || normalized.getUTCDate() !== day) {
147
+ return NaN;
148
+ }
149
+ return boundary === 'end' ? start + 24 * 60 * 60 * 1000 : start;
150
+ }
151
+ const parsed = Date.parse(raw);
152
+ return Number.isFinite(parsed) ? parsed : NaN;
153
+ }
154
+
155
+ function formatUsageExportDay(timestamp) {
156
+ return new Date(timestamp).toISOString().slice(0, 10);
157
+ }
158
+
159
+ function normalizeUsageExportFormat(value) {
160
+ const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
161
+ return normalized === 'json' ? 'json' : 'csv';
162
+ }
163
+
164
+ function normalizeUsageExportModelFilters(params = {}) {
165
+ const raw = [];
166
+ const push = (value) => {
167
+ if (Array.isArray(value)) {
168
+ value.forEach(push);
169
+ return;
170
+ }
171
+ if (typeof value !== 'string') {
172
+ return;
173
+ }
174
+ value.split(',').forEach((item) => {
175
+ const normalized = item.trim().toLowerCase();
176
+ if (normalized) raw.push(normalized);
177
+ });
178
+ };
179
+ push(params.model);
180
+ push(params.models);
181
+ // API-facing alias: callers may pass modelType when they reuse usage filters
182
+ // outside the CLI flag surface.
183
+ push(params.modelType);
184
+ return [...new Set(raw)];
185
+ }
186
+
187
+ function sessionMatchesUsageExportModelFilters(session, filters) {
188
+ if (!filters.length) {
189
+ return true;
190
+ }
191
+ const models = [];
192
+ if (typeof session.model === 'string') models.push(session.model);
193
+ if (Array.isArray(session.models)) models.push(...session.models.filter(item => typeof item === 'string'));
194
+ const normalizedModels = models.map(item => item.trim().toLowerCase()).filter(Boolean);
195
+ return filters.some(filter => normalizedModels.some(model => model === filter || model.includes(filter)));
196
+ }
197
+
198
+ function escapeUsageCsvCell(value) {
199
+ const raw = value === undefined || value === null ? '' : String(value);
200
+ if (!/[",\n\r]/.test(raw)) {
201
+ return raw;
202
+ }
203
+ return `"${raw.replace(/"/g, '""')}"`;
204
+ }
205
+
206
+ function serializeUsageExportRowsToCsv(rows) {
207
+ const columns = ['date', 'model', 'tokens', 'sessions'];
208
+ const lines = [columns.join(',')];
209
+ for (const row of rows) {
210
+ lines.push(columns.map(column => escapeUsageCsvCell(row[column])).join(','));
211
+ }
212
+ return lines.join('\r\n') + '\r\n';
213
+ }
214
+
215
+ function buildUsageExportRows(sessions = [], params = {}) {
216
+ const fromTime = parseUsageExportDate(params.from ?? params.startDate, 'start');
217
+ const toTime = parseUsageExportDate(params.to ?? params.endDate, 'end');
218
+ if (Number.isNaN(fromTime)) {
219
+ return { error: 'Invalid from date' };
220
+ }
221
+ if (Number.isNaN(toTime)) {
222
+ return { error: 'Invalid to date' };
223
+ }
224
+ if (fromTime !== null && toTime !== null && fromTime >= toTime) {
225
+ return { error: 'from date must be before to date' };
226
+ }
227
+
228
+ const modelFilters = normalizeUsageExportModelFilters(params);
229
+ const groups = new Map();
230
+ for (const session of Array.isArray(sessions) ? sessions : []) {
231
+ if (!session || typeof session !== 'object' || Array.isArray(session)) {
232
+ continue;
233
+ }
234
+ if (!sessionMatchesUsageExportModelFilters(session, modelFilters)) {
235
+ continue;
236
+ }
237
+ const timestamp = Date.parse(session.updatedAt || session.createdAt || '');
238
+ if (!Number.isFinite(timestamp)) {
239
+ continue;
240
+ }
241
+ if (fromTime !== null && timestamp < fromTime) {
242
+ continue;
243
+ }
244
+ if (toTime !== null && timestamp >= toTime) {
245
+ continue;
246
+ }
247
+ const model = typeof session.model === 'string' && session.model.trim()
248
+ ? session.model.trim()
249
+ : (Array.isArray(session.models) && typeof session.models[0] === 'string' ? session.models[0].trim() : 'unknown');
250
+ if (!model) {
251
+ continue;
252
+ }
253
+ const date = formatUsageExportDay(timestamp);
254
+ const key = `${date}\u0000${model}`;
255
+ const current = groups.get(key) || { date, model, tokens: 0, sessions: 0 };
256
+ current.tokens += readNonNegativeInteger(session.totalTokens ?? session.tokens);
257
+ current.sessions += 1;
258
+ groups.set(key, current);
259
+ }
260
+
261
+ const rows = [...groups.values()].sort((a, b) => {
262
+ const dateCompare = a.date.localeCompare(b.date);
263
+ if (dateCompare !== 0) return dateCompare;
264
+ return a.model.localeCompare(b.model);
265
+ });
266
+ return { rows };
267
+ }
268
+
269
+ async function exportSessionUsageCore(params = {}, deps = {}) {
270
+ const listSessionUsage = typeof deps.listSessionUsage === 'function'
271
+ ? deps.listSessionUsage
272
+ : (options) => listSessionUsageCore(options, deps);
273
+ const sessions = Array.isArray(params.sessions)
274
+ ? params.sessions
275
+ : await listSessionUsage({
276
+ source: params.source,
277
+ limit: params.limit,
278
+ forceRefresh: !!params.forceRefresh
279
+ });
280
+ const built = buildUsageExportRows(sessions, params);
281
+ if (built.error) {
282
+ return { error: built.error };
283
+ }
284
+ const format = normalizeUsageExportFormat(params.format);
285
+ const rows = built.rows;
286
+ const content = format === 'json'
287
+ ? JSON.stringify({ rows }, null, 2) + '\n'
288
+ : serializeUsageExportRowsToCsv(rows);
289
+ const extension = format === 'json' ? 'json' : 'csv';
290
+ return {
291
+ format,
292
+ mimeType: format === 'json' ? 'application/json' : 'text/csv',
293
+ fileName: `usage-export.${extension}`,
294
+ rows,
295
+ content
296
+ };
297
+ }
298
+
116
299
  module.exports = {
117
- listSessionUsageCore
300
+ listSessionUsageCore,
301
+ buildUsageExportRows,
302
+ exportSessionUsageCore,
303
+ serializeUsageExportRowsToCsv
118
304
  };
package/cli.js CHANGED
@@ -162,7 +162,8 @@ const {
162
162
  extractSessionDetailPreviewFromTailText,
163
163
  extractSessionDetailPreviewFromFileFast
164
164
  } = require('./lib/cli-sessions');
165
- const { listSessionUsageCore } = require('./cli/session-usage');
165
+ const { listSessionUsageCore, exportSessionUsageCore } = require('./cli/session-usage');
166
+ const { parseAnalyticsExportArgs } = require('./cli/analytics-export-args');
166
167
  const {
167
168
  readBundledWebUiCss,
168
169
  readBundledWebUiHtml,
@@ -5204,6 +5205,12 @@ async function listSessionUsage(params = {}) {
5204
5205
  });
5205
5206
  }
5206
5207
 
5208
+ async function exportSessionUsage(params = {}) {
5209
+ return exportSessionUsageCore(params, {
5210
+ listSessionUsage
5211
+ });
5212
+ }
5213
+
5207
5214
  function listSessionPaths(params = {}) {
5208
5215
  const source = typeof params.source === 'string' ? params.source.trim().toLowerCase() : '';
5209
5216
  if (source && source !== 'codex' && source !== 'claude' && source !== 'gemini' && source !== 'codebuddy' && source !== 'all') {
@@ -9796,6 +9803,47 @@ async function cmdExportSession(args = []) {
9796
9803
  console.log();
9797
9804
  }
9798
9805
 
9806
+ function printAnalyticsUsage() {
9807
+ console.log('\n用法:');
9808
+ console.log(' codexmate analytics export [--format csv|json] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--model <MODEL>] [--source <codex|claude|gemini|codebuddy|all>] [--output <PATH|->] [-o <PATH|->]');
9809
+ console.log('');
9810
+ }
9811
+
9812
+ async function cmdAnalytics(args = []) {
9813
+ const subcommand = args[0];
9814
+ if (subcommand !== 'export') {
9815
+ printAnalyticsUsage();
9816
+ process.exit(subcommand ? 1 : 0);
9817
+ }
9818
+ const parsed = parseAnalyticsExportArgs(args.slice(1));
9819
+ if (parsed.options.help) {
9820
+ printAnalyticsUsage();
9821
+ process.exit(0);
9822
+ }
9823
+ if (parsed.error) {
9824
+ console.error('错误:', parsed.error);
9825
+ printAnalyticsUsage();
9826
+ process.exit(1);
9827
+ }
9828
+
9829
+ const result = await exportSessionUsage(parsed.options);
9830
+ if (result && result.error) {
9831
+ console.error('导出失败:', result.error);
9832
+ process.exit(1);
9833
+ }
9834
+ const output = parsed.options.output || (result && result.fileName) || `usage-export.${parsed.options.format}`;
9835
+ if (output === '-') {
9836
+ process.stdout.write(result && result.content ? result.content : '');
9837
+ return;
9838
+ }
9839
+ const outputPath = path.resolve(process.cwd(), output);
9840
+ ensureDir(path.dirname(outputPath));
9841
+ fs.writeFileSync(outputPath, result && result.content ? result.content : '', 'utf-8');
9842
+ console.log(`\n✓ Usage 已导出: ${outputPath}`);
9843
+ console.log(` 格式: ${result.format}; rows: ${Array.isArray(result.rows) ? result.rows.length : 0}`);
9844
+ console.log();
9845
+ }
9846
+
9799
9847
  function parseStartOptions(args = []) {
9800
9848
  const options = { host: '', noBrowser: false };
9801
9849
  if (!Array.isArray(args)) {
@@ -11077,6 +11125,20 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
11077
11125
  }
11078
11126
  }
11079
11127
  break;
11128
+ case 'export-sessions-usage':
11129
+ {
11130
+ const usageParams = isPlainObject(params) ? params : {};
11131
+ const source = typeof usageParams.source === 'string' ? usageParams.source.trim().toLowerCase() : '';
11132
+ if (source && source !== 'codex' && source !== 'claude' && source !== 'gemini' && source !== 'codebuddy' && source !== 'all') {
11133
+ result = { error: 'Invalid source. Must be codex, claude, gemini, codebuddy, or all' };
11134
+ } else {
11135
+ result = await exportSessionUsage({
11136
+ ...usageParams,
11137
+ source: source || 'all'
11138
+ });
11139
+ }
11140
+ }
11141
+ break;
11080
11142
  case 'list-session-paths':
11081
11143
  {
11082
11144
  const source = typeof params.source === 'string' ? params.source.trim().toLowerCase() : '';
@@ -15960,6 +16022,7 @@ function printMainHelp() {
15960
16022
  console.log(' codexmate delete-model <模型> 删除模型');
15961
16023
  console.log(' codexmate workflow <list|get|validate|run|runs> MCP 工作流中心');
15962
16024
  console.log(' codexmate task <plan|run|runs|queue|retry|cancel|logs> 本地任务编排');
16025
+ console.log(' codexmate analytics export [--format csv|json] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--model <MODEL>] [--output <PATH|->] [-o <PATH|->] 导出 Usage 数据');
15963
16026
  console.log(' codexmate run [--host <HOST>] [--no-browser] 启动 Web 界面');
15964
16027
  console.log(' codexmate update [--check] 检查并快速更新工具');
15965
16028
  console.log(' codexmate codex [参数...] [--follow-up <文本>|--queued-follow-up <文本> 可重复] 等同于 codex --yolo');
@@ -16052,6 +16115,7 @@ async function main() {
16052
16115
  case 'proxy': await cmdProxy(args.slice(1)); break;
16053
16116
  case 'workflow': await cmdWorkflow(args.slice(1)); break;
16054
16117
  case 'task': await cmdTask(args.slice(1)); break;
16118
+ case 'analytics': await cmdAnalytics(args.slice(1)); break;
16055
16119
  case 'run': cmdStart(parseStartOptions(args.slice(1))); break;
16056
16120
  case 'update': await cmdToolUpdate(args.slice(1)); break;
16057
16121
  case 'start':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codexmate",
3
- "version": "0.0.37",
3
+ "version": "0.0.38",
4
4
  "description": "Codex/Claude Code/OpenClaw 配置、会话与任务编排 CLI + Web 工具",
5
5
  "main": "cli.js",
6
6
  "bin": {
@@ -491,7 +491,7 @@ export function createSkillsMethods({ api }) {
491
491
  try {
492
492
  const res = await api('import-skills', {
493
493
  targetApp: this.skillsTargetApp,
494
- imports: [skill]
494
+ items: [skill]
495
495
  });
496
496
  if (res && res.error) {
497
497
  this.showMessage(res.error, 'error');
@@ -1,337 +0,0 @@
1
- <!-- Provider 配置模式(Codex) -->
2
- <div
3
- v-show="mainTab === 'config' && isProviderConfigMode"
4
- class="mode-content mode-cards"
5
- id="panel-config-provider"
6
- role="tabpanel"
7
- :aria-labelledby="forceCompactLayout ? 'tab-config' : ('side-tab-config-' + configMode)">
8
- <div v-if="forceCompactLayout && !sessionStandalone" class="segmented-control">
9
- <button type="button" :class="['segment', { active: configMode === 'codex' }]" @click="switchConfigMode('codex')">{{ t('tab.config.codex') }}</button>
10
- <button type="button" :class="['segment', { active: configMode === 'claude' }]" @click="switchConfigMode('claude')">{{ t('tab.config.claude') }}</button>
11
- <button type="button" :class="['segment', { active: configMode === 'openclaw' }]" @click="switchConfigMode('openclaw')">{{ t('tab.config.openclaw') }}</button>
12
- </div>
13
- <template v-if="isCodexConfigMode && shouldShowCliInstallPlaceholder('codex')">
14
- <div class="selector-section">
15
- <div class="empty-state">
16
- <div class="empty-state-title">{{ t('cli.missing.title', { name: 'Codex' }) }}</div>
17
- <div class="empty-state-subtitle">{{ t('cli.missing.subtitle', { name: 'Codex' }) }}</div>
18
- <div class="docs-command-row">
19
- <div class="docs-command-box" role="group" :aria-label="t('cli.missing.commandAria', { name: 'Codex' })">
20
- <code class="install-command">{{ getInstallCommand('codex', 'install') }}</code>
21
- <button
22
- type="button"
23
- class="btn-mini docs-copy-btn"
24
- :disabled="!getInstallCommand('codex', 'install')"
25
- @click="copyInstallCommand(getInstallCommand('codex', 'install'))">{{ t('common.copy') }}</button>
26
- </div>
27
- </div>
28
- <button type="button" class="btn-tool btn-tool-compact" @click="mainTab = 'docs'; setInstallCommandAction('install')">{{ t('cli.missing.openDocs') }}</button>
29
- </div>
30
- </div>
31
- </template>
32
- <template v-else>
33
- <!-- 添加提供商按钮 -->
34
- <button class="btn-add" @click="showAddModal = true" v-if="!loading && !initError">
35
- <svg class="icon" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2">
36
- <path d="M10 4v12M4 10h12"/>
37
- </svg>
38
- {{ t('config.addProvider') }}
39
- </button>
40
-
41
- <!-- 服务预设 -->
42
- <div class="selector-section" v-if="isCodexConfigMode && codexProviderTemplates.length">
43
- <div class="selector-header">
44
- <span class="selector-title">{{ t('config.providerTemplate.title') }}</span>
45
- </div>
46
- <div class="btn-group" style="flex-wrap: wrap; gap: 8px; margin-top: 0;">
47
- <button
48
- v-for="tpl in codexProviderTemplates"
49
- :key="tpl.name"
50
- type="button"
51
- class="btn-mini"
52
- @click="newProvider.name = tpl.name;
53
- newProvider.url = tpl.url;
54
- newProvider._suggestedModel = tpl.model || '';
55
- newProvider.useTransform = !!tpl.useTransform;
56
- showAddModal = true">
57
- {{ tpl.label }}
58
- </button>
59
- </div>
60
- </div>
61
-
62
- <!-- 模型选择器 -->
63
- <div class="selector-section">
64
- <div class="selector-header">
65
- <span class="selector-title">{{ t('config.models') }}</span>
66
- <div class="selector-actions">
67
- <button class="btn-icon" @click="showModelModal = true" :aria-label="t('modal.modelAdd.title')" :title="t('modal.modelAdd.title')" v-if="modelsSource === 'legacy'">+</button>
68
- <button class="btn-icon" @click="showModelListModal = true" :aria-label="t('modal.modelManage.title')" :title="t('modal.modelManage.title')" v-if="modelsSource === 'legacy'">≡</button>
69
- </div>
70
- </div>
71
- <select
72
- v-if="codexModelsLoading || modelsSource === 'remote'"
73
- class="model-select"
74
- v-model="currentModel"
75
- @change="onModelChange"
76
- :disabled="codexModelsLoading"
77
- >
78
- <option v-if="codexModelsLoading" value="">{{ t('config.modelLoading') }}</option>
79
- <option v-else v-for="model in codexModelOptions" :key="model" :value="model">{{ model }}</option>
80
- </select>
81
- <input
82
- v-if="!codexModelsLoading && (modelsSource !== 'remote' || !modelsHasCurrent)"
83
- class="model-input"
84
- v-model="currentModel"
85
- @blur="onModelChange"
86
- @keyup.enter="onModelChange"
87
- :placeholder="activeProviderModelPlaceholder"
88
- :list="codexModelHasList ? 'codex-model-options' : null"
89
- >
90
- <datalist v-if="codexModelHasList" id="codex-model-options">
91
- <option v-for="model in codexModelOptions" :key="model" :value="model"></option>
92
- </datalist>
93
- <div class="config-template-hint" v-if="modelsSource === 'unlimited'">
94
- {{ t('config.models.unlimited') }}
95
- </div>
96
- <div class="config-template-hint" v-if="modelsSource === 'error'">
97
- {{ t('config.models.error') }}
98
- </div>
99
- <div class="config-template-hint" v-if="modelsSource === 'remote' && !modelsHasCurrent">
100
- {{ isCodexConfigMode ? t('config.models.notInList.codex') : t('config.models.notInList.other') }}
101
- </div>
102
- <div class="config-template-hint" v-if="isCodexConfigMode">
103
- {{ t('config.template.editFirst') }}
104
- </div>
105
- <div class="config-template-hint" v-else-if="activeProviderBridgeHint">
106
- {{ t('config.template.bridgeCodexOnly', { hint: activeProviderBridgeHint }) }}
107
- </div>
108
- <button class="btn-tool btn-template-editor" v-if="isCodexConfigMode" @click="openConfigTemplateEditor" :disabled="loading || !!initError">
109
- {{ t('config.template.openEditor') }}
110
- </button>
111
- </div>
112
-
113
- <template v-if="isCodexConfigMode">
114
- <div class="selector-section">
115
- <div class="selector-header">
116
- <span class="selector-title">{{ t('config.serviceTier') }}</span>
117
- </div>
118
- <select class="model-select" v-model="serviceTier" @change="onServiceTierChange">
119
- <option value="fast">{{ t('config.serviceTier.fast') }}</option>
120
- <option value="standard">{{ t('config.serviceTier.standard') }}</option>
121
- </select>
122
- <div class="config-template-hint">
123
- {{ t('config.serviceTier.hint', { field: 'service_tier' }) }}
124
- </div>
125
- </div>
126
-
127
- <div class="selector-section">
128
- <div class="selector-header">
129
- <span class="selector-title">{{ t('config.reasoningEffort') }}</span>
130
- </div>
131
- <select class="model-select" v-model="modelReasoningEffort" @change="onReasoningEffortChange">
132
- <option value="high">high</option>
133
- <option value="medium">{{ t('config.reasoningEffort.medium') }}</option>
134
- <option value="low">low</option>
135
- <option value="xhigh">xhigh</option>
136
- </select>
137
- <div class="config-template-hint">
138
- {{ t('config.reasoningEffort.hint') }}
139
- </div>
140
- </div>
141
-
142
- <div class="selector-section">
143
- <div class="selector-header">
144
- <span class="selector-title">{{ t('config.contextBudget') }}</span>
145
- <div class="selector-actions">
146
- <button
147
- class="btn-tool btn-tool-compact"
148
- @click="resetCodexContextBudgetDefaults"
149
- :disabled="loading || !!initError || codexApplying">
150
- {{ t('config.reset') }}
151
- </button>
152
- </div>
153
- </div>
154
- <div class="codex-config-grid">
155
- <div class="form-group codex-config-field">
156
- <label class="form-label" for="codex-model-context-window">model_context_window</label>
157
- <input
158
- id="codex-model-context-window"
159
- v-model="modelContextWindowInput"
160
- class="form-input"
161
- inputmode="numeric"
162
- autocomplete="off"
163
- :placeholder="t('config.example', { value: 190000 })"
164
- @focus="editingCodexBudgetField = 'modelContextWindowInput'"
165
- @input="sanitizePositiveIntegerDraft('modelContextWindowInput')"
166
- @blur="onModelContextWindowBlur"
167
- @keydown.enter.prevent="onModelContextWindowBlur">
168
- <div class="form-hint">{{ t('config.contextWindow.hint') }}</div>
169
- </div>
170
- <div class="form-group codex-config-field">
171
- <label class="form-label" for="codex-model-auto-compact-token-limit">model_auto_compact_token_limit</label>
172
- <input
173
- id="codex-model-auto-compact-token-limit"
174
- v-model="modelAutoCompactTokenLimitInput"
175
- class="form-input"
176
- inputmode="numeric"
177
- autocomplete="off"
178
- :placeholder="t('config.example', { value: 185000 })"
179
- @focus="editingCodexBudgetField = 'modelAutoCompactTokenLimitInput'"
180
- @input="sanitizePositiveIntegerDraft('modelAutoCompactTokenLimitInput')"
181
- @blur="onModelAutoCompactTokenLimitBlur"
182
- @keydown.enter.prevent="onModelAutoCompactTokenLimitBlur">
183
- <div class="form-hint">{{ t('config.autoCompact.hint') }}</div>
184
- </div>
185
- </div>
186
- </div>
187
-
188
- <div class="selector-section">
189
- <div class="selector-header">
190
- <span class="selector-title">AGENTS.md</span>
191
- </div>
192
- <button class="btn-tool" @click="openAgentsEditor" :disabled="loading || !!initError || agentsLoading">
193
- {{ agentsLoading ? t('config.modelLoading') : t('config.agents.open') }}
194
- </button>
195
- </div>
196
-
197
- <div class="selector-section">
198
- <div class="selector-header">
199
- <span class="selector-title">{{ t('config.health.title') }}</span>
200
- </div>
201
- <button class="btn-tool" @click="runHealthCheck" :disabled="healthCheckLoading || loading || !!initError">
202
- {{ healthCheckLoading ? t('config.health.running') : t('config.health.run') }}
203
- </button>
204
- <div class="config-template-hint">{{ t('config.health.hint') }}</div>
205
- <div v-if="healthCheckLoading && healthCheckBatchTotal" class="config-template-hint">
206
- {{ t('config.health.progress', { done: healthCheckBatchDone, total: healthCheckBatchTotal, failed: healthCheckBatchFailed }) }}
207
- </div>
208
- <div v-if="healthCheckResult && !healthCheckLoading" class="config-template-hint">
209
- {{ healthCheckResult.ok ? t('config.health.ok') : t('config.health.fail') }} · {{ t('config.health.issues', { count: (healthCheckResult.issues || []).length }) }}
210
- </div>
211
- <button v-if="healthCheckResult && !healthCheckLoading" type="button" class="btn-mini" @click="showHealthCheckModal = true">
212
- {{ t('common.detail') }}
213
- </button>
214
- <div v-if="healthCheckResult && !healthCheckLoading && (healthCheckResult.issues || []).length">
215
- <div v-for="(issue, index) in healthCheckResult.issues" :key="issue.code || ('issue-' + index)" class="config-template-hint">
216
- {{ issue.message || issue.code || '' }}<span v-if="issue.suggestion"> · {{ issue.suggestion }}</span>
217
- </div>
218
- </div>
219
- </div>
220
-
221
- </template>
222
-
223
- <div v-if="!loading && !initError" class="card-list">
224
- <div v-for="provider in displayProvidersList" :key="provider.name"
225
- :class="['card', { active: displayCurrentProvider === provider.name }]"
226
- @click="switchProvider(provider.name)"
227
- @keydown.enter.self.prevent="switchProvider(provider.name)"
228
- @keydown.space.self.prevent="switchProvider(provider.name)"
229
- tabindex="0"
230
- role="button"
231
- :aria-current="displayCurrentProvider === provider.name ? 'true' : null">
232
- <div class="card-leading">
233
- <div class="card-icon">{{ provider.name.charAt(0).toUpperCase() }}<span v-if="isTransformProvider(provider)" class="card-icon-dot" title="通过内建转换适配"></span></div>
234
- <div class="card-content">
235
- <div class="card-title">
236
- <span>{{ provider.name }}</span>
237
- <span v-if="provider.readOnly" class="provider-readonly-badge">{{ t('config.badge.system') }}</span>
238
- </div>
239
- <div v-if="provider.name !== 'local'" class="card-subtitle card-subtitle-model">
240
- {{ activeProviderModel(provider.name) || t('config.model.unset') }}
241
- </div>
242
- <div v-if="provider.name !== 'local'" class="card-subtitle card-subtitle-url">
243
- {{ displayProviderUrl(provider) || t('config.url.unset') }}
244
- </div>
245
- </div>
246
- </div>
247
- <div class="card-trailing">
248
- <span v-if="speedResults[provider.name]" :class="['latency', speedResults[provider.name].ok ? 'ok' : 'error']">
249
- {{ formatLatency(speedResults[provider.name]) }}
250
- </span>
251
- <span :class="['pill', providerPillConfigured(provider) ? 'configured' : 'empty']">
252
- {{ providerPillText(provider) }}
253
- </span>
254
- <div class="card-actions" @click.stop>
255
- <button
256
- class="card-action-btn"
257
- :class="{ loading: speedLoading[provider.name] }"
258
- :disabled="!!speedLoading[provider.name]"
259
- @click="runSpeedTest(provider.name, { silent: true })"
260
- :aria-label="t('config.availabilityTestAria', { name: provider.name })"
261
- :title="t('config.availabilityTest')"
262
- >
263
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
264
- <path d="M13 2L3 14h7l-1 8 12-14h-7l1-6z"/>
265
- </svg>
266
- </button>
267
- <button
268
- v-if="!provider.readOnly"
269
- class="card-action-btn"
270
- :class="{ loading: providerShareLoading[provider.name], disabled: !shouldAllowProviderShare(provider) }"
271
- :disabled="providerShareLoading[provider.name] || !shouldAllowProviderShare(provider)"
272
- @click="copyProviderShareCommand(provider)"
273
- :title="shouldAllowProviderShare(provider) ? t('config.shareCommand') : t('config.shareDisabled')"
274
- :aria-label="t('config.shareCommand.aria')">
275
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
276
- <path d="M4 12v7a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-7"/>
277
- <path d="M16 6l-4-4-4 4"/>
278
- <path d="M12 2v14"/>
279
- </svg>
280
- </button>
281
- <button
282
- v-if="!provider.readOnly"
283
- class="card-action-btn"
284
- :class="{ disabled: !shouldShowProviderEdit(provider) }"
285
- :disabled="!shouldShowProviderEdit(provider)"
286
- @click="openEditModal(provider)"
287
- :aria-label="t('config.provider.edit.aria', { name: provider.name })"
288
- :title="shouldShowProviderEdit(provider) ? t('common.edit') : t('common.notEditable')">
289
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
290
- <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
291
- <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
292
- </svg>
293
- </button>
294
- <button
295
- v-if="!provider.readOnly"
296
- class="card-action-btn"
297
- @click="openCloneProviderModal(provider)"
298
- :aria-label="t('config.provider.clone.aria', { name: provider.name })"
299
- :title="t('config.provider.clone')">
300
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
301
- <rect x="9" y="9" width="13" height="13" rx="2"/>
302
- <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
303
- </svg>
304
- </button>
305
- <button
306
- v-if="!provider.readOnly"
307
- class="card-action-btn delete"
308
- :class="{ disabled: !shouldShowProviderDelete(provider) }"
309
- :disabled="!shouldShowProviderDelete(provider)"
310
- @click="deleteProvider(provider.name)"
311
- :aria-label="t('config.provider.delete.aria', { name: provider.name })"
312
- :title="shouldShowProviderDelete(provider) ? t('common.delete') : t('common.notDeletable')">
313
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
314
- <path d="M3 6h18"/>
315
- <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
316
- </svg>
317
- </button>
318
- </div>
319
- </div>
320
- </div>
321
- </div>
322
-
323
- <div v-if="displayCurrentProvider === 'local'" class="local-bridge-panel" style="margin-top:12px;padding:12px;background:var(--card-bg);border-radius:8px;border:1px solid var(--border-color)">
324
- <div style="font-size:13px;font-weight:600;margin-bottom:8px;color:var(--text-secondary)">轮询池 — 勾选参与负载均衡的提供商</div>
325
- <div v-if="localBridgeCandidateProviders().length === 0" style="font-size:12px;color:var(--text-muted)">暂无可用上游 provider,请先添加直连 provider</div>
326
- <label v-for="cp in localBridgeCandidateProviders()" :key="cp.name"
327
- style="display:flex;align-items:center;gap:8px;padding:6px 0;cursor:pointer;font-size:13px">
328
- <input type="checkbox"
329
- :checked="!isLocalBridgeExcluded(cp.name)"
330
- @change="toggleLocalBridgeExcluded(cp.name)"
331
- style="accent-color:var(--accent-color)" />
332
- <span>{{ cp.name }}</span>
333
- </label>
334
- </div>
335
-
336
- </template>
337
- </div>