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:
1 parent
dfba9b019f
commit
2b49fe6a25
19 files changed
+233
-23
No files matched your search
@@ -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)?;
|
||||
}
|
||||
|
||||
|
||||
@@ -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!({}));
|
||||
|
||||
@@ -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': 'すべて既定値に戻す',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '全部恢复默认值',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
}
|
||||
|
||||
.usage-request-log .usage-events-table {
|
||||
min-width: 100%;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -7,4 +7,5 @@
|
||||
@import './responsive-settings.css';
|
||||
@import './config-settings.css';
|
||||
@import '../pages/UsageEventsView.css';
|
||||
@import './usage-subpages.css';
|
||||
@import '../pages/AgentClientList.css';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user