/** * 图片兼容设置面板(桌面与手机端共用)。 * * ── 设计口径 ────────────────────────────────────────────────── * · 模型看不了图片时只有两种办法,所以这里是**两块固定设置**,不是可以增删的 * 配置列表:每块只回答两件事 —— 「哪些模型」和「用哪只视觉模型」; * · 没被任何名单选中的模型、以及本来就能看图的模型,一律原样放行,不需要 * 再写一条「不做兼容」的规则去声明; * · **没有保存步骤**:跟设置页里网关开关、重试、超时一样,改完立即提交。 * 所以这里没有草稿、没有「撤销 / 保存」,也就没有保存前的授权弹窗 —— * 「图片会交给哪只模型」由每块底部的「实际处理」那行直接说明; * · 两块名单重叠时按辅助识图处理,界面把这件事直接指出来,而不是静默二选一。 * * ── 与后端契约 ──────────────────────────────────────────────── * 读写仍然是 `/api/vision-compatibility` 的 GET / PUT(`revision`)。 * 旧版(全局二选一 + 模型单独配置)由服务端按相同语义展开成名单返回, * 展开结果只在用户真的改了一处之后才按新形式存回去。 */ import { useCallback, useEffect, useRef, useState } from 'react' import { Alert, Button, Card, CardAction, CardContent, CardHeader, CardTitle, Collapsible, CollapsibleContent, CollapsibleTrigger, Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, StatusDot, cn, } from '@ui' import type { Translate } from './assistant-read-types' import { FooterNote, Group, Row, SwitchRow, Tip } from './settings-vision-rows' import { VisionReasoningSelect } from './settings-vision-editors' import { VisionAssistCard, VisionRerouteCard, visionModelLabel } from './settings-vision-way' import { visionOverlap, type VisionCompatApi, type VisionSettings, type VisionView } from './settings-vision-types' /** * 折叠入口上的小箭头(与项目其它内联图标同一套几何:24 画布、currentColor)。 * `open` 传布尔是受控旋转;传 `'group'` 是跟随外层 `panel-fold` 折叠组的开合状态。 */ function Chevron({ open }: { open: boolean | 'group' }) { return } function InfoIcon() { return } /** 数字项:编辑期间只改本地文案,失焦 / 回车才提交(与设置页其它数字项同一套做法) */ function TimeoutField({ value, disabled, onCommit, t }: { value: number disabled: boolean onCommit(next: number): void t: Translate }) { const [text, setText] = useState(null) const shown = text ?? String(value) const parsed = Number(shown) const valid = Number.isInteger(parsed) && parsed >= 5 && parsed <= 120 return setText(event.currentTarget.value)} onKeyDown={event => { if (event.key === 'Enter') event.currentTarget.blur() }} onBlur={() => { if (text !== null) { if (valid) onCommit(parsed); setText(null) } }} /> {t('秒')} } function message(failure: unknown): string { return failure instanceof Error ? failure.message : String(failure) } /** 上次成功读取的视图:设置页岛重挂时先回种子再静默刷新,避免每次进页面都闪「加载中」 */ let cachedView: VisionView | null = null export function VisionCompatibilityPanel({ api, t, active = true }: { api: VisionCompatApi; t: Translate; active?: boolean }) { const [view, setView] = useState(() => cachedView) const [draft, setDraft] = useState(() => cachedView ? structuredClone(cachedView.config) : null) const [busy, setBusy] = useState(false) /** 首次读取进行中(无种子时首帧必须是「加载中」而不是误显「读取失败」) */ const [loading, setLoading] = useState(() => !cachedView) const [error, setError] = useState('') const [more, setMore] = useState(false) const alive = useRef(true) const sequence = useRef(0) /** 服务端最新状态:提交要用它的 revision,失败回滚也回到它 */ const latest = useRef(cachedView) /** 提交串行化:改动是逐次点出来的,但在途请求不能并发(否则后一次会拿到旧的 revision) */ const chain = useRef>(Promise.resolve()) const adopt = (next: VisionView) => { latest.current = next cachedView = next setView(next) setDraft(structuredClone(next.config)) } const load = useCallback(async (quiet = false) => { const token = ++sequence.current if (!quiet) setBusy(true) setLoading(true) setError('') try { const next = await api.load() if (alive.current && token === sequence.current) adopt(next) } catch (failure) { if (alive.current && token === sequence.current) setError(message(failure)) } finally { if (alive.current && token === sequence.current) setBusy(false) if (alive.current && token === sequence.current) setLoading(false) } }, [api]) useEffect(() => { alive.current = true return () => { alive.current = false; ++sequence.current } }, []) useEffect(() => { if (active) void load(true) }, [active, load]) /** 改一处就提交一次:界面上没有保存按钮,这里也不留草稿状态 */ const commit = (next: VisionSettings) => { setDraft(next) setError('') chain.current = chain.current.then(async () => { const current = latest.current if (!current) return setBusy(true) try { const saved = await api.save({ config: next, revision: current.revision }) if (!alive.current) return latest.current = saved setView(saved) } catch (failure) { if (!alive.current) return setError(message(failure)) setDraft(structuredClone(latest.current?.config ?? current.config)) } finally { if (alive.current) setBusy(false) } }) } if (!view || !draft) { return } className='panel'> {t('图片兼容')}

{loading || busy ? t('加载中…') : t('图片兼容设置读取失败')}

{error ? {error} : null} {!loading && !busy && error ? : null}
} const overlap = visionOverlap(draft) const patch = (next: Partial) => commit({ ...draft, ...next }) // 思考等级是逐只视觉模型存的;两块用同一个选择,所以改一次就写到当前所有目标上 const levels = [ ...(draft.assist.target ? [draft.assist.target.reasoning] : []), ...draft.reroute.targets.map(target => target.reasoning), ] const reasoning = levels.length > 0 && levels.every(level => level === levels[0]) ? levels[0] : '' const setReasoning = (next: string) => patch({ assist: { ...draft.assist, target: draft.assist.target ? { ...draft.assist.target, reasoning: next } : null }, reroute: { ...draft.reroute, targets: draft.reroute.targets.map(target => ({ ...target, reasoning: next })) }, }) return } className='panel panel-fold group/fold'>

{t('图片兼容')}

{draft.enabled ? t('已开启') : t('已关闭')}

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

{view.legacyExpanded ?

{t('原来的「全局方式 + 模型单独配置」已按相同意思自动填成下面的模型名单,可以照用也可以重新调整;改动一处之后就会按新形式存下来。')}

: null} {error ? {error} : null} patch({ enabled })} /> {!draft.enabled ?

{t('图片兼容当前是关闭的:下面两块设置照样能改、会被保留,但不会对任何请求生效。')}

: null}
patch({ assist: { ...draft.assist, ...assist } })} /> patch({ reroute: { ...draft.reroute, ...reroute } })} />
{overlap.length ?
{t('{models} 同时出现在两块设置里,会按「辅助识图」处理。想改的话,把其中一个从这边的模型名单里去掉。', { models: overlap.map(entry => visionModelLabel(view.models, entry, t)).join('、'), })}
: null}
}> {t('更多设置')} {t('思考等级、图片能力未知、等待上限')} } /> patch({ unknownCapability: unknownCapability as VisionSettings['unknownCapability'] })}> {draft.unknownCapability === 'try_original' ? t('先发送原图,明确拒图后补救') : t('直接按上面的设置兼容')} {t('先发送原图,明确拒图后补救')} {t('直接按上面的设置兼容')} } /> patch({ timeoutSeconds })} /> } /> {t('图片过大、格式错误或下载失败不会被当作模型不支持图片。')}
}