fix(opencode): switch session models without restart

This commit is contained in:
2026-08-20 22:38:18 +08:00
parent 1c6b004436
commit c0163bc507
18 changed files with 718 additions and 92 deletions

View File

@@ -863,6 +863,8 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
const abortSession = useOpencodeStore((state) => state.abortSession);
const deleteOpencodeSession = useOpencodeStore((state) => state.deleteSession);
const sendSessionMessage = useOpencodeStore((state) => state.sendSessionMessage);
const sessionModelBySessionId = useOpencodeStore((state) => state.sessionModelBySessionId);
const switchSessionModel = useOpencodeStore((state) => state.switchSessionModel);
const cancelQueuedSessionPrompt = useOpencodeStore((state) => state.cancelQueuedSessionPrompt);
const renameSession = useOpencodeStore((state) => state.renameSession);
const shareSession = useOpencodeStore((state) => state.shareSession);
@@ -951,6 +953,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
const seenCompactionIdsBySessionRef = useRef(new Map<string, Set<string>>());
const lastCompactionAnnouncementSessionRef = useRef<string | null>(null);
const composerAttachmentInputRef = useRef<HTMLInputElement | null>(null);
const modelSelectorRef = useRef<HTMLSelectElement | null>(null);
const composerStopArmTimerRef = useRef<number | null>(null);
const userModelConfigSyncRef = useRef<Promise<boolean> | null>(null);
const localProxyModelConfigSyncedRef = useRef(false);
@@ -1040,6 +1043,21 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
() => buildConfiguredModelOptions(providerAccounts, providerStatuses, providerDefaultAccountId),
[providerAccounts, providerDefaultAccountId, providerStatuses],
);
const activeSessionModel = selectedSessionId
? sessionModelBySessionId[selectedSessionId] ?? selectedProjectAgent?.model ?? ''
: selectedProjectAgent?.model ?? '';
const selectableChatModelOptions = useMemo(() => {
if (!activeSessionModel || configuredChatModelOptions.some((option) => option.modelRef === activeSessionModel)) {
return configuredChatModelOptions;
}
return [{
modelRef: activeSessionModel,
label: formatModelRefLabel(activeSessionModel),
runtimeProviderKey: activeSessionModel.split('/')[0] ?? '',
accountId: '',
capability: 'text' as const,
}, ...configuredChatModelOptions];
}, [activeSessionModel, configuredChatModelOptions]);
const canStopSession = canUseSessions && Boolean(selectedSessionId) && sessionBusy && composerStopArmed;
const canUseVoiceInput = canUseSessions
&& Boolean(selectedSessionId || selectedAgentId)
@@ -1128,7 +1146,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
hasUndoableUserMessage: Boolean(undoableUserMessage),
runtimeReady,
busy: sessionBusy || selectedSessionRunning,
hasModel: Boolean(selectedProjectAgent?.model?.trim()),
hasModel: Boolean(activeSessionModel.trim()),
shared: Boolean(currentSession?.share?.url),
reverted: Boolean(currentSession?.revert?.messageID),
shareEnabled: cachedCommandCatalog?.shareEnabled ?? true,
@@ -1596,10 +1614,10 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
const ensureChatModelConfigured = useCallback(async () => {
if (!(await refreshWorksSquareUserModelConfigIfNeeded())) return false;
if (selectedProjectAgent?.model?.trim()) return true;
if (activeSessionModel.trim()) return true;
setModelConfigPromptOpen(true);
return false;
}, [refreshWorksSquareUserModelConfigIfNeeded, selectedProjectAgent?.model]);
}, [activeSessionModel, refreshWorksSquareUserModelConfigIfNeeded]);
const openSubscriptionUpgrade = useCallback(() => {
void openWorksSquareSubscriptionUpgrade().catch(() => undefined);
@@ -1644,6 +1662,9 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
return;
}
const configuredAgentModel = submissionAgent.model?.trim();
const submissionModel = submissionSessionId
? useOpencodeStore.getState().sessionModelBySessionId[submissionSessionId] ?? configuredAgentModel
: configuredAgentModel;
if (!(await refreshWorksSquareUserModelConfigIfNeeded())) return;
if (!sessionStillMatches()) return;
const latestProviderState = useProviderStore.getState();
@@ -1652,7 +1673,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
latestProviderState.statuses,
latestProviderState.defaultAccountId,
);
if (!configuredAgentModel || !latestConfiguredChatModelOptions.some((option) => option.modelRef === configuredAgentModel)) {
if (!submissionModel || !latestConfiguredChatModelOptions.some((option) => option.modelRef === submissionModel)) {
setModelConfigPromptOpen(true);
toast.error('这个伙伴的模型不可用,请先更新伙伴配置。');
return;
@@ -1723,7 +1744,6 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
await sendSessionMessage(submissionSessionId, nextDraft, {
attachedFiles,
agentId: submissionAgent.id,
model: configuredAgentModel,
onHostAccepted,
});
} catch {
@@ -2085,6 +2105,32 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
});
}, []);
const openSessionModelSelector = useCallback(() => {
if (!selectedSessionId) throw new Error('请先选择会话');
const selector = modelSelectorRef.current;
if (!selector) throw new Error('当前没有可用的模型选择器');
selector.focus();
try {
selector.showPicker?.();
} catch {
// Focus is the supported fallback when the platform cannot open a native picker programmatically.
}
}, [selectedSessionId]);
const handleSessionModelChange = useCallback(async (event: ChangeEvent<HTMLSelectElement>) => {
const sessionId = selectedSessionId;
const model = event.target.value;
if (!sessionId || !model || model === activeSessionModel) return;
try {
await switchSessionModel(sessionId, model);
toast.success(`当前会话已切换到 ${formatModelRefLabel(model)}`);
} catch (switchError) {
toast.error('模型切换失败', {
description: normalizeCommandError(switchError),
});
}
}, [activeSessionModel, selectedSessionId, switchSessionModel]);
const runCompact = useCallback(async () => {
if (!(await ensureChatModelConfigured())) {
throw new Error('模型未就绪,请完成配置或手动重启运行时后重试。');
@@ -2092,12 +2138,12 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
const owner = submissionSessionRef.current;
const sessionId = owner.id;
if (!sessionId) throw new Error('请先选择会话');
await compactSession(sessionId, selectedProjectAgent?.model ?? undefined);
await compactSession(sessionId, activeSessionModel || undefined);
assertSubmissionSessionOwner(
owner,
submissionSessionRef.current,
);
}, [compactSession, ensureChatModelConfigured, selectedProjectAgent?.model]);
}, [activeSessionModel, compactSession, ensureChatModelConfigured]);
const runShare = useCallback(async () => {
const owner = submissionSessionRef.current;
@@ -2281,13 +2327,11 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
return { status: 'cancelled' };
}
const parts = buildProjectCommandPartsFromAttachedFiles(attachedFiles);
const model = selectedProjectAgent?.model;
const agent = selectedProjectAgent?.id ?? currentSession?.agent;
await executeProjectCommand(submissionSessionId, {
command: rawName,
arguments: rawArguments,
...(agent ? { agent } : {}),
...(model ? { model } : {}),
parts,
});
if (!sameSubmissionSessionOwner(
@@ -2303,12 +2347,12 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
ensureChatModelConfigured,
executeProjectCommand,
selectedProjectAgent?.id,
selectedProjectAgent?.model,
]);
const commandActions = useMemo<ChatCommandActions>(() => ({
rename: runRename,
timeline: runTimeline,
models: openSessionModelSelector,
compact: runCompact,
share: runShare,
unshare: runUnshare,
@@ -2329,6 +2373,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
runRename,
runShare,
runTimeline,
openSessionModelSelector,
runUndo,
runUnshare,
toggleThinking,
@@ -2964,7 +3009,7 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
<ComposerAttachmentCard
key={attachment.id}
attachment={attachment}
modelRef={selectedProjectAgent?.model}
modelRef={activeSessionModel}
onSelectVariant={(variant) => (
composer.selectVariant(attachment.id, variant)
)}
@@ -3106,17 +3151,34 @@ export function OpencodeChatPanel({ variant = 'main', navigationDraft, onOpenPro
</div>
)}
<div data-testid="opencode-composer-right-actions" className="flex min-w-0 items-center gap-0.5">
{selectedAgentId ? (
<span
data-testid="opencode-current-agent-model"
className={cn(
'shrink-0 whitespace-nowrap px-2 text-xs font-medium text-muted-foreground',
!selectedProjectAgent?.model && 'text-destructive',
)}
title={selectedProjectAgent?.model ?? '未配置模型'}
>
{selectedProjectAgent?.model ? formatModelRefLabel(selectedProjectAgent.model) : '未配置模型'}
</span>
{selectedSessionId || selectedAgentId ? (
<label className="relative min-w-0 shrink">
<span className="sr-only">当前会话模型</span>
<select
ref={modelSelectorRef}
data-testid="opencode-model-selector-trigger"
aria-label="当前会话模型"
className={cn(
'h-8 max-w-44 appearance-none truncate rounded-md border border-transparent bg-transparent py-1 pl-2 pr-7 text-xs font-medium text-muted-foreground outline-none transition-colors hover:border-foreground/10 hover:bg-surface-subtle focus:border-foreground/15 focus:bg-surface-subtle',
!activeSessionModel && 'text-destructive',
)}
value={activeSessionModel}
disabled={!selectedSessionId || loading || selectableChatModelOptions.length === 0}
onChange={(event) => void handleSessionModelChange(event)}
title={activeSessionModel || '未配置模型'}
>
{!activeSessionModel ? <option value="">未配置模型</option> : null}
{selectableChatModelOptions.map((option) => (
<option key={option.modelRef} value={option.modelRef}>
{formatModelRefLabel(option.modelRef)}
</option>
))}
</select>
<ChevronDown
aria-hidden="true"
className="pointer-events-none absolute right-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
/>
</label>
) : null}
<Button
type="button"

View File

@@ -1,5 +1,5 @@
export type BuiltinChatCommandName =
| 'share' | 'rename' | 'timeline' | 'compact' | 'unshare'
| 'share' | 'rename' | 'timeline' | 'models' | 'compact' | 'unshare'
| 'undo' | 'redo' | 'timestamps' | 'thinking' | 'process' | 'copy' | 'export';
export interface ProjectCommandInfo {
@@ -75,6 +75,7 @@ export const BUILTIN_CHAT_COMMANDS: readonly ChatCommandDefinition[] = [
builtin('share', [], '分享会话', '创建或复制分享链接', undefined, 'share-first'),
builtin('rename', [], '重命名会话', '修改当前会话标题', '[title]', 'none'),
builtin('timeline', [], '消息时间线', '跳转到一条已加载消息', undefined, 'none'),
builtin('models', ['model'], '切换模型', '切换当前会话后续轮次使用的模型', undefined, 'none'),
builtin('compact', ['summarize'], '压缩会话', '使用当前模型总结上下文', undefined, 'none'),
builtin('unshare', [], '撤销分享', '移除公开分享链接', undefined, 'always'),
builtin('undo', [], '撤销上一轮', '回退最近用户消息并恢复输入', undefined, 'always'),

View File

@@ -29,6 +29,7 @@ export interface ChatCommandKeyEvent {
export interface ChatCommandActions {
rename: (title: string) => Promise<void>;
timeline: (messageId: string) => Promise<void> | void;
models: () => Promise<void> | void;
compact: () => Promise<void>;
share: () => Promise<void>;
unshare: () => Promise<void>;
@@ -267,6 +268,15 @@ export function useChatCommands({
dialogOriginRef.current = origin;
setDialog({ kind: 'timeline', originatingDraft });
return true;
case 'models':
await actions.models();
clearIfOriginating(
setDraft,
originatingDraft,
origin,
currentOriginRef,
);
return true;
case 'compact':
await actions.compact();
clearIfOriginating(

View File

@@ -115,6 +115,12 @@ interface OpencodeSessionMessageActionResponse {
error?: string;
}
interface OpencodeSessionModelActionResponse {
success: boolean;
model?: string;
error?: string;
}
interface OpencodeSessionStatusActionResponse {
statuses?: OpencodeSessionStatusMap;
error?: string;
@@ -182,6 +188,7 @@ interface OpencodeState {
sessions: OpencodeSession[];
sessionsByProjectId: Record<string, OpencodeSession[]>;
selectedSessionId: string | null;
sessionModelBySessionId: Record<string, string>;
sessionStatuses: OpencodeSessionStatusMap;
sessionTranscriptBySessionId: Record<string, OpencodeSessionTranscriptState>;
projectEventStreamState: 'closed' | 'connecting' | 'connected' | 'reconnecting';
@@ -239,6 +246,7 @@ interface OpencodeState {
loadSessionStatuses: () => Promise<OpencodeSessionStatusMap>;
loadSessionMessages: (sessionId: string) => Promise<RawMessage[]>;
sendSessionMessage: (sessionId: string, text: string, options?: SendSessionMessageOptions) => Promise<RawMessage[]>;
switchSessionModel: (sessionId: string, model: string) => Promise<void>;
compactSession: (sessionId: string, model?: string) => Promise<RawMessage[]>;
executeProjectCommand: (
sessionId: string,
@@ -1893,6 +1901,26 @@ function deriveRevertedSessionIds(sessions: readonly OpencodeSession[]): Record<
return revertedSessionIds;
}
function getSessionModelRef(session: OpencodeSession): string | null {
const providerID = session.model?.providerID?.trim();
const modelID = (session.model?.modelID ?? session.model?.id)?.trim();
return providerID && modelID ? `${providerID}/${modelID}` : null;
}
function reconcileSessionModelRefs(
sessions: readonly OpencodeSession[],
current: Readonly<Record<string, string>>,
): Record<string, string> {
const models: Record<string, string> = {};
for (const session of sessions) {
const sessionId = getSessionId(session);
if (!sessionId) continue;
const model = getSessionModelRef(session) ?? current[sessionId];
if (model) models[sessionId] = model;
}
return models;
}
function resetProjectScopedState(sessions: OpencodeSession[] = []) {
closeActiveSessionEventSource();
sessionDiffLoadSequence += 1;
@@ -1900,6 +1928,7 @@ function resetProjectScopedState(sessions: OpencodeSession[] = []) {
return {
sessions,
selectedSessionId: null,
sessionModelBySessionId: {},
sessionStatuses: {},
sessionTranscriptBySessionId: {},
projectEventStreamState: 'closed' as const,
@@ -3556,6 +3585,7 @@ export const useOpencodeStore = create<OpencodeState>((set, get) => ({
sessions: [],
sessionsByProjectId: {},
selectedSessionId: null,
sessionModelBySessionId: {},
sessionStatuses: {},
sessionTranscriptBySessionId: {},
projectEventStreamState: 'closed',
@@ -3755,6 +3785,10 @@ export const useOpencodeStore = create<OpencodeState>((set, get) => ({
...(requestStillActive
? {
sessions,
sessionModelBySessionId: reconcileSessionModelRefs(
sessions,
state.sessionModelBySessionId,
),
revertedSessionIds: deriveRevertedSessionIds(sessions),
}
: {}),
@@ -3987,6 +4021,7 @@ export const useOpencodeStore = create<OpencodeState>((set, get) => ({
Object.entries(state.sessionStatuses).filter(([key]) => key !== sessionId),
),
selectedSessionId: deletingSelected ? null : selectedSessionId,
sessionModelBySessionId: withoutKey(state.sessionModelBySessionId, sessionId),
sessionMessages: deletingSelected ? [] : state.sessionMessages,
sessionMessagesBySessionId: withoutKey(state.sessionMessagesBySessionId, sessionId),
sessionTranscriptBySessionId: withoutKey(state.sessionTranscriptBySessionId, sessionId),
@@ -4621,6 +4656,25 @@ export const useOpencodeStore = create<OpencodeState>((set, get) => ({
return await runSessionPrompt(set, get, sessionId, trimmedText, optimisticUserMessage, options);
},
async switchSessionModel(sessionId, model) {
const response = await hostApiFetch<OpencodeSessionModelActionResponse>(
`/api/opencode/sessions/${encodeURIComponent(sessionId)}/model`,
{
method: 'POST',
body: JSON.stringify({ model }),
},
);
if (!response.success) {
throw new Error(response.error || 'Session model switch failed');
}
set((state) => ({
sessionModelBySessionId: {
...state.sessionModelBySessionId,
[sessionId]: response.model ?? model,
},
}));
},
async compactSession(sessionId, model) {
if (isSessionRunning(get(), sessionId)) throw new Error('Session is busy');
try {

View File

@@ -123,6 +123,11 @@ export interface OpencodeSession extends Record<string, unknown> {
id?: string;
sessionID?: string;
agent?: string | null;
model?: {
providerID?: string;
modelID?: string;
id?: string;
};
title?: string;
name?: string;
summary?: string;