AGC 官方 LLM Router 账号链路与流式联网输出 #242
@@ -50,5 +50,7 @@ test('edits alias and upstream model without changing the stable identifier or r
|
||||
models: [{ ...catalog.models[0], alias: '精细创作' }],
|
||||
}),
|
||||
);
|
||||
await screen.findByText('已保存');
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('已保存').length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Plus, RefreshCcw, Save, Trash2 } from 'lucide-react';
|
||||
import { CircleHelp, Plus, RefreshCcw, Save, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { getAgcModelCatalog, saveAgcModelCatalog } from '../api/adminApiClient';
|
||||
@@ -74,10 +74,47 @@ export function AdminAgcModelsPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="admin-agc-models">
|
||||
<header>
|
||||
<h2>AGC 模型</h2>
|
||||
<section className="admin-page admin-page-wide admin-agc-models">
|
||||
<div className="admin-page-heading">
|
||||
<div>
|
||||
<h2>AGC 模型</h2>
|
||||
<p>管理客户端可用模型与用户看到的名称</p>
|
||||
</div>
|
||||
<span className="admin-agc-models-revision">
|
||||
版本 v{catalog?.revision ?? '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-agc-models-summary">
|
||||
<div>
|
||||
<span>已启用</span>
|
||||
<strong>
|
||||
{catalog?.models.filter((model) => model.enabled).length ?? 0}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>默认模型</span>
|
||||
<strong>
|
||||
{catalog
|
||||
? (catalog.models.find(
|
||||
(model) => model.id === catalog.defaultModelId,
|
||||
)?.alias ?? '未设置')
|
||||
: '未设置'}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>发布状态</span>
|
||||
<strong>{busy ? '处理中' : saved ? '已保存' : '待修改'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<section className="admin-panel admin-agc-models-panel">
|
||||
<div className="admin-panel-heading">
|
||||
<div>
|
||||
<h3>模型目录</h3>
|
||||
<span>客户端仅显示别名,实际模型名仅在这里维护</span>
|
||||
</div>
|
||||
<CircleHelp size={17} aria-label="模型目录帮助" />
|
||||
</div>
|
||||
<div className="admin-agc-models-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
title="重新读取"
|
||||
@@ -122,96 +159,96 @@ export function AdminAgcModelsPage({
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{error ? <p role="alert">{error}</p> : null}
|
||||
{saved ? <p role="status">已保存</p> : null}
|
||||
{busy ? <p role="status">正在处理</p> : null}
|
||||
<div className="admin-agc-models-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>别名</th>
|
||||
<th>实际模型名</th>
|
||||
<th>启用</th>
|
||||
<th>默认</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{catalog?.models.map((model, index) => (
|
||||
<tr key={model.id}>
|
||||
<td>
|
||||
<input
|
||||
aria-label={`模型 ${index + 1} 别名`}
|
||||
maxLength={40}
|
||||
required
|
||||
value={model.alias}
|
||||
disabled={busy}
|
||||
onChange={(e) =>
|
||||
update(model.id, { alias: e.target.value })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
aria-label={`模型 ${index + 1} 实际模型名`}
|
||||
maxLength={200}
|
||||
required
|
||||
value={model.modelId}
|
||||
disabled={busy}
|
||||
onChange={(e) =>
|
||||
update(model.id, { modelId: e.target.value })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
aria-label={`模型 ${index + 1} 启用`}
|
||||
type="checkbox"
|
||||
checked={model.enabled}
|
||||
disabled={busy || model.id === catalog.defaultModelId}
|
||||
onChange={(e) =>
|
||||
update(model.id, { enabled: e.target.checked })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
aria-label={`模型 ${index + 1} 默认`}
|
||||
name="agc-default-model"
|
||||
type="radio"
|
||||
checked={model.id === catalog.defaultModelId}
|
||||
disabled={busy || !model.enabled}
|
||||
onChange={() => {
|
||||
setSaved(false);
|
||||
setCatalog({ ...catalog, defaultModelId: model.id });
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
title="删除模型"
|
||||
aria-label={`删除模型 ${index + 1}`}
|
||||
disabled={busy || model.id === catalog.defaultModelId}
|
||||
onClick={() => {
|
||||
setSaved(false);
|
||||
setCatalog({
|
||||
...catalog,
|
||||
models: catalog.models.filter(
|
||||
(candidate) => candidate.id !== model.id,
|
||||
),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</td>
|
||||
{error ? <p role="alert">{error}</p> : null}
|
||||
{saved ? <p role="status">已保存</p> : null}
|
||||
{busy ? <p role="status">正在处理</p> : null}
|
||||
<div className="admin-table-wrap admin-agc-models-table">
|
||||
<table className="admin-table admin-agc-models-table-grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>别名</th>
|
||||
<th>实际模型名</th>
|
||||
<th>启用</th>
|
||||
<th>默认</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
{catalog?.models.map((model, index) => (
|
||||
<tr key={model.id}>
|
||||
<td>
|
||||
<input
|
||||
aria-label={`模型 ${index + 1} 别名`}
|
||||
maxLength={40}
|
||||
required
|
||||
value={model.alias}
|
||||
disabled={busy}
|
||||
onChange={(e) =>
|
||||
update(model.id, { alias: e.target.value })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
aria-label={`模型 ${index + 1} 实际模型名`}
|
||||
maxLength={200}
|
||||
required
|
||||
value={model.modelId}
|
||||
disabled={busy}
|
||||
onChange={(e) =>
|
||||
update(model.id, { modelId: e.target.value })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
aria-label={`模型 ${index + 1} 启用`}
|
||||
type="checkbox"
|
||||
checked={model.enabled}
|
||||
disabled={busy || model.id === catalog.defaultModelId}
|
||||
onChange={(e) =>
|
||||
update(model.id, { enabled: e.target.checked })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
aria-label={`模型 ${index + 1} 默认`}
|
||||
name="agc-default-model"
|
||||
type="radio"
|
||||
checked={model.id === catalog.defaultModelId}
|
||||
disabled={busy || !model.enabled}
|
||||
onChange={() => {
|
||||
setSaved(false);
|
||||
setCatalog({ ...catalog, defaultModelId: model.id });
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
title="删除模型"
|
||||
aria-label={`删除模型 ${index + 1}`}
|
||||
disabled={busy || model.id === catalog.defaultModelId}
|
||||
onClick={() => {
|
||||
setSaved(false);
|
||||
setCatalog({
|
||||
...catalog,
|
||||
models: catalog.models.filter(
|
||||
(candidate) => candidate.id !== model.id,
|
||||
),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{confirmDialog}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -3110,37 +3110,123 @@ button:disabled {
|
||||
.admin-agc-models {
|
||||
min-width: 0;
|
||||
}
|
||||
.admin-agc-models > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
.admin-agc-models-revision {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #e7d9cc;
|
||||
border-radius: 999px;
|
||||
background: #fffaf6;
|
||||
color: #9a8170;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.admin-agc-models-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.admin-agc-models > header > div {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
.admin-agc-models-summary > div {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid #eadfd6;
|
||||
border-radius: 10px;
|
||||
background: #fffdfa;
|
||||
}
|
||||
.admin-agc-models button {
|
||||
.admin-agc-models-summary span,
|
||||
.admin-agc-models-panel > .admin-panel-heading span {
|
||||
color: #9a8170;
|
||||
font-size: 12px;
|
||||
}
|
||||
.admin-agc-models-summary strong {
|
||||
overflow: hidden;
|
||||
color: #3d2a20;
|
||||
font-size: 20px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.admin-agc-models-panel {
|
||||
border: 1px solid #eadfd6;
|
||||
border-radius: 10px;
|
||||
background: #fffdfa;
|
||||
box-shadow: 0 10px 30px rgb(78 48 28 / 6%);
|
||||
}
|
||||
.admin-agc-models-panel > .admin-panel-heading > div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
.admin-agc-models-panel > .admin-panel-heading > svg {
|
||||
color: #b9947a;
|
||||
}
|
||||
.admin-agc-models-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 4px 0 8px;
|
||||
}
|
||||
.admin-agc-models-toolbar button,
|
||||
.admin-agc-models-table-grid button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: 32px;
|
||||
min-height: 34px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid #e2d3c7;
|
||||
border-radius: 8px;
|
||||
background: #fffaf6;
|
||||
color: #684d3d;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.admin-agc-models-table {
|
||||
overflow-x: auto;
|
||||
.admin-agc-models-toolbar button:hover,
|
||||
.admin-agc-models-toolbar button:focus-visible,
|
||||
.admin-agc-models-table-grid button:hover,
|
||||
.admin-agc-models-table-grid button:focus-visible {
|
||||
border-color: #c99d80;
|
||||
background: #fff;
|
||||
outline: none;
|
||||
}
|
||||
.admin-agc-models table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.admin-agc-models th,
|
||||
.admin-agc-models td {
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
.admin-agc-models td input:not([type]) {
|
||||
min-width: 150px;
|
||||
.admin-agc-models-toolbar button:last-child {
|
||||
border-color: #a96442;
|
||||
background: #a96442;
|
||||
color: #fff;
|
||||
}
|
||||
.admin-agc-models-table-grid {
|
||||
min-width: 720px;
|
||||
}
|
||||
.admin-agc-models-table-grid th {
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
background: #fcf7f2;
|
||||
}
|
||||
.admin-agc-models-table-grid td {
|
||||
padding-top: 14px;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
.admin-agc-models-table-grid td input:not([type]) {
|
||||
width: 100%;
|
||||
min-width: 180px;
|
||||
box-sizing: border-box;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid #e1d3c8;
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
color: #3d2a20;
|
||||
}
|
||||
.admin-agc-models-table-grid td input:not([type]):focus-visible {
|
||||
border-color: #b97854;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgb(185 120 84 / 14%);
|
||||
}
|
||||
.admin-agc-models-table-grid td:has(input[type='checkbox']),
|
||||
.admin-agc-models-table-grid td:has(input[type='radio']) {
|
||||
width: 72px;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@media (max-width: 680px) {
|
||||
.admin-agc-models-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
+52
-20
@@ -1,4 +1,4 @@
|
||||
import { RefreshCcw } from 'lucide-react';
|
||||
import { Check, ChevronDown, RefreshCcw } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { resolveTauriInvoke } from '../../app/tauri';
|
||||
@@ -19,6 +19,7 @@ export function ConversationModelSelect({
|
||||
const [selected, setSelected] = useState('');
|
||||
const [busy, setBusy] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
const refresh = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
@@ -81,30 +82,61 @@ export function ConversationModelSelect({
|
||||
return (
|
||||
<div className="conversation-model-select">
|
||||
{error ? <span role="alert">{error}</span> : null}
|
||||
<select
|
||||
aria-label="对话模型"
|
||||
disabled={disabled || busy || models.length === 0}
|
||||
value={models.some((model) => model.id === selected) ? selected : ''}
|
||||
onChange={(event) => void select(event.currentTarget.value)}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{busy ? '正在读取模型' : '选择模型'}
|
||||
</option>
|
||||
{models.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.displayName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="刷新模型列表"
|
||||
title="刷新模型列表"
|
||||
className="conversation-model-trigger"
|
||||
aria-label="对话模型"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled || busy}
|
||||
onClick={() => void refresh()}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
<RefreshCcw size={14} />
|
||||
<span
|
||||
className="conversation-model-trigger-status"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="conversation-model-trigger-label">
|
||||
{models.find((model) => model.id === selected)?.displayName ??
|
||||
(busy ? '正在读取模型' : '选择模型')}
|
||||
</span>
|
||||
<ChevronDown size={13} aria-hidden="true" />
|
||||
</button>
|
||||
{open ? (
|
||||
<div
|
||||
className="conversation-model-menu"
|
||||
role="listbox"
|
||||
aria-label="对话模型"
|
||||
>
|
||||
{models.map((model) => (
|
||||
<button
|
||||
key={model.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={model.id === selected}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
void select(model.id);
|
||||
}}
|
||||
>
|
||||
<span>{model.displayName}</span>
|
||||
{model.id === selected ? (
|
||||
<Check size={13} aria-hidden="true" />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
<div className="conversation-model-menu-divider" />
|
||||
<button
|
||||
type="button"
|
||||
className="conversation-model-menu-refresh"
|
||||
aria-label="刷新模型列表"
|
||||
disabled={disabled || busy}
|
||||
onClick={() => void refresh()}
|
||||
>
|
||||
<RefreshCcw size={13} aria-hidden="true" />
|
||||
<span>刷新模型列表</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8397,32 +8397,201 @@ iframe.preview-frame {
|
||||
max-width: calc(100% - 64px);
|
||||
pointer-events: auto;
|
||||
}
|
||||
.project-supervisor-surface.is-direct-codex
|
||||
.project-supervisor-composer
|
||||
.conversation-model-select
|
||||
select {
|
||||
.project-supervisor-surface.is-direct-codex .conversation-model-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
max-width: 160px;
|
||||
width: 130px;
|
||||
max-width: 180px;
|
||||
height: 30px;
|
||||
padding: 0 6px;
|
||||
border: 1px solid var(--platform-surface-border, #d1d5db);
|
||||
border-radius: 6px;
|
||||
background: var(--platform-input-fill, #fff);
|
||||
color: var(--platform-text-strong, #171717);
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--platform-text-soft, #6b7280);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.conversation-model-trigger:hover,
|
||||
.conversation-model-trigger:focus-visible,
|
||||
.conversation-model-trigger[aria-expanded='true'] {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--platform-button-ghost-fill, #f3f4f6) 80%,
|
||||
transparent
|
||||
);
|
||||
color: var(--platform-text-strong, #30343b);
|
||||
outline: none;
|
||||
}
|
||||
.conversation-model-trigger-status {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 50%;
|
||||
opacity: 0.65;
|
||||
}
|
||||
.conversation-model-trigger-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.conversation-model-menu {
|
||||
position: absolute;
|
||||
right: 46px;
|
||||
bottom: 43px;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
min-width: 150px;
|
||||
max-width: 190px;
|
||||
padding: 5px;
|
||||
border: 1px solid var(--platform-surface-border, #e5e7eb);
|
||||
border-radius: 10px;
|
||||
background: var(--platform-input-fill, #fff) !important;
|
||||
box-shadow:
|
||||
0 14px 30px rgb(31 41 55 / 13%),
|
||||
0 2px 6px rgb(31 41 55 / 5%);
|
||||
pointer-events: auto;
|
||||
}
|
||||
.conversation-model-menu button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
min-height: 32px;
|
||||
padding: 0 9px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--platform-text-base, #374151);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.conversation-model-menu button:hover,
|
||||
.conversation-model-menu button:focus-visible,
|
||||
.conversation-model-menu button[aria-selected='true'] {
|
||||
background: var(--platform-button-ghost-fill, #f3f4f6);
|
||||
color: var(--platform-text-strong, #111827);
|
||||
outline: none;
|
||||
}
|
||||
.game-workbench-chat
|
||||
.project-supervisor-surface.is-direct-codex
|
||||
.project-supervisor-composer
|
||||
.conversation-model-select
|
||||
.conversation-model-menu
|
||||
button {
|
||||
position: static !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: space-between !important;
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
min-height: 32px !important;
|
||||
height: 32px !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 9px !important;
|
||||
border: 0 !important;
|
||||
border-radius: 6px !important;
|
||||
background: transparent !important;
|
||||
color: var(--platform-text-base, #374151) !important;
|
||||
box-shadow: none !important;
|
||||
font-size: 12px !important;
|
||||
font-weight: 500 !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
.game-workbench-chat
|
||||
.project-supervisor-surface.is-direct-codex
|
||||
.project-supervisor-composer
|
||||
.conversation-model-select
|
||||
.conversation-model-menu
|
||||
button:hover,
|
||||
.game-workbench-chat
|
||||
.project-supervisor-surface.is-direct-codex
|
||||
.project-supervisor-composer
|
||||
.conversation-model-select
|
||||
.conversation-model-menu
|
||||
button:focus-visible,
|
||||
.game-workbench-chat
|
||||
.project-supervisor-surface.is-direct-codex
|
||||
.project-supervisor-composer
|
||||
.conversation-model-select
|
||||
.conversation-model-menu
|
||||
button[aria-selected='true'] {
|
||||
background: #f3f1ee !important;
|
||||
color: var(--platform-text-strong, #1f2937) !important;
|
||||
outline: none !important;
|
||||
}
|
||||
.conversation-model-menu-divider {
|
||||
height: 1px;
|
||||
margin: 4px 5px;
|
||||
background: var(--platform-line-soft, #ece9e5);
|
||||
}
|
||||
.conversation-model-menu-refresh {
|
||||
justify-content: flex-start !important;
|
||||
gap: 7px !important;
|
||||
color: var(--platform-text-soft, #6b7280) !important;
|
||||
}
|
||||
.conversation-model-menu-refresh:hover,
|
||||
.conversation-model-menu-refresh:focus-visible {
|
||||
color: var(--platform-text-strong, #1f2937) !important;
|
||||
}
|
||||
.game-workbench-chat
|
||||
.project-supervisor-surface.is-direct-codex
|
||||
.project-supervisor-composer
|
||||
.conversation-model-select
|
||||
.conversation-model-trigger {
|
||||
position: static !important;
|
||||
display: inline-flex !important;
|
||||
width: auto !important;
|
||||
min-width: 0 !important;
|
||||
min-height: 30px !important;
|
||||
height: 30px !important;
|
||||
padding: 0 7px !important;
|
||||
border: 0 !important;
|
||||
border-radius: 7px !important;
|
||||
background: transparent !important;
|
||||
color: var(--platform-text-soft, #6b7280) !important;
|
||||
box-shadow: none !important;
|
||||
font-size: 12px !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
.game-workbench-chat
|
||||
.project-supervisor-surface.is-direct-codex
|
||||
.project-supervisor-composer
|
||||
.conversation-model-select
|
||||
.conversation-model-trigger:hover,
|
||||
.game-workbench-chat
|
||||
.project-supervisor-surface.is-direct-codex
|
||||
.project-supervisor-composer
|
||||
.conversation-model-select
|
||||
.conversation-model-trigger:focus-visible,
|
||||
.game-workbench-chat
|
||||
.project-supervisor-surface.is-direct-codex
|
||||
.project-supervisor-composer
|
||||
.conversation-model-select
|
||||
.conversation-model-trigger[aria-expanded='true'] {
|
||||
background: var(--platform-button-ghost-fill, #f3f4f6) !important;
|
||||
color: var(--platform-text-strong, #30343b) !important;
|
||||
}
|
||||
.project-supervisor-surface.is-direct-codex
|
||||
.project-supervisor-composer
|
||||
.conversation-model-trigger {
|
||||
min-width: 0;
|
||||
max-width: 180px;
|
||||
height: 30px;
|
||||
}
|
||||
.project-supervisor-surface.is-direct-codex
|
||||
.project-supervisor-composer
|
||||
.conversation-model-select
|
||||
.conversation-model-menu
|
||||
button {
|
||||
position: static;
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
width: auto;
|
||||
min-height: 32px;
|
||||
}
|
||||
.conversation-model-select [role='alert'] {
|
||||
position: absolute;
|
||||
|
||||
@@ -38,20 +38,19 @@ afterEach(cleanup);
|
||||
test('only displays aliases and persists selection through the native command', async () => {
|
||||
const onReady = vi.fn();
|
||||
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
|
||||
await screen.findByRole('option', { name: '高质量' });
|
||||
await screen.findByRole('button', { name: '对话模型' });
|
||||
expect(screen.queryByText('gpt-6-astra')).toBeNull();
|
||||
fireEvent.change(screen.getByRole('combobox', { name: '对话模型' }), {
|
||||
target: { value: 'fast' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
|
||||
fireEvent.click(screen.getByRole('option', { name: '快速' }));
|
||||
await waitFor(() =>
|
||||
expect(invoke).toHaveBeenCalledWith('select_game_creator_model', {
|
||||
modelId: 'fast',
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
|
||||
expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe(
|
||||
'fast',
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('button', { name: '对话模型' }).textContent,
|
||||
).toContain('快速');
|
||||
});
|
||||
|
||||
test('does not mark a removed selection ready or expose the old identifier', async () => {
|
||||
@@ -71,6 +70,7 @@ test('failed catalog can be refreshed without enabling submission', async () =>
|
||||
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
|
||||
await screen.findByText('模型列表加载失败');
|
||||
expect(onReady).toHaveBeenLastCalledWith(false);
|
||||
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' }));
|
||||
await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true));
|
||||
});
|
||||
@@ -82,8 +82,9 @@ test('a failed save keeps submission unavailable', async () => {
|
||||
});
|
||||
const onReady = vi.fn();
|
||||
render(<ConversationModelSelect disabled={false} onReady={onReady} />);
|
||||
await screen.findByRole('option', { name: '快速' });
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'fast' } });
|
||||
await screen.findByRole('button', { name: '对话模型' });
|
||||
fireEvent.click(screen.getByRole('button', { name: '对话模型' }));
|
||||
fireEvent.click(screen.getByRole('option', { name: '快速' }));
|
||||
await screen.findByText('模型选择保存失败');
|
||||
expect(onReady).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
@@ -4983,9 +4983,11 @@
|
||||
- 原因:Tauri Windows bundler 执行自己的 `<tauri_tools_path>\NSIS\makensis.exe`,默认位于当前用户 `%LOCALAPPDATA%\tauri`,不使用 PATH 中预装的 `makensis.exe`;Jenkins LocalSystem/systemprofile 的 AppData 可能无法启动该缓存程序。
|
||||
- 处理:Windows 专用 Tauri 配置设置 `bundle.useLocalToolsDir: true`,把工具缓存到 `src-tauri/target/.tauri/NSIS`;Jenkins 预检验证实际用户、项目工具目录可写,并在构建失败时打印实际缓存路径和绝对路径执行结果。
|
||||
- 验证:不要把 PATH 中 `makensis` 可发现当作 Tauri bundler 工具可执行的充分证据;需要在 Windows Agent 上检查 `target/.tauri/NSIS/makensis.exe`、ACL、EDR/Defender 和直接 `-VERSION` 结果。
|
||||
|
||||
## AGC 前端等待超时与 worker 端口冲突
|
||||
|
||||
- `backend` 模式需要同时探测 API、worker 和必要的 SpacetimeDB 端口。只让 API 漂移会遗漏仍被旧进程占用的 worker 端口。
|
||||
- AGC Vite 在配套后端全部就绪后才启动。Tauri 的前端等待超时及随后的 `code=143` 可能是 worker 先失败导致的连带退出,应先检查 `.app/dev-stack.json` 各服务状态和监听进程,不能直接归因于 Vite 或数据库。
|
||||
- 外层 `start-tauri-dev.mjs` 应在启动 Tauri 前完成配套开发服务准备,并统一收束自有服务进程树;不要让冷编译和数据库发布挤占 Tauri 的前端就绪等待。自动发布必须保留数据库,不能靠清库解决启动问题。
|
||||
- CLI 与 standalone 可能是两个独立软链接。必须同时核对 `spacetime --version` 和 `spacetimedb-standalone --version`,不能把 CLI 的版本记录当作宿主版本证明;PATH 中存在宿主时启动器检查两者一致。更换宿主前停机备份数据,按原目录启动,不通过清库处理版本错配。
|
||||
- Router 配置缺失不应只在首次请求时报错。API/All 启动必须先校验官方地址、固定模型、provisioning secret、管理员 Token 和凭据加密密钥;否则服务看似 healthy,但登录后的 provisioning/模型调用才延迟失败。
|
||||
|
||||
@@ -964,3 +964,4 @@ node scripts/rebind-orphan-work-owners.mjs --in <exported-migration.json> --out
|
||||
## 维护页目标文件安全边界(2026-08-05)
|
||||
|
||||
`scripts/deploy/maintenance-on.sh` 只允许把同目录临时普通文件原子替换到普通文件或尚不存在的 `page.html` / `enabled` 目标。目标只要是符号链接(包括指向目录的链接)或目录,脚本必须在替换前失败,不能跟随链接把临时文件移入链接目标,也不能打印“已进入维护模式”。`page_temp` 与 `marker_temp` 必须在 `set -u` 下安全初始化,清理 trap 必须在首次 `mktemp` 前生效;任一失败退出都不得在目标同级遗留 `page.html.tmp.*` 或 `enabled.tmp.*`,成功替换后则清空临时路径并解除 trap,不能误删已安装目标。跨平台实现继续使用 POSIX `mv -f`,安全语义由替换函数的目标类型门禁保证;修改后运行 `bash -n scripts/deploy/maintenance-on.sh` 与 `npm run check:maintenance-page`。
|
||||
api-server 启动时会硬校验 AGC 官方 Router 配置:API/All 角色必须使用官方 HTTPS 地址和固定模型,并配置 provisioning secret、Router 管理员 Token,以及专用加密 secret 或有效 JWT secret;缺失或不匹配直接拒绝启动。`test` 环境仅允许 loopback fixture,worker-only 角色不执行 Router 配置校验。
|
||||
|
||||
@@ -153,6 +153,7 @@ fn main() -> Result<(), io::Error> {
|
||||
|
||||
async fn run_server(config: AppConfig) -> Result<(), io::Error> {
|
||||
validate_bgfilter_internal_token_for_startup(&config).map_err(io::Error::other)?;
|
||||
validate_llm_router_config_for_startup(&config).map_err(io::Error::other)?;
|
||||
init_tracing(
|
||||
&config.log_filter,
|
||||
OtelConfig {
|
||||
@@ -174,6 +175,69 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> {
|
||||
run_http_role(config).await
|
||||
}
|
||||
|
||||
fn validate_llm_router_config_for_startup(config: &AppConfig) -> Result<(), String> {
|
||||
if !matches!(config.process_role, ProcessRole::Api | ProcessRole::All) {
|
||||
return Ok(());
|
||||
}
|
||||
let base_url = config.llm_router_base_url.trim_end_matches('/');
|
||||
let url =
|
||||
reqwest::Url::parse(base_url).map_err(|error| format!("LLM Router 地址无效:{error}"))?;
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| "LLM Router 地址缺少主机名".to_string())?;
|
||||
let is_loopback = host.eq_ignore_ascii_case("localhost")
|
||||
|| host
|
||||
.parse::<std::net::IpAddr>()
|
||||
.is_ok_and(|address| address.is_loopback());
|
||||
|
||||
if config.is_test_environment() {
|
||||
if !is_loopback || !matches!(url.scheme(), "http" | "https") {
|
||||
return Err("test 环境的 LLM Router 必须是 HTTP/HTTPS loopback 地址".to_string());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if base_url != OFFICIAL_LLM_ROUTER_BASE_URL {
|
||||
return Err(format!(
|
||||
"LLM Router 必须使用官方固定地址 {OFFICIAL_LLM_ROUTER_BASE_URL}"
|
||||
));
|
||||
}
|
||||
if config.llm_router_model.trim() != OFFICIAL_LLM_ROUTER_MODEL {
|
||||
return Err(format!(
|
||||
"LLM Router 必须使用官方固定模型 {OFFICIAL_LLM_ROUTER_MODEL}"
|
||||
));
|
||||
}
|
||||
if url.scheme() != "https" {
|
||||
return Err("官方 LLM Router 必须使用 HTTPS".to_string());
|
||||
}
|
||||
if config
|
||||
.llm_router_provisioning_secret
|
||||
.as_deref()
|
||||
.is_none_or(|value| value.trim().is_empty())
|
||||
{
|
||||
return Err(
|
||||
"缺少 GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET 或对应 secret file".to_string(),
|
||||
);
|
||||
}
|
||||
if config
|
||||
.llm_router_admin_token
|
||||
.as_deref()
|
||||
.is_none_or(|value| value.trim().is_empty())
|
||||
{
|
||||
return Err("缺少 GENARRATIVE_LLM_ROUTER_ADMIN_TOKEN 或对应 secret file".to_string());
|
||||
}
|
||||
if config
|
||||
.effective_llm_router_api_key_encryption_secret()
|
||||
.is_none()
|
||||
{
|
||||
return Err(
|
||||
"缺少 GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET,且无法从 JWT secret 派生"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn log_llm_router_startup_warnings(config: &AppConfig) {
|
||||
let base_url = config.llm_router_base_url.trim_end_matches('/');
|
||||
let is_loopback = reqwest::Url::parse(base_url)
|
||||
@@ -778,7 +842,7 @@ mod tests {
|
||||
should_initialize_editor_generation_pricing_for_startup,
|
||||
should_restore_auth_store_for_startup, should_start_profile_recharge_expiration_listener,
|
||||
should_validate_bgfilter_internal_token_for_startup, strip_env_value,
|
||||
validate_bgfilter_internal_token_for_startup,
|
||||
validate_bgfilter_internal_token_for_startup, validate_llm_router_config_for_startup,
|
||||
};
|
||||
use crate::config::{AppConfig, ProcessRole};
|
||||
|
||||
@@ -898,6 +962,45 @@ mod tests {
|
||||
assert!(validate_bgfilter_internal_token_for_startup(&missing_config).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llm_router_config_startup_validation_requires_all_production_inputs() {
|
||||
let mut config = AppConfig::default();
|
||||
config.process_role = ProcessRole::Api;
|
||||
config.environment = "production".to_string();
|
||||
assert!(
|
||||
validate_llm_router_config_for_startup(&config)
|
||||
.unwrap_err()
|
||||
.contains("PROVISIONING_SECRET")
|
||||
);
|
||||
|
||||
config.llm_router_provisioning_secret = Some("provisioning".to_string());
|
||||
assert!(
|
||||
validate_llm_router_config_for_startup(&config)
|
||||
.unwrap_err()
|
||||
.contains("ADMIN_TOKEN")
|
||||
);
|
||||
|
||||
config.llm_router_admin_token = Some("admin".to_string());
|
||||
config.jwt_secret = "jwt-secret".to_string();
|
||||
assert!(validate_llm_router_config_for_startup(&config).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llm_router_config_startup_validation_allows_test_loopback_fixture() {
|
||||
let mut config = AppConfig::default();
|
||||
config.process_role = ProcessRole::Api;
|
||||
config.environment = "test".to_string();
|
||||
config.llm_router_base_url = "http://127.0.0.1:43125/v1".to_string();
|
||||
assert!(validate_llm_router_config_for_startup(&config).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llm_router_config_startup_validation_skips_non_api_roles() {
|
||||
let mut config = AppConfig::default();
|
||||
config.process_role = ProcessRole::BgfilterWorker;
|
||||
assert!(validate_llm_router_config_for_startup(&config).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_store_startup_restore_is_limited_to_http_roles() {
|
||||
assert!(should_restore_auth_store_for_startup(ProcessRole::Api));
|
||||
|
||||
Reference in New Issue
Block a user