/** * 路由页「特殊请求路由」的三块配置面板。 * * ── 版面口径(2026-11 改版)────────────────────────────────── * 三块配置的真实形状都是「一份规则列表」:压缩上下文是「哪个客户端发来的压缩请求 * 改成哪只模型」,图片兼容是「哪些模型看不了图、交给谁」,标题生成是「认哪些客户端、 * 用哪只模型」。原来把它们画成「触发 → 改写 → 目标」三个方框 + 一堆徽章,一屏里 * 十来个描边矩形,读者得自己拼出「谁触发、改成什么」。 * * 现在改成系统设置那种分组列表(与设置页的客户端识别面板同一口径): * 一句话说清这一类请求怎么被改写 → 一份规则列表(每行一句话)→ * 点列表里的一行改这条规则,「添加规则」开弹窗填字段。 * * 三块共用同一套零件(RulePanel / RuleSection / RuleRow / 弹窗字段), * 差别只在表单字段 —— 状态机也一样(读取 / 改草稿 / 保存 / 撤销), * 由 routing-special-state 的 useSpecialEditor 收口。 * * ── 迁移进度 ────────────────────────────────────────────────── * 已迁到新版式:压缩上下文。 * 仍用旧版式(SpecialShell / SpecialFlow,下面那一段):标题生成 / 图片兼容 —— * 它们要连同后端契约一起改(标题生成从「一套模型管所有客户端」改成按规则, * 图片兼容从「两块固定设置」改成按规则),改完一起删掉旧壳子。 */ import * as React from 'react' import { Button, Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader, DialogTitle, Label, MultiSelect, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Spinner, Switch, } from '@ui' import { t } from '../i18n' import { RouteIcon } from './routing-ui' import { SearchableSelect } from '../../../ui-shared/searchable-select' import { modelKey, type CompactConfig, type CompactRoute, type CompactView, type SpecialEditor, type TitleConfig, type TitleRule, type TitleView, type VisionConfig, type VisionModelRef, type VisionRuleItem, type VisionTarget, type VisionView, } from './routing-special-state' import '../styles/routing-special.css' type ModelChoice = { id: string; name?: string; providerLabel?: string; provider?: string; kind?: string } /** 模型下拉显示 ID,名称与提供商仅用于搜索。 */ function ModelSelect({ value, models, exclude, onChange, disabled, ariaLabel }: { value: string; models: ModelChoice[]; exclude?: string[]; onChange(next: string): void disabled?: boolean; ariaLabel: string }) { // 同一个模型 ID 可能来自多家(别名映射),按 key 去重后再列出。 const ids = [...new Map(models.map(model => [modelKey(model.id), model.id])).values()] .filter(id => !exclude?.includes(modelKey(id)) || modelKey(id) === modelKey(value)) const selectedId = ids.find(id => modelKey(id) === modelKey(value)) || value return ({ value: id, label: id, searchText: models.filter(model => modelKey(model.id) === modelKey(id)) .map(model => `${model.name || ''} ${model.providerLabel || ''} ${model.provider || ''}`).join(' '), }))} /> } /* ═══════════════ 新版式:整页 = 一句话 + 一份规则列表 ═══════════════ */ /** * 面板外壳:名字 + 一句话规则 + 总开关 + 正文 + 底部保存条。 * * 保存条留在面板底部而不是提到页头工具条:三块配置各有一份修订号、各自独立保存, * 页头那一格同时只能属于一块,「保存」按钮离它管的那块内容太远反而容易点错。 */ function RulePanel({ title, lede, enabled, onEnabled, busy, dirty, error, loading, onReload, onSave, onDiscard, children }: { title: string lede: string enabled: boolean onEnabled(next: boolean): void busy: boolean dirty: boolean error: string loading: boolean onReload(): void onSave(): void onDiscard(): void children: React.ReactNode }) { return

{title}

{lede}

{t('规则全局生效,不受原路由池限制;指定模型按自己的路由选账号,网关 Key 权限仍然生效。')}

{loading ?
{t('读取中…')}
: children} {error ?

{error}

: null}
{dirty ? t('有未保存的修改') : t('已保存')}
} /** 一个分组:13px 半粗标题(带计数与行尾操作)+ 一张无描边白底圆角卡 */ function RuleSection({ title, count, action, children }: { title: string; count?: string; action?: React.ReactNode; children: React.ReactNode }) { return

{title}

{count ? {count} : null} {action ? {action} : null}
{children}
} /** 一行规则:整行可点(真按钮,键盘能 Tab 到),右侧一枚雪佛龙暗示「点进去改」 */ function RuleRow({ title, detail, side, off, onClick }: { title: React.ReactNode detail: React.ReactNode side?: React.ReactNode off?: boolean onClick(): void }) { return } /** 弹窗表单的一格:标签在上、控件在下(与设置页的规则编辑弹窗同一口径) */ function DlgField({ label, children }: { label: string; children: React.ReactNode }) { return
{children}
} /** 弹窗顶部那条「启用规则」:开关在左、状态右对齐 */ function RuleSwitchRow({ enabled, disabled, onChange }: { enabled: boolean; disabled?: boolean; onChange(next: boolean): void }) { return
{t('启用规则')} {enabled ? t('已启用') : t('已停用')}
} /** 弹窗底栏:左边一枚删除(只编辑已有规则时出现),右边取消 + 保存 */ function RuleDialogFooter({ onClose, onSave, onDelete, disabled }: { onClose(): void; onSave(): void; onDelete?(): void; disabled?: boolean }) { return {onDelete ? : null} } /* ─────────────────── 压缩上下文 ─────────────────── */ function CompactRuleDialog({ rule, models, clients, saving, onClose, onSave, onDelete }: { rule: CompactRoute models: ModelChoice[] clients: CompactView['clients'] saving: boolean onClose(): void onSave(rule: CompactRoute): void onDelete?(): void }) { const [draft, setDraft] = React.useState(rule) const update = (patch: Partial) => setDraft(current => ({ ...current, ...patch })) const clientLabel = clients.find(client => client.id === draft.client)?.label || draft.client return { if (!next) onClose() }}> {onDelete ? t('编辑规则') : t('添加规则')}

