mirror of
https://github.com/rait-09/obsidian-agent-client.git
synced 2026-07-22 06:43:37 +00:00
Add experimental session model selection support
Introduces support for selecting AI models per session, including new types and UI for model selection. Updates the domain model, agent client port, and adapter to handle available models and current model state. Adds a model selector dropdown to the chat input, updates the session hook for model changes, and styles the new selector.
This commit is contained in:
parent
948119ff60
commit
704fb5ae16
7 changed files with 324 additions and 2 deletions
|
|
@ -22,6 +22,7 @@ import type AgentClientPlugin from "../../plugin";
|
|||
import type {
|
||||
SlashCommand,
|
||||
SessionModeState,
|
||||
SessionModelState,
|
||||
} from "src/domain/models/chat-session";
|
||||
import {
|
||||
wrapCommandForWsl,
|
||||
|
|
@ -518,9 +519,29 @@ export class AcpAdapter implements IAgentClient, IAcpClient {
|
|||
);
|
||||
}
|
||||
|
||||
// Convert models from ACP format to domain format (experimental)
|
||||
let models: SessionModelState | undefined;
|
||||
if (sessionResult.models) {
|
||||
models = {
|
||||
availableModels: sessionResult.models.availableModels.map(
|
||||
(m) => ({
|
||||
modelId: m.modelId,
|
||||
name: m.name,
|
||||
// Convert null to undefined for type compatibility
|
||||
description: m.description ?? undefined,
|
||||
}),
|
||||
),
|
||||
currentModelId: sessionResult.models.currentModelId,
|
||||
};
|
||||
this.logger.log(
|
||||
`[AcpAdapter] Session models: ${models.availableModels.map((m) => m.modelId).join(", ")} (current: ${models.currentModelId})`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: sessionResult.sessionId,
|
||||
modes,
|
||||
models,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error("[AcpAdapter] New Session Error:", error);
|
||||
|
|
@ -820,6 +841,35 @@ export class AcpAdapter implements IAgentClient, IAcpClient {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of IAgentClient.setSessionModel()
|
||||
*/
|
||||
async setSessionModel(sessionId: string, modelId: string): Promise<void> {
|
||||
if (!this.connection) {
|
||||
throw new Error(
|
||||
"Connection not initialized. Call initialize() first.",
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[AcpAdapter] Setting session model to: ${modelId} for session: ${sessionId}`,
|
||||
);
|
||||
|
||||
try {
|
||||
await this.connection.setSessionModel({
|
||||
sessionId,
|
||||
modelId,
|
||||
});
|
||||
this.logger.log(`[AcpAdapter] Session model set to: ${modelId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
"[AcpAdapter] Failed to set session model:",
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback to receive chat messages from the agent.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type { NoteMetadata } from "../../domain/ports/vault-access.port";
|
|||
import type {
|
||||
SlashCommand,
|
||||
SessionModeState,
|
||||
SessionModelState,
|
||||
} from "../../domain/models/chat-session";
|
||||
import type { UseMentionsReturn } from "../../hooks/useMentions";
|
||||
import type { UseSlashCommandsReturn } from "../../hooks/useSlashCommands";
|
||||
|
|
@ -51,6 +52,10 @@ export interface ChatInputProps {
|
|||
modes?: SessionModeState;
|
||||
/** Callback when mode is changed */
|
||||
onModeChange?: (modeId: string) => void;
|
||||
/** Session model state (available models and current model) - experimental */
|
||||
models?: SessionModelState;
|
||||
/** Callback when model is changed */
|
||||
onModelChange?: (modelId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -82,6 +87,8 @@ export function ChatInput({
|
|||
onRestoredMessageConsumed,
|
||||
modes,
|
||||
onModeChange,
|
||||
models,
|
||||
onModelChange,
|
||||
}: ChatInputProps) {
|
||||
const logger = useMemo(() => new Logger(plugin), [plugin]);
|
||||
|
||||
|
|
@ -95,6 +102,8 @@ export function ChatInput({
|
|||
const sendButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const modeDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const modeDropdownInstance = useRef<DropdownComponent | null>(null);
|
||||
const modelDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const modelDropdownInstance = useRef<DropdownComponent | null>(null);
|
||||
|
||||
/**
|
||||
* Common logic for setting cursor position after text replacement.
|
||||
|
|
@ -495,6 +504,67 @@ export function ChatInput({
|
|||
}
|
||||
}, [currentModeId]);
|
||||
|
||||
// Stable references for model callbacks
|
||||
const onModelChangeRef = useRef(onModelChange);
|
||||
onModelChangeRef.current = onModelChange;
|
||||
|
||||
// Initialize Model dropdown (only when availableModels change)
|
||||
const availableModels = models?.availableModels;
|
||||
const currentModelId = models?.currentModelId;
|
||||
|
||||
useEffect(() => {
|
||||
const containerEl = modelDropdownRef.current;
|
||||
if (!containerEl) return;
|
||||
|
||||
// Only show dropdown if there are multiple models
|
||||
if (!availableModels || availableModels.length <= 1) {
|
||||
// Clean up existing dropdown if models become unavailable
|
||||
if (modelDropdownInstance.current) {
|
||||
containerEl.empty();
|
||||
modelDropdownInstance.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Create dropdown if not exists
|
||||
if (!modelDropdownInstance.current) {
|
||||
const dropdown = new DropdownComponent(containerEl);
|
||||
modelDropdownInstance.current = dropdown;
|
||||
|
||||
// Add options
|
||||
for (const model of availableModels) {
|
||||
dropdown.addOption(model.modelId, model.name);
|
||||
}
|
||||
|
||||
// Set initial value
|
||||
if (currentModelId) {
|
||||
dropdown.setValue(currentModelId);
|
||||
}
|
||||
|
||||
// Handle change - use ref to avoid recreating dropdown on callback change
|
||||
dropdown.onChange((value) => {
|
||||
if (onModelChangeRef.current) {
|
||||
onModelChangeRef.current(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Cleanup on unmount or when availableModels change
|
||||
return () => {
|
||||
if (modelDropdownInstance.current) {
|
||||
containerEl.empty();
|
||||
modelDropdownInstance.current = null;
|
||||
}
|
||||
};
|
||||
}, [availableModels]);
|
||||
|
||||
// Update dropdown value when currentModelId changes (separate effect)
|
||||
useEffect(() => {
|
||||
if (modelDropdownInstance.current && currentModelId) {
|
||||
modelDropdownInstance.current.setValue(currentModelId);
|
||||
}
|
||||
}, [currentModelId]);
|
||||
|
||||
// Button disabled state
|
||||
const isButtonDisabled =
|
||||
!isSending && (inputValue.trim() === "" || !isSessionReady);
|
||||
|
|
@ -597,7 +667,7 @@ export function ChatInput({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Input Actions (Mode Selector + Send Button) */}
|
||||
{/* Input Actions (Mode Selector + Model Selector + Send Button) */}
|
||||
<div className="chat-input-actions">
|
||||
{/* Mode Selector */}
|
||||
{modes && modes.availableModes.length > 1 && (
|
||||
|
|
@ -619,6 +689,26 @@ export function ChatInput({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Model Selector (experimental) */}
|
||||
{models && models.availableModels.length > 1 && (
|
||||
<div
|
||||
ref={modelDropdownRef}
|
||||
className="model-selector"
|
||||
title={
|
||||
models.availableModels.find(
|
||||
(m) => m.modelId === models.currentModelId,
|
||||
)?.description ?? "Select model"
|
||||
}
|
||||
>
|
||||
<span
|
||||
className="model-selector-icon"
|
||||
ref={(el) => {
|
||||
if (el) setIcon(el, "chevron-down");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Send/Stop Button */}
|
||||
<button
|
||||
ref={sendButtonRef}
|
||||
|
|
|
|||
|
|
@ -518,6 +518,8 @@ function ChatComponent({
|
|||
onRestoredMessageConsumed={handleRestoredMessageConsumed}
|
||||
modes={session.modes}
|
||||
onModeChange={(modeId) => void agentSession.setMode(modeId)}
|
||||
models={session.models}
|
||||
onModelChange={(modelId) => void agentSession.setModel(modelId)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -120,6 +120,42 @@ export interface SessionModeState {
|
|||
currentModeId: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Model (Experimental)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Represents an AI model available in a session.
|
||||
*
|
||||
* Models determine which AI model is used for responses.
|
||||
* This is an experimental feature and may change.
|
||||
*/
|
||||
export interface SessionModel {
|
||||
/** Unique identifier for this model (e.g., "claude-sonnet-4") */
|
||||
modelId: string;
|
||||
|
||||
/** Human-readable name for display */
|
||||
name: string;
|
||||
|
||||
/** Optional description of this model */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* State of available models in a session.
|
||||
*
|
||||
* Contains both the list of available models and the currently active model.
|
||||
* Updated via NewSessionResponse initially.
|
||||
* Note: Unlike modes, there is no dedicated notification for model changes.
|
||||
*/
|
||||
export interface SessionModelState {
|
||||
/** List of models available in this session */
|
||||
availableModels: SessionModel[];
|
||||
|
||||
/** ID of the currently active model */
|
||||
currentModelId: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Chat Session
|
||||
// ============================================================================
|
||||
|
|
@ -166,6 +202,13 @@ export interface ChatSession {
|
|||
*/
|
||||
modes?: SessionModeState;
|
||||
|
||||
/**
|
||||
* Model state for this session (experimental).
|
||||
* Contains available models and the currently active model.
|
||||
* Updated via NewSessionResponse initially.
|
||||
*/
|
||||
models?: SessionModelState;
|
||||
|
||||
/** Timestamp when the session was created */
|
||||
createdAt: Date;
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import type { AgentError } from "../models/agent-error";
|
|||
import type {
|
||||
AuthenticationMethod,
|
||||
SessionModeState,
|
||||
SessionModelState,
|
||||
} from "../models/chat-session";
|
||||
|
||||
/**
|
||||
|
|
@ -101,6 +102,13 @@ export interface NewSessionResult {
|
|||
* Undefined if the agent does not support modes.
|
||||
*/
|
||||
modes?: SessionModeState;
|
||||
|
||||
/**
|
||||
* Model state for this session (experimental).
|
||||
* Contains available models and the currently active model.
|
||||
* Undefined if the agent does not support model selection.
|
||||
*/
|
||||
models?: SessionModelState;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -247,4 +255,11 @@ export interface IAgentClient {
|
|||
* @throws Error if connection is not initialized or mode is invalid
|
||||
*/
|
||||
setSessionMode(sessionId: string, modeId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Set the session model (experimental).
|
||||
* @param sessionId - The session ID
|
||||
* @param modelId - The model ID to set
|
||||
*/
|
||||
setSessionModel(sessionId: string, modelId: string): Promise<void>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,6 +106,13 @@ export interface UseAgentSessionReturn {
|
|||
* @param modeId - ID of the mode to set
|
||||
*/
|
||||
setMode: (modeId: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Set the session model (experimental).
|
||||
* Sends a request to the agent to change the model.
|
||||
* @param modelId - ID of the model to set
|
||||
*/
|
||||
setModel: (modelId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -253,6 +260,7 @@ function createInitialSession(
|
|||
authMethods: [],
|
||||
availableCommands: undefined,
|
||||
modes: undefined,
|
||||
models: undefined,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
workingDirectory,
|
||||
|
|
@ -318,6 +326,7 @@ export function useAgentSession(
|
|||
authMethods: [],
|
||||
availableCommands: undefined,
|
||||
modes: undefined,
|
||||
models: undefined,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
}));
|
||||
|
|
@ -371,6 +380,7 @@ export function useAgentSession(
|
|||
state: "ready",
|
||||
authMethods: authMethods,
|
||||
modes: sessionResult.modes,
|
||||
models: sessionResult.models,
|
||||
lastActivityAt: new Date(),
|
||||
}));
|
||||
} catch (error) {
|
||||
|
|
@ -461,12 +471,13 @@ export function useAgentSession(
|
|||
await settingsAccess.updateSettings({ activeAgentId: agentId });
|
||||
|
||||
// Update session with new agent ID
|
||||
// Clear availableCommands and modes (new agent will send its own)
|
||||
// Clear availableCommands, modes, and models (new agent will send its own)
|
||||
setSession((prev) => ({
|
||||
...prev,
|
||||
agentId,
|
||||
availableCommands: undefined,
|
||||
modes: undefined,
|
||||
models: undefined,
|
||||
}));
|
||||
},
|
||||
[settingsAccess],
|
||||
|
|
@ -562,6 +573,56 @@ export function useAgentSession(
|
|||
[agentClient, session.sessionId, session.modes?.currentModeId],
|
||||
);
|
||||
|
||||
/**
|
||||
* Set the session model (experimental).
|
||||
* Sends a request to the agent to change the model.
|
||||
*/
|
||||
const setModel = useCallback(
|
||||
async (modelId: string) => {
|
||||
if (!session.sessionId) {
|
||||
console.warn("Cannot set model: no active session");
|
||||
return;
|
||||
}
|
||||
|
||||
// Store previous model for rollback on error
|
||||
const previousModelId = session.models?.currentModelId;
|
||||
|
||||
// Optimistic update - update UI immediately
|
||||
setSession((prev) => {
|
||||
if (!prev.models) return prev;
|
||||
return {
|
||||
...prev,
|
||||
models: {
|
||||
...prev.models,
|
||||
currentModelId: modelId,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await agentClient.setSessionModel(session.sessionId, modelId);
|
||||
// Note: Unlike modes, there is no dedicated notification for model changes.
|
||||
// UI is already updated optimistically above.
|
||||
} catch (error) {
|
||||
console.error("Failed to set model:", error);
|
||||
// Rollback to previous model on error
|
||||
if (previousModelId) {
|
||||
setSession((prev) => {
|
||||
if (!prev.models) return prev;
|
||||
return {
|
||||
...prev,
|
||||
models: {
|
||||
...prev.models,
|
||||
currentModelId: previousModelId,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[agentClient, session.sessionId, session.models?.currentModelId],
|
||||
);
|
||||
|
||||
return {
|
||||
session,
|
||||
isReady,
|
||||
|
|
@ -575,5 +636,6 @@ export function useAgentSession(
|
|||
updateAvailableCommands,
|
||||
updateCurrentMode,
|
||||
setMode,
|
||||
setModel,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
60
styles.css
60
styles.css
|
|
@ -680,6 +680,66 @@ If your plugin does not need CSS, delete this file.
|
|||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Model Selector (experimental) */
|
||||
.model-selector {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease;
|
||||
height: 20px;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.model-selector:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.model-selector-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
color: var(--text-faint);
|
||||
margin-left: 2px;
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.model-selector-icon svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.model-selector select.dropdown {
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
background: none !important;
|
||||
background-color: transparent !important;
|
||||
background-image: none !important;
|
||||
box-shadow: none !important;
|
||||
color: var(--text-faint) !important;
|
||||
font-size: 11px !important;
|
||||
cursor: pointer !important;
|
||||
outline: none !important;
|
||||
appearance: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
-moz-appearance: none !important;
|
||||
order: 1;
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.model-selector select.dropdown:hover,
|
||||
.model-selector select.dropdown:focus {
|
||||
color: var(--text-faint) !important;
|
||||
background-color: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Send button */
|
||||
.chat-send-button {
|
||||
width: 20px;
|
||||
|
|
|
|||
Loading…
Reference in a new issue