feat(FN-1882): merge fusion/fn-1882
This commit is contained in:
@@ -11,8 +11,10 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useChat } from "../hooks/useChat";
|
||||
import { useViewportMode } from "./Header";
|
||||
import { fetchAgents } from "../api";
|
||||
import { fetchAgents, fetchModels } from "../api";
|
||||
import type { Agent } from "@fusion/core";
|
||||
import type { ModelInfo } from "../api";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
|
||||
export interface ChatViewProps {
|
||||
projectId?: string;
|
||||
@@ -110,13 +112,16 @@ const KB_AGENT_ID = "__kb_agent__";
|
||||
|
||||
interface NewChatDialogProps {
|
||||
onClose: () => void;
|
||||
onCreate: (input: { agentId: string }) => void;
|
||||
onCreate: (input: { agentId: string; modelProvider?: string; modelId?: string }) => void;
|
||||
}
|
||||
|
||||
function NewChatDialog({ onClose, onCreate }: NewChatDialogProps) {
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [agentsLoading, setAgentsLoading] = useState(true);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string>("");
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(true);
|
||||
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||
|
||||
// Load agents on mount
|
||||
useEffect(() => {
|
||||
@@ -134,10 +139,41 @@ function NewChatDialog({ onClose, onCreate }: NewChatDialogProps) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Load models on mount
|
||||
useEffect(() => {
|
||||
setModelsLoading(true);
|
||||
fetchModels()
|
||||
.then((response) => {
|
||||
setModels(response.models);
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently fail - show empty list
|
||||
setModels([]);
|
||||
})
|
||||
.finally(() => {
|
||||
setModelsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedAgentId) return;
|
||||
onCreate({ agentId: selectedAgentId });
|
||||
|
||||
// Parse model selection into provider and modelId
|
||||
let modelProvider: string | undefined;
|
||||
let modelId: string | undefined;
|
||||
if (selectedModel) {
|
||||
const slashIdx = selectedModel.indexOf("/");
|
||||
if (slashIdx > 0) {
|
||||
modelProvider = selectedModel.slice(0, slashIdx);
|
||||
modelId = selectedModel.slice(slashIdx + 1);
|
||||
}
|
||||
}
|
||||
|
||||
onCreate({
|
||||
agentId: selectedAgentId,
|
||||
...(modelProvider && modelId ? { modelProvider, modelId } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -169,6 +205,19 @@ function NewChatDialog({ onClose, onCreate }: NewChatDialogProps) {
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
<div className="chat-new-dialog-model-dropdown">
|
||||
{modelsLoading ? (
|
||||
<div className="chat-new-dialog-loading">Loading models...</div>
|
||||
) : (
|
||||
<CustomModelDropdown
|
||||
models={models}
|
||||
value={selectedModel}
|
||||
onChange={setSelectedModel}
|
||||
label="Model"
|
||||
placeholder="Use agent default"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="chat-new-dialog-actions">
|
||||
<button type="button" className="btn btn-sm" onClick={onClose}>
|
||||
Cancel
|
||||
@@ -252,7 +301,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
|
||||
|
||||
// Handle create session
|
||||
const handleCreateSession = useCallback(
|
||||
async (input: { agentId: string }) => {
|
||||
async (input: { agentId: string; modelProvider?: string; modelId?: string }) => {
|
||||
try {
|
||||
await createSession(input);
|
||||
setShowNewDialog(false);
|
||||
|
||||
@@ -236,6 +236,60 @@ describe("ChatView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("creates session with model selection", async () => {
|
||||
const createSession = vi.fn().mockResolvedValue({ id: "session-new", agentId: "agent-001" });
|
||||
setupMockChat({ sessions: [], filteredSessions: [], createSession });
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
await userEvent.click(screen.getByTestId("chat-new-btn"));
|
||||
|
||||
const dialog = document.querySelector(".chat-new-dialog");
|
||||
|
||||
// Click on an agent to select it
|
||||
await userEvent.click(within(dialog!).getByTestId("agent-option-agent-001"));
|
||||
|
||||
// Select a model from the dropdown
|
||||
const modelDropdown = within(dialog!).getByTestId("mock-model-dropdown");
|
||||
await userEvent.selectOptions(modelDropdown, "anthropic/claude-sonnet-4-5");
|
||||
|
||||
await userEvent.click(within(dialog!).getByText("Create"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createSession).toHaveBeenCalledWith({
|
||||
agentId: "agent-001",
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("creates session without model selection omits model fields", async () => {
|
||||
const createSession = vi.fn().mockResolvedValue({ id: "session-new", agentId: "agent-001" });
|
||||
setupMockChat({ sessions: [], filteredSessions: [], createSession });
|
||||
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
await userEvent.click(screen.getByTestId("chat-new-btn"));
|
||||
|
||||
const dialog = document.querySelector(".chat-new-dialog");
|
||||
|
||||
// Click on an agent to select it
|
||||
await userEvent.click(within(dialog!).getByTestId("agent-option-agent-001"));
|
||||
|
||||
// Make sure no model is selected (use default)
|
||||
const modelDropdown = within(dialog!).getByTestId("mock-model-dropdown");
|
||||
await userEvent.selectOptions(modelDropdown, "");
|
||||
|
||||
await userEvent.click(within(dialog!).getByText("Create"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createSession).toHaveBeenCalledWith({
|
||||
agentId: "agent-001",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("renders messages for active session", () => {
|
||||
setupMockChat({
|
||||
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
|
||||
@@ -8381,8 +8381,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
/**
|
||||
* POST /api/chat/sessions
|
||||
* Create a new chat session.
|
||||
* Body: { agentId: string, title?: string }
|
||||
* The model is resolved from the agent's runtimeConfig.model setting.
|
||||
* Body: { agentId: string, title?: string, modelProvider?: string, modelId?: string }
|
||||
* If modelProvider and modelId are provided, those are used. Otherwise the model is
|
||||
* resolved from the agent's runtimeConfig.model setting.
|
||||
* The session is scoped to the project identified by projectId query param or header.
|
||||
*/
|
||||
router.post("/chat/sessions", rateLimit(RATE_LIMITS.mutation), async (req, res) => {
|
||||
@@ -8398,35 +8399,54 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const { agentId, title } = req.body as {
|
||||
const { agentId, title, modelProvider, modelId } = req.body as {
|
||||
agentId?: string;
|
||||
title?: string;
|
||||
modelProvider?: string;
|
||||
modelId?: string;
|
||||
};
|
||||
|
||||
if (!agentId || typeof agentId !== "string" || !agentId.trim()) {
|
||||
throw badRequest("agentId is required");
|
||||
}
|
||||
|
||||
// Fetch the agent to resolve model configuration
|
||||
const agent = await agentStore.getAgent(agentId);
|
||||
if (!agent) {
|
||||
throw notFound(`Agent ${agentId} not found`);
|
||||
// Validate that if one model field is provided, the other must also be provided
|
||||
const hasClientModelProvider = typeof modelProvider === "string" && modelProvider.trim() !== "";
|
||||
const hasClientModelId = typeof modelId === "string" && modelId.trim() !== "";
|
||||
if (hasClientModelProvider !== hasClientModelId) {
|
||||
throw badRequest("Both modelProvider and modelId must be provided together, or neither should be provided");
|
||||
}
|
||||
|
||||
// Parse the agent's model config from runtimeConfig.model
|
||||
// Format: "provider/modelId" (e.g., "anthropic/claude-sonnet-4-5")
|
||||
const runtimeModel = typeof agent.runtimeConfig?.model === "string" ? agent.runtimeConfig.model : "";
|
||||
const slashIdx = runtimeModel.indexOf("/");
|
||||
const resolvedProvider = slashIdx > 0 ? runtimeModel.slice(0, slashIdx) : undefined;
|
||||
const resolvedModelId = slashIdx > 0 ? runtimeModel.slice(slashIdx + 1) : undefined;
|
||||
// Fetch the agent to resolve model configuration (only if client didn't provide model)
|
||||
let resolvedProvider: string | null = null;
|
||||
let resolvedModelId: string | null = null;
|
||||
|
||||
if (hasClientModelProvider && hasClientModelId) {
|
||||
// Use client-provided model
|
||||
resolvedProvider = modelProvider!.trim();
|
||||
resolvedModelId = modelId!.trim();
|
||||
} else {
|
||||
// Resolve from agent's runtimeConfig.model
|
||||
const agent = await agentStore.getAgent(agentId);
|
||||
if (!agent) {
|
||||
throw notFound(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
// Parse the agent's model config from runtimeConfig.model
|
||||
// Format: "provider/modelId" (e.g., "anthropic/claude-sonnet-4-5")
|
||||
const runtimeModel = typeof agent.runtimeConfig?.model === "string" ? agent.runtimeConfig.model : "";
|
||||
const slashIdx = runtimeModel.indexOf("/");
|
||||
resolvedProvider = slashIdx > 0 ? runtimeModel.slice(0, slashIdx) : null;
|
||||
resolvedModelId = slashIdx > 0 ? runtimeModel.slice(slashIdx + 1) : null;
|
||||
}
|
||||
|
||||
// Create the chat session with projectId for multi-project scoping
|
||||
const session = chatStore.createSession({
|
||||
agentId: agentId.trim(),
|
||||
title: title?.trim() || null,
|
||||
projectId: projectId ?? null,
|
||||
modelProvider: resolvedProvider ?? null,
|
||||
modelId: resolvedModelId ?? null,
|
||||
modelProvider: resolvedProvider,
|
||||
modelId: resolvedModelId,
|
||||
});
|
||||
|
||||
res.status(201).json({ session });
|
||||
|
||||
Reference in New Issue
Block a user