From 87379287de5f592480ae84b4097695f8011d5614 Mon Sep 17 00:00:00 2001 From: ymkiux Date: Sat, 20 Jun 2026 12:34:51 +0800 Subject: [PATCH 01/11] fix(sessions): prevent list flash during refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only show full-page loading state on initial load (when list is empty). During refresh with existing data, preserve the list and detail view to eliminate visual flash/disappearance. Changes: - v-if="sessionsLoading" → v-if="sessionsLoading && sessionsList.length === 0" - v-else-if="sessionsList.length === 0" → v-else-if="!sessionsLoading && sessionsList.length === 0" Co-Authored-By: Claude Opus 4.7 --- web-ui/partials/index/panel-sessions.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web-ui/partials/index/panel-sessions.html b/web-ui/partials/index/panel-sessions.html index d2a59347..228dad42 100644 --- a/web-ui/partials/index/panel-sessions.html +++ b/web-ui/partials/index/panel-sessions.html @@ -119,11 +119,11 @@ -
+
{{ t('sessions.loadingList') }}
-
+
{{ t('sessions.empty') }}
From 96c4e650fe539c3996f43db8f4d887e1e815f654 Mon Sep 17 00:00:00 2001 From: awsl233777 Date: Sat, 20 Jun 2026 06:34:51 +0000 Subject: [PATCH 02/11] feat(settings): add provider cache viewer --- cli.js | 326 ++++++++++++++++++ tests/unit/provider-cache-records.test.mjs | 100 ++++++ tests/unit/run.mjs | 2 + tests/unit/web-ui-behavior-parity.test.mjs | 29 +- tests/unit/web-ui-preferences.test.mjs | 123 +++++++ web-ui/app.js | 19 + web-ui/modules/app.methods.index.mjs | 4 + web-ui/modules/app.methods.navigation.mjs | 3 + web-ui/modules/app.methods.provider-cache.mjs | 128 +++++++ .../modules/app.methods.session-actions.mjs | 12 + .../modules/app.methods.session-browser.mjs | 3 + web-ui/modules/app.methods.session-trash.mjs | 3 + .../app.methods.web-ui-preferences.mjs | 146 ++++++++ web-ui/modules/i18n/locales/en.mjs | 20 ++ web-ui/modules/i18n/locales/ja.mjs | 20 ++ web-ui/modules/i18n/locales/vi.mjs | 20 ++ web-ui/modules/i18n/locales/zh-tw.mjs | 20 ++ web-ui/modules/i18n/locales/zh.mjs | 20 ++ web-ui/partials/index/modals-basic.html | 75 ++++ web-ui/partials/index/panel-settings.html | 13 + web-ui/res/web-ui-render.precompiled.js | 180 +++++++++- web-ui/styles/settings-panel.css | 189 ++++++++++ 22 files changed, 1439 insertions(+), 16 deletions(-) create mode 100644 tests/unit/provider-cache-records.test.mjs create mode 100644 tests/unit/web-ui-preferences.test.mjs create mode 100644 web-ui/modules/app.methods.provider-cache.mjs create mode 100644 web-ui/modules/app.methods.web-ui-preferences.mjs diff --git a/cli.js b/cli.js index 98c51603..ee001ef7 100644 --- a/cli.js +++ b/cli.js @@ -218,6 +218,20 @@ const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects'); const CODEBUDDY_DIR = path.join(os.homedir(), '.codebuddy'); const CODEBUDDY_PROJECTS_DIR = path.join(CODEBUDDY_DIR, 'projects'); const CODEXMATE_DIR = path.join(os.homedir(), '.codexmate'); +const PROVIDER_CACHE_FILE_GROUPS = Object.freeze({ + claude: [ + 'claude-providers.json' + ], + codex: [ + 'codex-providers.json', + 'codex-provider-current-models.json' + ], + opencode: [ + 'opencode-providers.json', + 'opencode-provider-current-models.json' + ] +}); +const PROVIDER_CACHE_MAX_FILE_BYTES = 256 * 1024; const CODEXMATE_PREFERENCES_FILE = path.join(CODEXMATE_DIR, 'preferences.json'); const CODEXMATE_OPENCODE_DIR = path.join(CODEXMATE_DIR, 'opencode'); const CODEXMATE_OPENCODE_PROVIDER_STORE_FILE = path.join(CODEXMATE_OPENCODE_DIR, 'providers.json'); @@ -915,6 +929,102 @@ function writeCodexmatePreferences(preferences) { writeJsonAtomic(CODEXMATE_PREFERENCES_FILE, isPlainObject(preferences) ? preferences : {}); } +function normalizeShareCommandPrefixPreference(value) { + const normalized = typeof value === 'string' ? value.trim().toLowerCase() : ''; + return normalized === 'codexmate' ? 'codexmate' : 'npm start'; +} + +function normalizeBooleanPreference(value, defaultValue = true) { + if (value === true) return true; + if (value === false) return false; + const normalized = typeof value === 'string' ? value.trim().toLowerCase() : ''; + if (normalized === '1' || normalized === 'true' || normalized === 'on' || normalized === 'yes') return true; + if (normalized === '0' || normalized === 'false' || normalized === 'off' || normalized === 'no') return false; + return defaultValue !== false; +} + +function normalizeSessionTrashRetentionPreference(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric < 1) return 30; + return Math.min(365, Math.max(1, Math.floor(numeric))); +} + +function normalizeSessionTimelineStylePreference(value) { + const normalized = typeof value === 'string' ? value.trim().toLowerCase() : ''; + return normalized === 'bar' ? 'bar' : 'dots'; +} + +function normalizeSettingsTabPreference(value) { + const normalized = typeof value === 'string' ? value.trim().toLowerCase() : ''; + return normalized === 'data' ? 'data' : 'general'; +} + +function normalizeMainTabPreference(value) { + const normalized = typeof value === 'string' ? value.trim().toLowerCase() : ''; + const allowed = new Set(['dashboard', 'config', 'sessions', 'usage', 'orchestration', 'market', 'plugins', 'docs', 'settings', 'trash', 'prompts']); + return allowed.has(normalized) ? normalized : 'dashboard'; +} + +function normalizeConfigModePreference(value) { + const normalized = typeof value === 'string' ? value.trim().toLowerCase() : ''; + return ['codex', 'claude', 'opencode'].includes(normalized) ? normalized : 'codex'; +} + +function normalizeUsageTimeRangePreference(value) { + const normalized = typeof value === 'string' ? value.trim().toLowerCase() : ''; + if (normalized === 'all' || normalized === '30d') return normalized; + return '7d'; +} + +function normalizePromptsSubTabPreference(value) { + const normalized = typeof value === 'string' ? value.trim().toLowerCase() : ''; + return normalized === 'claude-project' ? 'claude-project' : 'codex'; +} + +function normalizeWebUiPreferences(value = {}) { + const source = isPlainObject(value) ? value : {}; + const navigation = isPlainObject(source.navigation) ? source.navigation : {}; + return { + shareCommandPrefix: normalizeShareCommandPrefixPreference(source.shareCommandPrefix), + sessionTrashEnabled: normalizeBooleanPreference(source.sessionTrashEnabled, true), + sessionTrashRetentionDays: normalizeSessionTrashRetentionPreference(source.sessionTrashRetentionDays), + sessionTimelineStyle: normalizeSessionTimelineStylePreference(source.sessionTimelineStyle), + configTemplateDiffConfirmEnabled: normalizeBooleanPreference(source.configTemplateDiffConfirmEnabled, true), + sessionsUsageTimeRange: normalizeUsageTimeRangePreference(source.sessionsUsageTimeRange), + promptsSubTab: normalizePromptsSubTabPreference(source.promptsSubTab), + projectClaudeMdPath: typeof source.projectClaudeMdPath === 'string' ? source.projectClaudeMdPath : '', + navigation: { + mainTab: normalizeMainTabPreference(navigation.mainTab), + configMode: normalizeConfigModePreference(navigation.configMode), + settingsTab: normalizeSettingsTabPreference(navigation.settingsTab), + skillsTargetApp: navigation.skillsTargetApp === 'claude' ? 'claude' : 'codex', + promptTemplatesMode: navigation.promptTemplatesMode === 'manage' ? 'manage' : 'compose' + } + }; +} + +function readWebUiPreferences() { + const preferences = readCodexmatePreferences(); + return normalizeWebUiPreferences(preferences.webUi || {}); +} + +function setWebUiPreferences(params = {}) { + const preferences = readCodexmatePreferences(); + const current = isPlainObject(preferences.webUi) ? preferences.webUi : {}; + const incoming = isPlainObject(params && params.preferences) ? params.preferences : {}; + const next = normalizeWebUiPreferences({ + ...current, + ...incoming, + navigation: { + ...(isPlainObject(current.navigation) ? current.navigation : {}), + ...(isPlainObject(incoming.navigation) ? incoming.navigation : {}) + } + }); + preferences.webUi = next; + writeCodexmatePreferences(preferences); + return { success: true, preferences: next }; +} + function readToolConfigPermissions() { const preferences = readCodexmatePreferences(); return normalizeToolConfigPermissions(preferences.toolConfigPermissions || TOOL_CONFIG_PERMISSION_DEFAULTS); @@ -2506,6 +2616,213 @@ function updateProviderInConfig(params = {}) { } } + +function redactProviderCacheValue(value) { + const secretKeyPattern = /(?:^key$|api[_-]?key|auth[_-]?token|access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|passwd|secret|credential|authorization|bearer|cookie|session|private[_-]?key|client[_-]?secret|x-api-key)/i; + const secretQueryPattern = /(?:api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|passwd|secret|credential|authorization|client[_-]?secret|key)/i; + const secretValuePattern = /^(?:bearer\s+|sk-[A-Za-z0-9]|sk_[A-Za-z0-9]|gsk_|AIza|xox[baprs]-|gh[pousr]_|or-[A-Za-z0-9]|ds-[A-Za-z0-9])/i; + const redactString = (text) => { + const valueText = String(text || ''); + if (!valueText) return ''; + if (valueText.length <= 8) return '***'; + return `${valueText.slice(0, 4)}…${valueText.slice(-4)}`; + }; + const redactUrlString = (text) => { + if (typeof text !== 'string' || !/^https?:\/\//i.test(text)) return text; + try { + const parsed = new URL(text); + if (parsed.username) parsed.username = '***'; + if (parsed.password) parsed.password = '***'; + for (const key of Array.from(parsed.searchParams.keys())) { + if (secretQueryPattern.test(key)) parsed.searchParams.set(key, '***'); + } + return parsed.toString(); + } catch (_) { + return text.replace(/([?&](?:api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|passwd|secret|credential|authorization|client[_-]?secret|key)=)[^&#]*/gi, '$1***'); + } + }; + const visit = (input, key = '') => { + if (secretKeyPattern.test(key)) { + if (typeof input === 'boolean' || typeof input === 'number') return input; + if (input === null || input === undefined || input === '') return input === undefined ? null : input; + return redactString(input); + } + if (Array.isArray(input)) { + return input.map((item) => visit(item, key)); + } + if (isPlainObject(input)) { + const output = {}; + for (const [childKey, childValue] of Object.entries(input)) { + output[childKey] = visit(childValue, childKey); + } + return output; + } + if (typeof input === 'string') { + const urlRedacted = redactUrlString(input); + if (urlRedacted !== input) return urlRedacted; + if (secretValuePattern.test(input.trim())) return redactString(input.trim()); + } + return input; + }; + return visit(value); +} + +function getProviderCacheDisplayPath(fileName) { + return `~/.codexmate/${fileName}`; +} + +function pickProviderCacheString(source, keys) { + if (!isPlainObject(source)) return ''; + for (const key of keys) { + const value = source[key]; + if (typeof value === 'string' && value.trim()) return value.trim(); + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + } + return ''; +} + +function summarizeProviderCacheEntry(name, entry = {}) { + const provider = isPlainObject(entry) ? entry : {}; + const providerName = pickProviderCacheString(provider, ['name', 'id', 'provider', 'title']) || String(name || '').trim() || 'provider'; + const baseUrl = pickProviderCacheString(provider, ['base_url', 'baseUrl', 'url', 'endpoint']); + const wireApi = pickProviderCacheString(provider, ['wire_api', 'wireApi', 'api', 'type']); + const authMethod = pickProviderCacheString(provider, ['preferred_auth_method', 'authMethod', 'auth_method', 'auth']); + const model = pickProviderCacheString(provider, ['model', 'default_model', 'defaultModel']); + return { + name: providerName, + baseUrl: baseUrl ? redactProviderCacheValue(baseUrl) : '', + wireApi, + authMethod, + model, + data: redactProviderCacheValue(provider) + }; +} + +function extractProviderCacheSummaries(data) { + const providers = []; + const seen = new Set(); + const addProvider = (name, entry) => { + const summary = summarizeProviderCacheEntry(name, entry); + const key = `${summary.name}\u0000${summary.baseUrl}\u0000${summary.wireApi}`; + if (seen.has(key)) return; + seen.add(key); + providers.push(summary); + }; + const visitContainer = (container) => { + if (!container) return; + if (Array.isArray(container)) { + for (const item of container) { + if (!isPlainObject(item)) continue; + addProvider(pickProviderCacheString(item, ['name', 'id', 'provider']) || `provider-${providers.length + 1}`, item); + } + return; + } + if (!isPlainObject(container)) return; + for (const [name, entry] of Object.entries(container)) { + if (isPlainObject(entry)) addProvider(name, entry); + } + }; + + if (isPlainObject(data)) { + visitContainer(data.providers); + visitContainer(data.configs); + visitContainer(data.providerConfigs); + visitContainer(data.items); + if (providers.length === 0 && (data.base_url || data.baseUrl || data.url || data.endpoint)) { + addProvider(pickProviderCacheString(data, ['name', 'id', 'provider']) || 'default', data); + } + } else if (Array.isArray(data)) { + visitContainer(data); + } + return providers.sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''))); +} + +function buildProviderCacheFileRecord(fileName) { + const filePath = path.join(CODEXMATE_DIR, fileName); + const displayPath = getProviderCacheDisplayPath(fileName); + const record = { + name: fileName, + path: displayPath, + displayPath, + exists: false, + ok: true, + tooLarge: false, + size: 0, + mtime: '', + data: null, + providers: [], + providerCount: 0, + error: '' + }; + if (!fs.existsSync(filePath)) { + return record; + } + record.exists = true; + try { + const stat = fs.statSync(filePath); + record.size = Number(stat.size) || 0; + record.mtime = stat.mtime instanceof Date && !Number.isNaN(stat.mtime.getTime()) + ? stat.mtime.toISOString() + : ''; + } catch (_) {} + if (record.size > PROVIDER_CACHE_MAX_FILE_BYTES) { + record.ok = false; + record.tooLarge = true; + record.error = `缓存文件过大,已跳过 JSON 读取(${record.size} bytes > ${PROVIDER_CACHE_MAX_FILE_BYTES} bytes)`; + return record; + } + try { + const content = stripUtf8Bom(fs.readFileSync(filePath, 'utf-8')); + const parsed = content.trim() ? JSON.parse(content) : null; + record.data = redactProviderCacheValue(parsed); + record.providers = extractProviderCacheSummaries(parsed); + record.providerCount = record.providers.length; + } catch (e) { + record.ok = false; + record.error = e && e.message ? e.message : String(e || '读取缓存文件失败'); + } + return record; +} + +function listProviderCacheFileNamesForGroup(groupKey) { + const defaults = PROVIDER_CACHE_FILE_GROUPS[groupKey] || []; + const names = new Set(defaults); + try { + if (fs.existsSync(CODEXMATE_DIR)) { + for (const fileName of fs.readdirSync(CODEXMATE_DIR)) { + if (typeof fileName !== 'string' || !fileName.endsWith('.json')) continue; + if (groupKey === 'opencode' && /^opencode[-_]/i.test(fileName)) { + names.add(fileName); + } + } + } + } catch (_) {} + return Array.from(names).sort((a, b) => a.localeCompare(b)); +} + +function readProviderCacheRecords() { + const groupLabels = { + claude: 'Claude', + codex: 'Codex', + opencode: 'OpenCode' + }; + const groups = Object.keys(PROVIDER_CACHE_FILE_GROUPS).map((key) => { + const files = listProviderCacheFileNamesForGroup(key).map((fileName) => buildProviderCacheFileRecord(fileName)); + return { + key, + label: groupLabels[key] || key, + files, + existingCount: files.filter((file) => file && file.exists).length + }; + }); + return { + root: '~/.codexmate', + maxFileBytes: PROVIDER_CACHE_MAX_FILE_BYTES, + generatedAt: new Date().toISOString(), + groups + }; +} + function getProviderKey(params = {}) { const name = typeof params.name === 'string' ? params.name.trim() : ''; if (!name) return { error: '名称不能为空' }; @@ -11695,6 +12012,12 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser case 'get-tool-config-permissions': result = { permissions: readToolConfigPermissions() }; break; + case 'get-web-ui-preferences': + result = { preferences: readWebUiPreferences() }; + break; + case 'set-web-ui-preferences': + result = setWebUiPreferences(params || {}); + break; case 'set-tool-config-permission': result = setToolConfigPermission(params || {}); break; @@ -11839,6 +12162,9 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser case 'get-provider-key': result = getProviderKey(params || {}); break; + case 'get-provider-cache-records': + result = readProviderCacheRecords(); + break; case 'delete-provider': result = deleteProviderFromConfig(params || {}); break; diff --git a/tests/unit/provider-cache-records.test.mjs b/tests/unit/provider-cache-records.test.mjs new file mode 100644 index 00000000..dc69cda7 --- /dev/null +++ b/tests/unit/provider-cache-records.test.mjs @@ -0,0 +1,100 @@ +import assert from 'assert'; +import { createProviderCacheMethods } from '../../web-ui/modules/app.methods.provider-cache.mjs'; +import { + readBundledWebUiCss, + readProjectFile +} from './helpers/web-ui-source.mjs'; + +function createContext(records = {}) { + const methods = createProviderCacheMethods({ + api: async () => records + }); + return { + providerCacheRecords: records, + providerCacheLoadedOnce: false, + providerCacheLoadedAt: '', + providerCacheLoading: false, + providerCacheError: '', + showProviderCacheModal: false, + t(key, params = {}) { + if (key === 'modal.providerCache.providerCount') return `${params.count} providers`; + if (key === 'modal.providerCache.tooLarge') return 'too large'; + if (key === 'modal.providerCache.parseFailed') return 'parse failed'; + if (key === 'modal.providerCache.rawJsonOnly') return 'raw only'; + return key; + }, + ...methods + }; +} + +test('provider cache methods expose provider summaries before raw JSON', () => { + const context = createContext(); + const file = { + name: 'codex-provider-cache.json', + displayPath: '~/.codexmate/codex-provider-cache.json', + exists: true, + ok: true, + size: 2048, + providerCount: 2, + providers: [ + { + name: 'openai', + baseUrl: 'https://api.openai.com/v1', + wireApi: 'responses', + authMethod: 'api-key', + data: { name: 'openai', authorization: 'Bear…1234' } + }, + { + name: 'deepseek', + baseUrl: 'https://api.deepseek.com/v1', + wireApi: 'chat_completions', + data: { name: 'deepseek' } + } + ], + data: { providers: {} } + }; + + assert.strictEqual(context.getProviderCacheFileKey(file), '~/.codexmate/codex-provider-cache.json'); + assert.strictEqual(context.getProviderCacheFilePath(file), '~/.codexmate/codex-provider-cache.json'); + assert.strictEqual(context.getProviderCacheFileSummary(file), '2 providers'); + assert.strictEqual(context.hasProviderCacheProviders(file), true); + assert.deepStrictEqual(context.getProviderCacheProviderMeta(file.providers[0]), [ + { label: 'base_url', value: 'https://api.openai.com/v1' }, + { label: 'wire_api', value: 'responses' }, + { label: 'auth', value: 'api-key' } + ]); + assert.match(context.getProviderCacheProviderText(file.providers[0]), /Bear…1234/); +}); + +test('provider cache UI template renders provider cards and collapsible raw JSON', () => { + const html = readProjectFile('web-ui/partials/index/modals-basic.html'); + const css = readBundledWebUiCss(); + + assert.match(html, /provider-cache-provider-list/); + assert.match(html, /getProviderCacheFileProviders\(file\)/); + assert.match(html, /getProviderCacheProviderMeta\(provider\)/); + assert.match(html, /modal\.providerCache\.rawJson/); + assert.match(html, /provider-cache-footer/); + + assert.match(css, /\.provider-cache-body/); + assert.match(css, /\.provider-cache-provider-list/); + assert.match(css, /\.provider-cache-json-compact/); + assert.match(css, /\.provider-cache-footer/); +}); + +test('provider cache backend avoids absolute path response and readConfig restore side effect', () => { + const cli = readProjectFile('cli.js'); + const readConfigStart = cli.indexOf('function readConfig()'); + const readConfigEnd = cli.indexOf('function writeConfig', readConfigStart); + const readConfigSource = cli.slice(readConfigStart, readConfigEnd); + + assert.ok(readConfigStart >= 0, 'readConfig must exist'); + assert.doesNotMatch(readConfigSource, /appendMissingCachedCodexProviders/); + assert.doesNotMatch(readConfigSource, /writeConfig\(/); + assert.match(cli, /const PROVIDER_CACHE_MAX_FILE_BYTES = 256 \* 1024/); + assert.match(cli, /function getProviderCacheDisplayPath\(fileName\)/); + assert.match(cli, /root: '~\/\.codexmate'/); + assert.doesNotMatch(cli, /root: CODEXMATE_DIR/); + assert.match(cli, /secretQueryPattern/); + assert.match(cli, /extractProviderCacheSummaries/); +}); diff --git a/tests/unit/run.mjs b/tests/unit/run.mjs index 832097ab..8549ba91 100644 --- a/tests/unit/run.mjs +++ b/tests/unit/run.mjs @@ -51,6 +51,8 @@ await import(pathToFileURL(path.join(__dirname, 'text-diff.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'claude-settings-sync.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'unzip-ext.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'provider-share-command.test.mjs'))); +await import(pathToFileURL(path.join(__dirname, 'web-ui-preferences.test.mjs'))); +await import(pathToFileURL(path.join(__dirname, 'provider-cache-records.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'providers-validation.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'provider-switch-regression.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'codex-proxy-options.test.mjs'))); diff --git a/tests/unit/web-ui-behavior-parity.test.mjs b/tests/unit/web-ui-behavior-parity.test.mjs index b6432acd..1ad471d5 100644 --- a/tests/unit/web-ui-behavior-parity.test.mjs +++ b/tests/unit/web-ui-behavior-parity.test.mjs @@ -516,7 +516,13 @@ test('captured bundled app skeleton only exposes expected data key drift versus 'opencodeAutoCompact', 'opencodeMaxTokens', 'opencodeReasoningEffort', - 'sessionTimelineStyle' + 'sessionTimelineStyle', + 'providerCacheError', + 'providerCacheLoadedAt', + 'providerCacheLoadedOnce', + 'providerCacheLoading', + 'providerCacheRecords', + 'showProviderCacheModal' ); if (parityAgainstHead) { const allowedExtraKeySet = new Set(allowedExtraCurrentKeys); @@ -735,7 +741,26 @@ test('captured bundled app skeleton only exposes expected data key drift versus 'validateModelId', 'getSessionFilePath', 'copySessionPath', - 'canBuildStandaloneUrl' + 'canBuildStandaloneUrl', + 'openProviderCacheModal', + 'closeProviderCacheModal', + 'loadProviderCacheRecords', + 'getProviderCacheGroups', + 'hasProviderCacheExistingFiles', + 'getProviderCacheExistingFiles', + 'getProviderCacheFileKey', + 'getProviderCacheFilePath', + 'getProviderCacheFileSummary', + 'formatProviderCacheFileSize', + 'hasProviderCacheProviders', + 'getProviderCacheFileProviders', + 'getProviderCacheProviderMeta', + 'getProviderCacheProviderText', + 'getProviderCacheRecordText', + 'buildWebUiPreferencesSnapshot', + 'applyWebUiPreferences', + 'loadWebUiPreferences', + 'persistWebUiPreferences' ); const allowedMissingCurrentMethodKeys = [ 'convertSession', diff --git a/tests/unit/web-ui-preferences.test.mjs b/tests/unit/web-ui-preferences.test.mjs new file mode 100644 index 00000000..959c44fe --- /dev/null +++ b/tests/unit/web-ui-preferences.test.mjs @@ -0,0 +1,123 @@ +import assert from 'assert'; +import { createWebUiPreferencesMethods } from '../../web-ui/modules/app.methods.web-ui-preferences.mjs'; +import { createSessionActionMethods } from '../../web-ui/modules/app.methods.session-actions.mjs'; +import { createSessionTrashMethods } from '../../web-ui/modules/app.methods.session-trash.mjs'; +import { createNavigationMethods } from '../../web-ui/modules/app.methods.navigation.mjs'; +import { readProjectFile } from './helpers/web-ui-source.mjs'; + +function createMemoryStorage() { + const data = new Map(); + return { + getItem(key) { return data.has(key) ? data.get(key) : null; }, + setItem(key, value) { data.set(key, String(value)); }, + removeItem(key) { data.delete(key); }, + dump() { return Object.fromEntries(data.entries()); } + }; +} + +function createContext(apiCalls = [], storage = null) { + const webPreferenceMethods = createWebUiPreferencesMethods({ + storage, + api: async (action, params = {}) => { + apiCalls.push({ action, params }); + if (action === 'get-web-ui-preferences') { + return { + preferences: { + shareCommandPrefix: 'codexmate', + sessionTrashEnabled: false, + sessionTrashRetentionDays: 9, + sessionTimelineStyle: 'bar', + configTemplateDiffConfirmEnabled: false, + sessionsUsageTimeRange: '30d', + promptsSubTab: 'claude-project', + projectClaudeMdPath: '/tmp/project', + navigation: { + mainTab: 'settings', + configMode: 'claude', + settingsTab: 'data', + skillsTargetApp: 'claude', + promptTemplatesMode: 'manage' + } + } + }; + } + return { success: true }; + } + }); + const sessionActionMethods = createSessionActionMethods({ api: async () => ({}), apiBase: 'http://127.0.0.1' }); + const sessionTrashMethods = createSessionTrashMethods({ api: async () => ({}), sessionTrashListLimit: 20, sessionTrashPageSize: 20 }); + const navigationMethods = createNavigationMethods({ + configModeSet: new Set(['codex', 'claude', 'opencode']), + switchMainTabHelper: () => {}, + loadMoreSessionMessagesHelper: () => {} + }); + return { + shareCommandPrefix: 'npm start', + sessionTrashEnabled: true, + sessionTrashRetentionDays: 30, + sessionTimelineStyle: 'dots', + configTemplateDiffConfirmEnabled: true, + sessionsUsageTimeRange: '7d', + promptsSubTab: 'codex', + projectClaudeMdPath: '', + mainTab: 'dashboard', + configMode: 'codex', + settingsTab: 'general', + skillsTargetApp: 'codex', + promptTemplatesMode: 'compose', + ...sessionActionMethods, + ...sessionTrashMethods, + ...navigationMethods, + ...webPreferenceMethods + }; +} + +test('web UI preferences load from local backend and mirror into localStorage fallback', async () => { + const storage = createMemoryStorage(); + const apiCalls = []; + const context = createContext(apiCalls, storage); + await context.loadWebUiPreferences(); + + assert.strictEqual(context.shareCommandPrefix, 'codexmate'); + assert.strictEqual(context.sessionTrashEnabled, false); + assert.strictEqual(context.sessionTrashRetentionDays, 9); + assert.strictEqual(context.sessionTimelineStyle, 'bar'); + assert.strictEqual(context.configTemplateDiffConfirmEnabled, false); + assert.strictEqual(context.sessionsUsageTimeRange, '30d'); + assert.strictEqual(context.promptsSubTab, 'claude-project'); + assert.strictEqual(context.projectClaudeMdPath, '/tmp/project'); + assert.strictEqual(context.mainTab, 'settings'); + assert.strictEqual(context.settingsTab, 'data'); + assert.strictEqual(storage.getItem('codexmateShareCommandPrefix'), 'codexmate'); + assert.strictEqual(storage.getItem('codexmateSessionTrashEnabled'), 'false'); + assert.strictEqual(storage.getItem('codexmateSessionTrashRetentionDays'), '9'); + assert.strictEqual(storage.getItem('codexmateSessionTimelineStyle'), 'bar'); + assert.strictEqual(storage.getItem('sessionsUsageTimeRange'), '30d'); + assert.deepStrictEqual(apiCalls.map((call) => call.action), ['get-web-ui-preferences']); +}); + +test('web UI setters persist preferences to local backend', async () => { + const apiCalls = []; + const context = createContext(apiCalls, createMemoryStorage()); + context.setShareCommandPrefix('codexmate'); + await new Promise((resolve) => setTimeout(resolve, 160)); + + const writeCall = apiCalls.find((call) => call.action === 'set-web-ui-preferences'); + assert.ok(writeCall, 'setter must persist web UI preferences'); + assert.strictEqual(writeCall.params.preferences.shareCommandPrefix, 'codexmate'); + assert.strictEqual(writeCall.params.preferences.sessionTrashRetentionDays, 30); + assert.strictEqual(writeCall.params.preferences.navigation.settingsTab, 'general'); +}); + +test('web UI preferences backend actions and startup hook are wired', () => { + const cli = readProjectFile('cli.js'); + const app = readProjectFile('web-ui/app.js'); + const index = readProjectFile('web-ui/modules/app.methods.index.mjs'); + + assert.match(cli, /CODEXMATE_PREFERENCES_FILE/); + assert.match(cli, /function normalizeWebUiPreferences/); + assert.match(cli, /case 'get-web-ui-preferences'/); + assert.match(cli, /case 'set-web-ui-preferences'/); + assert.match(app, /loadWebUiPreferences/); + assert.match(index, /createWebUiPreferencesMethods/); +}); diff --git a/web-ui/app.js b/web-ui/app.js index dccbe922..78e1cdf2 100644 --- a/web-ui/app.js +++ b/web-ui/app.js @@ -79,6 +79,7 @@ document.addEventListener('DOMContentLoaded', () => { projectPathOptionsLoading: false, showSkillsModal: false, showHealthCheckModal: false, + showProviderCacheModal: false, showCodexBridgePoolModal: false, showClaudeBridgePoolModal: false, showWebhookModal: false, @@ -378,6 +379,11 @@ document.addEventListener('DOMContentLoaded', () => { codexDownloadLoading: false, codexDownloadProgress: 0, codexDownloadTimer: null, + providerCacheRecords: { root: '', generatedAt: '', groups: [] }, + providerCacheLoadedOnce: false, + providerCacheLoadedAt: '', + providerCacheLoading: false, + providerCacheError: '', settingsTab: 'general', toolConfigPermissions: (function() { try { @@ -506,6 +512,9 @@ document.addEventListener('DOMContentLoaded', () => { if (typeof this.loadWebhookSettings === 'function') { this.loadWebhookSettings(); } + if (typeof this.loadWebUiPreferences === 'function') { + void this.loadWebUiPreferences(); + } if (typeof this.t === 'function') { this.confirmDialogConfirmText = this.t('confirm.ok'); this.confirmDialogCancelText = this.t('confirm.cancel'); @@ -743,6 +752,10 @@ document.addEventListener('DOMContentLoaded', () => { clearTimeout(this._initialLoadTimer); this._initialLoadTimer = 0; } + if (this.__webUiPreferencesPersistTimer) { + clearTimeout(this.__webUiPreferencesPersistTimer); + this.__webUiPreferencesPersistTimer = 0; + } window.removeEventListener('resize', this.onWindowResize); window.removeEventListener('keydown', this.handleGlobalKeydown); window.removeEventListener('beforeunload', this.handleBeforeUnload); @@ -767,6 +780,9 @@ document.addEventListener('DOMContentLoaded', () => { try { localStorage.setItem('codexmate_prompts_sub_tab', newVal); } catch (_) {} + if (typeof this.persistWebUiPreferences === 'function') { + this.persistWebUiPreferences({ promptsSubTab: newVal }); + } if (this.mainTab === 'prompts' && typeof this.loadPromptsContent === 'function') { this.loadPromptsContent(); } @@ -779,6 +795,9 @@ document.addEventListener('DOMContentLoaded', () => { localStorage.removeItem('codexmate_project_claude_md_path'); } } catch (_) {} + if (typeof this.persistWebUiPreferences === 'function') { + this.persistWebUiPreferences({ projectClaudeMdPath: newPath || '' }); + } } }, diff --git a/web-ui/modules/app.methods.index.mjs b/web-ui/modules/app.methods.index.mjs index b34672fc..0b8f0bbd 100644 --- a/web-ui/modules/app.methods.index.mjs +++ b/web-ui/modules/app.methods.index.mjs @@ -21,6 +21,7 @@ import { createOpenclawEditingMethods } from './app.methods.openclaw-editing.mjs import { createOpenclawPersistMethods } from './app.methods.openclaw-persist.mjs'; import { createOpencodeConfigMethods } from './app.methods.opencode-config.mjs'; import { createProvidersMethods } from './app.methods.providers.mjs'; +import { createProviderCacheMethods } from './app.methods.provider-cache.mjs'; import { createRuntimeMethods } from './app.methods.runtime.mjs'; import { createToolConfigPermissionMethods } from './app.methods.tool-config-permissions.mjs'; import { createTaskOrchestrationMethods } from './app.methods.task-orchestration.mjs'; @@ -33,6 +34,7 @@ import { createSkillsMethods } from './skills.methods.mjs'; import { createPluginsMethods } from './plugins.methods.mjs'; import { createI18nMethods } from './i18n.mjs'; import { createWebhookMethods } from './app.methods.webhook.mjs'; +import { createWebUiPreferencesMethods } from './app.methods.web-ui-preferences.mjs'; import { CONFIG_MODE_SET, getProviderConfigModeMeta @@ -83,6 +85,8 @@ export function createAppMethods() { ...createPluginsMethods(), ...createAgentsMethods({ api, apiWithMeta }), ...createProvidersMethods({ api }), + ...createProviderCacheMethods({ api }), + ...createWebUiPreferencesMethods({ api }), ...createClaudeConfigMethods({ api }), ...createToolConfigPermissionMethods({ api }), ...createOpenclawCoreMethods(), diff --git a/web-ui/modules/app.methods.navigation.mjs b/web-ui/modules/app.methods.navigation.mjs index 74870f27..28eade8c 100644 --- a/web-ui/modules/app.methods.navigation.mjs +++ b/web-ui/modules/app.methods.navigation.mjs @@ -108,6 +108,9 @@ try { localStorage.setItem(NAV_STATE_STORAGE_KEY, JSON.stringify(snapshot)); } catch (_) {} + if (typeof vm.persistWebUiPreferences === 'function') { + vm.persistWebUiPreferences({ navigation: snapshot }); + } }; return { diff --git a/web-ui/modules/app.methods.provider-cache.mjs b/web-ui/modules/app.methods.provider-cache.mjs new file mode 100644 index 00000000..5ca74431 --- /dev/null +++ b/web-ui/modules/app.methods.provider-cache.mjs @@ -0,0 +1,128 @@ +export function createProviderCacheMethods(options = {}) { + const { api } = options; + + return { + async openProviderCacheModal(options = {}) { + this.showProviderCacheModal = true; + if (options.forceRefresh === true || !this.providerCacheLoadedOnce) { + await this.loadProviderCacheRecords({ forceRefresh: options.forceRefresh === true }); + } + }, + + closeProviderCacheModal() { + this.showProviderCacheModal = false; + }, + + async loadProviderCacheRecords() { + if (this.providerCacheLoading) return; + this.providerCacheLoading = true; + this.providerCacheError = ''; + try { + const res = await api('get-provider-cache-records'); + if (res && res.error) { + this.providerCacheError = res.error; + return; + } + this.providerCacheRecords = res && typeof res === 'object' ? res : { groups: [] }; + this.providerCacheLoadedOnce = true; + this.providerCacheLoadedAt = this.providerCacheRecords.generatedAt || new Date().toISOString(); + } catch (e) { + this.providerCacheError = e && e.message ? e.message : String(e || '加载缓存记录失败'); + } finally { + this.providerCacheLoading = false; + } + }, + + getProviderCacheGroups() { + const records = this.providerCacheRecords && typeof this.providerCacheRecords === 'object' + ? this.providerCacheRecords + : {}; + return Array.isArray(records.groups) ? records.groups : []; + }, + + getProviderCacheExistingFiles(group) { + const files = group && Array.isArray(group.files) ? group.files : []; + return files.filter((file) => file && file.exists); + }, + + hasProviderCacheExistingFiles(group) { + return this.getProviderCacheExistingFiles(group).length > 0; + }, + + getProviderCacheFileKey(file) { + if (!file) return ''; + return file.displayPath || file.path || file.name || ''; + }, + + getProviderCacheFilePath(file) { + if (!file) return ''; + return file.displayPath || file.path || file.name || ''; + }, + + getProviderCacheFileSummary(file) { + if (!file || !file.exists) return ''; + const count = Number(file.providerCount || 0); + if (count > 0) return this.t('modal.providerCache.providerCount', { count }); + if (file.tooLarge) return this.t('modal.providerCache.tooLarge'); + if (file.ok === false) return this.t('modal.providerCache.parseFailed'); + return this.t('modal.providerCache.rawJsonOnly'); + }, + + getProviderCacheFileProviders(file) { + const providers = file && Array.isArray(file.providers) ? file.providers : []; + return providers.filter((provider) => provider && typeof provider === 'object'); + }, + + hasProviderCacheProviders(file) { + return this.getProviderCacheFileProviders(file).length > 0; + }, + + getProviderCacheProviderMeta(provider) { + if (!provider || typeof provider !== 'object') return []; + const fields = [ + ['baseUrl', 'base_url'], + ['wireApi', 'wire_api'], + ['authMethod', 'auth'], + ['model', 'model'] + ]; + return fields + .map(([key, label]) => { + const value = provider[key]; + if (value === undefined || value === null || value === '') return null; + return { label, value: String(value) }; + }) + .filter(Boolean); + }, + + getProviderCacheProviderText(provider) { + if (!provider || typeof provider !== 'object') return ''; + try { + return JSON.stringify(provider.data === undefined ? provider : provider.data, null, 2); + } catch (_) { + return String(provider.name || ''); + } + }, + + getProviderCacheRecordText(record) { + if (!record || !record.exists) return ''; + if (record.ok === false) { + return record.error || ''; + } + try { + return JSON.stringify(record.data === undefined ? null : record.data, null, 2); + } catch (_) { + return String(record.data || ''); + } + }, + + formatProviderCacheFileSize(size) { + const bytes = Number(size); + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'; + if (bytes < 1024) return `${Math.floor(bytes)} B`; + const kib = bytes / 1024; + if (kib < 1024) return `${kib.toFixed(kib >= 10 ? 0 : 1)} KiB`; + const mib = kib / 1024; + return `${mib.toFixed(mib >= 10 ? 1 : 2)} MiB`; + } + }; +} diff --git a/web-ui/modules/app.methods.session-actions.mjs b/web-ui/modules/app.methods.session-actions.mjs index a25a0854..587f336e 100644 --- a/web-ui/modules/app.methods.session-actions.mjs +++ b/web-ui/modules/app.methods.session-actions.mjs @@ -281,6 +281,9 @@ export function createSessionActionMethods(options = {}) { try { localStorage.setItem('codexmateSessionTrashEnabled', enabled ? 'true' : 'false'); } catch (_) {} + if (typeof this.persistWebUiPreferences === 'function') { + this.persistWebUiPreferences({ sessionTrashEnabled: enabled }); + } }, setSessionTimelineStyle(style) { @@ -289,12 +292,18 @@ export function createSessionActionMethods(options = {}) { try { localStorage.setItem('codexmateSessionTimelineStyle', normalized); } catch (_) {} + if (typeof this.persistWebUiPreferences === 'function') { + this.persistWebUiPreferences({ sessionTimelineStyle: normalized }); + } }, setConfigTemplateDiffConfirmEnabled(value) { const enabled = this.normalizeConfigTemplateDiffConfirmEnabled(value); this.configTemplateDiffConfirmEnabled = enabled; persistConfigTemplateDiffConfirmEnabledToStorage(enabled); + if (typeof this.persistWebUiPreferences === 'function') { + this.persistWebUiPreferences({ configTemplateDiffConfirmEnabled: enabled }); + } }, getShareCommandPrefixInvocation() { @@ -308,6 +317,9 @@ export function createSessionActionMethods(options = {}) { try { localStorage.setItem('codexmateShareCommandPrefix', normalized); } catch (_) {} + if (typeof this.persistWebUiPreferences === 'function') { + this.persistWebUiPreferences({ shareCommandPrefix: normalized }); + } }, fallbackCopyText(text) { diff --git a/web-ui/modules/app.methods.session-browser.mjs b/web-ui/modules/app.methods.session-browser.mjs index c87bdafa..f7e6892c 100644 --- a/web-ui/modules/app.methods.session-browser.mjs +++ b/web-ui/modules/app.methods.session-browser.mjs @@ -803,6 +803,9 @@ export function createSessionBrowserMethods(options = {}) { const range = normalized === 'all' ? 'all' : (normalized === '30d' ? '30d' : '7d'); this.sessionsUsageTimeRange = range; try { localStorage.setItem('sessionsUsageTimeRange', range); } catch (_) {} + if (typeof this.persistWebUiPreferences === 'function') { + this.persistWebUiPreferences({ sessionsUsageTimeRange: range }); + } if (range === 'all') { this.sessionsUsageCompareEnabled = false; } diff --git a/web-ui/modules/app.methods.session-trash.mjs b/web-ui/modules/app.methods.session-trash.mjs index 5c4ae9f4..beb98e08 100644 --- a/web-ui/modules/app.methods.session-trash.mjs +++ b/web-ui/modules/app.methods.session-trash.mjs @@ -311,6 +311,9 @@ export function createSessionTrashMethods(options = {}) { const normalized = this.normalizeSessionTrashRetentionDays(days); this.sessionTrashRetentionDays = normalized; try { localStorage.setItem('codexmateSessionTrashRetentionDays', String(normalized)); } catch (_) {} + if (typeof this.persistWebUiPreferences === 'function') { + this.persistWebUiPreferences({ sessionTrashRetentionDays: normalized }); + } }, getSessionTrashActionKey(item) { diff --git a/web-ui/modules/app.methods.web-ui-preferences.mjs b/web-ui/modules/app.methods.web-ui-preferences.mjs new file mode 100644 index 00000000..80002ee0 --- /dev/null +++ b/web-ui/modules/app.methods.web-ui-preferences.mjs @@ -0,0 +1,146 @@ +export function createWebUiPreferencesMethods(options = {}) { + const api = typeof options.api === 'function' ? options.api : async () => ({}); + const storageOverride = options.storage && typeof options.storage === 'object' ? options.storage : null; + + const getLocalStorage = () => { + const storage = storageOverride || (typeof globalThis !== 'undefined' ? globalThis.localStorage : null); + return storage && typeof storage.setItem === 'function' && typeof storage.removeItem === 'function' + ? storage + : null; + }; + + const setLocalStorageValue = (key, value) => { + const storage = getLocalStorage(); + if (!storage) return; + try { + if (value === null || value === undefined || value === '') { + storage.removeItem(key); + } else { + storage.setItem(key, String(value)); + } + } catch (_) {} + }; + + const normalizeUsageTimeRange = (value) => { + const normalized = typeof value === 'string' ? value.trim().toLowerCase() : ''; + if (normalized === 'all' || normalized === '30d') return normalized; + return '7d'; + }; + + const normalizePromptsSubTab = (value) => { + const normalized = typeof value === 'string' ? value.trim().toLowerCase() : ''; + return normalized === 'claude-project' ? 'claude-project' : 'codex'; + }; + + const normalizeNavigationSnapshot = (vm, source = {}) => ({ + mainTab: typeof source.mainTab === 'string' ? source.mainTab : vm.mainTab, + configMode: typeof source.configMode === 'string' ? source.configMode : vm.configMode, + settingsTab: typeof source.settingsTab === 'string' ? source.settingsTab : vm.settingsTab, + skillsTargetApp: source.skillsTargetApp === 'claude' ? 'claude' : 'codex', + promptTemplatesMode: source.promptTemplatesMode === 'manage' ? 'manage' : 'compose' + }); + + return { + buildWebUiPreferencesSnapshot(overrides = {}) { + const navigationOverride = overrides && typeof overrides.navigation === 'object' && overrides.navigation + ? overrides.navigation + : null; + return { + shareCommandPrefix: typeof this.normalizeShareCommandPrefix === 'function' + ? this.normalizeShareCommandPrefix(overrides.shareCommandPrefix || this.shareCommandPrefix) + : (this.shareCommandPrefix || 'npm start'), + sessionTrashEnabled: typeof this.normalizeSessionTrashEnabled === 'function' + ? this.normalizeSessionTrashEnabled(Object.prototype.hasOwnProperty.call(overrides, 'sessionTrashEnabled') ? overrides.sessionTrashEnabled : this.sessionTrashEnabled) + : this.sessionTrashEnabled !== false, + sessionTrashRetentionDays: typeof this.normalizeSessionTrashRetentionDays === 'function' + ? this.normalizeSessionTrashRetentionDays(Object.prototype.hasOwnProperty.call(overrides, 'sessionTrashRetentionDays') ? overrides.sessionTrashRetentionDays : this.sessionTrashRetentionDays) + : 30, + sessionTimelineStyle: typeof this.normalizeSessionTimelineStyle === 'function' + ? this.normalizeSessionTimelineStyle(overrides.sessionTimelineStyle || this.sessionTimelineStyle) + : (this.sessionTimelineStyle === 'bar' ? 'bar' : 'dots'), + configTemplateDiffConfirmEnabled: typeof this.normalizeConfigTemplateDiffConfirmEnabled === 'function' + ? this.normalizeConfigTemplateDiffConfirmEnabled(Object.prototype.hasOwnProperty.call(overrides, 'configTemplateDiffConfirmEnabled') ? overrides.configTemplateDiffConfirmEnabled : this.configTemplateDiffConfirmEnabled) + : this.configTemplateDiffConfirmEnabled !== false, + sessionsUsageTimeRange: normalizeUsageTimeRange(overrides.sessionsUsageTimeRange || this.sessionsUsageTimeRange), + promptsSubTab: normalizePromptsSubTab(overrides.promptsSubTab || this.promptsSubTab), + projectClaudeMdPath: typeof overrides.projectClaudeMdPath === 'string' + ? overrides.projectClaudeMdPath + : (typeof this.projectClaudeMdPath === 'string' ? this.projectClaudeMdPath : ''), + navigation: normalizeNavigationSnapshot(this, navigationOverride || {}) + }; + }, + + applyWebUiPreferences(preferences = {}) { + const source = preferences && typeof preferences === 'object' ? preferences : {}; + this.__webUiPreferencesApplying = true; + try { + if (typeof source.shareCommandPrefix === 'string' && typeof this.normalizeShareCommandPrefix === 'function') { + this.shareCommandPrefix = this.normalizeShareCommandPrefix(source.shareCommandPrefix); + setLocalStorageValue('codexmateShareCommandPrefix', this.shareCommandPrefix); + } + if (Object.prototype.hasOwnProperty.call(source, 'sessionTrashEnabled') && typeof this.normalizeSessionTrashEnabled === 'function') { + this.sessionTrashEnabled = this.normalizeSessionTrashEnabled(source.sessionTrashEnabled); + setLocalStorageValue('codexmateSessionTrashEnabled', this.sessionTrashEnabled ? 'true' : 'false'); + } + if (Object.prototype.hasOwnProperty.call(source, 'sessionTrashRetentionDays') && typeof this.normalizeSessionTrashRetentionDays === 'function') { + this.sessionTrashRetentionDays = this.normalizeSessionTrashRetentionDays(source.sessionTrashRetentionDays); + setLocalStorageValue('codexmateSessionTrashRetentionDays', this.sessionTrashRetentionDays); + } + if (typeof source.sessionTimelineStyle === 'string' && typeof this.normalizeSessionTimelineStyle === 'function') { + this.sessionTimelineStyle = this.normalizeSessionTimelineStyle(source.sessionTimelineStyle); + setLocalStorageValue('codexmateSessionTimelineStyle', this.sessionTimelineStyle); + } + if (Object.prototype.hasOwnProperty.call(source, 'configTemplateDiffConfirmEnabled') && typeof this.normalizeConfigTemplateDiffConfirmEnabled === 'function') { + this.configTemplateDiffConfirmEnabled = this.normalizeConfigTemplateDiffConfirmEnabled(source.configTemplateDiffConfirmEnabled); + setLocalStorageValue('codexmateConfigTemplateDiffConfirmEnabled', this.configTemplateDiffConfirmEnabled ? 'true' : 'false'); + } + if (typeof source.sessionsUsageTimeRange === 'string') { + this.sessionsUsageTimeRange = normalizeUsageTimeRange(source.sessionsUsageTimeRange); + setLocalStorageValue('sessionsUsageTimeRange', this.sessionsUsageTimeRange); + } + if (typeof source.promptsSubTab === 'string') { + this.promptsSubTab = normalizePromptsSubTab(source.promptsSubTab); + setLocalStorageValue('codexmate_prompts_sub_tab', this.promptsSubTab); + } + if (typeof source.projectClaudeMdPath === 'string') { + this.projectClaudeMdPath = source.projectClaudeMdPath; + setLocalStorageValue('codexmate_project_claude_md_path', this.projectClaudeMdPath); + } + if (source.navigation && typeof source.navigation === 'object') { + const nav = source.navigation; + if (typeof nav.settingsTab === 'string' && typeof this.normalizeSettingsTab === 'function') { + this.settingsTab = this.normalizeSettingsTab(nav.settingsTab); + } + if (typeof nav.mainTab === 'string') this.mainTab = nav.mainTab; + if (typeof nav.configMode === 'string') this.configMode = nav.configMode; + if (nav.skillsTargetApp === 'codex' || nav.skillsTargetApp === 'claude') this.skillsTargetApp = nav.skillsTargetApp; + if (nav.promptTemplatesMode === 'compose' || nav.promptTemplatesMode === 'manage') this.promptTemplatesMode = nav.promptTemplatesMode; + if (typeof this.saveNavState === 'function') this.saveNavState(); + } + } finally { + this.__webUiPreferencesApplying = false; + } + }, + + async loadWebUiPreferences() { + try { + const res = await api('get-web-ui-preferences'); + if (res && res.preferences && typeof res.preferences === 'object') { + this.applyWebUiPreferences(res.preferences); + } + } catch (_) {} + }, + + persistWebUiPreferences(overrides = {}) { + if (this.__webUiPreferencesApplying) return; + const snapshot = this.buildWebUiPreferencesSnapshot(overrides && typeof overrides === 'object' ? overrides : {}); + if (this.__webUiPreferencesPersistTimer) { + clearTimeout(this.__webUiPreferencesPersistTimer); + } + this.__webUiPreferencesPersistTimer = setTimeout(() => { + this.__webUiPreferencesPersistTimer = 0; + api('set-web-ui-preferences', { preferences: snapshot }).catch(() => {}); + }, 120); + } + }; +} diff --git a/web-ui/modules/i18n/locales/en.mjs b/web-ui/modules/i18n/locales/en.mjs index ca79f542..29f739f0 100644 --- a/web-ui/modules/i18n/locales/en.mjs +++ b/web-ui/modules/i18n/locales/en.mjs @@ -1108,6 +1108,26 @@ const en = Object.freeze({ 'settings.backup.importCodex': 'Import ~/.codex backup', 'settings.importing': 'Importing...', + 'settings.providerCache.title': 'Provider cache records', + 'settings.providerCache.meta': 'Inspect Claude / Codex / OpenCode caches under ~/.codexmate', + 'settings.providerCache.open': 'View cache records', + 'settings.providerCache.loading': 'Loading...', + 'settings.providerCache.hint': 'Read-only view of cache files. Sensitive fields are redacted automatically.', + 'modal.providerCache.title': 'Provider cache records', + 'modal.providerCache.root': 'Cache directory', + 'modal.providerCache.refresh': 'Refresh', + 'modal.providerCache.refreshing': 'Refreshing...', + 'modal.providerCache.loading': 'Loading cache records...', + 'modal.providerCache.loadedAt': 'Loaded at', + 'modal.providerCache.groupMeta': '{count} cache files found', + 'modal.providerCache.empty': 'No cache files found', + 'modal.providerCache.providerCount': '{count} providers', + 'modal.providerCache.rawJsonOnly': 'No provider summary detected; showing raw JSON', + 'modal.providerCache.tooLarge': 'File is too large; JSON read skipped', + 'modal.providerCache.parseFailed': 'JSON parse failed', + 'modal.providerCache.rawJson': 'Raw JSON', + 'modal.providerCache.errorDetails': 'Error details', + 'settings.deleteBehavior.title': 'Session deletion behavior', 'settings.deleteBehavior.meta': 'Whether “Delete” moves to trash first', 'settings.deleteBehavior.toggle': 'Move deleted sessions to trash first', diff --git a/web-ui/modules/i18n/locales/ja.mjs b/web-ui/modules/i18n/locales/ja.mjs index 50a42a04..faa5ad98 100644 --- a/web-ui/modules/i18n/locales/ja.mjs +++ b/web-ui/modules/i18n/locales/ja.mjs @@ -1099,6 +1099,26 @@ const ja = Object.freeze({ 'settings.backup.importCodex': '~/.codex バックアップをインポート', 'settings.importing': 'インポート中...', + 'settings.providerCache.title': 'Provider キャッシュ記録', + 'settings.providerCache.meta': '~/.codexmate 内の Claude / Codex / OpenCode キャッシュを確認', + 'settings.providerCache.open': 'キャッシュ記録を表示', + 'settings.providerCache.loading': '読み込み中...', + 'settings.providerCache.hint': 'キャッシュファイルの読み取り専用表示です。機密フィールドは自動的にマスクされます。', + 'modal.providerCache.title': 'Provider キャッシュ記録', + 'modal.providerCache.root': 'キャッシュディレクトリ', + 'modal.providerCache.refresh': '更新', + 'modal.providerCache.refreshing': '更新中...', + 'modal.providerCache.loading': 'キャッシュ記録を読み込み中...', + 'modal.providerCache.loadedAt': '読み込み時刻', + 'modal.providerCache.groupMeta': '{count} 個のキャッシュファイル', + 'modal.providerCache.empty': 'キャッシュファイルは見つかりません', + 'modal.providerCache.providerCount': '{count} 個の provider', + 'modal.providerCache.rawJsonOnly': 'provider の概要を検出できないため、Raw JSON を表示します', + 'modal.providerCache.tooLarge': 'ファイルが大きすぎるため JSON 読み取りをスキップしました', + 'modal.providerCache.parseFailed': 'JSON 解析に失敗しました', + 'modal.providerCache.rawJson': 'Raw JSON', + 'modal.providerCache.errorDetails': 'エラー詳細', + 'settings.trashConfig.title': 'ゴミ箱設定', 'settings.trashConfig.meta': 'ゴミ箱の有効/無効と自動クリーンアップ日数', 'settings.deleteBehavior.title': 'セッション削除動作', diff --git a/web-ui/modules/i18n/locales/vi.mjs b/web-ui/modules/i18n/locales/vi.mjs index 530fb33e..2b575604 100644 --- a/web-ui/modules/i18n/locales/vi.mjs +++ b/web-ui/modules/i18n/locales/vi.mjs @@ -247,6 +247,26 @@ const vi = Object.freeze({ 'settings.trash.emptyHint': 'Không có phiên đã xóa trong thời gian lưu {days} ngày.', 'settings.trash.retentionUnit': 'ngày', + 'settings.providerCache.title': 'Bản ghi cache Provider', + 'settings.providerCache.meta': 'Xem cache Claude / Codex / OpenCode trong ~/.codexmate', + 'settings.providerCache.open': 'Xem bản ghi cache', + 'settings.providerCache.loading': 'Đang tải...', + 'settings.providerCache.hint': 'Chế độ chỉ đọc cho tệp cache. Trường nhạy cảm sẽ được che tự động.', + 'modal.providerCache.title': 'Bản ghi cache Provider', + 'modal.providerCache.root': 'Thư mục cache', + 'modal.providerCache.refresh': 'Làm mới', + 'modal.providerCache.refreshing': 'Đang làm mới...', + 'modal.providerCache.loading': 'Đang tải bản ghi cache...', + 'modal.providerCache.loadedAt': 'Đã tải lúc', + 'modal.providerCache.groupMeta': 'Đã tìm thấy {count} tệp cache', + 'modal.providerCache.empty': 'Không tìm thấy tệp cache', + 'modal.providerCache.providerCount': '{count} provider', + 'modal.providerCache.rawJsonOnly': 'Không nhận diện được tóm tắt provider; hiển thị JSON gốc', + 'modal.providerCache.tooLarge': 'Tệp quá lớn; đã bỏ qua đọc JSON', + 'modal.providerCache.parseFailed': 'Phân tích JSON thất bại', + 'modal.providerCache.rawJson': 'JSON gốc', + 'modal.providerCache.errorDetails': 'Chi tiết lỗi', + 'orchestration.readiness.target.label': 'Mục tiêu', 'orchestration.readiness.target.done': 'Đã viết mục tiêu', 'orchestration.readiness.target.missing': 'Chưa viết mục tiêu', diff --git a/web-ui/modules/i18n/locales/zh-tw.mjs b/web-ui/modules/i18n/locales/zh-tw.mjs index 7d5d6b95..2a7c48e7 100644 --- a/web-ui/modules/i18n/locales/zh-tw.mjs +++ b/web-ui/modules/i18n/locales/zh-tw.mjs @@ -1109,6 +1109,26 @@ const zhTw = Object.freeze({ 'settings.backup.importCodex': '匯入 ~/.codex 備份', 'settings.importing': '匯入中...', + 'settings.providerCache.title': 'Provider 快取記錄', + 'settings.providerCache.meta': '查看 ~/.codexmate 內的 Claude / Codex / OpenCode 快取', + 'settings.providerCache.open': '查看快取記錄', + 'settings.providerCache.loading': '載入中...', + 'settings.providerCache.hint': '只讀展示快取文件,敏感欄位會自動遮罩。', + 'modal.providerCache.title': 'Provider 快取記錄', + 'modal.providerCache.root': '快取目錄', + 'modal.providerCache.refresh': '重新整理', + 'modal.providerCache.refreshing': '重新整理中...', + 'modal.providerCache.loading': '正在載入快取記錄...', + 'modal.providerCache.loadedAt': '載入時間', + 'modal.providerCache.groupMeta': '已發現 {count} 個快取文件', + 'modal.providerCache.empty': '沒有發現快取文件', + 'modal.providerCache.providerCount': '{count} 個 provider', + 'modal.providerCache.rawJsonOnly': '未識別到 provider 摘要,顯示原始 JSON', + 'modal.providerCache.tooLarge': '文件過大,已跳過 JSON 讀取', + 'modal.providerCache.parseFailed': 'JSON 解析失敗', + 'modal.providerCache.rawJson': '原始 JSON', + 'modal.providerCache.errorDetails': '錯誤詳情', + 'settings.trashConfig.title': '回收站設定', 'settings.trashConfig.meta': '回收站開關與自動清理天數', 'settings.deleteBehavior.title': '會話刪除行為', diff --git a/web-ui/modules/i18n/locales/zh.mjs b/web-ui/modules/i18n/locales/zh.mjs index c1d4e6bb..30dcbbf3 100644 --- a/web-ui/modules/i18n/locales/zh.mjs +++ b/web-ui/modules/i18n/locales/zh.mjs @@ -1109,6 +1109,26 @@ const zh = Object.freeze({ 'settings.backup.importCodex': '导入 ~/.codex 备份', 'settings.importing': '导入中...', + 'settings.providerCache.title': 'Provider 缓存记录', + 'settings.providerCache.meta': '查看 ~/.codexmate 内的 Claude / Codex / OpenCode 缓存', + 'settings.providerCache.open': '查看缓存记录', + 'settings.providerCache.loading': '加载中...', + 'settings.providerCache.hint': '只读展示缓存文件,敏感字段会自动遮罩。', + 'modal.providerCache.title': 'Provider 缓存记录', + 'modal.providerCache.root': '缓存目录', + 'modal.providerCache.refresh': '刷新', + 'modal.providerCache.refreshing': '刷新中...', + 'modal.providerCache.loading': '正在加载缓存记录...', + 'modal.providerCache.loadedAt': '加载时间', + 'modal.providerCache.groupMeta': '已发现 {count} 个缓存文件', + 'modal.providerCache.empty': '没有发现缓存文件', + 'modal.providerCache.providerCount': '{count} 个 provider', + 'modal.providerCache.rawJsonOnly': '未识别到 provider 摘要,显示原始 JSON', + 'modal.providerCache.tooLarge': '文件过大,已跳过 JSON 读取', + 'modal.providerCache.parseFailed': 'JSON 解析失败', + 'modal.providerCache.rawJson': '原始 JSON', + 'modal.providerCache.errorDetails': '错误详情', + 'settings.trashConfig.title': '回收站配置', 'settings.trashConfig.meta': '回收站开关与自动清理天数', 'settings.deleteBehavior.title': '会话删除行为', diff --git a/web-ui/partials/index/modals-basic.html b/web-ui/partials/index/modals-basic.html index 382fa4fe..c00811f1 100644 --- a/web-ui/partials/index/modals-basic.html +++ b/web-ui/partials/index/modals-basic.html @@ -134,6 +134,81 @@
+ + + + + + - +