feat: refine usage views and add Codex system prompt editing

- Brighten usage subpages with consistent surfaces and text contrast
- Show reasoning effort by default and migrate saved column settings
- Hide HTTP method prefixes and tighten request table spacing
- Support custom Codex system prompts and synchronize instruction templates
- Add a clear button to plugin search
- Update localization, mocks, and regression tests
This commit is contained in:
lzt404 committed 2026-10-04 17:31:52 +08:00
1 parent dfba9b019f
commit 2b49fe6a25
19 files changed
+233 -23

No files matched your search

+1
View File
@@ -494,6 +494,7 @@ fn prepare_catalog_with_customizations(
}
for entry in &mut entries {
entry.value.insert("supports_parallel_tool_calls".to_string(), Value::Bool(true));
customizations::apply_customizations(&mut entry.value, customizations)?;
}
+27 -1
View File
@@ -6,9 +6,10 @@ use std::path::Path;
pub(super) type ModelCustomizations = BTreeMap<String, Map<String, Value>>;
pub(super) const EDITABLE_FIELDS: [&str; 11] = [
pub(super) const EDITABLE_FIELDS: [&str; 12] = [
"display_name",
"description",
"base_instructions",
"context_window",
"max_context_window",
"effective_context_window_percent",
@@ -78,6 +79,11 @@ fn editable_configuration(model: &Map<String, Value>) -> Map<String, Value> {
}
fn validate_configuration(model: &Map<String, Value>) -> Result<(), String> {
if let Some(value) = model.get("base_instructions") {
if !value.as_str().is_some_and(|text| !text.trim().is_empty()) {
return Err("System prompt must be non-empty text".to_string());
}
}
for field in model.keys() {
if !EDITABLE_FIELDS.contains(&field.as_str()) {
return Err(format!("Model field {field} cannot be modified"));
@@ -198,6 +204,17 @@ pub(super) fn apply_customizations(
let slug = string_value(model, "slug");
if let Some(customization) = customizations.get(&normalize_id(&slug)) {
model.extend(customization.clone());
// Codex clients can prefer the message template over base_instructions.
// Keep both paths consistent when the user replaces the system prompt.
if let Some(prompt) = customization.get("base_instructions") {
let messages = model.entry("model_messages".to_string())
.or_insert_with(|| Value::Object(Map::new()));
if !messages.is_object() {
*messages = Value::Object(Map::new());
}
messages.as_object_mut().unwrap()
.insert("instructions_template".to_string(), prompt.clone());
}
validate_configuration(&editable_configuration(model))
.map_err(|error| format!("Invalid custom configuration for model {slug}: {error}"))?;
enable_fast_mode(model);
@@ -417,6 +434,10 @@ mod tests {
let runtime_models = vec![runtime_model("third-party-model")];
let snapshot = snapshot_for_state(&runtime_models, &state).unwrap();
let mut configuration = snapshot.models[0].configuration.clone();
assert_eq!(configuration["supports_parallel_tool_calls"], true);
let prompt = "请用中文回答。\nPreserve whitespace and newlines.\n";
configuration.insert("base_instructions".to_string(), serde_json::json!(prompt));
configuration.insert("supports_parallel_tool_calls".to_string(), Value::Bool(false));
configuration.insert("context_window".to_string(), serde_json::json!(131_072));
configuration.insert("max_context_window".to_string(), serde_json::json!(262_144));
let path = temporary_path();
@@ -446,6 +467,9 @@ mod tests {
.unwrap();
let generated: Value = serde_json::from_str(&generated.json).unwrap();
assert_eq!(generated["models"][0]["context_window"], 131_072);
assert_eq!(generated["models"][0]["base_instructions"], prompt);
assert_eq!(generated["models"][0]["model_messages"]["instructions_template"], prompt);
assert_eq!(generated["models"][0]["supports_parallel_tool_calls"], false);
assert_eq!(generated["models"][0]["max_context_window"], 262_144);
let loaded = decode_customizations(&std::fs::read(&path).unwrap()).unwrap();
@@ -465,6 +489,8 @@ mod tests {
)
.unwrap();
assert!(!restored.models[0].customized);
assert_eq!(restored.models[0].configuration["supports_parallel_tool_calls"], true);
assert_eq!(restored.models[0].configuration["base_instructions"], restored.models[0].defaults["base_instructions"]);
assert!(state.customizations.is_empty());
let persisted: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(persisted["models"], serde_json::json!({}));
+3
View File
@@ -351,6 +351,9 @@ export const jaOverrides = {
'agents.catalog.image': '画像',
'agents.catalog.visible': 'Codex のモデル一覧に表示',
'agents.catalog.parallel': '並列ツール呼び出しを許可',
'agents.catalog.systemPrompt': 'システムプロンプト',
'agents.catalog.systemPromptHint': 'このモデルの既定のシステムプロンプトを置き換え、保存時に Codex モデル一覧へ同期します。モデルのリセットで元に戻せます。',
'agents.catalog.invalidPrompt': 'システムプロンプトは空にできません。',
'agents.catalog.capabilityHint': 'Fast は常に選択可能です。推論・画像・ツール機能の動作は上流モデルの対応状況によります。',
'agents.catalog.resetModel': 'このモデルの既定値に戻す',
'agents.catalog.resetAll': 'すべて既定値に戻す',
+3
View File
@@ -300,6 +300,9 @@ export const en: Record<MessageKey, string> = {
'agents.catalog.image': 'Image',
'agents.catalog.visible': 'Show in the Codex model list',
'agents.catalog.parallel': 'Allow parallel tool calls',
'agents.catalog.systemPrompt': 'System prompt',
'agents.catalog.systemPromptHint': 'Replaces this model’s default system prompt and syncs to the Codex model list when saved. Use “Reset model” to restore it.',
'agents.catalog.invalidPrompt': 'System prompt cannot be empty.',
'agents.catalog.capabilityHint': 'Fast is always available. Reasoning, image and tool capabilities depend on the upstream model.',
'agents.catalog.resetModel': 'Restore model defaults',
'agents.catalog.resetAll': 'Restore all defaults',
+3
View File
@@ -299,6 +299,9 @@ export const zhCN = {
'agents.catalog.image': '图片',
'agents.catalog.visible': '在 Codex 模型列表中显示',
'agents.catalog.parallel': '允许并行工具调用',
'agents.catalog.systemPrompt': '系统提示词',
'agents.catalog.systemPromptHint': '替换当前模型的默认系统提示词,保存后同步到 Codex 模型列表。可通过“恢复此模型默认值”还原。',
'agents.catalog.invalidPrompt': '系统提示词不能为空。',
'agents.catalog.capabilityHint': 'Fast 始终可选;思考等级、图片与工具能力是否生效取决于上游模型。',
'agents.catalog.resetModel': '恢复此模型默认值',
'agents.catalog.resetAll': '全部恢复默认值',
+1
View File
@@ -585,6 +585,7 @@ function createState(scenario: BrowserMockScenario) {
input_modalities: ['text', 'image'],
visibility: 'list',
supports_parallel_tool_calls: true,
base_instructions: 'You are Codex, a coding assistant.',
};
return {
slug,
+1
View File
@@ -283,6 +283,7 @@ export function CodexModelCatalogDialog({ onClose, onSaved }: CodexModelCatalogD
<label className="codex-catalog-switch"><input type="checkbox" checked={activeModel.configuration.visibility === 'list'} onChange={(event) => updateField('visibility', event.currentTarget.checked ? 'list' : 'hide')} /><span>{t('agents.catalog.visible')}</span></label>
<label className="codex-catalog-switch"><input type="checkbox" checked={activeModel.configuration.supports_parallel_tool_calls} onChange={(event) => updateField('supports_parallel_tool_calls', event.currentTarget.checked)} /><span>{t('agents.catalog.parallel')}</span></label>
<label className="wide"><span>{t('agents.catalog.systemPrompt')}</span><textarea className="codex-catalog-system-prompt" rows={12} spellCheck={false} value={activeModel.configuration.base_instructions} onChange={(event) => updateField('base_instructions', event.currentTarget.value)} aria-describedby="codex-catalog-prompt-hint" /><small id="codex-catalog-prompt-hint">{t('agents.catalog.systemPromptHint')}</small></label>
</div>
<p className="codex-catalog-hint" role="note">{t(codexContextSourceHint(activeModel))}</p>
<p className="codex-catalog-hint">{t('agents.catalog.capabilityHint')}</p>
+10 -3
View File
@@ -15,9 +15,16 @@
.plugin-tabs button { border: 1px solid transparent; padding: 7px 14px; border-radius: 7px; background: transparent; color: var(--text-tertiary); font-size: 13px; }
.plugin-tabs button.active { border-color: var(--border-color); background: var(--bg-card); color: var(--primary-color); font-weight: 650; box-shadow: 0 1px 2px rgb(0 0 0 / 4%); }
.plugin-toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.plugin-search { display: flex; align-items: center; gap: 9px; max-width: 440px; min-width: 180px; flex: 1; padding: 0 12px; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-card); color: var(--text-tertiary); }
.plugin-search input { border: 0; background: transparent; color: var(--text-primary); width: 100%; min-width: 0; padding: 11px 0; font: inherit; font-size: 13px; outline: none; }
.plugin-search:focus-within { outline: 2px solid var(--primary-color); outline-offset: 2px; }
.plugin-search { display: flex; align-items: center; gap: 10px; max-width: 440px; min-width: 180px; min-height: 44px; box-sizing: border-box; flex: 1; padding: 0 8px 0 12px; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-card); color: var(--text-tertiary); }
.plugin-search > svg { flex: none; }
.plugin-search input { appearance: none; border: 0; border-radius: 0; background: transparent; color: var(--text-primary); flex: 1; width: 100%; min-width: 0; margin: 0; padding: 11px 0; font: inherit; font-size: 13px; line-height: 20px; }
.plugin-search input:focus, .plugin-search input:focus-visible { outline: none; box-shadow: none; }
.plugin-search input::-webkit-search-cancel-button, .plugin-search input::-webkit-search-decoration { -webkit-appearance: none; }
.plugin-search:focus-within { border-color: var(--primary-color); box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 12%, transparent); }
.plugin-search-clear { display: inline-flex; align-items: center; justify-content: center; flex: none; width: 28px; height: 28px; padding: 0; border: 0; border-radius: 5px; background: transparent; color: var(--text-tertiary); cursor: pointer; }
.plugin-search-clear:hover { background: var(--bg-hover); color: var(--text-primary); }
.plugin-search-clear:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 0; }
.plugin-search-clear:disabled { visibility: hidden; }
.plugin-toolbar select { border: 1px solid var(--border-color); border-radius: 8px; color: var(--text-primary); background: var(--bg-card); padding: 10px 12px; }
.plugin-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 330px), 1fr)); gap: 16px; }
.plugin-card { min-width: 0; display: flex; flex-direction: column; background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 12px; padding: 20px; gap: 12px; }
+11 -2
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { ArrowLeft, Download, ExternalLink, Folder, LogIn, Puzzle, RefreshCw, Search, Settings2, Trash2 } from 'lucide-react';
import { ArrowLeft, Download, ExternalLink, Folder, LogIn, Puzzle, RefreshCw, Search, Settings2, Trash2, X } from 'lucide-react';
import { useI18n } from '../i18n';
import { pluginText } from '../i18n/plugins';
import { FloatingNotice, useAppNotice } from '../appNotice';
@@ -28,6 +28,7 @@ export function PluginsPage() {
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [search, setSearch] = useState('');
const searchRef = useRef<HTMLInputElement>(null);
const [filter, setFilter] = useState('all');
const [configPlugin, setConfigPlugin] = useState<PluginListEntry | null>(null);
const [installEntry, setInstallEntry] = useState<PluginStoreEntry | null>(null);
@@ -138,7 +139,15 @@ export function PluginsPage() {
<div className="plugin-navigation">
<nav className="plugin-tabs" aria-label={pt('title')}>{(['installed', 'store', 'settings'] as const).map(value => <button key={value} className={tab === value ? 'active' : ''} aria-current={tab === value ? 'page' : undefined} disabled={busy} onClick={() => { setTab(value); setSearch(''); setFilter('all'); }}>{pt(value === 'installed' ? 'local' : value)}</button>)}</nav>
{tab !== 'settings' &&
<div className="plugin-toolbar"><label className="plugin-search"><Search size={17} aria-hidden="true" /><input type="search" value={search} onChange={e => setSearch(e.target.value)} placeholder={pt('search')} aria-label={pt('search')} /></label>{tab === 'store' && <select aria-label={pt('store')} value={filter} onChange={e => setFilter(e.target.value)}>{['all', 'installed', 'updates'].map(value => <option key={value} value={value}>{pt(value as 'all' | 'installed' | 'updates')}</option>)}</select>}</div>}
<div className="plugin-toolbar">
<div className="plugin-search">
<Search size={17} aria-hidden="true" />
<input ref={searchRef} type="search" value={search} onChange={e => setSearch(e.target.value)} placeholder={pt('search')} aria-label={pt('search')} />
<button type="button" className="plugin-search-clear" disabled={!search} aria-label={t('common.clear')} title={t('common.clear')}
onClick={() => { setSearch(''); searchRef.current?.focus(); }}><X size={15} aria-hidden="true" /></button>
</div>
{tab === 'store' && <select aria-label={pt('store')} value={filter} onChange={e => setFilter(e.target.value)}>{['all', 'installed', 'updates'].map(value => <option key={value} value={value}>{pt(value as 'all' | 'installed' | 'updates')}</option>)}</select>}
</div>}
</div>
{tab === 'settings' ? <PluginSettingsPanel onSaved={() => { notice.showNotice(pt('saved')); void load(); }} /> : <>
{loading && <p className="plugin-loading" role="status">{t('common.loading')}…</p>}
+1 -1
View File
@@ -91,7 +91,7 @@
}
.usage-request-log .usage-events-table {
min-width: 100%;
min-width: 0;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
+28 -14
View File
@@ -86,12 +86,12 @@ type EventColumnDef = {
const EVENT_COLUMNS: readonly EventColumnDef[] = [
{ key: 'time', labelKey: 'usage.column.time', defaultWidth: 84, minWidth: 76, align: 'left' },
{ key: 'provider', labelKey: 'usage.column.provider', defaultWidth: 108, minWidth: 88, align: 'left' },
{ key: 'key', labelKey: 'usage.column.key', defaultWidth: 120, minWidth: 96, align: 'left' },
{ key: 'source', labelKey: 'usage.column.source', defaultWidth: 180, minWidth: 120, align: 'left' },
{ key: 'model', labelKey: 'usage.column.model', defaultWidth: 132, minWidth: 104, align: 'left' },
{ key: 'key', labelKey: 'usage.column.key', defaultWidth: 112, minWidth: 96, align: 'left' },
{ key: 'source', labelKey: 'usage.column.source', defaultWidth: 128, minWidth: 104, align: 'left' },
{ key: 'model', labelKey: 'usage.column.model', defaultWidth: 160, minWidth: 104, align: 'left' },
{ key: 'effort', labelKey: 'usage.column.effort', defaultWidth: 76, minWidth: 64, align: 'left' },
{ key: 'result', labelKey: 'usage.column.result', defaultWidth: 80, minWidth: 68, align: 'left' },
{ key: 'request', labelKey: 'usage.column.request', defaultWidth: 112, minWidth: 88, align: 'left' },
{ key: 'request', labelKey: 'usage.column.request', defaultWidth: 128, minWidth: 88, align: 'left' },
{ key: 'latency', labelKey: 'usage.column.latency', defaultWidth: 112, minWidth: 96, align: 'left' },
{ key: 'speed', labelKey: 'usage.column.speed', defaultWidth: 88, minWidth: 72, align: 'left' },
{ key: 'total', labelKey: 'usage.column.tokens', defaultWidth: 120, minWidth: 112, align: 'left' },
@@ -106,22 +106,27 @@ const LEGACY_DEFAULT_EVENT_VISIBLE_COLUMNS: readonly string[] = [
'time', 'model', 'input', 'output', 'cache', 'cacheRate', 'total', 'speed', 'ttft', 'latency', 'result', 'provider', 'source',
];
const EVENT_COL_WIDTHS_STORAGE_KEY = 'cpa-gui.usage-events-col-widths.v2';
const LEGACY_EVENT_COL_WIDTHS_STORAGE_KEY = 'cpa-gui.usage-events-col-widths.v1';
const EVENT_COL_WIDTHS_STORAGE_KEY = 'cpa-gui.usage-events-col-widths.v3';
const LEGACY_EVENT_COL_WIDTHS_STORAGE_KEY = 'cpa-gui.usage-events-col-widths.v2';
const PREVIOUS_COMPACT_WIDTHS: Partial<Record<EventColumnKey, number>> = {
key: 120, source: 180, model: 132, request: 112,
};
const PREVIOUS_EVENT_COLUMN_WIDTHS: Record<EventColumnKey, number> = {
time: 105, key: 150, source: 205, model: 150, effort: 100, result: 95,
request: 145, latency: 125, speed: 110, total: 145, cache: 135, provider: 135,
cost: 112,
};
const EVENT_VISIBLE_COLS_STORAGE_KEY = 'cpa-gui.usage-events-visible-cols.v4';
const LEGACY_EVENT_VISIBLE_COLS_STORAGE_KEY = 'cpa-gui.usage-events-visible-cols.v3';
const EVENT_VISIBLE_COLS_STORAGE_KEY = 'cpa-gui.usage-events-visible-cols.v5';
const LEGACY_EVENT_VISIBLE_COLS_STORAGE_KEY = 'cpa-gui.usage-events-visible-cols.v4';
const getAllEventColumnKeys = () => EVENT_COLUMNS.map((column) => column.key);
const getInitialVisibleColumns = (): EventColumnKey[] => {
try {
const currentRaw = localStorage.getItem(EVENT_VISIBLE_COLS_STORAGE_KEY);
const raw = currentRaw ?? localStorage.getItem(LEGACY_EVENT_VISIBLE_COLS_STORAGE_KEY)
const previousRaw = localStorage.getItem(LEGACY_EVENT_VISIBLE_COLS_STORAGE_KEY);
const raw = currentRaw ?? previousRaw
?? localStorage.getItem('cpa-gui.usage-events-visible-cols.v3')
?? localStorage.getItem('cpa-gui.usage-events-visible-cols.v2');
if (raw) {
const parsed: unknown = JSON.parse(raw);
@@ -140,7 +145,11 @@ const getInitialVisibleColumns = (): EventColumnKey[] => {
&& parsed.length === LEGACY_DEFAULT_EVENT_VISIBLE_COLUMNS.length
&& LEGACY_DEFAULT_EVENT_VISIBLE_COLUMNS.every((key) => parsed.includes(key));
if (wasLegacyDefault) return [...DEFAULT_EVENT_VISIBLE_COLUMNS];
return currentRaw === null && !savedKeys.includes('cost') ? [...savedKeys, 'cost'] : savedKeys;
if (currentRaw === null) {
if (!savedKeys.includes('effort')) savedKeys.push('effort');
if (previousRaw === null && !savedKeys.includes('cost')) savedKeys.push('cost');
}
return savedKeys;
}
}
}
@@ -156,7 +165,8 @@ const getInitialColumnWidths = (): Record<EventColumnKey, number> => {
}
try {
const currentRaw = localStorage.getItem(EVENT_COL_WIDTHS_STORAGE_KEY);
const raw = currentRaw ?? localStorage.getItem(LEGACY_EVENT_COL_WIDTHS_STORAGE_KEY);
const previousRaw = localStorage.getItem(LEGACY_EVENT_COL_WIDTHS_STORAGE_KEY);
const raw = currentRaw ?? previousRaw ?? localStorage.getItem('cpa-gui.usage-events-col-widths.v1');
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
@@ -167,7 +177,9 @@ const getInitialColumnWidths = (): Record<EventColumnKey, number> => {
parsed[col.key] >= col.minWidth
) {
// Adopt tighter defaults while retaining columns the user resized.
const wasDefault = currentRaw === null && parsed[col.key] === PREVIOUS_EVENT_COLUMN_WIDTHS[col.key];
const oldDefault = previousRaw === null ? PREVIOUS_EVENT_COLUMN_WIDTHS[col.key]
: (PREVIOUS_COMPACT_WIDTHS[col.key] ?? col.defaultWidth);
const wasDefault = currentRaw === null && parsed[col.key] === oldDefault;
if (!wasDefault) initial[col.key] = Math.min(800, Math.round(parsed[col.key]));
}
}
@@ -330,8 +342,10 @@ function UsageEventCell({
}
case 'effort':
return <td className="usage-stacked-cell align-left" title={record.reasoning_effort || 'auto'}><strong>{record.reasoning_effort || 'auto'}</strong></td>;
case 'request':
return <td className="usage-stacked-cell align-left" title={record.endpoint || undefined}><strong>{record.endpoint || '—'}</strong></td>;
case 'request': {
const path = record.endpoint.trim().replace(/^(?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|CONNECT|TRACE)\s+/i, '');
return <td className="usage-stacked-cell usage-td-request align-left" title={path || undefined}><strong>{path || '—'}</strong></td>;
}
case 'provider': {
const provider = usageProviderDetails(record.provider, record.auth_type);
return (
+3
View File
@@ -11,6 +11,7 @@ export type CodexReasoningLevel = {
export type CodexModelConfiguration = {
display_name: string;
description: string | null;
base_instructions: string;
context_window: number;
max_context_window: number;
effective_context_window_percent: number;
@@ -60,6 +61,7 @@ export function cloneCodexModelConfiguration(configuration: CodexModelConfigurat
export function sameCodexModelConfiguration(left: CodexModelConfiguration, right: CodexModelConfiguration): boolean {
return left.display_name === right.display_name
&& left.description === right.description
&& left.base_instructions === right.base_instructions
&& Object.is(left.context_window, right.context_window)
&& Object.is(left.max_context_window, right.max_context_window)
&& Object.is(left.effective_context_window_percent, right.effective_context_window_percent)
@@ -95,6 +97,7 @@ export function toggleCodexReasoningLevel(
}
export function validateCodexModelConfiguration(configuration: CodexModelConfiguration): MessageKey | null {
if (!configuration.base_instructions.trim()) return 'agents.catalog.invalidPrompt';
if (!configuration.display_name.trim() || configuration.display_name.length > 4_000
|| (configuration.description?.length ?? 0) > 4_000) return 'agents.catalog.invalidText';
if (![configuration.context_window, configuration.max_context_window].every((value) => Number.isSafeInteger(value) && value > 0)) {
+5
View File
@@ -7974,6 +7974,11 @@ textarea:focus-visible {
resize: vertical;
}
.codex-catalog-form textarea.codex-catalog-system-prompt {
min-height: 240px;
line-height: 1.5;
}
.codex-catalog-form input:focus,
.codex-catalog-form select:focus,
.codex-catalog-form textarea:focus {
+1
View File
@@ -7,4 +7,5 @@
@import './responsive-settings.css';
@import './config-settings.css';
@import '../pages/UsageEventsView.css';
@import './usage-subpages.css';
@import '../pages/AgentClientList.css';
+99
View File
@@ -0,0 +1,99 @@
/* Shared surfaces for usage subpages; the overview keeps its own chart palette. */
.usage-records-page .usage-tab-panel:not([aria-labelledby='usage-tab-overview']) {
--usage-page-surface: #ffffff;
--usage-page-soft: #fafafa;
--usage-page-hover: #f4f4f5;
--usage-page-text: #17212f;
--usage-page-muted: #526071;
--usage-page-border: #e4e7ec;
--bg-primary: var(--usage-page-surface);
--bg-card: var(--usage-page-surface);
--bg-secondary: var(--usage-page-soft);
--bg-tertiary: var(--usage-page-border);
--bg-hover: var(--usage-page-hover);
--text-primary: var(--usage-page-text);
--text-secondary: var(--usage-page-text);
--text-tertiary: var(--usage-page-muted);
--text-quaternary: var(--usage-page-muted);
--border-secondary: var(--usage-page-border);
--border-color: var(--usage-page-border);
--theme-fff: var(--usage-page-surface);
--theme-fffdf8: var(--usage-page-surface);
--theme-f8f5ee: var(--usage-page-surface);
--theme-f4f1ea: var(--usage-page-surface);
--theme-e3e1db: var(--usage-page-border);
--theme-d8d4cc: var(--usage-page-border);
--theme-eeeae2: var(--usage-page-border);
--theme-2d2a26: var(--usage-page-text);
--theme-5f5a53: var(--usage-page-text);
--theme-716b61: var(--usage-page-muted);
--theme-7a746c: var(--usage-page-muted);
--theme-8a847c: var(--usage-page-muted);
color: var(--usage-page-text);
}
:root[data-theme='dark'] .usage-records-page .usage-tab-panel:not([aria-labelledby='usage-tab-overview']) {
--usage-page-surface: #222936;
--usage-page-soft: #2b3443;
--usage-page-hover: #333e4f;
--usage-page-text: #f4f7fb;
--usage-page-muted: #c0cbda;
--usage-page-border: #414d60;
}
.usage-records-page .usage-tab-panel:not([aria-labelledby='usage-tab-overview']) :is(
.usage-analysis-card, .usage-analysis-context, .usage-pricing-panel,
.usage-data-management-panel, .usage-data-management-action,
.usage-data-management-result > div, .usage-price-editor, .usage-price-sync-dialog
) {
background: var(--usage-page-surface);
border-color: var(--usage-page-border);
color: var(--usage-page-text);
box-shadow: 0 1px 3px rgb(0 0 0 / 3%);
}
.usage-records-page .usage-tab-panel .usage-pricing-table :is(th, td) {
background: var(--usage-page-surface);
color: var(--usage-page-text);
border-bottom-color: var(--usage-page-border);
}
.usage-records-page .usage-tab-panel .usage-pricing-table tbody tr:hover td {
background: var(--usage-page-hover);
}
.usage-records-page .usage-tab-panel .usage-pricing-table th {
font-weight: 650;
}
:root .usage-records-page .usage-tab-panel .usage-request-log {
--usage-log-surface: var(--usage-page-surface);
--usage-log-text: var(--usage-page-text);
--usage-log-muted: var(--usage-page-muted);
--usage-log-line: var(--usage-page-border);
--usage-log-soft: var(--usage-page-soft);
--usage-log-hover: var(--usage-page-hover);
}
.usage-records-page .usage-tab-panel .usage-data-management-heading h2,
.usage-records-page .usage-tab-panel .usage-data-management-action strong {
color: var(--usage-page-text);
}
.usage-records-page .usage-tab-panel:not([aria-labelledby='usage-tab-overview']) :is(
input:not([type='checkbox']):not([type='radio']), select,
.usage-price-row-actions button, .secondary-button
) {
background: var(--usage-page-surface);
color: var(--usage-page-text);
border-color: var(--usage-page-border);
}
.usage-records-page .usage-tab-panel:not([aria-labelledby='usage-tab-overview']) input::placeholder {
color: var(--usage-page-muted);
opacity: 1;
}
.usage-records-page .usage-tab-panel .usage-price-row-actions button:hover {
background: var(--usage-page-hover);
}
+9
View File
@@ -12,6 +12,7 @@ import {
const configuration = (): CodexModelConfiguration => ({
display_name: 'Third Party',
description: null,
base_instructions: 'You are a helpful coding assistant.',
context_window: 128_000,
max_context_window: 128_000,
effective_context_window_percent: 95,
@@ -28,6 +29,14 @@ const configuration = (): CodexModelConfiguration => ({
});
describe('Codex 模型列表编辑', () => {
test('识别系统提示词修改并拒绝空提示词', () => {
const original = configuration();
const edited = { ...original, base_instructions: '请用中文回答。\n保留换行。' };
expect(sameCodexModelConfiguration(original, edited)).toBeFalse();
expect(validateCodexModelConfiguration(edited)).toBeNull();
expect(validateCodexModelConfiguration({ ...original, base_instructions: ' \n ' }))
.toBe('agents.catalog.invalidPrompt');
});
test("区分 Codex 客户端 API、模板后备与自定义上下文", () => {
const model: CodexCatalogEditorModel = {
slug: "model-a", hasOfficialTemplate: true, customized: false,
+4 -2
View File
@@ -9,11 +9,13 @@ const params = new URLSearchParams(location.search);
localStorage.setItem('easy-cli-proxy-api.locale', params.get('locale') || 'zh-CN');
// Keep the model column visible even if a previous fixture changed the user's layout.
localStorage.setItem('cpa-gui.usage-events-visible-cols.v3', JSON.stringify([
'time', 'key', 'source', 'model', 'effort', 'result', 'request', 'latency', 'speed', 'total', 'cache', 'provider',
'time', 'key', 'source', 'model', 'result', 'request', 'latency', 'speed', 'total', 'cache', 'provider',
]));
localStorage.removeItem('cpa-gui.usage-events-visible-cols.v2');
localStorage.removeItem('cpa-gui.usage-events-visible-cols.v4');
localStorage.removeItem('cpa-gui.usage-events-visible-cols.v5');
localStorage.removeItem('cpa-gui.usage-events-col-widths.v2');
localStorage.removeItem('cpa-gui.usage-events-col-widths.v3');
document.documentElement.dataset.theme = params.get('theme') || 'light';
const tokens = {
@@ -44,7 +46,7 @@ const record = (id: string, model: string, alias: string, response_model?: strin
alias,
...(response_model === undefined ? {} : { response_model }),
reasoning_effort: 'high',
endpoint: '/v1/responses',
endpoint: 'POST /v1/responses',
api_key_hash: 'ui-key',
api_key_display: 'sk-ui',
api_key_remark: 'UI fixture',
+20
View File
@@ -49,6 +49,23 @@ const fs = require('node:fs');
assert.equal(await page.evaluate(() => window.pluginsFixture.commands.some(call => call.command === 'open_core_logs_directory')), true);
await noOverflow();
const searchInput = page.getByRole('searchbox');
await searchInput.fill('qoder');
const focusedSearch = await searchInput.evaluate(input => ({
outline: getComputedStyle(input).outlineStyle,
shadow: getComputedStyle(input).boxShadow,
containerShadow: getComputedStyle(input.parentElement).boxShadow,
}));
assert.equal(focusedSearch.outline, 'none', 'the input must not draw a second focus ring inside the search field');
assert.equal(focusedSearch.shadow, 'none');
assert.notEqual(focusedSearch.containerShadow, 'none', 'the outer search field retains a visible focus indicator');
await page.keyboard.press('Tab');
assert.equal(await page.locator('.plugin-search-clear').evaluate(button => button === document.activeElement), true);
await page.keyboard.press('Enter');
assert.equal(await searchInput.inputValue(), '');
assert.equal(await searchInput.evaluate(input => input === document.activeElement), true, 'clearing returns focus to search');
assert.equal(await page.locator('.plugin-card').count(), 4, 'clearing restores the unfiltered list');
await card('kiro').getByRole('button', { name: 'Find in store', exact: true }).click();
await card('Kiro').getByRole('button', { name: 'Install', exact: true }).waitFor();
assert.equal(await tab('Plugin store').getAttribute('aria-current'), 'page');
@@ -177,6 +194,9 @@ const fs = require('node:fs');
if (process.env.PLUGIN_SCREENSHOT_DIR) {
fs.mkdirSync(process.env.PLUGIN_SCREENSHOT_DIR, { recursive: true });
await page.screenshot({ path: path.join(process.env.PLUGIN_SCREENSHOT_DIR, `plugins-${locale}-${width}.png`), fullPage: true });
await page.getByRole('searchbox').fill('qoder');
await page.screenshot({ path: path.join(process.env.PLUGIN_SCREENSHOT_DIR, `plugins-search-${locale}-${width}.png`), fullPage: true });
await page.locator('.plugin-search-clear').click();
}
await card('Community analytics').getByRole('button', { name: locale === 'en' ? 'Install' : '安装', exact: true }).click();
await dialog().waitFor();
+3
View File
@@ -89,6 +89,9 @@ function parseCsv(text) {
await open('zh-CN');
assert.equal(await modelCells().count(), 9, 'Every fixture record renders a model cell');
const zhDetails = await modelDetails();
assert.equal(await page.locator('.usage-th-effort').count(), 1, 'Migration enables reasoning effort for existing layouts');
assert.equal(await page.locator('.usage-td-request').first().textContent(), '/v1/responses');
assert.equal(await page.locator('.usage-td-request').first().getAttribute('title'), '/v1/responses');
assert.match(await modelCells().first().getAttribute('title'), /response_model.*上游声明/);
assert.deepEqual(await page.locator('.usage-events-table th').evaluateAll(cells => cells.slice(0, 2).map(cell => cell.className.split(' ')[0])), ['usage-th-time', 'usage-th-provider']);
assert.equal(await page.locator('.usage-td-provider strong').first().textContent(), 'Codex');