{t('命中这条规则的压缩请求会改用「压缩模型」重发一次;改写之后按新模型自己的路由挑账号。')}

update({ enabled })} /> update({ source })} /> update({ target })} />
onSave(draft)} onDelete={onDelete} />
} export function CompactSpecialPanel({ editor }: { editor: SpecialEditor }) { const { view, draft, loading, saving, dirty, error } = editor const models = view?.models || [] const clients = view?.clients || [] const maxRules = view?.maxRules || 0 const rules = draft?.rules || [] /** 正在编辑的那条规则:index = -1 表示新增(保存时追加到末尾) */ const [editing, setEditing] = React.useState<{ rule: CompactRoute; index: number } | null>(null) const clientLabel = (id: string) => clients.find(client => client.id === id)?.label || id const full = maxRules > 0 && rules.length >= maxRules const write = (index: number, rule: CompactRoute) => editor.update(current => ({ ...current, rules: index < 0 ? [...current.rules, rule] : current.rules.map((item, at) => at === index ? rule : item), })) const blank: CompactRoute = { client: clients[0]?.id || '', source: '', target: '', enabled: true } return editor.update(current => ({ ...current, enabled: next }))} busy={saving || loading} dirty={dirty} error={error} loading={loading} onReload={() => void editor.reload()} onSave={() => void editor.save()} onDiscard={editor.discard}> {draft ? <> setEditing({ rule: blank, index: -1 })}> {t('添加规则')} }> {rules.length ? rules.map((rule, index) => {rule.source || t('还没选原模型')} {rule.target || t('还没选压缩模型')} } side={rule.enabled ? null : t('已停用')} onClick={() => setEditing({ rule, index })} />) :

