codexmate 0.0.33 → 0.0.34

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.
@@ -92,6 +92,9 @@ function createAgentsFileController(deps = {}) {
92
92
  function applyClaudeMdFile(params = {}) {
93
93
  const filePath = resolveClaudeMdFilePath();
94
94
  const content = typeof params.content === 'string' ? params.content : '';
95
+ if (content.length > 2 * 1024 * 1024) {
96
+ return { error: '内容过大(最大 2MB)' };
97
+ }
95
98
  const lineEnding = params.lineEnding === '\r\n' ? '\r\n' : '\n';
96
99
  const normalized = normalizeLineEnding(content, lineEnding);
97
100
  const finalContent = ensureUtf8Bom(normalized);
@@ -150,6 +153,9 @@ function createAgentsFileController(deps = {}) {
150
153
  }
151
154
 
152
155
  const content = typeof params.content === 'string' ? params.content : '';
156
+ if (content.length > 2 * 1024 * 1024) {
157
+ return { error: '内容过大(最大 2MB)' };
158
+ }
153
159
  const lineEnding = params.lineEnding === '\r\n' ? '\r\n' : '\n';
154
160
  const normalized = normalizeLineEnding(content, lineEnding);
155
161
  const finalContent = ensureUtf8Bom(normalized);
@@ -4,6 +4,7 @@ function createArchiveHelperController(deps = {}) {
4
4
  path,
5
5
  os,
6
6
  execSync,
7
+ execFileSync,
7
8
  zipLib,
8
9
  yauzl,
9
10
  ensureDir,
@@ -341,8 +342,11 @@ function createArchiveHelperController(deps = {}) {
341
342
 
342
343
  const zipTool = resolveZipTool();
343
344
  if (zipTool.type === 'zip') {
344
- const cmd = `"${zipTool.cmd}" -0 -q -r "${zipFilePath}" "${dirPath}"`;
345
- execSync(cmd, { stdio: 'ignore' });
345
+ if (typeof execFileSync === 'function') {
346
+ execFileSync(zipTool.cmd, ['-0', '-q', '-r', zipFilePath, dirPath], { stdio: 'ignore' });
347
+ } else {
348
+ execSync(`"${zipTool.cmd}" -0 -q -r "${zipFilePath}" "${dirPath}"`, { stdio: 'ignore' });
349
+ }
346
350
  } else {
347
351
  await zipWithLibrary(dirPath, zipFilePath);
348
352
  }
@@ -371,8 +375,11 @@ function createArchiveHelperController(deps = {}) {
371
375
 
372
376
  try {
373
377
  if (zipTool.type === 'zip') {
374
- const cmd = `"${zipTool.cmd}" -0 -q -r "${zipFilePath}" "${dirPath}"`;
375
- execSync(cmd, { stdio: 'ignore' });
378
+ if (typeof execFileSync === 'function') {
379
+ execFileSync(zipTool.cmd, ['-0', '-q', '-r', zipFilePath, dirPath], { stdio: 'ignore' });
380
+ } else {
381
+ execSync(`"${zipTool.cmd}" -0 -q -r "${zipFilePath}" "${dirPath}"`, { stdio: 'ignore' });
382
+ }
376
383
  } else {
377
384
  await zipWithLibrary(dirPath, zipFilePath);
378
385
  }
@@ -74,7 +74,7 @@ function buildClaudeUpstreamPool(claudeProvidersFile, excludedProviders) {
74
74
  if (excludedSet.has(name.toLowerCase())) continue;
75
75
  const baseUrl = typeof p.baseUrl === 'string' ? p.baseUrl.trim() : '';
76
76
  if (!baseUrl || !isValidHttpUrl(normalizeBaseUrl(baseUrl))) continue;
77
- pool.push({ name, baseUrl: normalizeBaseUrl(baseUrl), apiKey: typeof p.apiKey === 'string' ? p.apiKey : '' });
77
+ pool.push({ name, baseUrl: normalizeBaseUrl(baseUrl), apiKey: typeof p.apiKey === 'string' ? p.apiKey : '', model: typeof p.model === 'string' ? p.model.trim() : '' });
78
78
  }
79
79
  if (pool.length === 0) return { error: '请先添加可用的 Claude 上游提供商' };
80
80
  return { pool };
@@ -271,7 +271,7 @@ function createLocalBridgeHttpHandler(options = {}) {
271
271
  return;
272
272
  }
273
273
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
274
- res.end(JSON.stringify({ object: 'codexmate.claude_local_bridge', provider: entry.name, status: 'ok', pool: pool.map(p => p.name) }));
274
+ res.end(JSON.stringify({ object: 'codexmate.claude_local_bridge', provider: entry.name, model: entry.model || '', status: 'ok', pool: pool.map(p => p.name) }));
275
275
  return;
276
276
  }
277
277
 
@@ -285,7 +285,12 @@ function createLocalBridgeHttpHandler(options = {}) {
285
285
 
286
286
  let parsedBody;
287
287
  try { parsedBody = bodyResult.body ? JSON.parse(bodyResult.body) : {}; } catch (_) { parsedBody = {}; }
288
+ // Override model to match the selected upstream provider
289
+ if (entry.model && parsedBody && typeof parsedBody === 'object') {
290
+ parsedBody.model = entry.model;
291
+ }
288
292
  const wantsStream = !!(parsedBody && parsedBody.stream);
293
+ const bodyToForward = JSON.stringify(parsedBody);
289
294
  const upstreamUrl = joinApiUrl(entry.baseUrl.replace(/\/+$/, ''), suffix);
290
295
  const headers = { 'Content-Type': 'application/json' };
291
296
  if (entry.apiKey) {
@@ -300,7 +305,7 @@ function createLocalBridgeHttpHandler(options = {}) {
300
305
  // Streaming proxy: pipe upstream SSE directly to client
301
306
  const upstreamResult = await streamClaudeUpstream(upstreamUrl, {
302
307
  method: req.method || 'POST',
303
- body: bodyResult.body,
308
+ body: bodyToForward,
304
309
  headers,
305
310
  maxBytes: maxUpstreamBytes,
306
311
  httpAgent,
@@ -322,7 +327,7 @@ function createLocalBridgeHttpHandler(options = {}) {
322
327
  // Non-streaming proxy
323
328
  const upstreamResult = await retryTransientRequest(() => proxyRequestJson(upstreamUrl, {
324
329
  method: req.method || 'POST',
325
- body: bodyResult.body || null,
330
+ body: bodyToForward || null,
326
331
  headers,
327
332
  maxBytes: maxUpstreamBytes,
328
333
  httpAgent,
@@ -5,7 +5,7 @@ const { StringDecoder } = require('string_decoder');
5
5
  const { readJsonFile, writeJsonAtomic } = require('../lib/cli-file-utils');
6
6
  const { isValidHttpUrl, normalizeBaseUrl, joinApiUrl } = require('../lib/cli-utils');
7
7
 
8
- const DEFAULT_BRIDGE_TOKEN = 'codexmate';
8
+ const DEFAULT_BRIDGE_TOKEN = crypto.randomBytes(16).toString('hex');
9
9
  const SETTINGS_VERSION = 1;
10
10
  // 推理模型 reasoning 阶段可能长时间无字节输出,需匹配 codex 的 stream_idle_timeout_ms=300000。
11
11
  const STREAM_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
package/cli/update.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
+ const os = require('os');
3
4
  const https = require('https');
4
5
  const { execSync, spawn } = require('child_process');
5
6
 
@@ -147,8 +148,16 @@ function updateViaStandalone(version) {
147
148
  if (process.platform !== 'win32') {
148
149
  console.log('[Update] 尝试自动执行安装脚本...');
149
150
  try {
150
- const script = 'curl -fsSL https://raw.githubusercontent.com/SakuraByteCore/codexmate/main/scripts/install.sh | bash';
151
- execSync(script, { stdio: 'inherit' });
151
+ const crypto = require('crypto');
152
+ const tmpScript = path.join(os.tmpdir(), `codexmate-install-${Date.now()}.sh`);
153
+ const { execFileSync, execSync: _unused } = require('child_process');
154
+ execSync(`curl -fsSL -o "${tmpScript}" https://raw.githubusercontent.com/SakuraByteCore/codexmate/main/scripts/install.sh`, { stdio: 'inherit' });
155
+ const scriptContent = fs.readFileSync(tmpScript, 'utf-8');
156
+ const checksum = crypto.createHash('sha256').update(scriptContent).digest('hex');
157
+ console.log(`[Update] Script checksum: ${checksum}`);
158
+ fs.chmodSync(tmpScript, 0o755);
159
+ execFileSync('bash', [tmpScript], { stdio: 'inherit' });
160
+ try { fs.unlinkSync(tmpScript); } catch (_) {}
152
161
  } catch (e) {
153
162
  console.warn('[Update] 自动脚本执行失败,请手动运行。');
154
163
  }
package/cli.js CHANGED
@@ -7,7 +7,7 @@ const toml = require('@iarna/toml');
7
7
  const JSON5 = require('json5');
8
8
  const zipLib = require('zip-lib');
9
9
  const yauzl = require('yauzl');
10
- const { exec, execSync, spawn, spawnSync } = require('child_process');
10
+ const { exec, execSync, execFileSync, spawn, spawnSync } = require('child_process');
11
11
  const http = require('http');
12
12
  const https = require('https');
13
13
  const net = require('net');
@@ -175,7 +175,7 @@ const {
175
175
  } = require('./lib/download-artifacts');
176
176
 
177
177
  const DEFAULT_WEB_PORT = 3737;
178
- const DEFAULT_WEB_HOST = '0.0.0.0';
178
+ const DEFAULT_WEB_HOST = '127.0.0.1';
179
179
  const DEFAULT_WEB_OPEN_HOST = '127.0.0.1';
180
180
 
181
181
  // ============================================================================
@@ -758,7 +758,7 @@ function updateAuthJson(apiKey) {
758
758
  } catch (e) { }
759
759
  }
760
760
  authData['OPENAI_API_KEY'] = apiKey;
761
- fs.writeFileSync(AUTH_FILE, JSON.stringify(authData, null, 2), 'utf-8');
761
+ fs.writeFileSync(AUTH_FILE, JSON.stringify(authData, null, 2), { encoding: 'utf-8', mode: 0o600 });
762
762
  }
763
763
 
764
764
  function isPlainObject(value) {
@@ -1711,6 +1711,7 @@ const {
1711
1711
  path,
1712
1712
  os,
1713
1713
  execSync,
1714
+ execFileSync,
1714
1715
  zipLib,
1715
1716
  yauzl,
1716
1717
  ensureDir,
@@ -4234,6 +4235,9 @@ function parseClaudeSessionSummary(filePath, options = {}) {
4234
4235
 
4235
4236
  const tailRecords = parseJsonlTailRecords(filePath, summaryReadBytes);
4236
4237
  for (const record of tailRecords) {
4238
+ if (record && record.timestamp) {
4239
+ updatedAt = updateLatestIso(updatedAt, record.timestamp);
4240
+ }
4237
4241
  applySessionUsageSummaryFromRecord(usageState, record, 'claude');
4238
4242
  totalTokens = usageState.totalTokens || 0;
4239
4243
  contextWindow = usageState.contextWindow || 0;
@@ -4741,8 +4745,8 @@ function listClaudeSessions(limit, options = {}) {
4741
4745
  continue;
4742
4746
  }
4743
4747
 
4744
- const updatedAt = toIsoTime(entry.modified || entry.fileMtime, '');
4745
- const createdAt = toIsoTime(entry.created, '');
4748
+ let updatedAt = toIsoTime(entry.modified || entry.fileMtime, fileStat.mtime.toISOString());
4749
+ let createdAt = toIsoTime(entry.created, '');
4746
4750
  let title = truncateText(entry.summary || entry.firstPrompt || sessionId, 120);
4747
4751
  let messageCount = Number.isFinite(entry.messageCount) ? Math.max(0, entry.messageCount - 1) : 0;
4748
4752
  let totalTokens = 0;
@@ -4774,6 +4778,12 @@ function listClaudeSessions(limit, options = {}) {
4774
4778
 
4775
4779
  const quickMessages = [];
4776
4780
  for (const record of quickRecords) {
4781
+ if (record && record.timestamp) {
4782
+ if (!createdAt) {
4783
+ createdAt = toIsoTime(record.timestamp, createdAt);
4784
+ }
4785
+ updatedAt = updateLatestIso(updatedAt, record.timestamp);
4786
+ }
4777
4787
  applySessionUsageSummaryFromRecord(usageState, record, 'claude');
4778
4788
  const recordModels = readSessionModelsFromRecord(record);
4779
4789
  for (const recordModel of recordModels) {
@@ -4804,6 +4814,12 @@ function listClaudeSessions(limit, options = {}) {
4804
4814
 
4805
4815
  const tailRecords = parseJsonlTailRecords(filePath, summaryReadBytes);
4806
4816
  for (const record of tailRecords) {
4817
+ if (record && record.timestamp) {
4818
+ if (!createdAt) {
4819
+ createdAt = toIsoTime(record.timestamp, createdAt);
4820
+ }
4821
+ updatedAt = updateLatestIso(updatedAt, record.timestamp);
4822
+ }
4807
4823
  applySessionUsageSummaryFromRecord(usageState, record, 'claude');
4808
4824
  const recordModels = readSessionModelsFromRecord(record);
4809
4825
  for (const recordModel of recordModels) {
@@ -7892,7 +7908,7 @@ function normalizeImportPayload(payload) {
7892
7908
  const name = item.name || item.provider || '';
7893
7909
  const baseUrl = item.baseUrl || item.base_url || item.url || '';
7894
7910
  const apiKey = item.apiKey ?? item.key ?? item.preferred_auth_method ?? null;
7895
- if (name && baseUrl) {
7911
+ if (name && baseUrl && /^[a-zA-Z0-9_\-.\s]+$/.test(name)) {
7896
7912
  providers[name] = { baseUrl, apiKey };
7897
7913
  }
7898
7914
  }
@@ -7901,7 +7917,7 @@ function normalizeImportPayload(payload) {
7901
7917
  if (!item || typeof item !== 'object') continue;
7902
7918
  const baseUrl = item.baseUrl || item.base_url || item.url || '';
7903
7919
  const apiKey = item.apiKey ?? item.key ?? item.preferred_auth_method ?? null;
7904
- if (name && baseUrl) {
7920
+ if (name && baseUrl && /^[a-zA-Z0-9_\-.\s]+$/.test(name)) {
7905
7921
  providers[name] = { baseUrl, apiKey };
7906
7922
  }
7907
7923
  }
@@ -9237,6 +9253,9 @@ function applyClaudeSettingsRaw(params = {}) {
9237
9253
  if (!content.trim()) {
9238
9254
  return { error: '内容不能为空' };
9239
9255
  }
9256
+ if (content.length > 1024 * 1024) {
9257
+ return { error: '内容过大(最大 1MB)' };
9258
+ }
9240
9259
  let parsed;
9241
9260
  try {
9242
9261
  parsed = JSON.parse(content);
@@ -10119,7 +10138,7 @@ function assertRequestAuthorized(req, res) {
10119
10138
  return { ok: false, mode: 'missing-token' };
10120
10139
  }
10121
10140
  const actual = extractRequestToken(req);
10122
- if (!actual || actual !== expected) {
10141
+ if (!actual || !safeTimingEqual(actual, expected)) {
10123
10142
  writeJsonResponse(res, 401, { error: 'Unauthorized' });
10124
10143
  return { ok: false, mode: 'unauthorized' };
10125
10144
  }
@@ -10576,7 +10595,38 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
10576
10595
  res.end('Internal Server Error');
10577
10596
  };
10578
10597
 
10598
+ const rateLimitMap = new Map();
10599
+ const RATE_LIMIT_WINDOW_MS = 60000;
10600
+ const RATE_LIMIT_MAX = 120;
10601
+ function checkRateLimit(key) {
10602
+ const now = Date.now();
10603
+ const entry = rateLimitMap.get(key);
10604
+ if (!entry || now - entry.start > RATE_LIMIT_WINDOW_MS) {
10605
+ rateLimitMap.set(key, { start: now, count: 1 });
10606
+ return true;
10607
+ }
10608
+ entry.count++;
10609
+ if (entry.count > RATE_LIMIT_MAX) return false;
10610
+ return true;
10611
+ }
10612
+ setInterval(function () {
10613
+ const now = Date.now();
10614
+ for (const [key, entry] of rateLimitMap.entries()) {
10615
+ if (now - entry.start > RATE_LIMIT_WINDOW_MS * 2) rateLimitMap.delete(key);
10616
+ }
10617
+ }, RATE_LIMIT_WINDOW_MS).unref();
10618
+
10579
10619
  const server = http.createServer((req, res) => {
10620
+ const securityHeaders = {
10621
+ 'X-Content-Type-Options': 'nosniff',
10622
+ 'X-Frame-Options': 'DENY',
10623
+ 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:"
10624
+ };
10625
+ const origWriteHead = res.writeHead.bind(res);
10626
+ res.writeHead = function (statusCode, headers) {
10627
+ const merged = Object.assign({}, securityHeaders, headers || {});
10628
+ return origWriteHead(statusCode, merged);
10629
+ };
10580
10630
  const requestPath = (req.url || '/').split('?')[0];
10581
10631
  const sendJson = (statusCode, payload) => {
10582
10632
  const body = JSON.stringify(payload || {}, null, 2);
@@ -10604,6 +10654,12 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
10604
10654
  || remoteAddr === '::1'
10605
10655
  || remoteAddr === '::ffff:127.0.0.1';
10606
10656
  if (!isLoopback) {
10657
+ const rateLimitKey = (remoteAddr || 'unknown') + ':' + requestPath;
10658
+ if (!checkRateLimit(rateLimitKey)) {
10659
+ res.writeHead(429, { 'Content-Type': 'application/json; charset=utf-8', 'Retry-After': '60' });
10660
+ res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
10661
+ return;
10662
+ }
10607
10663
  const expected = typeof process.env.CODEXMATE_HTTP_TOKEN === 'string'
10608
10664
  ? process.env.CODEXMATE_HTTP_TOKEN.trim()
10609
10665
  : '';
@@ -10619,7 +10675,7 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
10619
10675
  const actual = match && match[1]
10620
10676
  ? match[1].trim()
10621
10677
  : (rawAuth ? rawAuth : (typeof headers['x-codexmate-token'] === 'string' ? String(headers['x-codexmate-token']).trim() : ''));
10622
- if (!actual || actual !== expected) {
10678
+ if (!actual || !safeTimingEqual(actual, expected)) {
10623
10679
  sendJson(401, { error: 'Unauthorized' });
10624
10680
  return;
10625
10681
  }
@@ -3,10 +3,33 @@ const fs = require('fs');
3
3
  const http = require('http');
4
4
  const https = require('https');
5
5
  const os = require('os');
6
+ const net = require('net');
6
7
 
7
8
  const ALLOWED_EVENTS = ['provider-switch', 'claude-md-edit'];
8
9
  const DEFAULT_TIMEOUT_MS = 5000;
9
10
 
11
+ function isPrivateNetworkHost(hostname) {
12
+ const host = typeof hostname === 'string' ? hostname.trim().toLowerCase() : '';
13
+ if (!host) return true;
14
+ if (host === 'localhost') return true;
15
+ const ipVer = net.isIP(host);
16
+ if (!ipVer) return false;
17
+ if (ipVer === 4) {
18
+ const parts = host.split('.').map(function (x) { return parseInt(x, 10); });
19
+ if (parts[0] === 10) return true;
20
+ if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
21
+ if (parts[0] === 192 && parts[1] === 168) return true;
22
+ if (parts[0] === 127) return true;
23
+ if (parts[0] === 0) return true;
24
+ if (parts[0] === 169 && parts[1] === 254) return true;
25
+ return false;
26
+ }
27
+ if (host === '::1' || host === '::') return true;
28
+ if (host.startsWith('fc') || host.startsWith('fd')) return true;
29
+ if (host.startsWith('fe80')) return true;
30
+ return false;
31
+ }
32
+
10
33
  function defaultConfigPath() {
11
34
  return path.join(os.homedir(), '.codex', 'codexmate-webhook.json');
12
35
  }
@@ -42,7 +65,7 @@ function saveWebhookConfig(cfg, filePath) {
42
65
  try {
43
66
  fs.mkdirSync(path.dirname(target), { recursive: true });
44
67
  } catch (_) {}
45
- fs.writeFileSync(target, JSON.stringify(normalized, null, 2), 'utf-8');
68
+ fs.writeFileSync(target, JSON.stringify(normalized, null, 2), { encoding: 'utf-8', mode: 0o600 });
46
69
  return normalized;
47
70
  }
48
71
 
@@ -55,6 +78,11 @@ function postJson(targetUrl, payload, timeoutMs) {
55
78
  resolve({ ok: false, error: 'invalid-url' });
56
79
  return;
57
80
  }
81
+ const allowPrivateWebhook = process.env.CODEXMATE_ALLOW_PRIVATE_WEBHOOK === '1';
82
+ if (!allowPrivateWebhook && isPrivateNetworkHost(parsed.hostname || '')) {
83
+ resolve({ ok: false, error: 'Refusing to send webhook to private network host' });
84
+ return;
85
+ }
58
86
  const transport = parsed.protocol === 'https:' ? https : http;
59
87
  const body = JSON.stringify(payload || {});
60
88
  let req;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codexmate",
3
- "version": "0.0.33",
3
+ "version": "0.0.34",
4
4
  "description": "Codex/Claude Code/OpenClaw 配置、会话与任务编排 CLI + Web 工具",
5
5
  "main": "cli.js",
6
6
  "bin": {
package/web-ui/app.js CHANGED
@@ -71,6 +71,9 @@ document.addEventListener('DOMContentLoaded', () => {
71
71
  showAgentsModal: false,
72
72
  showSkillsModal: false,
73
73
  showHealthCheckModal: false,
74
+ showCodexBridgePoolModal: false,
75
+ showClaudeBridgePoolModal: false,
76
+ showWebhookModal: false,
74
77
  // Plugins
75
78
  pluginsActiveId: 'prompt-templates',
76
79
  pluginsLoading: false,
@@ -98,6 +101,7 @@ document.addEventListener('DOMContentLoaded', () => {
98
101
  confirmDialogResolver: null,
99
102
  configTemplateContent: '',
100
103
  configTemplateApplying: false,
104
+ configTemplateContext: 'codex',
101
105
  configTemplateDiffVisible: false,
102
106
  configTemplateDiffLoading: false,
103
107
  configTemplateDiffError: '',
package/web-ui/index.html CHANGED
@@ -26,6 +26,7 @@
26
26
  <!-- @include ./partials/index/panel-plugins.html -->
27
27
  <!-- @include ./partials/index/layout-footer.html -->
28
28
  <!-- @include ./partials/index/modals-basic.html -->
29
+ <!-- @include ./partials/index/modal-webhook.html -->
29
30
  <!-- @include ./partials/index/modal-openclaw-config.html -->
30
31
  <!-- @include ./partials/index/modal-config-template-agents.html -->
31
32
  <!-- @include ./partials/index/modal-skills.html -->
@@ -111,6 +111,10 @@ export function matchClaudeConfigFromSettings(claudeConfigs = {}, env = {}) {
111
111
  if (!normalizedSettings.baseUrl || !normalizedSettings.model || !hasClaudeCredential(normalizedSettings)) {
112
112
  return '';
113
113
  }
114
+ // 检测本地桥接 URL
115
+ if (typeof normalizedSettings.baseUrl === 'string' && normalizedSettings.baseUrl.includes('/bridge/claude-local/')) {
116
+ return 'claude-local';
117
+ }
114
118
  const comparableSettingsUrl = normalizeClaudeComparableUrl(normalizedSettings.baseUrl);
115
119
  const entries = Object.entries(claudeConfigs || {});
116
120
  for (const [name, config] of entries) {
@@ -264,6 +264,43 @@ export function createClaudeConfigMethods(options = {}) {
264
264
 
265
265
  claudeLocalBridgeConfigured() {
266
266
  return this.claudeLocalBridgeCandidateProviders().some(p => p.hasKey);
267
+ },
268
+
269
+ async applyClaudeLocalBridge() {
270
+ this.currentClaudeConfig = 'claude-local';
271
+ try { localStorage.setItem('currentClaudeConfig', 'claude-local'); } catch (_) {}
272
+ this.refreshClaudeModelContext();
273
+
274
+ const candidates = this.claudeLocalBridgeCandidateProviders();
275
+ if (candidates.length === 0) {
276
+ return this.showMessage('请先添加并配置至少一个 Claude 提供商', 'error');
277
+ }
278
+
279
+ try {
280
+ const res = await api('claude-local-bridge-toggle', { enable: true });
281
+ if (res.error) {
282
+ this.showMessage(res.error || '启用本地负载均衡失败', 'error');
283
+ return;
284
+ }
285
+ this.showMessage('Claude 本地负载均衡已启用', 'success');
286
+ } catch (e) {
287
+ this.showMessage('启用本地负载均衡失败', 'error');
288
+ }
289
+ },
290
+
291
+ async openClaudeConfigTemplateEditor() {
292
+ try {
293
+ const res = await api('get-claude-settings-raw');
294
+ if (res.error) {
295
+ this.showMessage(res.error, 'error');
296
+ return;
297
+ }
298
+ this.configTemplateContent = res.content || '{}';
299
+ this.configTemplateContext = 'claude';
300
+ this.showConfigTemplateModal = true;
301
+ } catch (e) {
302
+ this.showMessage('加载 Claude settings 失败', 'error');
303
+ }
267
304
  }
268
305
  };
269
306
  }
@@ -558,6 +558,7 @@ export function createCodexConfigMethods(options = {}) {
558
558
  template = `${template.trimEnd()}\n\n${appendBlock}\n`;
559
559
  }
560
560
  this.configTemplateContent = template;
561
+ this.configTemplateContext = 'codex';
561
562
  this.showConfigTemplateModal = true;
562
563
  } catch (e) {
563
564
  this.showMessage('加载模板失败', 'error');
@@ -807,9 +808,16 @@ export function createCodexConfigMethods(options = {}) {
807
808
  const performApply = async () => {
808
809
  this.configTemplateApplying = true;
809
810
  try {
810
- const res = await api('apply-config-template', {
811
- template: this.configTemplateContent
812
- });
811
+ let res;
812
+ if (this.configTemplateContext === 'claude') {
813
+ res = await api('apply-claude-settings-raw', {
814
+ content: this.configTemplateContent
815
+ });
816
+ } else {
817
+ res = await api('apply-config-template', {
818
+ template: this.configTemplateContent
819
+ });
820
+ }
813
821
  if (res.error) {
814
822
  this.showMessage(res.error, 'error');
815
823
  return;
@@ -419,6 +419,20 @@
419
419
  mainTab: targetTab,
420
420
  configMode: targetTab === 'config' ? this.configMode : this.configMode
421
421
  });
422
+ if (targetTab !== 'sessions') {
423
+ try {
424
+ const url = new URL(window.location.href);
425
+ if (url.pathname !== '/session') {
426
+ url.searchParams.delete('s_source');
427
+ url.searchParams.delete('s_path');
428
+ url.searchParams.delete('s_query');
429
+ url.searchParams.delete('s_role');
430
+ url.searchParams.delete('s_time');
431
+ url.searchParams.delete('tab');
432
+ window.history.replaceState(null, '', url.toString());
433
+ }
434
+ } catch (_) {}
435
+ }
422
436
  this.cancelTouchNavIntentReset();
423
437
  if (targetTab === 'sessions') {
424
438
  this.cancelScheduledSessionTabDeferredTeardown();
@@ -479,14 +479,13 @@ export function createSessionBrowserMethods(options = {}) {
479
479
  if (typeof text !== 'string' || !text) return text;
480
480
  var tokens = this.queryTokens;
481
481
  if (!tokens || tokens.length === 0) return text;
482
- var result = text;
482
+ var escaped = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
483
483
  for (var i = 0; i < tokens.length; i++) {
484
- var token = tokens[i];
485
- var escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\ async onSessionSourceChange(event) {');
486
- var re = new RegExp('(' + escaped + ')', 'gi');
487
- result = result.replace(re, '<mark>$1</mark>');
484
+ var token = tokens[i].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
485
+ var re = new RegExp('(' + token + ')', 'gi');
486
+ escaped = escaped.replace(re, '<mark>$1</mark>');
488
487
  }
489
- return result;
488
+ return escaped;
490
489
  },
491
490
 
492
491
  async onSessionSourceChange(event) {
@@ -383,6 +383,15 @@ export function createStartupClaudeMethods(options = {}) {
383
383
  },
384
384
 
385
385
  syncClaudeModelFromConfig() {
386
+ if (this.currentClaudeConfig === 'claude-local') {
387
+ const candidates = this.claudeLocalBridgeCandidateProviders
388
+ ? this.claudeLocalBridgeCandidateProviders()
389
+ : [];
390
+ const active = candidates.find(cp => !this.isClaudeLocalBridgeExcluded(cp.name));
391
+ this.currentClaudeModel = active && active.model ? active.model : '';
392
+ this.claudeCustomModelDraft = this.currentClaudeModel;
393
+ return;
394
+ }
386
395
  const config = this.getCurrentClaudeConfig();
387
396
  this.currentClaudeModel = config && config.model ? config.model : '';
388
397
  this.claudeCustomModelDraft = this.currentClaudeModel;
@@ -2,6 +2,14 @@ import { api } from './api.mjs';
2
2
 
3
3
  export function createWebhookMethods() {
4
4
  return {
5
+ openWebhookModal() {
6
+ this.showWebhookModal = true;
7
+ },
8
+
9
+ closeWebhookModal() {
10
+ this.showWebhookModal = false;
11
+ },
12
+
5
13
  async loadWebhookSettings() {
6
14
  try {
7
15
  const data = await api('get-webhook-config');
@@ -910,6 +910,7 @@ const DICT = Object.freeze({
910
910
  'settings.tab.general': '通用',
911
911
  'settings.tab.data': '数据',
912
912
  'settings.tabs.aria': '设置分类',
913
+ 'settings.quickSettings.title': '快捷设置',
913
914
  'settings.sharePrefix.title': '分享命令前缀',
914
915
  'settings.sharePrefix.meta': '影响 Web UI 里“复制分享命令”的前缀',
915
916
  'settings.sharePrefix.label': '前缀',
@@ -1965,6 +1966,7 @@ const DICT = Object.freeze({
1965
1966
  'settings.tab.general': '一般',
1966
1967
  'settings.tab.data': 'データ',
1967
1968
  'settings.tabs.aria': '設定カテゴリ',
1969
+ 'settings.quickSettings.title': 'クイック設定',
1968
1970
  'settings.sharePrefix.title': '共有コマンドプレフィックス',
1969
1971
  'settings.sharePrefix.meta': 'Web UI の「共有コマンドをコピー」のプレフィックスに影響',
1970
1972
  'settings.sharePrefix.label': 'プレフィックス',
@@ -3034,6 +3036,7 @@ const DICT = Object.freeze({
3034
3036
  'settings.tab.general': 'General',
3035
3037
  'settings.tab.data': 'Data',
3036
3038
  'settings.tabs.aria': 'Settings categories',
3039
+ 'settings.quickSettings.title': 'Quick Settings',
3037
3040
  'settings.sharePrefix.title': 'Share command prefix',
3038
3041
  'settings.sharePrefix.meta': 'Used as the prefix for “Copy share command” in the Web UI',
3039
3042
  'settings.sharePrefix.label': 'Prefix',
@@ -0,0 +1,42 @@
1
+ <!-- Webhook 配置模态框 -->
2
+ <div v-if="showWebhookModal" class="modal-overlay" @click.self="closeWebhookModal">
3
+ <div class="modal" role="dialog" aria-modal="true" aria-labelledby="webhook-modal-title">
4
+ <div class="modal-title" id="webhook-modal-title">Webhook 配置</div>
5
+
6
+ <div class="form-group">
7
+ <label class="form-label">启用状态</label>
8
+ <label class="settings-toggle">
9
+ <input type="checkbox" v-model="webhookConfig.enabled">
10
+ <span>启用 Webhook</span>
11
+ </label>
12
+ </div>
13
+
14
+ <div class="form-group">
15
+ <label class="form-label">URL</label>
16
+ <input
17
+ v-model="webhookConfig.url"
18
+ class="form-input"
19
+ type="url"
20
+ placeholder="https://example.com/webhook"
21
+ autocomplete="off"
22
+ spellcheck="false">
23
+ </div>
24
+
25
+ <div class="form-group">
26
+ <label class="form-label">事件</label>
27
+ <div class="webhook-events-checkbox-list">
28
+ <label v-for="ev in webhookEventOptions" :key="ev" class="webhook-event-checkbox-item">
29
+ <input type="checkbox" :checked="webhookConfig.events.includes(ev)" @change="toggleWebhookEvent(ev)">
30
+ <span>{{ ev }}</span>
31
+ </label>
32
+ </div>
33
+ </div>
34
+
35
+ <div class="btn-group">
36
+ <button class="btn btn-cancel" @click="closeWebhookModal">取消</button>
37
+ <button class="btn btn-confirm" @click="saveWebhookSettings" :disabled="webhookSaving">
38
+ {{ webhookSaving ? '保存中...' : '保存' }}
39
+ </button>
40
+ </div>
41
+ </div>
42
+ </div>