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('announcement.project.feature.config.title') }}
+ {{ t('announcement.project.feature.config.meta') }}
+
+
+ {{ t('announcement.project.feature.sessions.title') }}
+ {{ t('announcement.project.feature.sessions.meta') }}
+
+
+ {{ t('announcement.project.feature.usage.title') }}
+ {{ t('announcement.project.feature.usage.meta') }}
+
+
+ {{ t('announcement.project.feature.tasks.title') }}
+ {{ t('announcement.project.feature.tasks.meta') }}
+
+
+ {{ t('announcement.project.feature.skills.title') }}
+ {{ t('announcement.project.feature.skills.meta') }}
+
+
+ {{ t('announcement.project.feature.data.title') }}
+ {{ t('announcement.project.feature.data.meta') }}
+
+
+
+
+
+
+ {{ t('announcement.project.cache.title') }}
+ {{ t('announcement.project.cache.meta') }}
+
+ {{ t('modal.providerCache.loadedAt') }}: {{ getProviderCacheAnnouncementSummary().loadedAt }}
+
+
+
+ {{ providerCacheError }}
+
+
+ {{ providerCacheSyncMessage }}
+
+
+ {{ t('modal.providerCache.loading') }}
+
+
+
+ {{ t('announcement.project.status.provider') }}: {{ currentProvider || t('common.notSelected') }}
+ {{ t('announcement.project.status.model') }}: {{ currentModel || t('common.notSelected') }}
+ {{ t('announcement.project.status.cacheFiles') }}: {{ getProviderCacheAnnouncementSummary().fileCount }}
+
+
+
+ {{ t('announcement.project.cache.files') }}
+ {{ getProviderCacheAnnouncementSummary().fileCount }}
+
+
+ {{ t('announcement.project.cache.providers') }}
+ {{ getProviderCacheAnnouncementSummary().providerCount }}
+
+
+ {{ t('announcement.project.cache.groups') }}
+ {{ getProviderCacheAnnouncementSummary().groupCount }}
+
+
+
+
+ {{ group.label }}
+ {{ t('announcement.project.cache.groupSummary', { files: group.existingCount, providers: group.providerCount }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('modal.providerCache.title') }}
+
+ {{ t('modal.providerCache.root') }}: {{ (providerCacheRecords && providerCacheRecords.root) || '~/.codexmate' }}
+
+
+
+
+
+ {{ t('modal.providerCache.loadedAt') }}: {{ providerCacheLoadedAt }}
+
+
+
+ {{ providerCacheError }}
+
+
+ {{ providerCacheSyncMessage }}
+
+
+ {{ t('modal.providerCache.loading') }}
+
+
+
+
+
+
+ {{ t('modal.providerCache.empty') }}
+
+
+
+
+ {{ getProviderCacheFilePath(file) }}
+
+
+
+
+ {{ provider.name || 'provider' }}
+
+
+ {{ item.label }}: {{ item.value }}
+
+
+
+ {{ getProviderCacheProviderText(provider) }}
+
+
+
+
+ {{ file.ok === false ? t('modal.providerCache.errorDetails') : t('modal.providerCache.rawJson') }}
+ {{ getProviderCacheRecordText(file) }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web-ui/partials/index/panel-sessions.html b/web-ui/partials/index/panel-sessions.html
index d2a59347..228dad42 100644
--- a/web-ui/partials/index/panel-sessions.html
+++ b/web-ui/partials/index/panel-sessions.html
@@ -119,11 +119,11 @@
-
+
{{ t('sessions.loadingList') }}
-
+
{{ t('sessions.empty') }}
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);
+}