{t('还没有规则。压缩请求会按原模型照常转发。')}

}
{!models.length ?

{t('没有可选的对话模型,请先启用模型和对应账号。')}

: null} {full ?

{t('规则最多 {n} 条。', { n: maxRules })}

: null} : null} {editing ? setEditing(null)} onSave={rule => { write(editing.index, rule); setEditing(null) }} onDelete={editing.index < 0 ? undefined : () => editor.update(current => ({ ...current, rules: current.rules.filter((_, at) => at !== editing.index), }))} /> : null}
} /* ─────────────────── 图片兼容 ─────────────────── */ const visionKey = (target: { provider: string; model: string }) => JSON.stringify([target.provider, target.model]) const parseVisionKey = (key: string): VisionModelRef | null => { try { const parsed: unknown = JSON.parse(key) return Array.isArray(parsed) && parsed.length === 2 && parsed.every(item => typeof item === 'string') ? { provider: String(parsed[0]), model: String(parsed[1]) } : null } catch { return null } } /** 图片兼容按提供商绑定模型,显示 ID 时仍需保留提供商以区分同名模型。 */ function visionLabel(models: VisionView['models'], ref: { provider: string; model: string } | null): string { if (!ref) return '' const found = models.find(model => model.provider === ref.provider && modelKey(model.model) === modelKey(ref.model)) return `${found?.providerLabel || ref.provider} · ${found?.model || ref.model}` } function VisionRuleDialog({ rule, view, saving, onClose, onSave, onDelete }: { rule: VisionRuleItem view: VisionView saving: boolean onClose(): void onSave(rule: VisionRuleItem): void onDelete?(): void }) { const [draft, setDraft] = React.useState(rule) const [candidate, setCandidate] = React.useState('') const update = (patch: Partial) => setDraft(current => ({ ...current, ...patch })) const listChoices = view.models.filter(model => model.capabilities?.supportsImages !== true) .map(model => ({ value: visionKey(model), label: `${model.providerLabel || model.provider} · ${model.model}` })) const targetChoices = view.models.filter(model => model.capabilities?.supportsImages === true) .map(model => ({ value: visionKey(model), label: `${model.providerLabel || model.provider} · ${model.model}`, searchText: `${model.name || ''} ${model.provider}` })) const [primary, ...backups] = draft.targets const setTargets = (targets: VisionTarget[]) => update({ targets, fallback: targets.length ? 'designated' : 'stop' }) const pick = (value: string): VisionTarget | null => { const ref = parseVisionKey(value) return ref ? { ...ref, reasoning: '' } : null } const move = (index: number, direction: number) => { const next = [...draft.targets] const to = index + direction if (to < 0 || to >= next.length) return ;[next[index], next[to]] = [next[to], next[index]] setTargets(next) } return { if (!next) onClose() }}> {onDelete ? t('编辑规则') : t('添加规则')}

{t('这条规则里的模型收到图片时按选定的方式处理;没被任何规则选中的模型一律原样放行。')}

update({ enabled })} /> update({ models: keys.map(parseVisionKey).filter((ref): ref is VisionModelRef => ref !== null) })} /> {draft.mode === 'assist' ? <> { if (next === 'shared') { update({ source: 'shared' }); return } const target = pick(next) if (target) update({ source: 'custom', target: { ...target, reasoning: draft.target?.reasoning || '' } }) }} />
update({ reuseResults: next === true })} /> {t('同一会话、同一问题、相同权限范围内复用;追问新细节会重新识读。')}
: <>
update({ preferCurrentRoute: next === true })} /> {t('先尝试当前路由内的视觉模型,不可用时再按顺序尝试指定模型;指定模型可跨路由池。')}
{ const target = pick(next) if (target) setTargets(target ? [{ ...target, reasoning: primary?.reasoning || '' }, ...backups] : backups) }} />
= view.maxTargets} aria-label={t('备选模型')} searchPlaceholder={t('搜索模型')} displayValue={visionLabel(view.models, parseVisionKey(candidate)) || t('请选择模型')} options={targetChoices} onValueChange={setCandidate} />
{backups.map((backup, index) =>
{visionLabel(view.models, backup)}
)} {t('备选按顺序尝试;一个都不加时,当前路由内找不到视觉模型就停止并说明原因。')}
}
onSave(draft)} onDelete={onDelete} />
} export function VisionSpecialPanel({ editor }: { editor: SpecialEditor }) { const { view, draft, loading, saving, dirty, error } = editor const rules = draft?.rules || [] const [editing, setEditing] = React.useState<{ rule: VisionRuleItem; index: number } | null>(null) const blank: VisionRuleItem = { enabled: true, models: [], mode: 'assist', source: 'shared', target: null, reuseResults: true, preferCurrentRoute: true, fallback: 'stop', targets: [], } const write = (index: number, rule: VisionRuleItem) => editor.update(current => ({ ...current, rules: index < 0 ? [...current.rules, rule] : current.rules.map((item, at) => at === index ? rule : item), })) const detail = (rule: VisionRuleItem) => { const label = visionLabel(view?.models || [], rule.targets[0] || rule.target) if (rule.mode === 'assist') { const reader = rule.source === 'shared' ? t('工具服务的视觉模型') : (label || t('还没选视觉模型')) return <>{t('先读图再交回')}{reader}· {t('原模型继续回答')} } const taker = rule.preferCurrentRoute ? t('当前路由内的视觉模型') : (label || t('还没选视觉模型')) return <>{t('整次请求改交')}{taker} } return editor.update(current => ({ ...current, enabled: next }))} busy={saving || loading} dirty={dirty} error={error} loading={loading} onReload={() => void editor.reload()} onSave={() => void editor.save()} onDiscard={editor.discard}> {draft && view ? <> setEditing({ rule: blank, index: -1 })}> {t('添加规则')} }> {rules.length ? rules.map((rule, index) => visionLabel(view.models, model)).join('、') }) : t('未选择模型')} detail={detail(rule)} side={rule.enabled ? null : t('已停用')} onClick={() => setEditing({ rule, index })} />) :

