feat: add API access remarks functionality and validation across the application
This commit is contained in:
1 parent
027bc0b73b
commit
47cd4b5bd2
6 files changed
+337
-12
No files matched your search
@@ -529,6 +529,7 @@ export const jaOverrides = {
|
||||
'apiAccess.error.noModels': '公開可能なモデルが見つかりません。Base URL と API キーを確認してください',
|
||||
'apiAccess.error.stale': '接続設定が別の操作で変更されました。更新してから再試行してください',
|
||||
'apiAccess.error.duplicate': '同じ接続設定がすでに存在します',
|
||||
'apiAccess.error.remarkInvalid': 'メモは 80 文字以内で、改行や制御文字を含めないでください',
|
||||
'apiAccess.notice.updated': '接続を更新しました',
|
||||
'apiAccess.notice.added': '接続を追加しました',
|
||||
'apiAccess.deleteConfirm': '「{name}」を削除しますか?',
|
||||
@@ -547,6 +548,7 @@ export const jaOverrides = {
|
||||
'apiAccess.empty.addFirst': '右上の「追加」から最初の接続を追加してください',
|
||||
'apiAccess.keys.count': 'キー {count} 件',
|
||||
'apiAccess.keys.summary': '{key} · 合計 {count} 件',
|
||||
'apiAccess.remarkValue': 'メモ:{remark}',
|
||||
'apiAccess.defaultUrl': '既定のアドレスを使用',
|
||||
'apiAccess.models.summary': 'モデル {count} 件 · {models}',
|
||||
'apiAccess.priorityValue': '優先度 {priority}',
|
||||
@@ -572,6 +574,8 @@ export const jaOverrides = {
|
||||
'apiAccess.field.name': '名前',
|
||||
'apiAccess.field.keysMany': 'API キー(1 行に 1 件)',
|
||||
'apiAccess.field.key': 'API キー',
|
||||
'apiAccess.field.remark': 'メモ',
|
||||
'apiAccess.remarkPlaceholder': '例:本番環境、チームのメインアカウント',
|
||||
'apiAccess.baseRequiredPlaceholder': '必須、例:https://api.example.com',
|
||||
'apiAccess.baseOptionalPlaceholder': '任意。空欄の場合は既定のアドレスを使用',
|
||||
'apiAccess.preset.title': 'OpenAI 互換プリセット',
|
||||
|
||||
@@ -532,6 +532,7 @@ export const zhCN = {
|
||||
'apiAccess.error.noModels': '没有发现可放行的模型,请确认 Base URL 和 API 密钥',
|
||||
'apiAccess.error.stale': '接入配置已被其他操作修改,请刷新后重试',
|
||||
'apiAccess.error.duplicate': '相同的接入配置已经存在',
|
||||
'apiAccess.error.remarkInvalid': '备注不能超过 80 个字符,且不能包含换行或控制字符',
|
||||
'apiAccess.notice.updated': '接入已更新',
|
||||
'apiAccess.notice.added': '接入已新增',
|
||||
'apiAccess.deleteConfirm': '确定删除“{name}”吗?',
|
||||
@@ -550,6 +551,7 @@ export const zhCN = {
|
||||
'apiAccess.empty.addFirst': '点击右上角“新增”添加第一个接入',
|
||||
'apiAccess.keys.count': '{count} 个密钥',
|
||||
'apiAccess.keys.summary': '{key} · 共 {count} 个密钥',
|
||||
'apiAccess.remarkValue': '备注:{remark}',
|
||||
'apiAccess.defaultUrl': '使用默认地址',
|
||||
'apiAccess.models.summary': '模型 {count} 个 · {models}',
|
||||
'apiAccess.priorityValue': '优先级 {priority}',
|
||||
@@ -575,6 +577,8 @@ export const zhCN = {
|
||||
'apiAccess.field.name': '名称',
|
||||
'apiAccess.field.keysMany': 'API 密钥(每行一个)',
|
||||
'apiAccess.field.key': 'API 密钥',
|
||||
'apiAccess.field.remark': '备注',
|
||||
'apiAccess.remarkPlaceholder': '例如:生产环境、团队主账号',
|
||||
'apiAccess.baseRequiredPlaceholder': '必填,例如 https://api.example.com',
|
||||
'apiAccess.baseOptionalPlaceholder': '可选,留空使用默认地址',
|
||||
'apiAccess.preset.title': 'OpenAI 兼容预设',
|
||||
@@ -1344,6 +1348,7 @@ export const en: Record<MessageKey, string> = {
|
||||
'apiAccess.error.noModels': 'No models were discovered. Check the Base URL and API key.',
|
||||
'apiAccess.error.stale': 'The connection configuration changed elsewhere. Refresh and try again.',
|
||||
'apiAccess.error.duplicate': 'An identical connection already exists',
|
||||
'apiAccess.error.remarkInvalid': 'The remark cannot exceed 80 characters or contain line breaks or control characters',
|
||||
'apiAccess.notice.updated': 'Connection updated',
|
||||
'apiAccess.notice.added': 'Connection added',
|
||||
'apiAccess.deleteConfirm': 'Delete “{name}”?',
|
||||
@@ -1362,6 +1367,7 @@ export const en: Record<MessageKey, string> = {
|
||||
'apiAccess.empty.addFirst': 'Click “Add” in the upper-right to create your first connection',
|
||||
'apiAccess.keys.count': '{count} keys',
|
||||
'apiAccess.keys.summary': '{key} · {count} keys total',
|
||||
'apiAccess.remarkValue': 'Remark: {remark}',
|
||||
'apiAccess.defaultUrl': 'Using default address',
|
||||
'apiAccess.models.summary': '{count} models · {models}',
|
||||
'apiAccess.priorityValue': 'Priority {priority}',
|
||||
@@ -1387,6 +1393,8 @@ export const en: Record<MessageKey, string> = {
|
||||
'apiAccess.field.name': 'Name',
|
||||
'apiAccess.field.keysMany': 'API Keys (one per line)',
|
||||
'apiAccess.field.key': 'API Key',
|
||||
'apiAccess.field.remark': 'Remark',
|
||||
'apiAccess.remarkPlaceholder': 'For example, Production or Team primary account',
|
||||
'apiAccess.baseRequiredPlaceholder': 'Required, for example https://api.example.com',
|
||||
'apiAccess.baseOptionalPlaceholder': 'Optional; leave blank to use the default address',
|
||||
'apiAccess.preset.title': 'OpenAI-Compatible Preset',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import {
|
||||
Check,
|
||||
Edit3,
|
||||
@@ -67,11 +68,13 @@ type ProviderRow = {
|
||||
disabled: boolean;
|
||||
priority: number | null;
|
||||
authIndex: string;
|
||||
remark: string;
|
||||
};
|
||||
|
||||
export type ProviderDraft = {
|
||||
name: string;
|
||||
apiKey: string;
|
||||
remark: string;
|
||||
baseUrl: string;
|
||||
priority: string;
|
||||
models: ModelOption[];
|
||||
@@ -182,9 +185,13 @@ const rowFromRecord = (
|
||||
authIndex: entry
|
||||
? readString(entry, 'auth-index', 'authIndex')
|
||||
: readString(record, 'auth-index', 'authIndex'),
|
||||
remark: '',
|
||||
};
|
||||
};
|
||||
|
||||
const providerRemarkIdentity = (section: ProviderSection, apiKeys: string[]) =>
|
||||
`${section}\u0000${apiKeys.join('\u0000')}`;
|
||||
|
||||
export const stripResponseFields = (record: Record<string, unknown>) => {
|
||||
const next = { ...record };
|
||||
delete next['auth-index'];
|
||||
@@ -316,6 +323,7 @@ const thinkingLevelsFromModels = (models: ModelOption[]): string[] => {
|
||||
const draftFromRow = (row: ProviderRow): ProviderDraft => ({
|
||||
name: row.name,
|
||||
apiKey: definitionFor(row.section).openAi ? row.apiKeys.join('\n') : row.apiKey,
|
||||
remark: row.remark,
|
||||
baseUrl: row.baseUrl,
|
||||
priority: row.priority === null ? '' : String(row.priority),
|
||||
models: row.models,
|
||||
@@ -351,6 +359,7 @@ const draftFromRow = (row: ProviderRow): ProviderDraft => ({
|
||||
const emptyProviderDraft = (): ProviderDraft => ({
|
||||
name: '',
|
||||
apiKey: '',
|
||||
remark: '',
|
||||
baseUrl: '',
|
||||
priority: '',
|
||||
models: [],
|
||||
@@ -604,6 +613,7 @@ export function ApiAccessPage() {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingRow, setEditingRow] = useState<ProviderRow | null>(null);
|
||||
const [dialogDraft, setDialogDraft] = useState<ProviderDraft>(emptyProviderDraft);
|
||||
const [apiAccessRemarks, setApiAccessRemarks] = useState<Record<string, string>>({});
|
||||
const activeDefinition = definitionFor(activeCategory);
|
||||
const activeSection = activeDefinition.section;
|
||||
|
||||
@@ -647,20 +657,51 @@ export function ApiAccessPage() {
|
||||
void loadProviders();
|
||||
}, [loadProviders]);
|
||||
|
||||
useEffect(() => {
|
||||
const providerRows = (Object.entries(records) as [ProviderSection, Record<string, unknown>[]][])
|
||||
.flatMap(([section, items]) => items.map((record, index) => rowFromRecord(section, record, index)));
|
||||
if (providerRows.length === 0) {
|
||||
setApiAccessRemarks({});
|
||||
return;
|
||||
}
|
||||
let disposed = false;
|
||||
void invoke<string[]>('resolve_api_access_remarks', {
|
||||
queries: providerRows.map((row) => ({
|
||||
providerSection: row.section,
|
||||
apiKeys: row.apiKeys,
|
||||
})),
|
||||
}).then((remarks) => {
|
||||
if (disposed) return;
|
||||
setApiAccessRemarks(Object.fromEntries(providerRows.map((row, index) => [
|
||||
providerRemarkIdentity(row.section, row.apiKeys),
|
||||
remarks[index] ?? '',
|
||||
])));
|
||||
}).catch(() => {
|
||||
if (!disposed) setApiAccessRemarks({});
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [records]);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
records[activeSection]
|
||||
.map((record, index) => rowFromRecord(activeSection, record, index))
|
||||
.map((row) => ({
|
||||
...row,
|
||||
remark: apiAccessRemarks[providerRemarkIdentity(row.section, row.apiKeys)] ?? '',
|
||||
}))
|
||||
.filter((row) => providerCategoryMatchesRecord(activeCategory, row.record))
|
||||
.filter((row) => {
|
||||
const query = filter.trim().toLowerCase();
|
||||
if (!query) return true;
|
||||
return [row.name, row.apiKey, row.baseUrl, row.models.map((model) => model.name).join(' ')]
|
||||
return [row.name, row.remark, row.apiKey, row.baseUrl, row.models.map((model) => model.name).join(' ')]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(query);
|
||||
}),
|
||||
[activeCategory, activeSection, filter, records],
|
||||
[activeCategory, activeSection, apiAccessRemarks, filter, records],
|
||||
);
|
||||
|
||||
const openCreate = () => {
|
||||
@@ -700,6 +741,10 @@ export function ApiAccessPage() {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (Array.from(preparedDraft.remark.trim()).length > 80 || /[\u0000-\u001f\u007f]/.test(preparedDraft.remark)) {
|
||||
setError(t('apiAccess.error.remarkInvalid'));
|
||||
return false;
|
||||
}
|
||||
let baseUrl = preparedDraft.baseUrl.trim();
|
||||
let providerHeaders: Record<string, string> = {};
|
||||
try {
|
||||
@@ -762,6 +807,14 @@ export function ApiAccessPage() {
|
||||
}
|
||||
|
||||
await managementApi.put(`/${activeSection}`, nextList.map(stripResponseFields));
|
||||
await invoke('save_api_access_remark', {
|
||||
update: {
|
||||
providerSection: activeSection,
|
||||
previousApiKeys: editingRow?.apiKeys ?? [],
|
||||
apiKeys: parsedApiKeys,
|
||||
remark: draftToSave.remark,
|
||||
},
|
||||
});
|
||||
setNotice(editingRow ? t('apiAccess.notice.updated') : t('apiAccess.notice.added'));
|
||||
await loadProviders();
|
||||
return true;
|
||||
@@ -785,6 +838,14 @@ export function ApiAccessPage() {
|
||||
query: { 'api-key': row.apiKey, 'base-url': row.baseUrl },
|
||||
});
|
||||
}
|
||||
await invoke('save_api_access_remark', {
|
||||
update: {
|
||||
providerSection: row.section,
|
||||
previousApiKeys: row.apiKeys,
|
||||
apiKeys: [],
|
||||
remark: '',
|
||||
},
|
||||
});
|
||||
setNotice(t('apiAccess.notice.deleted'));
|
||||
await loadProviders();
|
||||
} catch (requestError) {
|
||||
@@ -915,6 +976,7 @@ export function ApiAccessPage() {
|
||||
? t('apiAccess.keys.summary', { key: maskSecret(row.apiKey), count: row.apiKeys.length })
|
||||
: maskSecret(row.apiKey)}
|
||||
</code>
|
||||
{row.remark ? <span className="provider-row-remark" title={row.remark}>{t('apiAccess.remarkValue', { remark: row.remark })}</span> : null}
|
||||
<span className="provider-row-url" title={row.baseUrl || undefined}>{row.baseUrl || t('apiAccess.defaultUrl')}</span>
|
||||
{row.models.length > 0 ? <span className="provider-row-models" title={row.models.map((model) => model.name).join(', ')}>{t('apiAccess.models.summary', { count: row.models.length, models: row.models.slice(0, 3).map((model) => model.name).join(', ') })}</span> : null}
|
||||
</div>
|
||||
@@ -1016,7 +1078,7 @@ function ApiProviderDialog({
|
||||
&& visibleModelOptions.every((model) => selectedModelNames.has(model.name.toLowerCase()));
|
||||
|
||||
const updateTextField = (
|
||||
field: 'name' | 'apiKey' | 'baseUrl' | 'priority' | 'prefix' | 'headersText' | 'excludedModelsText' | 'testModel' | 'cloakMode' | 'cloakSensitiveWordsText',
|
||||
field: 'name' | 'apiKey' | 'remark' | 'baseUrl' | 'priority' | 'prefix' | 'headersText' | 'excludedModelsText' | 'testModel' | 'cloakMode' | 'cloakSensitiveWordsText',
|
||||
value: string,
|
||||
) => {
|
||||
setDraft((current) => ({ ...current, [field]: value }));
|
||||
@@ -1189,6 +1251,7 @@ function ApiProviderDialog({
|
||||
<input autoFocus type="password" value={draft.apiKey} onChange={(event) => updateTextField('apiKey', event.currentTarget.value)} placeholder="sk-..." />
|
||||
)}
|
||||
</label>
|
||||
<label><span>{t('apiAccess.field.remark')}</span><input value={draft.remark} maxLength={80} onChange={(event) => updateTextField('remark', event.currentTarget.value)} placeholder={t('apiAccess.remarkPlaceholder')} /></label>
|
||||
<label><span>Base URL</span><input value={draft.baseUrl} onChange={(event) => updateTextField('baseUrl', event.currentTarget.value)} placeholder={activeSection === 'codex-api-key' || activeSection === 'openai-compatibility' ? t('apiAccess.baseRequiredPlaceholder') : t('apiAccess.baseOptionalPlaceholder')} /></label>
|
||||
{activeCategory === 'deepseek' ? (
|
||||
<div className="provider-preset-summary">
|
||||
|
||||
@@ -60,6 +60,7 @@ type UsageRecord = {
|
||||
latency_ms: number;
|
||||
ttft_ms: number | null;
|
||||
source: string;
|
||||
source_display: string;
|
||||
failed: boolean;
|
||||
provider: string;
|
||||
model: string;
|
||||
@@ -384,7 +385,7 @@ function CategoryPanel({ title, items, compactLabels = false }: { title: string;
|
||||
|
||||
function EventsView({ events, onPage }: { events: UsageEventPage; onPage: (page: number) => void }) {
|
||||
const { t } = useI18n();
|
||||
return <section className="panel usage-events-panel"><div className="usage-events-summary"><span>{t('usage.events.total', { count: compactNumber(events.total) })}</span><span>{t('usage.events.page', { page: events.page, total: events.totalPages })}</span></div>{events.items.length ? <div className="usage-table-wrap"><table className="usage-events-table"><thead><tr><th>{t('usage.column.time')}</th><th>{t('usage.column.model')}</th><th>Provider</th><th>{t('usage.column.source')}</th><th>API Key</th><th>{t('usage.column.result')}</th><th>{t('usage.column.latency')}</th><th>TTFT</th><th>{t('usage.column.input')}</th><th>{t('usage.column.output')}</th><th>{t('usage.column.reasoning')}</th><th>{t('usage.column.cache')}</th><th>{t('usage.column.total')}</th></tr></thead><tbody>{events.items.map((record) => <tr key={record.id}><td>{formatTime(record.timestamp)}</td><td className="usage-stacked-cell"><strong title={record.alias || record.model}>{record.alias || record.model}</strong>{record.alias || record.reasoning_effort ? <small title={record.model}>{record.alias ? record.model : ''}{record.alias && record.reasoning_effort ? ' · ' : ''}{record.reasoning_effort}</small> : null}</td><td title={record.provider}>{record.provider || '—'}</td><td title={record.source}>{record.source || '—'}</td><td className="usage-stacked-cell"><strong title={record.api_key_remark}>{record.api_key_remark || t('usage.key.noRemark')}</strong><small>{record.api_key_display || '—'}</small></td><td><span className={`usage-result ${record.failed ? 'failed' : 'success'}`}>{record.failed ? t('usage.result.failed') : t('usage.result.success')}</span></td><td>{record.latency_ms} ms</td><td>{record.ttft_ms == null ? '—' : `${record.ttft_ms} ms`}</td><td>{compactNumber(record.tokens.input_tokens)}</td><td>{compactNumber(record.tokens.output_tokens)}</td><td>{compactNumber(record.tokens.reasoning_tokens)}</td><td>{compactNumber(record.tokens.cache_read_tokens)}</td><td><strong>{compactNumber(record.tokens.total_tokens)}</strong></td></tr>)}</tbody></table></div> : <UsageEmpty />}<div className="usage-pagination"><button type="button" className="secondary-button" disabled={events.page <= 1} onClick={() => onPage(events.page - 1)}>{t('usage.previous')}</button><button type="button" className="secondary-button" disabled={events.page >= events.totalPages} onClick={() => onPage(events.page + 1)}>{t('usage.next')}</button></div></section>;
|
||||
return <section className="panel usage-events-panel"><div className="usage-events-summary"><span>{t('usage.events.total', { count: compactNumber(events.total) })}</span><span>{t('usage.events.page', { page: events.page, total: events.totalPages })}</span></div>{events.items.length ? <div className="usage-table-wrap"><table className="usage-events-table"><thead><tr><th>{t('usage.column.time')}</th><th>{t('usage.column.model')}</th><th>Provider</th><th>{t('usage.column.source')}</th><th>API Key</th><th>{t('usage.column.result')}</th><th>{t('usage.column.latency')}</th><th>TTFT</th><th>{t('usage.column.input')}</th><th>{t('usage.column.output')}</th><th>{t('usage.column.reasoning')}</th><th>{t('usage.column.cache')}</th><th>{t('usage.column.total')}</th></tr></thead><tbody>{events.items.map((record) => <tr key={record.id}><td>{formatTime(record.timestamp)}</td><td className="usage-stacked-cell"><strong title={record.alias || record.model}>{record.alias || record.model}</strong>{record.alias || record.reasoning_effort ? <small title={record.model}>{record.alias ? record.model : ''}{record.alias && record.reasoning_effort ? ' · ' : ''}{record.reasoning_effort}</small> : null}</td><td title={record.provider}>{record.provider || '—'}</td><td title={record.source_display || undefined}>{record.source_display || '—'}</td><td className="usage-stacked-cell"><strong title={record.api_key_remark}>{record.api_key_remark || t('usage.key.noRemark')}</strong><small>{record.api_key_display || '—'}</small></td><td><span className={`usage-result ${record.failed ? 'failed' : 'success'}`}>{record.failed ? t('usage.result.failed') : t('usage.result.success')}</span></td><td>{record.latency_ms} ms</td><td>{record.ttft_ms == null ? '—' : `${record.ttft_ms} ms`}</td><td>{compactNumber(record.tokens.input_tokens)}</td><td>{compactNumber(record.tokens.output_tokens)}</td><td>{compactNumber(record.tokens.reasoning_tokens)}</td><td>{compactNumber(record.tokens.cache_read_tokens)}</td><td><strong>{compactNumber(record.tokens.total_tokens)}</strong></td></tr>)}</tbody></table></div> : <UsageEmpty />}<div className="usage-pagination"><button type="button" className="secondary-button" disabled={events.page <= 1} onClick={() => onPage(events.page - 1)}>{t('usage.previous')}</button><button type="button" className="secondary-button" disabled={events.page >= events.totalPages} onClick={() => onPage(events.page + 1)}>{t('usage.next')}</button></div></section>;
|
||||
}
|
||||
|
||||
function UsageEmpty() {
|
||||
|
||||
Reference in new issue
Block a user