chat: remember last model

This commit is contained in:
2026-02-14 22:06:30 -08:00
parent a76b4ba232
commit fb306e154a
8 changed files with 173 additions and 9 deletions

View File

@@ -111,6 +111,7 @@ Behavior notes:
- If `chatId` is present, server validates chat existence.
- For `chatId` calls, server stores only *new* non-assistant messages from provided history to avoid duplicates.
- Server persists final assistant output and call metadata (`LlmCall`) in DB.
- Server updates chat-level model metadata on each call: `lastUsedProvider`/`lastUsedModel`; first successful/failed call also initializes `initiatedProvider`/`initiatedModel` if unset.
## Searches
@@ -151,7 +152,16 @@ Search run notes:
`ChatSummary`
```json
{ "id": "...", "title": null, "createdAt": "...", "updatedAt": "..." }
{
"id": "...",
"title": null,
"createdAt": "...",
"updatedAt": "...",
"initiatedProvider": "openai|anthropic|xai|null",
"initiatedModel": "string|null",
"lastUsedProvider": "openai|anthropic|xai|null",
"lastUsedModel": "string|null"
}
```
`Message`
@@ -172,6 +182,10 @@ Search run notes:
"title": null,
"createdAt": "...",
"updatedAt": "...",
"initiatedProvider": "openai|anthropic|xai|null",
"initiatedModel": "string|null",
"lastUsedProvider": "openai|anthropic|xai|null",
"lastUsedModel": "string|null",
"messages": [Message]
}
```

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Chat" ADD COLUMN "initiatedProvider" TEXT;
ALTER TABLE "Chat" ADD COLUMN "initiatedModel" TEXT;
ALTER TABLE "Chat" ADD COLUMN "lastUsedProvider" TEXT;
ALTER TABLE "Chat" ADD COLUMN "lastUsedModel" TEXT;

View File

@@ -45,6 +45,11 @@ model Chat {
title String?
initiatedProvider Provider?
initiatedModel String?
lastUsedProvider Provider?
lastUsedModel String?
user User? @relation(fields: [userId], references: [id])
userId String?

View File

@@ -10,11 +10,12 @@ function asProviderEnum(p: Provider) {
export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResponse> {
const t0 = performance.now();
const chatId = req.chatId ?? (await prisma.chat.create({ data: {}, select: { id: true } })).id;
// Persist call record early so we can attach errors.
const call = await prisma.llmCall.create({
data: {
chatId: req.chatId ?? (await prisma.chat.create({ data: {} })).id,
chatId,
provider: asProviderEnum(req.provider) as any,
model: req.model,
request: req as any,
@@ -22,6 +23,23 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
select: { id: true, chatId: true },
});
await prisma.$transaction([
prisma.chat.update({
where: { id: chatId },
data: {
lastUsedProvider: asProviderEnum(req.provider) as any,
lastUsedModel: req.model,
},
}),
prisma.chat.updateMany({
where: { id: chatId, initiatedProvider: null },
data: {
initiatedProvider: asProviderEnum(req.provider) as any,
initiatedModel: req.model,
},
}),
]);
try {
let outText = "";
let usage: MultiplexResponse["usage"] | undefined;

View File

@@ -29,6 +29,23 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
select: { id: true },
});
await prisma.$transaction([
prisma.chat.update({
where: { id: chatId },
data: {
lastUsedProvider: req.provider as any,
lastUsedModel: req.model,
},
}),
prisma.chat.updateMany({
where: { id: chatId, initiatedProvider: null },
data: {
initiatedProvider: req.provider as any,
initiatedModel: req.model,
},
}),
]);
yield { type: "meta", chatId, callId: call.id, provider: req.provider, model: req.model };
let text = "";

View File

@@ -174,7 +174,16 @@ export async function registerRoutes(app: FastifyInstance) {
const chats = await prisma.chat.findMany({
orderBy: { updatedAt: "desc" },
take: 100,
select: { id: true, title: true, createdAt: true, updatedAt: true },
select: {
id: true,
title: true,
createdAt: true,
updatedAt: true,
initiatedProvider: true,
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
},
});
return { chats };
});
@@ -183,7 +192,19 @@ export async function registerRoutes(app: FastifyInstance) {
requireAdmin(req);
const Body = z.object({ title: z.string().optional() });
const body = Body.parse(req.body ?? {});
const chat = await prisma.chat.create({ data: { title: body.title } });
const chat = await prisma.chat.create({
data: { title: body.title },
select: {
id: true,
title: true,
createdAt: true,
updatedAt: true,
initiatedProvider: true,
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
},
});
return { chat };
});
@@ -203,7 +224,16 @@ export async function registerRoutes(app: FastifyInstance) {
const chat = await prisma.chat.findUnique({
where: { id: chatId },
select: { id: true, title: true, createdAt: true, updatedAt: true },
select: {
id: true,
title: true,
createdAt: true,
updatedAt: true,
initiatedProvider: true,
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
},
});
if (!chat) return app.httpErrors.notFound("chat not found");
return { chat };
@@ -219,7 +249,16 @@ export async function registerRoutes(app: FastifyInstance) {
const existing = await prisma.chat.findUnique({
where: { id: body.chatId },
select: { id: true, title: true, createdAt: true, updatedAt: true },
select: {
id: true,
title: true,
createdAt: true,
updatedAt: true,
initiatedProvider: true,
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
},
});
if (!existing) return app.httpErrors.notFound("chat not found");
if (existing.title?.trim()) return { chat: existing };
@@ -231,7 +270,16 @@ export async function registerRoutes(app: FastifyInstance) {
const chat = await prisma.chat.update({
where: { id: body.chatId },
data: { title },
select: { id: true, title: true, createdAt: true, updatedAt: true },
select: {
id: true,
title: true,
createdAt: true,
updatedAt: true,
initiatedProvider: true,
initiatedModel: true,
lastUsedProvider: true,
lastUsedModel: true,
},
});
return { chat };

View File

@@ -37,6 +37,10 @@ type SidebarItem = SidebarSelection & {
title: string;
updatedAt: string;
createdAt: string;
initiatedProvider: Provider | null;
initiatedModel: string | null;
lastUsedProvider: Provider | null;
lastUsedModel: string | null;
};
type ContextMenuState = {
item: SidebarSelection;
@@ -89,11 +93,26 @@ function loadStoredModelPreferences() {
}
function pickProviderModel(options: string[], preferred: string | null, fallback: string | null = null) {
if (preferred && options.includes(preferred)) return preferred;
if (fallback && options.includes(fallback)) return fallback;
if (preferred && options.includes(preferred)) return preferred;
return options[0] ?? "";
}
function getProviderLabel(provider: Provider | null | undefined) {
if (provider === "openai") return "OpenAI";
if (provider === "anthropic") return "Anthropic";
if (provider === "xai") return "xAI";
return "";
}
function getChatModelSelection(chat: Pick<ChatSummary, "lastUsedProvider" | "lastUsedModel"> | Pick<ChatDetail, "lastUsedProvider" | "lastUsedModel"> | null) {
if (!chat?.lastUsedProvider || !chat.lastUsedModel?.trim()) return null;
return {
provider: chat.lastUsedProvider,
model: chat.lastUsedModel.trim(),
};
}
type ModelComboboxProps = {
options: string[];
value: string;
@@ -212,6 +231,10 @@ function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): Sid
title: getChatTitle(chat),
updatedAt: chat.updatedAt,
createdAt: chat.createdAt,
initiatedProvider: chat.initiatedProvider,
initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider,
lastUsedModel: chat.lastUsedModel,
})),
...searches.map((search) => ({
kind: "search" as const,
@@ -219,6 +242,10 @@ function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): Sid
title: getSearchTitle(search),
updatedAt: search.updatedAt,
createdAt: search.createdAt,
initiatedProvider: null,
initiatedModel: null,
lastUsedProvider: null,
lastUsedModel: null,
})),
];
@@ -473,6 +500,16 @@ export default function App() {
return searches.find((search) => search.id === selectedItem.id) ?? null;
}, [searches, selectedItem]);
useEffect(() => {
if (draftKind || selectedItem?.kind !== "chat") return;
const detailSelection = selectedChat?.id === selectedItem.id ? getChatModelSelection(selectedChat) : null;
const summarySelection = getChatModelSelection(selectedChatSummary);
const nextSelection = detailSelection ?? summarySelection;
if (!nextSelection) return;
setProvider(nextSelection.provider);
setModel(nextSelection.model);
}, [draftKind, selectedChat, selectedChatSummary, selectedItem]);
const selectedTitle = useMemo(() => {
if (draftKind === "chat") return "New chat";
if (draftKind === "search") return "New search";
@@ -611,6 +648,10 @@ export default function App() {
title: chat.title,
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
initiatedProvider: chat.initiatedProvider,
initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider,
lastUsedModel: chat.lastUsedModel,
messages: [],
});
setSelectedSearch(null);
@@ -937,6 +978,9 @@ export default function App() {
) : null}
{sidebarItems.map((item) => {
const active = selectedItem?.kind === item.kind && selectedItem.id === item.id;
const initiatedLabel = item.kind === "chat" && item.initiatedModel
? `${getProviderLabel(item.initiatedProvider)}${item.initiatedProvider ? " · " : ""}${item.initiatedModel}`
: null;
return (
<button
key={`${item.kind}-${item.id}`}
@@ -956,7 +1000,12 @@ export default function App() {
{item.kind === "chat" ? <MessageSquare className="h-3.5 w-3.5" /> : <Search className="h-3.5 w-3.5" />}
<p className="truncate text-sm font-medium">{item.title}</p>
</div>
<p className={cn("mt-1 text-xs", active ? "text-violet-100/90" : "text-violet-300/60")}>{formatDate(item.updatedAt)}</p>
<div className="mt-1 flex items-center gap-2 text-xs">
<p className={cn("shrink-0", active ? "text-violet-100/90" : "text-violet-300/60")}>{formatDate(item.updatedAt)}</p>
{initiatedLabel ? (
<p className={cn("ml-auto truncate text-right", active ? "text-violet-200/65" : "text-violet-300/45")}>{initiatedLabel}</p>
) : null}
</div>
</button>
);
})}

View File

@@ -3,6 +3,10 @@ export type ChatSummary = {
title: string | null;
createdAt: string;
updatedAt: string;
initiatedProvider: Provider | null;
initiatedModel: string | null;
lastUsedProvider: Provider | null;
lastUsedModel: string | null;
};
export type SearchSummary = {
@@ -26,6 +30,10 @@ export type ChatDetail = {
title: string | null;
createdAt: string;
updatedAt: string;
initiatedProvider: Provider | null;
initiatedModel: string | null;
lastUsedProvider: Provider | null;
lastUsedModel: string | null;
messages: Message[];
};