{t('还没有规则。图片会照常交给原模型,看不了的就会报错。')}

}
{!view.models.length ?

{t('还没有可用的模型,请先启用模型和对应账号。')}

: null} : null} {editing && view ? setEditing(null)} onSave={rule => { write(editing.index, rule); setEditing(null) }} onDelete={editing.index < 0 ? undefined : () => editor.update(current => ({ ...current, rules: current.rules.filter((_, at) => at !== editing.index), }))} /> : null}
} /* ─────────────────── 标题生成 ─────────────────── */ function TitleRuleDialog({ rule, models, clients, saving, onClose, onSave, onDelete }: { rule: TitleRule models: ModelChoice[] clients: TitleView['clients'] saving: boolean onClose(): void onSave(rule: TitleRule): void onDelete?(): void }) { const [draft, setDraft] = React.useState(rule) const update = (patch: Partial) => setDraft(current => ({ ...current, ...patch })) return { if (!next) onClose() }}> {onDelete ? t('编辑规则') : t('添加规则')}

{t('这条规则只改写被识别为标题生成的小请求,普通对话不受影响。')}

update({ enabled })} /> ({ value: client.id, label: client.label, disabled: !client.supported }))} placeholder={t('选择一个或多个客户端')} searchPlaceholder={t('搜索客户端')} aria-label={t('识别客户端')} onValueChange={next => update({ clients: next })} /> update({ model })} />
onSave(draft)} onDelete={onDelete} />
} export function TitleSpecialPanel({ editor }: { editor: SpecialEditor }) { const { view, draft, loading, saving, dirty, error } = editor const models = view?.models || [] const clients = view?.clients || [] const rules = draft?.rules || [] const [editing, setEditing] = React.useState<{ rule: TitleRule; index: number } | null>(null) const clientLabel = (id: string) => clients.find(client => client.id === id)?.label || id const write = (index: number, rule: TitleRule) => editor.update(current => ({ ...current, rules: index < 0 ? [...current.rules, rule] : current.rules.map((item, at) => at === index ? rule : item), })) const blank: TitleRule = { enabled: true, clients: [], model: '', fallback: 'original' } return editor.update(current => ({ ...current, enabled: next }))} busy={saving || loading} dirty={dirty} error={error} loading={loading} onReload={() => void editor.reload()} onSave={() => void editor.save()} onDiscard={editor.discard}> {draft ? <> setEditing({ rule: blank, index: -1 })}> {t('添加规则')} }> {rules.length ? rules.map((rule, index) => {t('改用')} {rule.model || t('还没选标题模型')} · {rule.fallback === 'original' ? t('失败时退回原模型') : t('失败时直接报错')} } side={rule.enabled ? null : t('已停用')} onClick={() => setEditing({ rule, index })} />) :

{t('还没有规则。标题请求会照常交给原模型。')}

}
{!models.length ?

{t('没有可选的对话模型,请先启用模型和对应账号。')}

: null} : null} {editing ? setEditing(null)} onSave={rule => { write(editing.index, rule); setEditing(null) }} onDelete={editing.index < 0 ? undefined : () => editor.update(current => ({ ...current, rules: current.rules.filter((_, at) => at !== editing.index), }))} /> : null}
}