fix(opencode): switch session models without restart
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user