chat: remember last model
This commit is contained in:
@@ -111,6 +111,7 @@ Behavior notes:
|
|||||||
- If `chatId` is present, server validates chat existence.
|
- If `chatId` is present, server validates chat existence.
|
||||||
- For `chatId` calls, server stores only *new* non-assistant messages from provided history to avoid duplicates.
|
- 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 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
|
## Searches
|
||||||
|
|
||||||
@@ -151,7 +152,16 @@ Search run notes:
|
|||||||
|
|
||||||
`ChatSummary`
|
`ChatSummary`
|
||||||
```json
|
```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`
|
`Message`
|
||||||
@@ -172,6 +182,10 @@ Search run notes:
|
|||||||
"title": null,
|
"title": null,
|
||||||
"createdAt": "...",
|
"createdAt": "...",
|
||||||
"updatedAt": "...",
|
"updatedAt": "...",
|
||||||
|
"initiatedProvider": "openai|anthropic|xai|null",
|
||||||
|
"initiatedModel": "string|null",
|
||||||
|
"lastUsedProvider": "openai|anthropic|xai|null",
|
||||||
|
"lastUsedModel": "string|null",
|
||||||
"messages": [Message]
|
"messages": [Message]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -45,6 +45,11 @@ model Chat {
|
|||||||
|
|
||||||
title String?
|
title String?
|
||||||
|
|
||||||
|
initiatedProvider Provider?
|
||||||
|
initiatedModel String?
|
||||||
|
lastUsedProvider Provider?
|
||||||
|
lastUsedModel String?
|
||||||
|
|
||||||
user User? @relation(fields: [userId], references: [id])
|
user User? @relation(fields: [userId], references: [id])
|
||||||
userId String?
|
userId String?
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ function asProviderEnum(p: Provider) {
|
|||||||
|
|
||||||
export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResponse> {
|
export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResponse> {
|
||||||
const t0 = performance.now();
|
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.
|
// Persist call record early so we can attach errors.
|
||||||
const call = await prisma.llmCall.create({
|
const call = await prisma.llmCall.create({
|
||||||
data: {
|
data: {
|
||||||
chatId: req.chatId ?? (await prisma.chat.create({ data: {} })).id,
|
chatId,
|
||||||
provider: asProviderEnum(req.provider) as any,
|
provider: asProviderEnum(req.provider) as any,
|
||||||
model: req.model,
|
model: req.model,
|
||||||
request: req as any,
|
request: req as any,
|
||||||
@@ -22,6 +23,23 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
|
|||||||
select: { id: true, chatId: true },
|
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 {
|
try {
|
||||||
let outText = "";
|
let outText = "";
|
||||||
let usage: MultiplexResponse["usage"] | undefined;
|
let usage: MultiplexResponse["usage"] | undefined;
|
||||||
|
|||||||
@@ -29,6 +29,23 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
|||||||
select: { id: true },
|
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 };
|
yield { type: "meta", chatId, callId: call.id, provider: req.provider, model: req.model };
|
||||||
|
|
||||||
let text = "";
|
let text = "";
|
||||||
|
|||||||
@@ -174,7 +174,16 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
const chats = await prisma.chat.findMany({
|
const chats = await prisma.chat.findMany({
|
||||||
orderBy: { updatedAt: "desc" },
|
orderBy: { updatedAt: "desc" },
|
||||||
take: 100,
|
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 };
|
return { chats };
|
||||||
});
|
});
|
||||||
@@ -183,7 +192,19 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
requireAdmin(req);
|
requireAdmin(req);
|
||||||
const Body = z.object({ title: z.string().optional() });
|
const Body = z.object({ title: z.string().optional() });
|
||||||
const body = Body.parse(req.body ?? {});
|
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 };
|
return { chat };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -203,7 +224,16 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
const chat = await prisma.chat.findUnique({
|
const chat = await prisma.chat.findUnique({
|
||||||
where: { id: chatId },
|
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");
|
if (!chat) return app.httpErrors.notFound("chat not found");
|
||||||
return { chat };
|
return { chat };
|
||||||
@@ -219,7 +249,16 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
const existing = await prisma.chat.findUnique({
|
const existing = await prisma.chat.findUnique({
|
||||||
where: { id: body.chatId },
|
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) return app.httpErrors.notFound("chat not found");
|
||||||
if (existing.title?.trim()) return { chat: existing };
|
if (existing.title?.trim()) return { chat: existing };
|
||||||
@@ -231,7 +270,16 @@ export async function registerRoutes(app: FastifyInstance) {
|
|||||||
const chat = await prisma.chat.update({
|
const chat = await prisma.chat.update({
|
||||||
where: { id: body.chatId },
|
where: { id: body.chatId },
|
||||||
data: { title },
|
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 };
|
return { chat };
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ type SidebarItem = SidebarSelection & {
|
|||||||
title: string;
|
title: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
initiatedProvider: Provider | null;
|
||||||
|
initiatedModel: string | null;
|
||||||
|
lastUsedProvider: Provider | null;
|
||||||
|
lastUsedModel: string | null;
|
||||||
};
|
};
|
||||||
type ContextMenuState = {
|
type ContextMenuState = {
|
||||||
item: SidebarSelection;
|
item: SidebarSelection;
|
||||||
@@ -89,11 +93,26 @@ function loadStoredModelPreferences() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pickProviderModel(options: string[], preferred: string | null, fallback: string | null = null) {
|
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 (fallback && options.includes(fallback)) return fallback;
|
||||||
|
if (preferred && options.includes(preferred)) return preferred;
|
||||||
return options[0] ?? "";
|
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 = {
|
type ModelComboboxProps = {
|
||||||
options: string[];
|
options: string[];
|
||||||
value: string;
|
value: string;
|
||||||
@@ -212,6 +231,10 @@ function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): Sid
|
|||||||
title: getChatTitle(chat),
|
title: getChatTitle(chat),
|
||||||
updatedAt: chat.updatedAt,
|
updatedAt: chat.updatedAt,
|
||||||
createdAt: chat.createdAt,
|
createdAt: chat.createdAt,
|
||||||
|
initiatedProvider: chat.initiatedProvider,
|
||||||
|
initiatedModel: chat.initiatedModel,
|
||||||
|
lastUsedProvider: chat.lastUsedProvider,
|
||||||
|
lastUsedModel: chat.lastUsedModel,
|
||||||
})),
|
})),
|
||||||
...searches.map((search) => ({
|
...searches.map((search) => ({
|
||||||
kind: "search" as const,
|
kind: "search" as const,
|
||||||
@@ -219,6 +242,10 @@ function buildSidebarItems(chats: ChatSummary[], searches: SearchSummary[]): Sid
|
|||||||
title: getSearchTitle(search),
|
title: getSearchTitle(search),
|
||||||
updatedAt: search.updatedAt,
|
updatedAt: search.updatedAt,
|
||||||
createdAt: search.createdAt,
|
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;
|
return searches.find((search) => search.id === selectedItem.id) ?? null;
|
||||||
}, [searches, selectedItem]);
|
}, [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(() => {
|
const selectedTitle = useMemo(() => {
|
||||||
if (draftKind === "chat") return "New chat";
|
if (draftKind === "chat") return "New chat";
|
||||||
if (draftKind === "search") return "New search";
|
if (draftKind === "search") return "New search";
|
||||||
@@ -611,6 +648,10 @@ export default function App() {
|
|||||||
title: chat.title,
|
title: chat.title,
|
||||||
createdAt: chat.createdAt,
|
createdAt: chat.createdAt,
|
||||||
updatedAt: chat.updatedAt,
|
updatedAt: chat.updatedAt,
|
||||||
|
initiatedProvider: chat.initiatedProvider,
|
||||||
|
initiatedModel: chat.initiatedModel,
|
||||||
|
lastUsedProvider: chat.lastUsedProvider,
|
||||||
|
lastUsedModel: chat.lastUsedModel,
|
||||||
messages: [],
|
messages: [],
|
||||||
});
|
});
|
||||||
setSelectedSearch(null);
|
setSelectedSearch(null);
|
||||||
@@ -937,6 +978,9 @@ export default function App() {
|
|||||||
) : null}
|
) : null}
|
||||||
{sidebarItems.map((item) => {
|
{sidebarItems.map((item) => {
|
||||||
const active = selectedItem?.kind === item.kind && selectedItem.id === item.id;
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={`${item.kind}-${item.id}`}
|
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" />}
|
{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>
|
<p className="truncate text-sm font-medium">{item.title}</p>
|
||||||
</div>
|
</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>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ export type ChatSummary = {
|
|||||||
title: string | null;
|
title: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
initiatedProvider: Provider | null;
|
||||||
|
initiatedModel: string | null;
|
||||||
|
lastUsedProvider: Provider | null;
|
||||||
|
lastUsedModel: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SearchSummary = {
|
export type SearchSummary = {
|
||||||
@@ -26,6 +30,10 @@ export type ChatDetail = {
|
|||||||
title: string | null;
|
title: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
initiatedProvider: Provider | null;
|
||||||
|
initiatedModel: string | null;
|
||||||
|
lastUsedProvider: Provider | null;
|
||||||
|
lastUsedModel: string | null;
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user