diff --git a/cli.js b/cli.js index 98c51603..d3c6103c 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', 'openclaw', '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,386 @@ 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 redactSecretString = (text) => String(text || '') ? '***' : ''; + 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 redactSecretString(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 redactSecretString(input.trim()); + } + return input; + }; + return visit(value); +} + +function getProviderCacheDisplayPath(fileName) { + return `~/.codexmate/${fileName}`; +} + +function sanitizeProviderCacheErrorMessage(message, fileName, fallback = '读取缓存文件失败') { + const raw = typeof message === 'string' && message.trim() ? message : fallback; + const displayPath = getProviderCacheDisplayPath(fileName); + const absolutePath = path.join(CODEXMATE_DIR, fileName); + return raw + .split(absolutePath).join(displayPath) + .split(CODEXMATE_DIR).join('~/.codexmate'); +} + +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']); + const model = pickProviderCacheString(provider, ['model', 'default_model', 'defaultModel']); + return { + name: providerName, + baseUrl: baseUrl ? redactProviderCacheValue(baseUrl) : '', + wireApi, + authMethod: authMethod ? redactProviderCacheValue(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); + if (!stat.isFile()) { + record.ok = false; + record.error = '缓存路径不是普通文件,已跳过读取'; + return record; + } + record.size = Number(stat.size) || 0; + record.mtime = stat.mtime instanceof Date && !Number.isNaN(stat.mtime.getTime()) + ? stat.mtime.toISOString() + : ''; + } catch (e) { + record.ok = false; + record.error = sanitizeProviderCacheErrorMessage(e && e.message ? e.message : String(e || '读取缓存文件状态失败'), fileName, '读取缓存文件状态失败'); + return record; + } + 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 = sanitizeProviderCacheErrorMessage(e && e.message ? e.message : String(e || '读取缓存文件失败'), fileName); + } + 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 readProviderCacheJsonObject(fileName) { + const filePath = path.join(CODEXMATE_DIR, fileName); + try { + if (!fs.existsSync(filePath)) return {}; + const stat = fs.statSync(filePath); + if (!stat.isFile()) return {}; + const parsed = JSON.parse(stripUtf8Bom(fs.readFileSync(filePath, 'utf-8')) || '{}'); + return isPlainObject(parsed) ? parsed : {}; + } catch (_) { + return {}; + } +} + +function writeProviderCacheJsonObject(fileName, data) { + ensureDir(CODEXMATE_DIR); + const filePath = path.join(CODEXMATE_DIR, fileName); + writeJsonAtomic(filePath, isPlainObject(data) ? data : {}); + try { + fs.chmodSync(filePath, 0o600); + } catch (_) {} + return getProviderCacheDisplayPath(fileName); +} + +function normalizeProviderCacheProviderMap(rawProviders) { + const providers = {}; + if (Array.isArray(rawProviders)) { + for (const item of rawProviders) { + if (!isPlainObject(item)) continue; + const name = pickProviderCacheString(item, ['name', 'id', 'provider']); + if (name) providers[name] = item; + } + return providers; + } + if (isPlainObject(rawProviders)) { + for (const [name, entry] of Object.entries(rawProviders)) { + if (isPlainObject(entry)) providers[name] = entry; + } + } + return providers; +} + +function buildProviderCacheSyncProviders() { + const configResult = readConfigOrVirtualDefault(); + if (hasConfigLoadError(configResult)) { + return { error: (configResult.error && configResult.error.configPublicReason) || '读取 config.toml 失败' }; + } + const config = configResult.config || {}; + const providers = isPlainObject(config.model_providers) ? config.model_providers : {}; + const currentModels = readCurrentModels(); + const activeProvider = typeof config.model_provider === 'string' ? config.model_provider.trim() : ''; + const activeModel = typeof config.model === 'string' ? config.model.trim() : ''; + const syncProviders = []; + + for (const [name, provider] of Object.entries(providers)) { + if (!name || !isPlainObject(provider) || isBuiltinManagedProvider(name)) continue; + const bridgeType = typeof provider.codexmate_bridge === 'string' ? provider.codexmate_bridge.trim() : ''; + const isOpenaiBridgeProvider = bridgeType === 'openai' + || (typeof provider.base_url === 'string' && provider.base_url.includes('/bridge/openai/')); + let baseUrl = typeof provider.base_url === 'string' ? provider.base_url.trim() : ''; + let apiKey = typeof provider.preferred_auth_method === 'string' ? provider.preferred_auth_method : ''; + if (isOpenaiBridgeProvider) { + const upstream = resolveOpenaiBridgeUpstream(OPENAI_BRIDGE_SETTINGS_FILE, name); + if (upstream && !upstream.error) { + baseUrl = upstream.baseUrl || baseUrl; + apiKey = upstream.apiKey || apiKey; + } + } + const wireApi = typeof provider.wire_api === 'string' && provider.wire_api.trim() + ? provider.wire_api.trim() + : 'responses'; + const model = typeof currentModels[name] === 'string' && currentModels[name].trim() + ? currentModels[name].trim() + : (activeProvider === name ? activeModel : ''); + syncProviders.push({ + name, + baseUrl, + apiKey, + wireApi, + model, + bridge: bridgeType || (isOpenaiBridgeProvider ? 'openai' : '') + }); + } + return { providers: syncProviders.sort((a, b) => a.name.localeCompare(b.name)) }; +} + +function mergeProviderCacheFile(fileName, nextProviders, buildEntry) { + const existing = readProviderCacheJsonObject(fileName); + const existingProviders = normalizeProviderCacheProviderMap(existing.providers); + const providers = { ...existingProviders }; + for (const provider of nextProviders) { + const previous = isPlainObject(providers[provider.name]) ? providers[provider.name] : {}; + providers[provider.name] = { ...previous, ...buildEntry(provider) }; + } + const next = { + ...existing, + version: Number(existing.version) > 0 ? Number(existing.version) : 1, + generatedAt: new Date().toISOString(), + providers + }; + const displayPath = writeProviderCacheJsonObject(fileName, next); + return { path: displayPath, providerCount: Object.keys(providers).length }; +} + +function mergeProviderCacheCurrentModelsFile(fileName, nextProviders) { + const existing = readProviderCacheJsonObject(fileName); + const next = { ...existing }; + for (const provider of nextProviders) { + if (provider.model) next[provider.name] = provider.model; + } + const displayPath = writeProviderCacheJsonObject(fileName, next); + return { path: displayPath, modelCount: Object.keys(next).length }; +} + +function syncProviderCacheRecords() { + const built = buildProviderCacheSyncProviders(); + if (built.error) return { error: built.error }; + const providers = built.providers || []; + if (providers.length === 0) { + return { errorKey: 'modal.providerCache.noSyncableProviders', error: 'No syncable providers' }; + } + + const writtenFiles = []; + writtenFiles.push(mergeProviderCacheFile('codex-providers.json', providers, (provider) => ({ + name: provider.name, + base_url: provider.baseUrl, + wire_api: provider.wireApi, + preferred_auth_method: provider.apiKey, + model: provider.model, + ...(provider.bridge ? { codexmate_bridge: provider.bridge } : {}) + }))); + writtenFiles.push(mergeProviderCacheCurrentModelsFile('codex-provider-current-models.json', providers)); + writtenFiles.push(mergeProviderCacheFile('claude-providers.json', providers, (provider) => ({ + name: provider.name, + baseUrl: provider.baseUrl, + apiKey: provider.apiKey, + model: provider.model, + targetApi: normalizeClaudeTargetApi(provider.wireApi), + ...(provider.bridge ? { bridge: provider.bridge } : {}) + }))); + writtenFiles.push(mergeProviderCacheFile('opencode-providers.json', providers, (provider) => ({ + name: provider.name, + baseUrl: provider.baseUrl, + apiKey: provider.apiKey, + model: provider.model, + disabled: false, + ...(provider.bridge ? { bridge: provider.bridge } : {}) + }))); + writtenFiles.push(mergeProviderCacheCurrentModelsFile('opencode-provider-current-models.json', providers)); + + return { + success: true, + summary: { + providerCount: providers.length, + fileCount: writtenFiles.length, + writtenFiles + }, + records: readProviderCacheRecords() + }; +} + function getProviderKey(params = {}) { const name = typeof params.name === 'string' ? params.name.trim() : ''; if (!name) return { error: '名称不能为空' }; @@ -11695,6 +12185,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 +12335,12 @@ 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 'sync-provider-cache-records': + result = syncProviderCacheRecords(); + break; case 'delete-provider': result = deleteProviderFromConfig(params || {}); break; diff --git a/tests/unit/i18n-locales.test.mjs b/tests/unit/i18n-locales.test.mjs index 9730e969..28169856 100644 --- a/tests/unit/i18n-locales.test.mjs +++ b/tests/unit/i18n-locales.test.mjs @@ -96,6 +96,91 @@ test('Japanese orchestration template copy stays localized', () => { } }); +test('provider cache and local Web preference settings are localized in every locale', () => { + const keys = [ + 'announcement.providerCache.open', + 'announcement.project.eyebrow', + 'announcement.project.title', + 'announcement.project.subtitle', + 'announcement.project.closeAria', + 'announcement.project.primaryAction', + 'announcement.project.features.aria', + 'announcement.project.feature.config.title', + 'announcement.project.feature.config.meta', + 'announcement.project.feature.sessions.title', + 'announcement.project.feature.sessions.meta', + 'announcement.project.feature.usage.title', + 'announcement.project.feature.usage.meta', + 'announcement.project.feature.tasks.title', + 'announcement.project.feature.tasks.meta', + 'announcement.project.feature.skills.title', + 'announcement.project.feature.skills.meta', + 'announcement.project.feature.data.title', + 'announcement.project.feature.data.meta', + 'announcement.project.status.aria', + 'announcement.project.status.provider', + 'announcement.project.status.model', + 'announcement.project.status.cacheFiles', + 'announcement.project.cache.title', + 'announcement.project.cache.meta', + 'announcement.project.cache.files', + 'announcement.project.cache.providers', + 'announcement.project.cache.groups', + 'announcement.project.cache.groupList', + 'announcement.project.cache.groupSummary', + 'announcement.project.cache.sync', + 'announcement.project.cache.refresh', + 'announcement.project.cache.details', + 'settings.sharePrefix.title', + 'settings.sharePrefix.meta', + 'settings.sharePrefix.label', + 'settings.sharePrefix.hint', + 'settings.providerCache.title', + 'settings.providerCache.meta', + 'settings.providerCache.open', + 'settings.providerCache.sync', + 'settings.providerCache.syncing', + 'settings.providerCache.loading', + 'settings.providerCache.hint', + 'settings.trashConfig.title', + 'settings.trashConfig.meta', + 'modal.providerCache.title', + 'modal.providerCache.root', + 'modal.providerCache.refresh', + 'modal.providerCache.refreshing', + 'modal.providerCache.sync', + 'modal.providerCache.syncing', + 'modal.providerCache.syncSucceeded', + 'modal.providerCache.syncFailed', + 'modal.providerCache.noSyncableProviders', + 'modal.providerCache.loading', + 'modal.providerCache.loadedAt', + 'modal.providerCache.groupMeta', + 'modal.providerCache.empty', + 'modal.providerCache.providerCount', + 'modal.providerCache.rawJsonOnly', + 'modal.providerCache.tooLarge', + 'modal.providerCache.parseFailed', + 'modal.providerCache.rawJson', + 'modal.providerCache.errorDetails', + 'modal.providerCache.loadFailed' + ]; + for (const code of expectedLocales) { + for (const key of keys) { + assert.strictEqual(typeof DICT[code][key], 'string', `${code} should define ${key}`); + assert(DICT[code][key].trim(), `${code} ${key} should not be empty`); + } + assert( + !/localStorage|browser local|stored in the browser|浏览器本地|瀏覽器本地|ブラウザローカル/i.test(DICT[code]['settings.sharePrefix.hint']), + `${code} share prefix hint should describe backend preferences persistence, not browser-only storage` + ); + assert( + DICT[code]['settings.sharePrefix.hint'].includes('~/.codexmate/preferences.json'), + `${code} share prefix hint should mention ~/.codexmate/preferences.json` + ); + } +}); + test('plugins catalog metadata is localized from i18n dictionaries', async () => { const { createPluginsComputed } = await import('../../plugins/prompt-templates/computed.mjs'); diff --git a/tests/unit/provider-cache-records.test.mjs b/tests/unit/provider-cache-records.test.mjs new file mode 100644 index 00000000..ff28a9fa --- /dev/null +++ b/tests/unit/provider-cache-records.test.mjs @@ -0,0 +1,324 @@ +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, + providerCacheSyncing: false, + providerCacheSyncMessage: '', + providerCacheError: '', + showProviderCacheModal: false, + showProviderCacheAnnouncementModal: 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'; + if (key === 'modal.providerCache.syncSucceeded') return `synced ${params.count}/${params.fileCount}`; + if (key === 'modal.providerCache.syncFailed') return 'sync failed'; + 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 announcement modal opens from sidebar and summarizes cache records', async () => { + const calls = []; + const records = { + root: '~/.codexmate', + generatedAt: 'summary-time', + groups: [ + { + key: 'codex', + label: 'Codex', + files: [ + { exists: true, providerCount: 2, providers: [{ name: 'alpha' }, { name: 'beta' }] }, + { exists: false, providerCount: 99, providers: [{ name: 'ignored' }] } + ] + }, + { + key: 'claude', + label: 'Claude', + files: [ + { exists: true, providers: [{ name: 'gamma' }] } + ] + } + ] + }; + const methods = createProviderCacheMethods({ + api: async (action) => { + calls.push(action); + return records; + } + }); + const context = { + providerCacheRecords: { root: '', generatedAt: '', groups: [] }, + providerCacheLoadedOnce: false, + providerCacheLoadedAt: '', + providerCacheLoading: false, + providerCacheError: '', + showProviderCacheModal: false, + showProviderCacheAnnouncementModal: false, + t(key) { return key; }, + ...methods + }; + + await context.openProviderCacheAnnouncementModal(); + + assert.deepStrictEqual(calls, ['get-provider-cache-records']); + assert.strictEqual(context.showProviderCacheAnnouncementModal, true); + assert.deepStrictEqual(context.getProviderCacheAnnouncementSummary(), { + groupCount: 2, + fileCount: 2, + providerCount: 3, + loadedAt: 'summary-time' + }); + assert.deepStrictEqual(context.getProviderCacheAnnouncementGroups(), [ + { key: 'codex', label: 'Codex', existingCount: 1, providerCount: 2 }, + { key: 'claude', label: 'Claude', existingCount: 1, providerCount: 1 } + ]); +}); + +test('provider cache sync method calls sync API then refreshes redacted records', async () => { + const calls = []; + const syncedRecords = { root: '~/.codexmate', generatedAt: 'sync-time', groups: [] }; + const refreshedRecords = { root: '~/.codexmate', generatedAt: 'refresh-time', groups: [] }; + const methods = createProviderCacheMethods({ + api: async (action) => { + calls.push(action); + if (action === 'sync-provider-cache-records') { + return { success: true, summary: { providerCount: 2, fileCount: 5 }, records: syncedRecords }; + } + if (action === 'get-provider-cache-records') { + return refreshedRecords; + } + throw new Error(`unexpected action: ${action}`); + } + }); + const context = { + providerCacheRecords: {}, + providerCacheLoadedOnce: false, + providerCacheLoadedAt: '', + providerCacheLoading: false, + providerCacheSyncing: false, + providerCacheSyncMessage: '', + providerCacheError: '', + showProviderCacheModal: false, + showProviderCacheAnnouncementModal: false, + t(key, params = {}) { + if (key === 'modal.providerCache.syncSucceeded') return `synced ${params.count}/${params.fileCount}`; + return key; + }, + ...methods + }; + + await context.syncProviderCacheRecords(); + + assert.deepStrictEqual(calls, ['sync-provider-cache-records', 'get-provider-cache-records']); + assert.strictEqual(context.providerCacheSyncing, false); + assert.strictEqual(context.providerCacheSyncMessage, 'synced 2/5'); + assert.strictEqual(context.providerCacheError, ''); + assert.strictEqual(context.providerCacheLoadedOnce, true); + assert.strictEqual(context.providerCacheLoadedAt, 'refresh-time'); + assert.deepStrictEqual(context.providerCacheRecords, refreshedRecords); +}); + +test('provider cache sync method uses localized fallback on thrown errors', async () => { + const methods = createProviderCacheMethods({ + api: async () => { + throw new Error(''); + } + }); + const context = { + providerCacheRecords: {}, + providerCacheLoadedOnce: false, + providerCacheLoadedAt: '', + providerCacheLoading: false, + providerCacheSyncing: false, + providerCacheSyncMessage: '', + providerCacheError: '', + showProviderCacheModal: false, + showProviderCacheAnnouncementModal: false, + t(key) { + assert.strictEqual(key, 'modal.providerCache.syncFailed'); + return 'localized sync failed'; + }, + ...methods + }; + + await context.syncProviderCacheRecords(); + + assert.strictEqual(context.providerCacheError, 'localized sync failed'); + assert.strictEqual(context.providerCacheSyncing, false); +}); + +test('provider cache sync method localizes backend error keys', async () => { + const methods = createProviderCacheMethods({ + api: async () => ({ errorKey: 'modal.providerCache.noSyncableProviders', error: 'No syncable providers' }) + }); + const context = { + providerCacheRecords: {}, + providerCacheLoadedOnce: false, + providerCacheLoadedAt: '', + providerCacheLoading: false, + providerCacheSyncing: false, + providerCacheSyncMessage: '', + providerCacheError: '', + showProviderCacheModal: false, + showProviderCacheAnnouncementModal: false, + t(key) { + assert.strictEqual(key, 'modal.providerCache.noSyncableProviders'); + return 'localized no providers'; + }, + ...methods + }; + + await context.syncProviderCacheRecords(); + + assert.strictEqual(context.providerCacheError, 'localized no providers'); + assert.strictEqual(context.providerCacheSyncMessage, ''); + assert.strictEqual(context.providerCacheSyncing, false); +}); + +test('provider cache load fallback uses localized error text', async () => { + const methods = createProviderCacheMethods({ + api: async () => { + throw new Error(''); + } + }); + const context = { + providerCacheRecords: {}, + providerCacheLoadedOnce: false, + providerCacheLoadedAt: '', + providerCacheLoading: false, + providerCacheError: '', + showProviderCacheModal: false, + showProviderCacheAnnouncementModal: false, + t(key) { + assert.strictEqual(key, 'modal.providerCache.loadFailed'); + return 'localized load failed'; + }, + ...methods + }; + + await context.loadProviderCacheRecords(); + + assert.strictEqual(context.providerCacheError, 'localized load failed'); + assert.strictEqual(context.providerCacheLoading, false); +}); + +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, /showProviderCacheAnnouncementModal/); + assert.match(html, /announcement\.project\.title/); + assert.match(html, /announcement\.project\.feature\.config\.title/); + assert.match(html, /announcement\.project\.cache\.title/); + assert.match(html, /getProviderCacheAnnouncementSummary\(\)\.providerCount/); + assert.match(html, /openProviderCacheDetailsFromAnnouncement/); + assert.match(readProjectFile('web-ui/partials/index/layout-header.html'), /side-announcement-button/); + assert.match(readProjectFile('web-ui/partials/index/layout-header.html'), /openProviderCacheAnnouncementModal/); + assert.match(html, /syncProviderCacheRecords/); + assert.match(html, /modal\.providerCache\.sync/); + assert.match(readProjectFile('web-ui/partials/index/panel-settings.html'), /settings\.providerCache\.sync/); + assert.doesNotMatch(html, /v-else-if="providerCacheSyncMessage"/); + assert.match(html, /\(provider, providerIndex\) in getProviderCacheFileProviders\(file\)/); + assert.match(html, /getProviderCacheFileKey\(file\) \+ ':' \+ providerIndex/); + assert.match(html, /getProviderCacheProviderMeta\(provider\)/); + assert.match(html, /modal\.providerCache\.rawJson/); + assert.match(html, /provider-cache-footer/); + + assert.match(css, /\.side-announcement-button/); + assert.match(css, /\.provider-cache-announcement-modal/); + assert.match(css, /\.project-announcement-feature-grid/); + assert.match(css, /\.provider-cache-summary-grid/); + 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); + + assert.ok(readConfigStart >= 0, 'readConfig must exist'); + assert.ok(readConfigEnd > readConfigStart, 'writeConfig must exist after readConfig'); + const readConfigSource = cli.slice(readConfigStart, readConfigEnd); + 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, /function sanitizeProviderCacheErrorMessage\(message, fileName/); + assert.match(cli, /function syncProviderCacheRecords\(\)/); + assert.match(cli, /errorKey: 'modal\.providerCache\.noSyncableProviders'/); + assert.doesNotMatch(cli, /没有可同步的 provider/); + assert.match(cli, /case 'sync-provider-cache-records'/); + assert.match(cli, /mergeProviderCacheFile\('codex-providers\.json'/); + assert.match(cli, /mergeProviderCacheFile\('claude-providers\.json'/); + assert.match(cli, /mergeProviderCacheFile\('opencode-providers\.json'/); + assert.match(cli, /fs\.chmodSync\(filePath, 0o600\)/); + assert.match(cli, /stat\.isFile\(\)/); + assert.match(cli, /sanitizeProviderCacheErrorMessage\(e && e\.message/); + assert.match(cli, /root: '~\/\.codexmate'/); + assert.doesNotMatch(cli, /root: CODEXMATE_DIR/); + assert.match(cli, /secretQueryPattern/); + assert.match(cli, /extractProviderCacheSummaries/); + assert.match(cli, /const authMethod = pickProviderCacheString\(provider, \['preferred_auth_method', 'authMethod', 'auth_method'\]\)/); + assert.match(cli, /authMethod: authMethod \? redactProviderCacheValue\(authMethod\) : ''/); + assert.doesNotMatch(cli, /'authMethod', 'auth_method', 'auth'/); + assert.match(cli, /const redactSecretString = \(text\) => String\(text \|\| ''\) \? '\*\*\*' : ''/); + assert.doesNotMatch(cli, /valueText\.slice\(0, 4\).*valueText\.slice\(-4\)/s); +}); 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..ecc8f883 100644 --- a/tests/unit/web-ui-behavior-parity.test.mjs +++ b/tests/unit/web-ui-behavior-parity.test.mjs @@ -516,7 +516,16 @@ test('captured bundled app skeleton only exposes expected data key drift versus 'opencodeAutoCompact', 'opencodeMaxTokens', 'opencodeReasoningEffort', - 'sessionTimelineStyle' + 'sessionTimelineStyle', + 'providerCacheError', + 'providerCacheLoadedAt', + 'providerCacheLoadedOnce', + 'providerCacheLoading', + 'providerCacheRecords', + 'providerCacheSyncing', + 'providerCacheSyncMessage', + 'showProviderCacheModal', + 'showProviderCacheAnnouncementModal' ); if (parityAgainstHead) { const allowedExtraKeySet = new Set(allowedExtraCurrentKeys); @@ -630,6 +639,7 @@ test('captured bundled app skeleton only exposes expected data key drift versus 'claudeLocalBridgeCandidateProviders', 'claudeLocalBridgeConfigured', 'syncClaudeBridgeProviders', + 'syncProviderCacheRecords', 'toggleAddClaudeConfigKey', 'toggleAddProviderKey', 'toggleEditClaudeConfigKey', @@ -735,7 +745,31 @@ test('captured bundled app skeleton only exposes expected data key drift versus 'validateModelId', 'getSessionFilePath', 'copySessionPath', - 'canBuildStandaloneUrl' + 'canBuildStandaloneUrl', + 'openProviderCacheModal', + 'closeProviderCacheModal', + 'openProviderCacheAnnouncementModal', + 'closeProviderCacheAnnouncementModal', + 'openProviderCacheDetailsFromAnnouncement', + 'loadProviderCacheRecords', + 'getProviderCacheGroups', + 'getProviderCacheAnnouncementSummary', + 'getProviderCacheAnnouncementGroups', + '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..979d7074 --- /dev/null +++ b/tests/unit/web-ui-preferences.test.mjs @@ -0,0 +1,171 @@ +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', 'openclaw', 'opencode']), + switchMainTabHelper: () => {}, + loadMoreSessionMessagesHelper: () => {} + }); + return { + switchMainTabCalls: [], + 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, + switchMainTab(tab) { + this.switchMainTabCalls.push(tab); + this.mainTab = tab; + }, + ...webPreferenceMethods + }; +} + +async function waitForApiCall(apiCalls, action, timeoutMs = 1000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const call = apiCalls.find((item) => item.action === action); + if (call) return call; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return apiCalls.find((item) => item.action === action) || null; +} + +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.deepStrictEqual(context.switchMainTabCalls, ['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'); + + const writeCall = await waitForApiCall(apiCalls, '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 preference snapshots preserve unrelated navigation sub-state', async () => { + const apiCalls = []; + const context = createContext(apiCalls, createMemoryStorage()); + context.skillsTargetApp = 'claude'; + context.promptTemplatesMode = 'manage'; + context.setShareCommandPrefix('codexmate'); + + const writeCall = await waitForApiCall(apiCalls, 'set-web-ui-preferences'); + assert.ok(writeCall, 'setter must persist web UI preferences'); + assert.strictEqual(writeCall.params.preferences.navigation.skillsTargetApp, 'claude'); + assert.strictEqual(writeCall.params.preferences.navigation.promptTemplatesMode, 'manage'); +}); + +test('web UI preference navigation restore can be disabled for explicit routes', () => { + const apiCalls = []; + const context = createContext(apiCalls, createMemoryStorage()); + + context.applyWebUiPreferences({ + navigation: { + mainTab: 'settings', + configMode: 'claude', + settingsTab: 'data' + } + }, { applyNavigation: false }); + + assert.strictEqual(context.mainTab, 'dashboard'); + assert.strictEqual(context.configMode, 'codex'); + assert.strictEqual(context.settingsTab, 'general'); + assert.deepStrictEqual(context.switchMainTabCalls, []); +}); + +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\(\{ applyNavigation: applyPreferenceNavigation \}\)/); + assert.match(app, /url\.pathname === '\/session'/); + assert.match(app, /url\.searchParams\.get\('tab'\)/); + assert.match(index, /createWebUiPreferencesMethods/); +}); diff --git a/web-ui/app.js b/web-ui/app.js index dccbe922..1481104a 100644 --- a/web-ui/app.js +++ b/web-ui/app.js @@ -79,6 +79,8 @@ document.addEventListener('DOMContentLoaded', () => { projectPathOptionsLoading: false, showSkillsModal: false, showHealthCheckModal: false, + showProviderCacheModal: false, + showProviderCacheAnnouncementModal: false, showCodexBridgePoolModal: false, showClaudeBridgePoolModal: false, showWebhookModal: false, @@ -378,6 +380,13 @@ document.addEventListener('DOMContentLoaded', () => { codexDownloadLoading: false, codexDownloadProgress: 0, codexDownloadTimer: null, + providerCacheRecords: { root: '', generatedAt: '', groups: [] }, + providerCacheLoadedOnce: false, + providerCacheLoadedAt: '', + providerCacheLoading: false, + providerCacheSyncing: false, + providerCacheSyncMessage: '', + providerCacheError: '', settingsTab: 'general', toolConfigPermissions: (function() { try { @@ -506,6 +515,18 @@ document.addEventListener('DOMContentLoaded', () => { if (typeof this.loadWebhookSettings === 'function') { this.loadWebhookSettings(); } + if (typeof this.loadWebUiPreferences === 'function') { + const applyPreferenceNavigation = (() => { + try { + const url = new URL(window.location.href); + if (url.pathname === '/session') return false; + return !String(url.searchParams.get('tab') || '').trim(); + } catch (_) { + return true; + } + })(); + void this.loadWebUiPreferences({ applyNavigation: applyPreferenceNavigation }); + } if (typeof this.t === 'function') { this.confirmDialogConfirmText = this.t('confirm.ok'); this.confirmDialogCancelText = this.t('confirm.cancel'); @@ -743,6 +764,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 +792,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 +807,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..bbb3a2aa --- /dev/null +++ b/web-ui/modules/app.methods.provider-cache.mjs @@ -0,0 +1,212 @@ +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 openProviderCacheAnnouncementModal() { + this.showProviderCacheAnnouncementModal = true; + if (!this.providerCacheLoadedOnce) { + await this.loadProviderCacheRecords(); + } + }, + + closeProviderCacheAnnouncementModal() { + this.showProviderCacheAnnouncementModal = false; + }, + + async openProviderCacheDetailsFromAnnouncement() { + this.showProviderCacheAnnouncementModal = false; + await this.openProviderCacheModal({ forceRefresh: 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 : this.t('modal.providerCache.loadFailed'); + } finally { + this.providerCacheLoading = false; + } + }, + + async syncProviderCacheRecords() { + if (this.providerCacheSyncing) return; + this.providerCacheSyncing = true; + this.providerCacheError = ''; + this.providerCacheSyncMessage = ''; + try { + const res = await api('sync-provider-cache-records'); + if (res && res.error) { + this.providerCacheError = res.errorKey ? this.t(res.errorKey) : res.error; + return; + } + const summary = res && res.summary && typeof res.summary === 'object' ? res.summary : {}; + const providerCount = Number(summary.providerCount || 0); + const fileCount = Number(summary.fileCount || 0); + this.providerCacheSyncMessage = this.t('modal.providerCache.syncSucceeded', { count: providerCount, fileCount }); + if (res && res.records && typeof res.records === 'object') { + this.providerCacheRecords = res.records; + this.providerCacheLoadedOnce = true; + this.providerCacheLoadedAt = this.providerCacheRecords.generatedAt || new Date().toISOString(); + } + await this.loadProviderCacheRecords(); + } catch (e) { + this.providerCacheError = e && e.message ? e.message : this.t('modal.providerCache.syncFailed'); + } finally { + this.providerCacheSyncing = false; + } + }, + + getProviderCacheGroups() { + const records = this.providerCacheRecords && typeof this.providerCacheRecords === 'object' + ? this.providerCacheRecords + : {}; + return Array.isArray(records.groups) ? records.groups : []; + }, + + getProviderCacheAnnouncementSummary() { + const groups = this.getProviderCacheGroups(); + let fileCount = 0; + let providerCount = 0; + for (const group of groups) { + const files = this.getProviderCacheExistingFiles(group); + fileCount += files.length; + for (const file of files) { + const count = Number(file && file.providerCount); + if (Number.isFinite(count) && count > 0) { + providerCount += count; + } else { + providerCount += this.getProviderCacheFileProviders(file).length; + } + } + } + return { + groupCount: groups.length, + fileCount, + providerCount, + loadedAt: this.providerCacheLoadedAt || (this.providerCacheRecords && this.providerCacheRecords.generatedAt) || '' + }; + }, + + getProviderCacheAnnouncementGroups() { + return this.getProviderCacheGroups().map((group) => { + const existingFiles = this.getProviderCacheExistingFiles(group); + return { + key: group && group.key ? group.key : '', + label: group && group.label ? group.label : '', + existingCount: existingFiles.length, + providerCount: existingFiles.reduce((sum, file) => { + const count = Number(file && file.providerCount); + if (Number.isFinite(count) && count > 0) return sum + count; + return sum + this.getProviderCacheFileProviders(file).length; + }, 0) + }; + }); + }, + + 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..9d12ec56 --- /dev/null +++ b/web-ui/modules/app.methods.web-ui-preferences.mjs @@ -0,0 +1,161 @@ +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 = {}) => { + const currentSkillsTargetApp = vm.skillsTargetApp === 'claude' ? 'claude' : 'codex'; + const currentPromptTemplatesMode = vm.promptTemplatesMode === 'manage' ? 'manage' : 'compose'; + return { + 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' || source.skillsTargetApp === 'codex' + ? source.skillsTargetApp + : currentSkillsTargetApp, + promptTemplatesMode: source.promptTemplatesMode === 'manage' || source.promptTemplatesMode === 'compose' + ? source.promptTemplatesMode + : currentPromptTemplatesMode + }; + }; + + 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 = {}, options = {}) { + const source = preferences && typeof preferences === 'object' ? preferences : {}; + const shouldApplyNavigation = !(options && options.applyNavigation === false); + 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 (shouldApplyNavigation && 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.configMode === 'string') this.configMode = nav.configMode; + if (typeof nav.mainTab === 'string') { + if (typeof this.switchMainTab === 'function') { + this.switchMainTab(nav.mainTab); + } else { + this.mainTab = nav.mainTab; + } + } + 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(options = {}) { + try { + const res = await api('get-web-ui-preferences'); + if (res && res.preferences && typeof res.preferences === 'object') { + this.applyWebUiPreferences(res.preferences, options && typeof options === 'object' ? options : {}); + } + } 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..03bd7266 100644 --- a/web-ui/modules/i18n/locales/en.mjs +++ b/web-ui/modules/i18n/locales/en.mjs @@ -1089,6 +1089,39 @@ const en = Object.freeze({ 'settings.tabs.aria': 'Settings categories', 'settings.quickSettings.title': 'Quick Settings', 'settings.language.sideLabel': 'Language: {language}', + 'announcement.providerCache.open': 'Feature announcement', + 'announcement.project.eyebrow': 'Start here', + 'announcement.project.closeAria': 'Close feature announcement', + 'announcement.project.primaryAction': 'Enter workspace', + 'announcement.project.title': 'Your AI tool hub starts here', + 'announcement.project.subtitle': 'Connect models, find conversations, review usage, and keep common maintenance actions in one workspace.', + 'announcement.project.features.aria': 'Codex Mate feature overview', + 'announcement.project.feature.config.title': 'Connect models', + 'announcement.project.feature.config.meta': 'Set models and service endpoints for your AI tools.', + 'announcement.project.feature.sessions.title': 'Find conversations', + 'announcement.project.feature.sessions.meta': 'Search, export, or clean past sessions by source.', + 'announcement.project.feature.usage.title': 'Check usage', + 'announcement.project.feature.usage.meta': 'Review recent and long-term local usage trends.', + 'announcement.project.feature.tasks.title': 'Track tasks', + 'announcement.project.feature.tasks.meta': 'Inspect plans, queues, and run history.', + 'announcement.project.feature.skills.title': 'Reuse workflows', + 'announcement.project.feature.skills.meta': 'Manage Skills and prompt templates.', + 'announcement.project.feature.data.title': 'Maintain data', + 'announcement.project.feature.data.meta': 'Handle backups, imports, trash, and caches.', + 'announcement.project.status.aria': 'Current workspace status', + 'announcement.project.status.provider': 'Provider', + 'announcement.project.status.model': 'Current model', + 'announcement.project.status.cacheFiles': 'Cache files', + 'announcement.project.cache.title': 'Advanced maintenance status', + 'announcement.project.cache.meta': 'Open only when you need troubleshooting or sync; you can ignore it for everyday use.', + 'announcement.project.cache.files': 'Cache files', + 'announcement.project.cache.providers': 'Provider summaries', + 'announcement.project.cache.groups': 'Cache groups', + 'announcement.project.cache.groupList': 'Provider cache group summary', + 'announcement.project.cache.groupSummary': '{files} files · {providers} providers', + 'announcement.project.cache.sync': 'Sync cache', + 'announcement.project.cache.refresh': 'Refresh summary', + 'announcement.project.cache.details': 'View cache details', 'settings.language.title': 'Language', 'settings.language.meta': 'Choose the Web UI display language', 'settings.language.label': 'Interface language', @@ -1096,7 +1129,7 @@ const en = Object.freeze({ 'settings.sharePrefix.title': 'Share command prefix', 'settings.sharePrefix.meta': 'Used as the prefix for “Copy share command” in the Web UI', 'settings.sharePrefix.label': 'Prefix', - 'settings.sharePrefix.hint': 'Defaults to npm start (project-local). You can switch to global codexmate. This setting is stored in the browser.', + 'settings.sharePrefix.hint': 'Defaults to npm start (project-local). You can switch to global codexmate. This setting is stored in ~/.codexmate/preferences.json.', 'settings.claude.title': 'Claude config', 'settings.claude.meta': 'Backup / import ~/.claude', 'settings.codex.title': 'Codex config', @@ -1108,6 +1141,37 @@ 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.sync': 'Sync cache records', + 'settings.providerCache.syncing': 'Syncing...', + 'settings.providerCache.loading': 'Loading...', + 'settings.providerCache.hint': 'Read-only view of cache files. Sensitive fields are redacted automatically. Sync writes the current provider configuration to ~/.codexmate cache files.', + 'modal.providerCache.title': 'Provider cache records', + 'modal.providerCache.root': 'Cache directory', + 'modal.providerCache.refresh': 'Refresh', + 'modal.providerCache.refreshing': 'Refreshing...', + 'modal.providerCache.sync': 'Sync', + 'modal.providerCache.syncing': 'Syncing...', + 'modal.providerCache.syncSucceeded': 'Synced {count} providers to {fileCount} cache files', + 'modal.providerCache.syncFailed': 'Failed to sync cache records', + 'modal.providerCache.noSyncableProviders': 'No syncable providers found', + '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', + 'modal.providerCache.loadFailed': 'Failed to load cache records', + + 'settings.trashConfig.title': 'Trash configuration', + 'settings.trashConfig.meta': 'Trash toggle and automatic cleanup retention', + '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..aa47fae6 100644 --- a/web-ui/modules/i18n/locales/ja.mjs +++ b/web-ui/modules/i18n/locales/ja.mjs @@ -1078,6 +1078,39 @@ const ja = Object.freeze({ 'settings.tabs.aria': '設定カテゴリ', 'settings.quickSettings.title': 'クイック設定', 'settings.language.sideLabel': '言語:{language}', + 'announcement.providerCache.open': '機能のお知らせ', + 'announcement.project.eyebrow': 'ここから開始', + 'announcement.project.closeAria': '機能のお知らせを閉じる', + 'announcement.project.primaryAction': 'ワークスペースへ', + 'announcement.project.title': 'AI ツール入口はここです', + 'announcement.project.subtitle': 'モデル接続、会話検索、使用量確認、保守操作をひとつのワークスペースにまとめます。', + 'announcement.project.features.aria': 'Codex Mate 機能概要', + 'announcement.project.feature.config.title': 'モデルを接続', + 'announcement.project.feature.config.meta': 'AI ツールのモデルと接続先を設定します。', + 'announcement.project.feature.sessions.title': '会話を探す', + 'announcement.project.feature.sessions.meta': '過去のセッションを検索、エクスポート、整理します。', + 'announcement.project.feature.usage.title': '使用量を見る', + 'announcement.project.feature.usage.meta': '最近と長期のローカル使用量を確認します。', + 'announcement.project.feature.tasks.title': 'タスクを追跡', + 'announcement.project.feature.tasks.meta': '計画、キュー、実行履歴を確認します。', + 'announcement.project.feature.skills.title': 'ワークフロー再利用', + 'announcement.project.feature.skills.meta': 'Skills とプロンプトテンプレートを管理します。', + 'announcement.project.feature.data.title': 'データ保守', + 'announcement.project.feature.data.meta': 'バックアップ、インポート、ゴミ箱、キャッシュを扱います。', + 'announcement.project.status.aria': '現在のワークスペース状態', + 'announcement.project.status.provider': 'Provider', + 'announcement.project.status.model': '現在のモデル', + 'announcement.project.status.cacheFiles': 'キャッシュファイル', + 'announcement.project.cache.title': '高度な保守状態', + 'announcement.project.cache.meta': '同期や調査が必要なときだけ展開します。普段は無視してかまいません。', + 'announcement.project.cache.files': 'キャッシュファイル', + 'announcement.project.cache.providers': 'Provider 概要', + 'announcement.project.cache.groups': 'キャッシュグループ', + 'announcement.project.cache.groupList': 'Provider キャッシュグループ概要', + 'announcement.project.cache.groupSummary': '{files} ファイル · {providers} provider', + 'announcement.project.cache.sync': 'キャッシュ同期', + 'announcement.project.cache.refresh': '概要を更新', + 'announcement.project.cache.details': 'キャッシュ詳細を見る', 'settings.language.title': '言語', 'settings.language.meta': 'Web UI の表示言語を選択', 'settings.language.label': 'インターフェース言語', @@ -1085,7 +1118,7 @@ const ja = Object.freeze({ 'settings.sharePrefix.title': '共有コマンドプレフィックス', 'settings.sharePrefix.meta': 'Web UI の「共有コマンドをコピー」のプレフィックスに影響', 'settings.sharePrefix.label': 'プレフィックス', - 'settings.sharePrefix.hint': 'デフォルトはプロジェクト内の npm start を使用します。グローバル codexmate に切り替えることもできます。この設定はブラウザローカルにキャッシュされます。', + 'settings.sharePrefix.hint': 'デフォルトはプロジェクト内の npm start を使用します。グローバル codexmate に切り替えることもできます。この設定は ~/.codexmate/preferences.json に保存されます。', 'settings.backup.title': 'データバックアップ', 'settings.backup.meta': 'Claude と Codex 設定のエクスポート / インポート', 'settings.claude.title': 'Claude 設定', @@ -1099,6 +1132,34 @@ 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.sync': 'キャッシュ記録を同期', + 'settings.providerCache.syncing': '同期中...', + 'settings.providerCache.loading': '読み込み中...', + 'settings.providerCache.hint': 'キャッシュファイルの読み取り専用表示です。機密フィールドは自動的にマスクされます。同期すると現在の provider 設定を ~/.codexmate キャッシュファイルへ書き込みます。', + 'modal.providerCache.title': 'Provider キャッシュ記録', + 'modal.providerCache.root': 'キャッシュディレクトリ', + 'modal.providerCache.refresh': '更新', + 'modal.providerCache.refreshing': '更新中...', + 'modal.providerCache.sync': '同期', + 'modal.providerCache.syncing': '同期中...', + 'modal.providerCache.syncSucceeded': '{count} 個の provider を {fileCount} 個のキャッシュファイルへ同期しました', + 'modal.providerCache.syncFailed': 'キャッシュ記録の同期に失敗しました', + 'modal.providerCache.noSyncableProviders': '同期できる provider がありません', + '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': 'エラー詳細', + 'modal.providerCache.loadFailed': 'キャッシュ記録の読み込みに失敗しました', + '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..4fc0ca78 100644 --- a/web-ui/modules/i18n/locales/vi.mjs +++ b/web-ui/modules/i18n/locales/vi.mjs @@ -242,11 +242,47 @@ const vi = Object.freeze({ // Task orchestration readiness + // Share command prefix + 'settings.sharePrefix.title': 'Tiền tố lệnh chia sẻ', + 'settings.sharePrefix.meta': 'Dùng làm tiền tố cho “Sao chép lệnh chia sẻ” trong Web UI', + 'settings.sharePrefix.label': 'Tiền tố', + 'settings.sharePrefix.hint': 'Mặc định dùng npm start theo dự án. Bạn có thể chuyển sang codexmate toàn cục. Thiết lập này được lưu vào ~/.codexmate/preferences.json.', + // Trash + 'settings.trashConfig.title': 'Cấu hình thùng rác', + 'settings.trashConfig.meta': 'Bật/tắt thùng rác và số ngày tự động dọn dẹp', 'settings.trash.empty': 'Thùng rác trống', '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.sync': 'Đồng bộ bản ghi cache', + 'settings.providerCache.syncing': 'Đang đồng bộ...', + '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. Đồng bộ sẽ ghi cấu hình provider hiện tại vào các tệp cache ~/.codexmate.', + '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.sync': 'Đồng bộ', + 'modal.providerCache.syncing': 'Đang đồng bộ...', + 'modal.providerCache.syncSucceeded': 'Đã đồng bộ {count} provider vào {fileCount} tệp cache', + 'modal.providerCache.syncFailed': 'Không đồng bộ được bản ghi cache', + 'modal.providerCache.noSyncableProviders': 'Không tìm thấy provider có thể đồng bộ', + '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', + 'modal.providerCache.loadFailed': 'Không tải được bản ghi cache', + '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', @@ -280,6 +316,39 @@ const vi = Object.freeze({ // Settings panel 'settings.language.sideLabel': 'Ngôn ngữ: {language}', + 'announcement.providerCache.open': 'Thông báo tính năng', + 'announcement.project.eyebrow': 'Bắt đầu ở đây', + 'announcement.project.closeAria': 'Đóng thông báo tính năng', + 'announcement.project.primaryAction': 'Vào workspace', + 'announcement.project.title': 'Trung tâm công cụ AI của bạn ở đây', + 'announcement.project.subtitle': 'Kết nối model, tìm cuộc trò chuyện, xem mức dùng và gom thao tác bảo trì thường dùng vào một workspace.', + 'announcement.project.features.aria': 'Tổng quan tính năng Codex Mate', + 'announcement.project.feature.config.title': 'Kết nối model', + 'announcement.project.feature.config.meta': 'Thiết lập model và endpoint cho công cụ AI.', + 'announcement.project.feature.sessions.title': 'Tìm cuộc trò chuyện', + 'announcement.project.feature.sessions.meta': 'Tìm kiếm, xuất hoặc dọn phiên cũ theo nguồn.', + 'announcement.project.feature.usage.title': 'Xem mức dùng', + 'announcement.project.feature.usage.meta': 'Xem xu hướng sử dụng gần đây và dài hạn.', + 'announcement.project.feature.tasks.title': 'Theo dõi tác vụ', + 'announcement.project.feature.tasks.meta': 'Xem kế hoạch, hàng đợi và lịch sử chạy.', + 'announcement.project.feature.skills.title': 'Tái dùng workflow', + 'announcement.project.feature.skills.meta': 'Quản lý Skills và mẫu prompt.', + 'announcement.project.feature.data.title': 'Bảo trì dữ liệu', + 'announcement.project.feature.data.meta': 'Xử lý sao lưu, nhập, thùng rác và cache.', + 'announcement.project.status.aria': 'Trạng thái workspace hiện tại', + 'announcement.project.status.provider': 'Provider', + 'announcement.project.status.model': 'Model hiện tại', + 'announcement.project.status.cacheFiles': 'Tệp bộ nhớ đệm', + 'announcement.project.cache.title': 'Trạng thái bảo trì nâng cao', + 'announcement.project.cache.meta': 'Chỉ mở khi cần kiểm tra hoặc đồng bộ; dùng hằng ngày có thể bỏ qua.', + 'announcement.project.cache.files': 'Tệp bộ nhớ đệm', + 'announcement.project.cache.providers': 'Tóm tắt provider', + 'announcement.project.cache.groups': 'Nhóm bộ nhớ đệm', + 'announcement.project.cache.groupList': 'Tóm tắt nhóm bộ nhớ đệm provider', + 'announcement.project.cache.groupSummary': '{files} tệp · {providers} provider', + 'announcement.project.cache.sync': 'Đồng bộ bộ nhớ đệm', + 'announcement.project.cache.refresh': 'Làm mới tóm tắt', + 'announcement.project.cache.details': 'Xem chi tiết bộ nhớ đệm', 'settings.language.title': 'Ngôn ngữ', 'settings.language.meta': 'Chọn ngôn ngữ hiển thị cho Web UI', 'settings.language.label': 'Ngôn ngữ giao diện', diff --git a/web-ui/modules/i18n/locales/zh-tw.mjs b/web-ui/modules/i18n/locales/zh-tw.mjs index 7d5d6b95..6d61f061 100644 --- a/web-ui/modules/i18n/locales/zh-tw.mjs +++ b/web-ui/modules/i18n/locales/zh-tw.mjs @@ -1088,6 +1088,39 @@ const zhTw = Object.freeze({ 'settings.tabs.aria': '設定分類', 'settings.quickSettings.title': '快捷設定', 'settings.language.sideLabel': '語言:{language}', + 'announcement.providerCache.open': '功能公告', + 'announcement.project.eyebrow': '新手入口', + 'announcement.project.closeAria': '關閉功能公告', + 'announcement.project.primaryAction': '進入工作台', + 'announcement.project.title': '你的 AI 工具入口在這裡', + 'announcement.project.subtitle': '從這裡連接模型、找回對話、查看用量,並把常用維護動作放到一個工作台裡。', + 'announcement.project.features.aria': 'Codex Mate 功能概覽', + 'announcement.project.feature.config.title': '連接模型', + 'announcement.project.feature.config.meta': '配置常用 AI 工具的模型和服務地址。', + 'announcement.project.feature.sessions.title': '找回對話', + 'announcement.project.feature.sessions.meta': '按來源搜尋、匯出或清理歷史會話。', + 'announcement.project.feature.usage.title': '查看花費', + 'announcement.project.feature.usage.meta': '看最近和長期的本地用量趨勢。', + 'announcement.project.feature.tasks.title': '追蹤任務', + 'announcement.project.feature.tasks.meta': '查看計畫、佇列和執行記錄。', + 'announcement.project.feature.skills.title': '複用工作流', + 'announcement.project.feature.skills.meta': '管理 Skills 和提示詞範本。', + 'announcement.project.feature.data.title': '整理資料', + 'announcement.project.feature.data.meta': '處理備份、匯入、回收站和快取。', + 'announcement.project.status.aria': '目前工作台狀態', + 'announcement.project.status.provider': 'Provider', + 'announcement.project.status.model': '目前模型', + 'announcement.project.status.cacheFiles': '快取檔案', + 'announcement.project.cache.title': '進階維護狀態', + 'announcement.project.cache.meta': '僅在需要排查或同步時展開;日常使用可以忽略。', + 'announcement.project.cache.files': '快取檔案', + 'announcement.project.cache.providers': 'Provider 摘要', + 'announcement.project.cache.groups': '快取分組', + 'announcement.project.cache.groupList': 'Provider 快取分組摘要', + 'announcement.project.cache.groupSummary': '{files} 檔案 · {providers} provider', + 'announcement.project.cache.sync': '同步快取', + 'announcement.project.cache.refresh': '重新整理摘要', + 'announcement.project.cache.details': '查看快取詳情', 'settings.language.title': '語言', 'settings.language.meta': '選擇 Web UI 的顯示語言', 'settings.language.label': '界面語言', @@ -1095,7 +1128,7 @@ const zhTw = Object.freeze({ 'settings.sharePrefix.title': '分享命令前綴', 'settings.sharePrefix.meta': '影響 Web UI 裡“複製分享命令”的前綴', 'settings.sharePrefix.label': '前綴', - 'settings.sharePrefix.hint': '預設走項目內 npm start,也可切到全局 codexmate。該設定會緩存到瀏覽器本地。', + 'settings.sharePrefix.hint': '預設走項目內 npm start,也可切到全局 codexmate。該設定會寫入 ~/.codexmate/preferences.json。', 'settings.backup.title': '資料備份', 'settings.backup.meta': '匯出 / 匯入 Claude 與 Codex 設定', 'settings.claude.title': 'Claude 設定', @@ -1109,6 +1142,34 @@ 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.sync': '同步快取記錄', + 'settings.providerCache.syncing': '同步中...', + 'settings.providerCache.loading': '載入中...', + 'settings.providerCache.hint': '只讀展示快取文件,敏感欄位會自動遮罩。同步會把目前 provider 設定寫入 ~/.codexmate 快取文件。', + 'modal.providerCache.title': 'Provider 快取記錄', + 'modal.providerCache.root': '快取目錄', + 'modal.providerCache.refresh': '重新整理', + 'modal.providerCache.refreshing': '重新整理中...', + 'modal.providerCache.sync': '同步', + 'modal.providerCache.syncing': '同步中...', + 'modal.providerCache.syncSucceeded': '已同步 {count} 個 provider 到 {fileCount} 個快取文件', + 'modal.providerCache.syncFailed': '同步快取記錄失敗', + 'modal.providerCache.noSyncableProviders': '沒有可同步的 provider', + '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': '錯誤詳情', + 'modal.providerCache.loadFailed': '載入快取記錄失敗', + '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..b8dce2b2 100644 --- a/web-ui/modules/i18n/locales/zh.mjs +++ b/web-ui/modules/i18n/locales/zh.mjs @@ -1088,6 +1088,39 @@ const zh = Object.freeze({ 'settings.tabs.aria': '设置分类', 'settings.quickSettings.title': '快捷设置', 'settings.language.sideLabel': '语言:{language}', + 'announcement.providerCache.open': '功能公告', + 'announcement.project.eyebrow': '新手入口', + 'announcement.project.closeAria': '关闭功能公告', + 'announcement.project.primaryAction': '进入工作台', + 'announcement.project.title': '你的 AI 工具入口在这里', + 'announcement.project.subtitle': '从这里连接模型、找回对话、查看用量,并把常用维护动作放到一个工作台里。', + 'announcement.project.features.aria': 'Codex Mate 功能概览', + 'announcement.project.feature.config.title': '连接模型', + 'announcement.project.feature.config.meta': '配置常用 AI 工具的模型和服务地址。', + 'announcement.project.feature.sessions.title': '找回对话', + 'announcement.project.feature.sessions.meta': '按来源搜索、导出或清理历史会话。', + 'announcement.project.feature.usage.title': '查看花费', + 'announcement.project.feature.usage.meta': '看最近和长期的本地用量趋势。', + 'announcement.project.feature.tasks.title': '跟踪任务', + 'announcement.project.feature.tasks.meta': '查看计划、队列和运行记录。', + 'announcement.project.feature.skills.title': '复用工作流', + 'announcement.project.feature.skills.meta': '管理 Skills 和提示词模板。', + 'announcement.project.feature.data.title': '整理数据', + 'announcement.project.feature.data.meta': '处理备份、导入、回收站和缓存。', + 'announcement.project.status.aria': '当前工作台状态', + 'announcement.project.status.provider': 'Provider', + 'announcement.project.status.model': '当前模型', + 'announcement.project.status.cacheFiles': '缓存文件', + 'announcement.project.cache.title': '高级维护状态', + 'announcement.project.cache.meta': '仅在需要排查或同步时展开;日常使用可以忽略。', + 'announcement.project.cache.files': '缓存文件', + 'announcement.project.cache.providers': 'Provider 摘要', + 'announcement.project.cache.groups': '缓存分组', + 'announcement.project.cache.groupList': 'Provider 缓存分组摘要', + 'announcement.project.cache.groupSummary': '{files} 文件 · {providers} provider', + 'announcement.project.cache.sync': '同步缓存', + 'announcement.project.cache.refresh': '刷新摘要', + 'announcement.project.cache.details': '查看缓存详情', 'settings.language.title': '语言', 'settings.language.meta': '选择 Web UI 的显示语言', 'settings.language.label': '界面语言', @@ -1095,7 +1128,7 @@ const zh = Object.freeze({ 'settings.sharePrefix.title': '分享命令前缀', 'settings.sharePrefix.meta': '影响 Web UI 里“复制分享命令”的前缀', 'settings.sharePrefix.label': '前缀', - 'settings.sharePrefix.hint': '默认走项目内 npm start,也可切到全局 codexmate。该设置会缓存到浏览器本地。', + 'settings.sharePrefix.hint': '默认走项目内 npm start,也可切到全局 codexmate。该设置会写入 ~/.codexmate/preferences.json。', 'settings.backup.title': '数据备份', 'settings.backup.meta': '导出 / 导入 Claude 与 Codex 配置', 'settings.claude.title': 'Claude 配置', @@ -1109,6 +1142,34 @@ 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.sync': '同步缓存记录', + 'settings.providerCache.syncing': '同步中...', + 'settings.providerCache.loading': '加载中...', + 'settings.providerCache.hint': '只读展示缓存文件,敏感字段会自动遮罩。同步会把当前 provider 配置写入 ~/.codexmate 缓存文件。', + 'modal.providerCache.title': 'Provider 缓存记录', + 'modal.providerCache.root': '缓存目录', + 'modal.providerCache.refresh': '刷新', + 'modal.providerCache.refreshing': '刷新中...', + 'modal.providerCache.sync': '同步', + 'modal.providerCache.syncing': '同步中...', + 'modal.providerCache.syncSucceeded': '已同步 {count} 个 provider 到 {fileCount} 个缓存文件', + 'modal.providerCache.syncFailed': '同步缓存记录失败', + 'modal.providerCache.noSyncableProviders': '没有可同步的 provider', + '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': '错误详情', + 'modal.providerCache.loadFailed': '加载缓存记录失败', + 'settings.trashConfig.title': '回收站配置', 'settings.trashConfig.meta': '回收站开关与自动清理天数', 'settings.deleteBehavior.title': '会话删除行为', diff --git a/web-ui/partials/index/layout-header.html b/web-ui/partials/index/layout-header.html index a8a9f8ec..585c7f3d 100644 --- a/web-ui/partials/index/layout-header.html +++ b/web-ui/partials/index/layout-header.html @@ -385,11 +385,24 @@
- +
+ + +
diff --git a/web-ui/partials/index/modals-basic.html b/web-ui/partials/index/modals-basic.html index 382fa4fe..d4e4fcdb 100644 --- a/web-ui/partials/index/modals-basic.html +++ b/web-ui/partials/index/modals-basic.html @@ -134,6 +134,188 @@ + + + + + + -
+
{{ t('sessions.loadingList') }}
-
+
{{ t('sessions.empty') }}
diff --git a/web-ui/partials/index/panel-settings.html b/web-ui/partials/index/panel-settings.html index a94ae28b..866f7f0b 100644 --- a/web-ui/partials/index/panel-settings.html +++ b/web-ui/partials/index/panel-settings.html @@ -166,6 +166,26 @@ +
+
+
+
{{ t('settings.providerCache.title') }}
+

{{ t('settings.providerCache.meta') }}

+

{{ t('settings.providerCache.hint') }}

+

{{ providerCacheSyncMessage }}

+

{{ providerCacheError }}

+
+
+
+ + +
+
+
diff --git a/web-ui/res/web-ui-render.precompiled.js b/web-ui/res/web-ui-render.precompiled.js index defcb650..1b7837d7 100644 --- a/web-ui/res/web-ui-render.precompiled.js +++ b/web-ui/res/web-ui-render.precompiled.js @@ -518,12 +518,36 @@ return function render(_ctx, _cache) { ]) ]), _createElementVNode("div", { class: "side-rail-lang" }, [ - _createElementVNode("button", { - type: "button", - class: "language-settings-link", - "aria-label": _ctx.t('settings.language.sideLabel', { language: _ctx.currentLanguageLabel() }), - onClick: _ctx.openLanguageSettings - }, _toDisplayString(_ctx.t('settings.language.sideLabel', { language: _ctx.currentLanguageLabel() })), 9 /* TEXT, PROPS */, ["aria-label", "onClick"]) + _createElementVNode("div", { class: "side-rail-lang-actions" }, [ + _createElementVNode("button", { + type: "button", + class: "language-settings-link", + "aria-label": _ctx.t('settings.language.sideLabel', { language: _ctx.currentLanguageLabel() }), + onClick: _ctx.openLanguageSettings + }, _toDisplayString(_ctx.t('settings.language.sideLabel', { language: _ctx.currentLanguageLabel() })), 9 /* TEXT, PROPS */, ["aria-label", "onClick"]), + _createElementVNode("button", { + type: "button", + class: "side-announcement-button", + "aria-label": _ctx.t('announcement.providerCache.open'), + title: _ctx.t('announcement.providerCache.open'), + onClick: _ctx.openProviderCacheAnnouncementModal + }, [ + (_openBlock(), _createElementBlock("svg", { + viewBox: "0 0 20 20", + fill: "none", + stroke: "currentColor", + "stroke-width": "1.7", + "stroke-linecap": "round", + "stroke-linejoin": "round", + width: "16", + height: "16", + "aria-hidden": "true" + }, [ + _createElementVNode("path", { d: "M15 8.5a5 5 0 0 0-10 0c0 2.75-1 4.25-2 5.5h14c-1-1.25-2-2.75-2-5.5Z" }), + _createElementVNode("path", { d: "M8 16a2 2 0 0 0 4 0" }) + ])) + ], 8 /* PROPS */, ["aria-label", "title", "onClick"]) + ]) ]) ])) : _createCommentVNode("v-if", true), @@ -2891,12 +2915,12 @@ return function render(_ctx, _cache) { ], 8 /* PROPS */, ["aria-label"])) : _createCommentVNode("v-if", true) ]), - (_ctx.sessionsLoading) + (_ctx.sessionsLoading && _ctx.sessionsList.length === 0) ? (_openBlock(), _createElementBlock("div", { key: 0, class: "state-message" }, _toDisplayString(_ctx.t('sessions.loadingList')), 1 /* TEXT */)) - : (_ctx.sessionsList.length === 0) + : (!_ctx.sessionsLoading && _ctx.sessionsList.length === 0) ? (_openBlock(), _createElementBlock("div", { key: 1, class: "session-empty" @@ -4913,6 +4937,42 @@ return function render(_ctx, _cache) { onChange: _ctx.handleCodexImportChange }, null, 40 /* PROPS, NEED_HYDRATION */, ["onChange"]) ], 8 /* PROPS */, ["aria-label"]), + _createElementVNode("section", { + class: "settings-card", + "aria-label": _ctx.t('settings.providerCache.title') + }, [ + _createElementVNode("div", { class: "settings-card-main" }, [ + _createElementVNode("div", { class: "settings-card-content" }, [ + _createElementVNode("div", { class: "settings-card-title" }, _toDisplayString(_ctx.t('settings.providerCache.title')), 1 /* TEXT */), + _createElementVNode("p", { class: "settings-card-desc" }, _toDisplayString(_ctx.t('settings.providerCache.meta')), 1 /* TEXT */), + _createElementVNode("p", { class: "settings-card-hint" }, _toDisplayString(_ctx.t('settings.providerCache.hint')), 1 /* TEXT */), + (_ctx.providerCacheSyncMessage) + ? (_openBlock(), _createElementBlock("p", { + key: 0, + class: "settings-card-hint provider-cache-sync-message" + }, _toDisplayString(_ctx.providerCacheSyncMessage), 1 /* TEXT */)) + : _createCommentVNode("v-if", true), + (_ctx.providerCacheError) + ? (_openBlock(), _createElementBlock("p", { + key: 1, + class: "settings-card-hint form-error" + }, _toDisplayString(_ctx.providerCacheError), 1 /* TEXT */)) + : _createCommentVNode("v-if", true) + ]) + ]), + _createElementVNode("div", { class: "settings-card-actions" }, [ + _createElementVNode("button", { + class: "settings-card-action", + onClick: $event => (_ctx.openProviderCacheModal({ forceRefresh: true })), + disabled: _ctx.providerCacheLoading || _ctx.providerCacheSyncing + }, _toDisplayString(_ctx.providerCacheLoading ? _ctx.t('settings.providerCache.loading') : _ctx.t('settings.providerCache.open')), 9 /* TEXT, PROPS */, ["onClick", "disabled"]), + _createElementVNode("button", { + class: "settings-card-action", + onClick: _ctx.syncProviderCacheRecords, + disabled: _ctx.providerCacheLoading || _ctx.providerCacheSyncing + }, _toDisplayString(_ctx.providerCacheSyncing ? _ctx.t('settings.providerCache.syncing') : _ctx.t('settings.providerCache.sync')), 9 /* TEXT, PROPS */, ["onClick", "disabled"]) + ]) + ], 8 /* PROPS */, ["aria-label"]), _createElementVNode("section", { class: "settings-card", "aria-label": _ctx.t('settings.trashConfig.title') @@ -6633,10 +6693,327 @@ return function render(_ctx, _cache) { ]) ], 8 /* PROPS */, ["onClick"])) : _createCommentVNode("v-if", true), + _createCommentVNode(" 项目功能公告模态框 "), + (_ctx.showProviderCacheAnnouncementModal) + ? (_openBlock(), _createElementBlock("div", { + key: 7, + class: "modal-overlay project-announcement-overlay", + onClick: _withModifiers(_ctx.closeProviderCacheAnnouncementModal, ["self"]) + }, [ + _createElementVNode("div", { + class: "modal provider-cache-announcement-modal project-announcement-modal", + role: "dialog", + "aria-modal": "true", + "aria-labelledby": "provider-cache-announcement-title" + }, [ + _createElementVNode("button", { + type: "button", + class: "project-announcement-close", + "aria-label": _ctx.t('announcement.project.closeAria'), + onClick: _ctx.closeProviderCacheAnnouncementModal + }, "×", 8 /* PROPS */, ["aria-label", "onClick"]), + _createElementVNode("div", { class: "project-announcement-header" }, [ + _createElementVNode("div", { class: "project-announcement-eyebrow" }, _toDisplayString(_ctx.t('announcement.project.eyebrow')), 1 /* TEXT */), + _createElementVNode("div", { + class: "modal-title project-announcement-title", + id: "provider-cache-announcement-title" + }, _toDisplayString(_ctx.t('announcement.project.title')), 1 /* TEXT */), + _createElementVNode("p", { class: "provider-cache-announcement-copy provider-cache-announcement-lede" }, _toDisplayString(_ctx.t('announcement.project.subtitle')), 1 /* TEXT */) + ]), + _createElementVNode("div", { + class: "project-announcement-feature-grid", + "aria-label": _ctx.t('announcement.project.features.aria') + }, [ + _createElementVNode("article", { class: "project-announcement-feature-card" }, [ + _createElementVNode("div", { class: "project-announcement-feature-title" }, _toDisplayString(_ctx.t('announcement.project.feature.config.title')), 1 /* TEXT */), + _createElementVNode("div", { class: "project-announcement-feature-meta" }, _toDisplayString(_ctx.t('announcement.project.feature.config.meta')), 1 /* TEXT */) + ]), + _createElementVNode("article", { class: "project-announcement-feature-card" }, [ + _createElementVNode("div", { class: "project-announcement-feature-title" }, _toDisplayString(_ctx.t('announcement.project.feature.sessions.title')), 1 /* TEXT */), + _createElementVNode("div", { class: "project-announcement-feature-meta" }, _toDisplayString(_ctx.t('announcement.project.feature.sessions.meta')), 1 /* TEXT */) + ]), + _createElementVNode("article", { class: "project-announcement-feature-card" }, [ + _createElementVNode("div", { class: "project-announcement-feature-title" }, _toDisplayString(_ctx.t('announcement.project.feature.usage.title')), 1 /* TEXT */), + _createElementVNode("div", { class: "project-announcement-feature-meta" }, _toDisplayString(_ctx.t('announcement.project.feature.usage.meta')), 1 /* TEXT */) + ]), + _createElementVNode("article", { class: "project-announcement-feature-card" }, [ + _createElementVNode("div", { class: "project-announcement-feature-title" }, _toDisplayString(_ctx.t('announcement.project.feature.tasks.title')), 1 /* TEXT */), + _createElementVNode("div", { class: "project-announcement-feature-meta" }, _toDisplayString(_ctx.t('announcement.project.feature.tasks.meta')), 1 /* TEXT */) + ]), + _createElementVNode("article", { class: "project-announcement-feature-card" }, [ + _createElementVNode("div", { class: "project-announcement-feature-title" }, _toDisplayString(_ctx.t('announcement.project.feature.skills.title')), 1 /* TEXT */), + _createElementVNode("div", { class: "project-announcement-feature-meta" }, _toDisplayString(_ctx.t('announcement.project.feature.skills.meta')), 1 /* TEXT */) + ]), + _createElementVNode("article", { class: "project-announcement-feature-card" }, [ + _createElementVNode("div", { class: "project-announcement-feature-title" }, _toDisplayString(_ctx.t('announcement.project.feature.data.title')), 1 /* TEXT */), + _createElementVNode("div", { class: "project-announcement-feature-meta" }, _toDisplayString(_ctx.t('announcement.project.feature.data.meta')), 1 /* TEXT */) + ]) + ], 8 /* PROPS */, ["aria-label"]), + _createElementVNode("details", { + class: "project-announcement-cache-section", + "aria-labelledby": "project-announcement-cache-title" + }, [ + _createElementVNode("summary", { class: "project-announcement-section-head" }, [ + _createElementVNode("span", null, [ + _createElementVNode("span", { + class: "project-announcement-section-title", + id: "project-announcement-cache-title" + }, _toDisplayString(_ctx.t('announcement.project.cache.title')), 1 /* TEXT */), + _createElementVNode("span", { class: "project-announcement-section-meta" }, _toDisplayString(_ctx.t('announcement.project.cache.meta')), 1 /* TEXT */) + ]), + (_ctx.getProviderCacheAnnouncementSummary().loadedAt) + ? (_openBlock(), _createElementBlock("span", { + key: 0, + class: "provider-cache-loaded-at" + }, _toDisplayString(_ctx.t('modal.providerCache.loadedAt')) + ": " + _toDisplayString(_ctx.getProviderCacheAnnouncementSummary().loadedAt), 1 /* TEXT */)) + : _createCommentVNode("v-if", true) + ]), + (_ctx.providerCacheError) + ? (_openBlock(), _createElementBlock("div", { + key: 0, + class: "state-message error provider-cache-error" + }, _toDisplayString(_ctx.providerCacheError), 1 /* TEXT */)) + : _createCommentVNode("v-if", true), + (_ctx.providerCacheSyncMessage) + ? (_openBlock(), _createElementBlock("div", { + key: 1, + class: "state-message provider-cache-sync-message" + }, _toDisplayString(_ctx.providerCacheSyncMessage), 1 /* TEXT */)) + : _createCommentVNode("v-if", true), + (_ctx.providerCacheLoading && !_ctx.providerCacheLoadedOnce) + ? (_openBlock(), _createElementBlock("div", { + key: 2, + class: "state-message" + }, _toDisplayString(_ctx.t('modal.providerCache.loading')), 1 /* TEXT */)) + : _createCommentVNode("v-if", true), + _createElementVNode("div", { class: "provider-cache-announcement-body" }, [ + _createElementVNode("div", { + class: "project-announcement-mini-status", + "aria-label": _ctx.t('announcement.project.status.aria') + }, [ + _createElementVNode("span", null, [ + _createTextVNode(_toDisplayString(_ctx.t('announcement.project.status.provider')) + ": ", 1 /* TEXT */), + _createElementVNode("strong", null, _toDisplayString(_ctx.currentProvider || _ctx.t('common.notSelected')), 1 /* TEXT */) + ]), + _createElementVNode("span", null, [ + _createTextVNode(_toDisplayString(_ctx.t('announcement.project.status.model')) + ": ", 1 /* TEXT */), + _createElementVNode("strong", null, _toDisplayString(_ctx.currentModel || _ctx.t('common.notSelected')), 1 /* TEXT */) + ]), + _createElementVNode("span", null, [ + _createTextVNode(_toDisplayString(_ctx.t('announcement.project.status.cacheFiles')) + ": ", 1 /* TEXT */), + _createElementVNode("strong", null, _toDisplayString(_ctx.getProviderCacheAnnouncementSummary().fileCount), 1 /* TEXT */) + ]) + ], 8 /* PROPS */, ["aria-label"]), + _createElementVNode("div", { class: "provider-cache-summary-grid" }, [ + _createElementVNode("div", { class: "provider-cache-summary-card" }, [ + _createElementVNode("span", { class: "provider-cache-summary-label" }, _toDisplayString(_ctx.t('announcement.project.cache.files')), 1 /* TEXT */), + _createElementVNode("strong", null, _toDisplayString(_ctx.getProviderCacheAnnouncementSummary().fileCount), 1 /* TEXT */) + ]), + _createElementVNode("div", { class: "provider-cache-summary-card" }, [ + _createElementVNode("span", { class: "provider-cache-summary-label" }, _toDisplayString(_ctx.t('announcement.project.cache.providers')), 1 /* TEXT */), + _createElementVNode("strong", null, _toDisplayString(_ctx.getProviderCacheAnnouncementSummary().providerCount), 1 /* TEXT */) + ]), + _createElementVNode("div", { class: "provider-cache-summary-card" }, [ + _createElementVNode("span", { class: "provider-cache-summary-label" }, _toDisplayString(_ctx.t('announcement.project.cache.groups')), 1 /* TEXT */), + _createElementVNode("strong", null, _toDisplayString(_ctx.getProviderCacheAnnouncementSummary().groupCount), 1 /* TEXT */) + ]) + ]), + _createElementVNode("div", { + class: "provider-cache-announcement-list", + "aria-label": _ctx.t('announcement.project.cache.groupList') + }, [ + (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.getProviderCacheAnnouncementGroups(), (group) => { + return (_openBlock(), _createElementBlock("div", { + key: group.key, + class: "provider-cache-announcement-row" + }, [ + _createElementVNode("span", null, _toDisplayString(group.label), 1 /* TEXT */), + _createElementVNode("span", null, _toDisplayString(_ctx.t('announcement.project.cache.groupSummary', { files: group.existingCount, providers: group.providerCount })), 1 /* TEXT */) + ])) + }), 128 /* KEYED_FRAGMENT */)) + ], 8 /* PROPS */, ["aria-label"]), + _createElementVNode("div", { class: "project-announcement-cache-actions" }, [ + _createElementVNode("button", { + type: "button", + class: "btn-tool btn-tool-compact", + onClick: _ctx.syncProviderCacheRecords, + disabled: _ctx.providerCacheLoading || _ctx.providerCacheSyncing + }, _toDisplayString(_ctx.providerCacheSyncing ? _ctx.t('modal.providerCache.syncing') : _ctx.t('announcement.project.cache.sync')), 9 /* TEXT, PROPS */, ["onClick", "disabled"]), + _createElementVNode("button", { + type: "button", + class: "btn-tool btn-tool-compact", + onClick: _ctx.loadProviderCacheRecords, + disabled: _ctx.providerCacheLoading + }, _toDisplayString(_ctx.providerCacheLoading ? _ctx.t('modal.providerCache.refreshing') : _ctx.t('announcement.project.cache.refresh')), 9 /* TEXT, PROPS */, ["onClick", "disabled"]), + _createElementVNode("button", { + type: "button", + class: "btn-tool btn-tool-compact", + onClick: _ctx.openProviderCacheDetailsFromAnnouncement + }, _toDisplayString(_ctx.t('announcement.project.cache.details')), 9 /* TEXT, PROPS */, ["onClick"]) + ]) + ]) + ]), + _createElementVNode("div", { class: "btn-group provider-cache-footer project-announcement-footer" }, [ + _createElementVNode("button", { + class: "btn btn-confirm", + onClick: _ctx.closeProviderCacheAnnouncementModal + }, _toDisplayString(_ctx.t('announcement.project.primaryAction')), 9 /* TEXT, PROPS */, ["onClick"]) + ]) + ]) + ], 8 /* PROPS */, ["onClick"])) + : _createCommentVNode("v-if", true), + _createCommentVNode(" Provider 缓存记录模态框 "), + (_ctx.showProviderCacheModal) + ? (_openBlock(), _createElementBlock("div", { + key: 8, + class: "modal-overlay", + onClick: _withModifiers(_ctx.closeProviderCacheModal, ["self"]) + }, [ + _createElementVNode("div", { + class: "modal modal-wide provider-cache-modal", + role: "dialog", + "aria-modal": "true", + "aria-labelledby": "provider-cache-modal-title" + }, [ + _createElementVNode("div", { + class: "modal-title", + id: "provider-cache-modal-title" + }, _toDisplayString(_ctx.t('modal.providerCache.title')), 1 /* TEXT */), + _createElementVNode("div", { class: "form-hint provider-cache-root" }, _toDisplayString(_ctx.t('modal.providerCache.root')) + ": " + _toDisplayString((_ctx.providerCacheRecords && _ctx.providerCacheRecords.root) || '~/.codexmate'), 1 /* TEXT */), + _createElementVNode("div", { class: "provider-cache-toolbar" }, [ + _createElementVNode("button", { + type: "button", + class: "btn-tool btn-tool-compact", + onClick: _ctx.loadProviderCacheRecords, + disabled: _ctx.providerCacheLoading + }, _toDisplayString(_ctx.providerCacheLoading ? _ctx.t('modal.providerCache.refreshing') : _ctx.t('modal.providerCache.refresh')), 9 /* TEXT, PROPS */, ["onClick", "disabled"]), + _createElementVNode("button", { + type: "button", + class: "btn-tool btn-tool-compact", + onClick: _ctx.syncProviderCacheRecords, + disabled: _ctx.providerCacheLoading || _ctx.providerCacheSyncing + }, _toDisplayString(_ctx.providerCacheSyncing ? _ctx.t('modal.providerCache.syncing') : _ctx.t('modal.providerCache.sync')), 9 /* TEXT, PROPS */, ["onClick", "disabled"]), + (_ctx.providerCacheLoadedAt) + ? (_openBlock(), _createElementBlock("span", { + key: 0, + class: "provider-cache-loaded-at" + }, _toDisplayString(_ctx.t('modal.providerCache.loadedAt')) + ": " + _toDisplayString(_ctx.providerCacheLoadedAt), 1 /* TEXT */)) + : _createCommentVNode("v-if", true) + ]), + (_ctx.providerCacheError) + ? (_openBlock(), _createElementBlock("div", { + key: 0, + class: "state-message error provider-cache-error" + }, _toDisplayString(_ctx.providerCacheError), 1 /* TEXT */)) + : _createCommentVNode("v-if", true), + (_ctx.providerCacheSyncMessage) + ? (_openBlock(), _createElementBlock("div", { + key: 1, + class: "state-message provider-cache-sync-message" + }, _toDisplayString(_ctx.providerCacheSyncMessage), 1 /* TEXT */)) + : _createCommentVNode("v-if", true), + (_ctx.providerCacheLoading && !_ctx.providerCacheLoadedOnce) + ? (_openBlock(), _createElementBlock("div", { + key: 2, + class: "state-message" + }, _toDisplayString(_ctx.t('modal.providerCache.loading')), 1 /* TEXT */)) + : (_openBlock(), _createElementBlock("div", { + key: 3, + class: "provider-cache-body" + }, [ + _createElementVNode("div", { class: "provider-cache-groups" }, [ + (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.getProviderCacheGroups(), (group) => { + return (_openBlock(), _createElementBlock("section", { + key: group.key, + class: "provider-cache-group" + }, [ + _createElementVNode("div", { class: "provider-cache-group-header" }, [ + _createElementVNode("div", { class: "provider-cache-group-title" }, _toDisplayString(group.label), 1 /* TEXT */), + _createElementVNode("div", { class: "provider-cache-group-meta" }, _toDisplayString(_ctx.t('modal.providerCache.groupMeta', { count: group.existingCount || 0 })), 1 /* TEXT */) + ]), + (!_ctx.hasProviderCacheExistingFiles(group)) + ? (_openBlock(), _createElementBlock("div", { + key: 0, + class: "provider-cache-empty" + }, _toDisplayString(_ctx.t('modal.providerCache.empty')), 1 /* TEXT */)) + : (_openBlock(), _createElementBlock("div", { + key: 1, + class: "provider-cache-file-list" + }, [ + (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.getProviderCacheExistingFiles(group), (file) => { + return (_openBlock(), _createElementBlock("article", { + key: _ctx.getProviderCacheFileKey(file), + class: "provider-cache-file" + }, [ + _createElementVNode("div", { class: "provider-cache-file-header" }, [ + _createElementVNode("div", null, [ + _createElementVNode("div", { class: "provider-cache-file-name" }, _toDisplayString(file.name), 1 /* TEXT */), + _createElementVNode("div", { class: "provider-cache-file-summary" }, _toDisplayString(_ctx.getProviderCacheFileSummary(file)), 1 /* TEXT */) + ]), + _createElementVNode("div", { class: "provider-cache-file-meta" }, [ + _createElementVNode("span", null, _toDisplayString(_ctx.formatProviderCacheFileSize(file.size)), 1 /* TEXT */), + (file.mtime) + ? (_openBlock(), _createElementBlock("span", { key: 0 }, _toDisplayString(file.mtime), 1 /* TEXT */)) + : _createCommentVNode("v-if", true) + ]) + ]), + _createElementVNode("div", { class: "provider-cache-file-path" }, _toDisplayString(_ctx.getProviderCacheFilePath(file)), 1 /* TEXT */), + (_ctx.hasProviderCacheProviders(file)) + ? (_openBlock(), _createElementBlock("div", { + key: 0, + class: "provider-cache-provider-list" + }, [ + (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.getProviderCacheFileProviders(file), (provider, providerIndex) => { + return (_openBlock(), _createElementBlock("details", { + key: provider.name || (_ctx.getProviderCacheFileKey(file) + ':' + providerIndex), + class: "provider-cache-provider", + open: "" + }, [ + _createElementVNode("summary", { class: "provider-cache-provider-summary" }, [ + _createElementVNode("span", { class: "provider-cache-provider-name" }, _toDisplayString(provider.name || 'provider'), 1 /* TEXT */), + _createElementVNode("span", { class: "provider-cache-provider-badges" }, [ + (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.getProviderCacheProviderMeta(provider), (item) => { + return (_openBlock(), _createElementBlock("span", { + key: item.label, + class: "provider-cache-provider-badge" + }, _toDisplayString(item.label) + ": " + _toDisplayString(item.value), 1 /* TEXT */)) + }), 128 /* KEYED_FRAGMENT */)) + ]) + ]), + _createElementVNode("pre", { class: "provider-cache-json provider-cache-json-compact" }, _toDisplayString(_ctx.getProviderCacheProviderText(provider)), 1 /* TEXT */) + ])) + }), 128 /* KEYED_FRAGMENT */)) + ])) + : _createCommentVNode("v-if", true), + _createElementVNode("details", { + class: "provider-cache-raw", + open: !_ctx.hasProviderCacheProviders(file) || file.ok === false + }, [ + _createElementVNode("summary", null, _toDisplayString(file.ok === false ? _ctx.t('modal.providerCache.errorDetails') : _ctx.t('modal.providerCache.rawJson')), 1 /* TEXT */), + _createElementVNode("pre", { + class: _normalizeClass(['provider-cache-json', { error: file.ok === false }]) + }, _toDisplayString(_ctx.getProviderCacheRecordText(file)), 3 /* TEXT, CLASS */) + ], 8 /* PROPS */, ["open"]) + ])) + }), 128 /* KEYED_FRAGMENT */)) + ])) + ])) + }), 128 /* KEYED_FRAGMENT */)) + ]) + ])), + _createElementVNode("div", { class: "btn-group provider-cache-footer" }, [ + _createElementVNode("button", { + class: "btn btn-confirm", + onClick: _ctx.closeProviderCacheModal + }, _toDisplayString(_ctx.t('common.close')), 9 /* TEXT, PROPS */, ["onClick"]) + ]) + ]) + ], 8 /* PROPS */, ["onClick"])) + : _createCommentVNode("v-if", true), _createCommentVNode(" 添加Claude配置模态框 "), (_ctx.showClaudeConfigModal) ? (_openBlock(), _createElementBlock("div", { - key: 7, + key: 9, class: "modal-overlay", onClick: _withModifiers(_ctx.closeClaudeConfigModal, ["self"]) }, [ @@ -6788,7 +7165,7 @@ return function render(_ctx, _cache) { _createCommentVNode(" 编辑Claude配置模态框 "), (_ctx.showEditConfigModal) ? (_openBlock(), _createElementBlock("div", { - key: 8, + key: 10, class: "modal-overlay", onClick: _withModifiers(_ctx.closeEditConfigModal, ["self"]) }, [ @@ -6941,7 +7318,7 @@ return function render(_ctx, _cache) { _createCommentVNode(" Codex bridge pool modal "), (_ctx.showCodexBridgePoolModal) ? (_openBlock(), _createElementBlock("div", { - key: 9, + key: 11, class: "modal-overlay", onClick: _withModifiers($event => (_ctx.showCodexBridgePoolModal = false), ["self"]) }, [ @@ -7034,7 +7411,7 @@ return function render(_ctx, _cache) { : _createCommentVNode("v-if", true), (_ctx.showClaudeBridgePoolModal) ? (_openBlock(), _createElementBlock("div", { - key: 10, + key: 12, class: "modal-overlay", onClick: _withModifiers($event => (_ctx.showClaudeBridgePoolModal = false), ["self"]) }, [ @@ -7128,7 +7505,7 @@ return function render(_ctx, _cache) { _createCommentVNode(" Webhook settings modal "), (_ctx.showWebhookModal) ? (_openBlock(), _createElementBlock("div", { - key: 11, + key: 13, class: "modal-overlay", onClick: _withModifiers(_ctx.closeWebhookModal, ["self"]) }, [ @@ -7201,7 +7578,7 @@ return function render(_ctx, _cache) { : _createCommentVNode("v-if", true), (_ctx.showOpenclawConfigModal) ? (_openBlock(), _createElementBlock("div", { - key: 12, + key: 14, class: "modal-overlay", onClick: _withModifiers($event => (!(_ctx.openclawSaving || _ctx.openclawApplying) && _ctx.closeOpenclawConfigModal()), ["self"]) }, [ @@ -7913,7 +8290,7 @@ return function render(_ctx, _cache) { : _createCommentVNode("v-if", true), (_ctx.showConfigTemplateModal) ? (_openBlock(), _createElementBlock("div", { - key: 13, + key: 15, class: "modal-overlay", onClick: _withModifiers($event => (!_ctx.configTemplateApplying && _ctx.closeConfigTemplateModal()), ["self"]) }, [ @@ -8058,7 +8435,7 @@ return function render(_ctx, _cache) { : _createCommentVNode("v-if", true), (_ctx.showAgentsModal) ? (_openBlock(), _createElementBlock("div", { - key: 14, + key: 16, class: "modal-overlay", onClick: _withModifiers(_ctx.closeAgentsModal, ["self"]) }, [ @@ -8225,7 +8602,7 @@ return function render(_ctx, _cache) { : _createCommentVNode("v-if", true), (_ctx.showSkillsModal) ? (_openBlock(), _createElementBlock("div", { - key: 15, + key: 17, class: "modal-overlay", onClick: _withModifiers(_ctx.closeSkillsModal, ["self"]) }, [ @@ -8523,7 +8900,7 @@ return function render(_ctx, _cache) { }, null, 40 /* PROPS, NEED_HYDRATION */, ["onChange"]), (_ctx.showHealthCheckModal) ? (_openBlock(), _createElementBlock("div", { - key: 16, + key: 18, class: "modal-overlay", onClick: _withModifiers($event => (_ctx.showHealthCheckModal = false), ["self"]) }, [ @@ -8614,7 +8991,7 @@ return function render(_ctx, _cache) { : _createCommentVNode("v-if", true), (_ctx.showConfirmDialog) ? (_openBlock(), _createElementBlock("div", { - key: 17, + key: 19, class: "modal-overlay", onClick: _withModifiers(_ctx.closeConfirmDialog, ["self"]) }, [ @@ -8652,7 +9029,7 @@ return function render(_ctx, _cache) { _createCommentVNode(" Toast "), (_ctx.message) ? (_openBlock(), _createElementBlock("div", { - key: 18, + key: 20, class: _normalizeClass(['toast', _ctx.messageType]), role: "status", "aria-live": "polite", diff --git a/web-ui/styles/layout-shell.css b/web-ui/styles/layout-shell.css index 6271ba32..d8928582 100644 --- a/web-ui/styles/layout-shell.css +++ b/web-ui/styles/layout-shell.css @@ -844,11 +844,46 @@ body::after { transform: translateY(0); } -.side-rail-lang .language-settings-link { +.side-rail-lang-actions { width: calc(100% - 20px); + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.side-rail-lang .language-settings-link { + min-width: 0; + flex: 1 1 auto; box-shadow: none; } +.side-announcement-button { + flex: 0 0 36px; + width: 36px; + height: 36px; + display: inline-grid; + place-items: center; + border-radius: 999px; + border: 1px solid rgba(163, 146, 134, 0.28); + background: rgba(255, 253, 252, 0.92); + color: var(--color-text-secondary); + box-shadow: none; + cursor: pointer; + transition: background-color var(--transition-fast) var(--ease-smooth), border-color var(--transition-fast) var(--ease-smooth), color var(--transition-fast) var(--ease-smooth), transform var(--transition-fast) var(--ease-smooth); +} + +.side-announcement-button:hover { + border-color: rgba(200, 121, 99, 0.58); + background: rgba(255, 255, 255, 0.96); + color: var(--color-text-primary); + transform: translateY(-1px); +} + +.side-announcement-button:active { + transform: translateY(0); +} + .lang-fab .language-settings-link { pointer-events: auto; position: relative; diff --git a/web-ui/styles/settings-panel.css b/web-ui/styles/settings-panel.css index 1eb92f67..fd3ce96e 100644 --- a/web-ui/styles/settings-panel.css +++ b/web-ui/styles/settings-panel.css @@ -464,3 +464,401 @@ .settings-language-select { max-width: 280px; } + +/* ---- Provider cache records ---- */ +.project-announcement-overlay { + background: + radial-gradient(circle at 50% 15%, rgba(255, 248, 241, 0.12), rgba(255, 248, 241, 0) 34%), + linear-gradient(to bottom, rgba(31, 26, 23, 0.48) 0%, rgba(31, 26, 23, 0.68) 100%); + backdrop-filter: blur(10px) saturate(120%); + -webkit-backdrop-filter: blur(10px) saturate(120%); +} + +.provider-cache-announcement-modal { + position: relative; + width: min(720px, calc(100vw - 32px)); + max-width: min(720px, calc(100vw - 32px)); + max-height: min(86vh, 900px); + display: flex; + flex-direction: column; + overflow: hidden; + background: rgba(255, 253, 250, 0.985); + border-color: rgba(137, 111, 94, 0.22); + font-size: 12px; +} + +.project-announcement-close { + position: absolute; + top: 12px; + right: 12px; + width: 30px; + height: 30px; + display: grid; + place-items: center; + border: 1px solid rgba(137, 111, 94, 0.18); + border-radius: 999px; + background: rgba(255, 255, 255, 0.74); + color: var(--color-text-tertiary); + font-size: 18px; + line-height: 1; + cursor: pointer; +} + +.project-announcement-close:hover { + color: var(--color-text-primary); + border-color: rgba(200, 121, 99, 0.42); +} + +.project-announcement-header { + padding-right: 36px; +} + +.project-announcement-eyebrow { + margin-bottom: 6px; + color: var(--color-brand); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.project-announcement-title { + margin-bottom: 8px; + font-size: 22px; + line-height: 1.2; +} + +.provider-cache-announcement-lede { + max-width: 620px; +} + +.project-announcement-feature-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin-top: 16px; +} + +.project-announcement-feature-card { + border: 1px solid rgba(137, 111, 94, 0.13); + border-radius: 14px; + background: rgba(255, 253, 252, 0.86); + padding: 11px; +} + +.project-announcement-feature-title, +.project-announcement-section-title { + font-weight: 800; + color: var(--color-text-primary); +} + +.project-announcement-feature-meta, +.project-announcement-section-meta { + margin-top: 4px; + color: var(--color-text-secondary); + font-size: 11px; + line-height: 1.45; +} + +.project-announcement-mini-status { + display: flex; + flex-wrap: wrap; + gap: 5px 10px; + padding: 7px 9px; + border-radius: 10px; + background: rgba(255, 253, 252, 0.52); + color: var(--color-text-tertiary); + font-size: 10px; +} + +.project-announcement-mini-status strong { + color: var(--color-text-secondary); + font-weight: 700; + overflow-wrap: anywhere; +} + +.project-announcement-cache-section { + margin-top: 14px; + border: 1px solid rgba(137, 111, 94, 0.10); + border-radius: 13px; + background: rgba(247, 240, 233, 0.22); + overflow: hidden; +} + +.project-announcement-section-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; + padding: 9px 11px; + cursor: pointer; + list-style-position: inside; +} + +.project-announcement-section-head .provider-cache-loaded-at { + flex-shrink: 0; + color: var(--color-text-tertiary); + font-size: 10px; +} + +.provider-cache-announcement-body { + display: flex; + flex-direction: column; + gap: 10px; + padding: 0 11px 11px; +} + +.provider-cache-announcement-copy { + margin: 0; + color: var(--color-text-secondary); + font-size: 12px; + line-height: 1.55; +} + +.provider-cache-summary-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.provider-cache-summary-card { + border: 1px solid rgba(137, 111, 94, 0.12); + border-radius: 12px; + background: rgba(255, 253, 252, 0.72); + padding: 8px 10px; +} + +.provider-cache-summary-label { + display: block; + margin-bottom: 5px; + color: var(--color-text-tertiary); + font-size: 10px; +} + +.provider-cache-summary-card strong { + color: var(--color-text-primary); + font-size: 18px; + line-height: 1; +} + +.provider-cache-announcement-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.provider-cache-announcement-row { + display: flex; + justify-content: space-between; + gap: 12px; + border: 1px solid rgba(137, 111, 94, 0.10); + border-radius: 10px; + background: rgba(255, 253, 252, 0.58); + padding: 7px 9px; + color: var(--color-text-secondary); + font-size: 11px; +} + +.provider-cache-announcement-row span:last-child { + flex-shrink: 0; + color: var(--color-text-tertiary); + font-family: var(--font-family-mono); +} + +.project-announcement-cache-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.project-announcement-footer { + justify-content: flex-end; +} + +.provider-cache-modal { + width: min(1080px, calc(100vw - 32px)); + max-width: min(1080px, calc(100vw - 32px)); + max-height: min(86vh, 920px); + display: flex; + flex-direction: column; +} + +.provider-cache-root, +.provider-cache-loaded-at { + font-family: var(--font-family-mono); + word-break: break-all; +} + +.provider-cache-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px; + margin: 10px 0 12px; +} + +.provider-cache-body { + min-height: 0; + overflow: auto; + padding-right: 4px; +} + +.provider-cache-groups { + display: flex; + flex-direction: column; + gap: 12px; +} + +.provider-cache-group { + border: 1px solid rgba(137, 111, 94, 0.14); + border-radius: 14px; + background: rgba(255, 253, 252, 0.76); + padding: 12px; +} + +.provider-cache-group-header, +.provider-cache-file-header { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; +} + +.provider-cache-group-title, +.provider-cache-file-name, +.provider-cache-provider-name { + font-weight: 700; + color: var(--color-text-primary); +} + +.provider-cache-group-meta, +.provider-cache-file-meta, +.provider-cache-file-path, +.provider-cache-file-summary, +.provider-cache-empty { + font-size: 11px; + color: var(--color-text-tertiary); +} + +.provider-cache-file-summary { + margin-top: 2px; +} + +.provider-cache-file-list { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 10px; +} + +.provider-cache-file { + border: 1px solid rgba(137, 111, 94, 0.12); + border-radius: 14px; + background: rgba(247, 240, 233, 0.44); + padding: 10px; +} + +.provider-cache-file-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; + font-family: var(--font-family-mono); +} + +.provider-cache-file-path { + margin-top: 6px; + font-family: var(--font-family-mono); + word-break: break-all; +} + +.provider-cache-provider-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 10px; + margin-top: 10px; +} + +.provider-cache-provider, +.provider-cache-raw { + border: 1px solid rgba(137, 111, 94, 0.14); + border-radius: 12px; + background: rgba(255, 253, 252, 0.72); + overflow: hidden; +} + +.provider-cache-provider-summary, +.provider-cache-raw summary { + cursor: pointer; + padding: 9px 10px; + list-style-position: inside; +} + +.provider-cache-provider-summary { + display: flex; + flex-direction: column; + gap: 6px; +} + +.provider-cache-provider-badges { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.provider-cache-provider-badge { + max-width: 100%; + padding: 2px 7px; + border-radius: 999px; + background: rgba(200, 121, 99, 0.10); + color: var(--color-text-secondary); + font-family: var(--font-family-mono); + font-size: 10px; + word-break: break-all; +} + +.provider-cache-raw { + margin-top: 10px; +} + +.provider-cache-json { + margin: 8px 0 0; + padding: 10px; + max-height: 260px; + overflow: auto; + border-radius: 12px; + border: 1px solid rgba(137, 111, 94, 0.14); + background: rgba(43, 37, 33, 0.94); + color: #f7efe7; + font-family: var(--font-family-mono); + font-size: 11px; + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.provider-cache-provider .provider-cache-json, +.provider-cache-raw .provider-cache-json { + margin: 0; + border-radius: 0; + border-right: 0; + border-bottom: 0; + border-left: 0; +} + +.provider-cache-json-compact { + max-height: 180px; +} + +.provider-cache-json.error { + color: #ffd7d0; +} + +.provider-cache-footer { + flex-shrink: 0; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid rgba(137, 111, 94, 0.14); + background: rgba(255, 253, 252, 0.92); +}