this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add model selector and improve character selector (#1)

authored by

James Blair and committed by
GitHub
b2f4d3e2 e52e837b

+346 -46
+5 -1
apps/api/src/index.ts
··· 1 1 import { Hono } from "hono"; 2 2 import { characters } from "./routes/characters"; 3 3 import { chats } from "./routes/chats"; 4 + import { models } from "./routes/models"; 4 5 5 - const api = new Hono().route("/characters", characters).route("/chats", chats); 6 + const api = new Hono() 7 + .route("/characters", characters) 8 + .route("/models", models) 9 + .route("/chats", chats); 6 10 7 11 export const app = new Hono().route("/api", api); 8 12
+7 -5
apps/api/src/lib/ai/chat.ts
··· 1 1 import { streamText } from "ai"; 2 - import { chatModel } from "./model"; 2 + import type { ChatModelId } from "../models"; 3 + import { getChatModel } from "./model"; 3 4 4 5 interface StreamChatOptions { 5 - system: string; 6 + system?: string; 7 + modelId: ChatModelId; 6 8 messages: Array<{ 7 9 role: "assistant" | "user"; 8 10 content: string; 9 11 }>; 10 12 } 11 13 12 - export function streamChat({ system, messages }: StreamChatOptions) { 14 + export function streamChat({ system, modelId, messages }: StreamChatOptions) { 13 15 return streamText({ 14 - model: chatModel, 15 - system, 16 + model: getChatModel(modelId), 17 + ...(system ? { system } : {}), 16 18 messages, 17 19 }); 18 20 }
+4 -1
apps/api/src/lib/ai/model.ts
··· 1 1 import { openai } from "@ai-sdk/openai"; 2 + import type { ChatModelId } from "../models"; 2 3 3 - export const chatModel = openai("gpt-5.4-mini"); 4 + export function getChatModel(modelId: ChatModelId) { 5 + return openai(modelId); 6 + }
+2 -2
apps/api/src/lib/ai/title.ts
··· 1 + import { openai } from "@ai-sdk/openai"; 1 2 import { generateObject } from "ai"; 2 3 import { z } from "zod"; 3 - import { chatModel } from "./model"; 4 4 5 5 const chatTitleSchema = z.object({ 6 6 title: z ··· 11 11 12 12 export function generateChatTitle(message: string) { 13 13 return generateObject({ 14 - model: chatModel, 14 + model: openai("gpt-5.4-mini"), 15 15 schema: chatTitleSchema, 16 16 schemaName: "ChatTitle", 17 17 system:
+16 -7
apps/api/src/lib/characters.ts
··· 1 - interface Character { 2 - id: string; 3 - name: string; 4 - prompt: string; 5 - } 6 - 7 - export const characters: Character[] = [ 1 + export const characters = [ 2 + { 3 + id: "assistant", 4 + name: "Assistant", 5 + }, 8 6 { 9 7 id: "kitsune", 10 8 name: "Kitsune", ··· 17 15 prompt: 18 16 "You are Neko, a playful and cute anime catgirl. Be cute, teasing, a little bratty, and adorable.\n\nRules: Fun chat only. No explanations or useful answers. Keep replies very short (1–2 sentences). Refuse or dodge anything that looks like work or learning. Stay bratty and playful.", 19 17 }, 18 + ] as const satisfies ReadonlyArray<{ 19 + id: string; 20 + name: string; 21 + prompt?: string; 22 + }>; 23 + 24 + export type CharacterId = (typeof characters)[number]["id"]; 25 + 26 + export const characterIds = characters.map((character) => character.id) as [ 27 + CharacterId, 28 + ...CharacterId[], 20 29 ];
+1
apps/api/src/lib/db/index.ts
··· 3 3 import * as schema from "./schema"; 4 4 5 5 const sqlite = new Database(process.env.DB_FILE_NAME ?? "sqlite.db"); 6 + 6 7 const db = drizzle({ client: sqlite, schema }); 7 8 8 9 export { db, schema, sqlite };
+4 -1
apps/api/src/lib/db/schema.ts
··· 1 1 import { relations } from "drizzle-orm"; 2 2 import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; 3 + import { characterIds } from "../characters"; 4 + import { modelIds } from "../models"; 3 5 4 6 export const chats = sqliteTable("chats", { 5 7 id: text("id").primaryKey(), 6 8 title: text("title").notNull(), 7 - characterId: text("character_id").notNull(), 9 + characterId: text("character_id", { enum: characterIds }).notNull(), 10 + modelId: text("model_id", { enum: modelIds }).notNull(), 8 11 createdAt: integer("created_at", { mode: "timestamp_ms" }) 9 12 .notNull() 10 13 .$defaultFn(() => new Date()),
+17
apps/api/src/lib/models.ts
··· 1 + export const models = [ 2 + { 3 + id: "gpt-5.4-mini", 4 + name: "GPT 5.4 Mini", 5 + }, 6 + { 7 + id: "gpt-5.4", 8 + name: "GPT 5.4", 9 + }, 10 + ] as const; 11 + 12 + export type ChatModelId = (typeof models)[number]["id"]; 13 + 14 + export const modelIds = models.map((model) => model.id) as [ 15 + ChatModelId, 16 + ...ChatModelId[], 17 + ];
+4 -1
apps/api/src/lib/schema.ts
··· 1 1 import z from "zod"; 2 + import { characterIds } from "./characters"; 3 + import { modelIds } from "./models"; 2 4 3 5 export const characterSchema = z.object({ 4 6 name: z.string().min(1), ··· 7 9 8 10 export const chatSchema = z.object({ 9 11 title: z.string().min(1), 10 - characterId: z.string().min(1), 12 + characterId: z.enum(characterIds), 13 + modelId: z.enum(modelIds), 11 14 }); 12 15 13 16 export const messageSchema = z.object({
+3 -1
apps/api/src/routes/chats.ts
··· 70 70 71 71 const titleResultPromise = 72 72 messages.length === 1 ? generateChatTitle(body.text) : undefined; 73 + const system = "prompt" in character ? character.prompt : undefined; 73 74 74 75 const res = streamChat({ 75 - system: character.prompt, 76 + system, 77 + modelId: chat.modelId, 76 78 messages: messages.map((message) => ({ 77 79 role: message.role, 78 80 content: message.text,
+8
apps/api/src/routes/models.ts
··· 1 + import { Hono } from "hono"; 2 + import { models } from "../lib/models"; 3 + 4 + const app = new Hono().get("/", async (c) => { 5 + return c.json(models); 6 + }); 7 + 8 + export { app as models };
+2 -1
apps/web/src/components/character-select.tsx
··· 1 1 import type * as React from "react"; 2 2 import { useCharacters } from "#/hooks/use-characters"; 3 + import type { CharacterId } from "#/lib/types"; 3 4 import { 4 5 Select, 5 6 SelectContent, ··· 22 23 <SelectValue placeholder="Characters"> 23 24 {(value) => 24 25 typeof value === "string" 25 - ? (characterNames.get(value) ?? value) 26 + ? (characterNames.get(value as CharacterId) ?? value) 26 27 : null 27 28 } 28 29 </SelectValue>
+93 -21
apps/web/src/components/chat-form.tsx
··· 1 1 import { SendIcon } from "lucide-react"; 2 2 import { useEffect, useState } from "react"; 3 3 import { useCharacters } from "#/hooks/use-characters"; 4 + import { useModels } from "#/hooks/use-models"; 4 5 import { useSendMessage } from "#/hooks/use-send-message"; 6 + import { useUpdateChatCharacter } from "#/hooks/use-update-chat-character"; 7 + import { useUpdateChatModel } from "#/hooks/use-update-chat-model"; 8 + import type { CharacterId, ChatModelId } from "#/lib/types"; 5 9 import { CharacterSelect } from "./character-select"; 6 10 import { Row, Stack } from "./layout"; 11 + import { ModelSelect } from "./model-select"; 7 12 import { Button } from "./ui/button"; 8 13 import { Textarea } from "./ui/textarea"; 9 14 10 15 interface ChatFormProps { 11 16 chatId?: string; 12 - characterId?: string; 17 + characterId?: CharacterId; 18 + modelId?: ChatModelId; 13 19 className?: string; 14 20 } 15 21 16 22 export function ChatForm({ 17 23 chatId, 18 24 characterId: chatCharacterId, 25 + modelId: chatModelId, 19 26 className, 20 27 }: ChatFormProps) { 21 28 const { data: characters = [] } = useCharacters(); 29 + const { data: models = [] } = useModels(); 22 30 const sendMessage = useSendMessage(); 23 - const [characterId, setCharacterId] = useState<string | null>(null); 31 + const updateChatCharacter = useUpdateChatCharacter(); 32 + const updateChatModel = useUpdateChatModel(); 33 + const [characterId, setCharacterId] = useState<CharacterId | null>(null); 34 + const [modelId, setModelId] = useState<ChatModelId | null>(null); 24 35 const [text, setText] = useState(""); 25 36 const selectedCharacterId = chatCharacterId ?? characterId; 37 + const selectedModelId = chatModelId ?? modelId; 26 38 27 39 useEffect(() => { 28 40 if (!chatCharacterId && !characterId && characters[0]) { 29 41 setCharacterId(characters[0].id); 30 42 } 31 43 }, [chatCharacterId, characterId, characters]); 44 + 45 + useEffect(() => { 46 + if (!chatModelId && !modelId && models[0]) { 47 + setModelId(models[0].id); 48 + } 49 + }, [chatModelId, modelId, models]); 32 50 33 51 const isDisabled = 34 - sendMessage.isPending || !text.trim() || (!chatId && !selectedCharacterId); 52 + sendMessage.isPending || 53 + !text.trim() || 54 + (!chatId && (!selectedCharacterId || !selectedModelId)); 55 + const isCharacterDisabled = 56 + sendMessage.isPending || updateChatCharacter.isPending; 57 + const isModelDisabled = sendMessage.isPending || updateChatModel.isPending; 58 + 59 + function handleCharacterChange(value: string) { 60 + const nextCharacterId = value as CharacterId; 61 + 62 + if (!chatId) { 63 + setCharacterId(nextCharacterId); 64 + return; 65 + } 66 + 67 + if (nextCharacterId === chatCharacterId) { 68 + return; 69 + } 70 + 71 + updateChatCharacter.mutate({ 72 + id: chatId, 73 + characterId: nextCharacterId, 74 + }); 75 + } 76 + 77 + function handleModelChange(value: string) { 78 + const nextModelId = value as ChatModelId; 79 + 80 + if (!chatId) { 81 + setModelId(nextModelId); 82 + return; 83 + } 84 + 85 + if (nextModelId === chatModelId) { 86 + return; 87 + } 88 + 89 + updateChatModel.mutate({ id: chatId, modelId: nextModelId }); 90 + } 91 + 92 + function handleSubmit(event: React.FormEvent<HTMLFormElement>) { 93 + event.preventDefault(); 94 + if (isDisabled) return; 95 + 96 + sendMessage.mutate({ 97 + chatId, 98 + characterId: selectedCharacterId, 99 + modelId: selectedModelId as ChatModelId, 100 + text, 101 + }); 102 + setText(""); 103 + } 35 104 36 105 return ( 37 - <form 38 - onSubmit={(event) => { 39 - event.preventDefault(); 40 - if (isDisabled) return; 41 - sendMessage.mutate({ chatId, characterId: selectedCharacterId, text }); 42 - setText(""); 43 - }} 44 - className={className} 45 - > 106 + <form onSubmit={handleSubmit} className={className}> 46 107 <Stack gap="sm"> 47 108 <Textarea 48 109 autoFocus ··· 58 119 }} 59 120 /> 60 121 <Row justify="between" items="end"> 61 - <CharacterSelect 62 - value={selectedCharacterId} 63 - onValueChange={(value) => { 64 - if (typeof value === "string") { 65 - setCharacterId(value); 66 - } 67 - }} 68 - disabled={sendMessage.isPending || Boolean(chatId)} 69 - /> 122 + <Row gap="sm" className="flex-wrap"> 123 + <CharacterSelect 124 + value={selectedCharacterId} 125 + onValueChange={(value) => { 126 + if (typeof value === "string") { 127 + handleCharacterChange(value); 128 + } 129 + }} 130 + disabled={isCharacterDisabled} 131 + /> 132 + <ModelSelect 133 + value={selectedModelId} 134 + onValueChange={(value) => { 135 + if (typeof value === "string") { 136 + handleModelChange(value); 137 + } 138 + }} 139 + disabled={isModelDisabled} 140 + /> 141 + </Row> 70 142 <Button type="submit" size="lg" disabled={isDisabled}> 71 143 Send 72 144 <SendIcon />
+5 -1
apps/web/src/components/chat.tsx
··· 13 13 return ( 14 14 <Stack className="flex-1 py-4"> 15 15 <ChatMessages messages={messages} /> 16 - <ChatForm chatId={chatId} characterId={chat?.characterId} /> 16 + <ChatForm 17 + chatId={chatId} 18 + characterId={chat?.characterId} 19 + modelId={chat?.modelId} 20 + /> 17 21 </Stack> 18 22 ); 19 23 }
+41
apps/web/src/components/model-select.tsx
··· 1 + import type * as React from "react"; 2 + import { useModels } from "#/hooks/use-models"; 3 + import type { ChatModelId } from "#/lib/types"; 4 + import { 5 + Select, 6 + SelectContent, 7 + SelectGroup, 8 + SelectItem, 9 + SelectLabel, 10 + SelectTrigger, 11 + SelectValue, 12 + } from "./ui/select"; 13 + 14 + export function ModelSelect(props: React.ComponentProps<typeof Select>) { 15 + const { data: models = [] } = useModels(); 16 + const modelNames = new Map(models.map((model) => [model.id, model.name])); 17 + 18 + return ( 19 + <Select {...props}> 20 + <SelectTrigger className="min-w-40 px-4 data-[size=default]:h-10"> 21 + <SelectValue placeholder="Model"> 22 + {(value) => 23 + typeof value === "string" 24 + ? (modelNames.get(value as ChatModelId) ?? value) 25 + : null 26 + } 27 + </SelectValue> 28 + </SelectTrigger> 29 + <SelectContent alignItemWithTrigger={false}> 30 + <SelectGroup> 31 + <SelectLabel>Models</SelectLabel> 32 + {models.map((model) => ( 33 + <SelectItem key={model.id} value={model.id}> 34 + {model.name} 35 + </SelectItem> 36 + ))} 37 + </SelectGroup> 38 + </SelectContent> 39 + </Select> 40 + ); 41 + }
+17
apps/web/src/hooks/use-models.ts
··· 1 + import { useQuery } from "@tanstack/react-query"; 2 + import { client } from "#/lib/api"; 3 + 4 + export function useModels() { 5 + return useQuery({ 6 + queryKey: ["models"], 7 + queryFn: async () => { 8 + const response = await client.api.models.$get(); 9 + 10 + if (!response.ok) { 11 + throw new Error("Failed to load models"); 12 + } 13 + 14 + return response.json(); 15 + }, 16 + }); 17 + }
+18 -3
apps/web/src/hooks/use-send-message.ts
··· 5 5 } from "@tanstack/react-query"; 6 6 import { useNavigate } from "@tanstack/react-router"; 7 7 import { client } from "#/lib/api"; 8 - import type { Chat, ChatMessage, ChatSummary } from "#/lib/types"; 8 + import type { 9 + CharacterId, 10 + Chat, 11 + ChatMessage, 12 + ChatModelId, 13 + ChatSummary, 14 + } from "#/lib/types"; 9 15 10 16 const chatQueryKey = (chatId: string) => ["chat", chatId] as const; 11 17 const chatsQueryKey = ["chats"] as const; 12 18 13 19 interface SendMessageInput { 14 20 chatId?: string; 15 - characterId: string | null; 21 + characterId: CharacterId | null; 22 + modelId: ChatModelId; 16 23 text: string; 17 24 } 18 25 ··· 114 121 async function getChatId({ 115 122 chatId, 116 123 characterId, 124 + modelId, 117 125 }: Omit<SendMessageInput, "text">) { 118 126 if (chatId) return chatId; 119 127 ··· 125 133 json: { 126 134 title: "New chat", 127 135 characterId, 136 + modelId, 128 137 }, 129 138 }); 130 139 ··· 145 154 } 146 155 147 156 return useMutation({ 148 - mutationFn: async ({ chatId, characterId, text }: SendMessageInput) => { 157 + mutationFn: async ({ 158 + chatId, 159 + characterId, 160 + modelId, 161 + text, 162 + }: SendMessageInput) => { 149 163 const trimmedText = text.trim(); 150 164 151 165 if (!trimmedText) { ··· 155 169 const id = await getChatId({ 156 170 chatId, 157 171 characterId, 172 + modelId, 158 173 }); 159 174 160 175 await queryClient.cancelQueries({ queryKey: chatQueryKey(id) });
+45
apps/web/src/hooks/use-update-chat-character.ts
··· 1 + import { useMutation, useQueryClient } from "@tanstack/react-query"; 2 + import { client } from "#/lib/api"; 3 + import type { 4 + Chat, 5 + ChatSummary, 6 + PatchChatJson, 7 + PatchChatParam, 8 + } from "#/lib/types"; 9 + 10 + type UpdateChatCharacterInput = PatchChatParam & 11 + Pick<PatchChatJson, "characterId">; 12 + 13 + export function useUpdateChatCharacter() { 14 + const queryClient = useQueryClient(); 15 + 16 + return useMutation({ 17 + mutationFn: async ({ id, characterId }: UpdateChatCharacterInput) => { 18 + const response = await client.api.chats[":id"].$patch({ 19 + param: { id }, 20 + json: { characterId }, 21 + }); 22 + 23 + if (!response.ok) { 24 + throw new Error("Failed to update chat character"); 25 + } 26 + 27 + return response.json(); 28 + }, 29 + onSuccess: (updatedChat) => { 30 + queryClient.setQueryData<ChatSummary[]>(["chats"], (chats = []) => 31 + chats.map((chat) => 32 + chat.id === updatedChat.id 33 + ? { ...chat, characterId: updatedChat.characterId } 34 + : chat, 35 + ), 36 + ); 37 + 38 + queryClient.setQueryData<Chat | undefined>( 39 + ["chat", updatedChat.id], 40 + (chat) => 41 + chat ? { ...chat, characterId: updatedChat.characterId } : chat, 42 + ); 43 + }, 44 + }); 45 + }
+43
apps/web/src/hooks/use-update-chat-model.ts
··· 1 + import { useMutation, useQueryClient } from "@tanstack/react-query"; 2 + import { client } from "#/lib/api"; 3 + import type { 4 + Chat, 5 + ChatSummary, 6 + PatchChatJson, 7 + PatchChatParam, 8 + } from "#/lib/types"; 9 + 10 + type UpdateChatModelInput = PatchChatParam & Pick<PatchChatJson, "modelId">; 11 + 12 + export function useUpdateChatModel() { 13 + const queryClient = useQueryClient(); 14 + 15 + return useMutation({ 16 + mutationFn: async ({ id, modelId }: UpdateChatModelInput) => { 17 + const response = await client.api.chats[":id"].$patch({ 18 + param: { id }, 19 + json: { modelId }, 20 + }); 21 + 22 + if (!response.ok) { 23 + throw new Error("Failed to update chat model"); 24 + } 25 + 26 + return response.json(); 27 + }, 28 + onSuccess: (updatedChat) => { 29 + queryClient.setQueryData<ChatSummary[]>(["chats"], (chats = []) => 30 + chats.map((chat) => 31 + chat.id === updatedChat.id 32 + ? { ...chat, modelId: updatedChat.modelId } 33 + : chat, 34 + ), 35 + ); 36 + 37 + queryClient.setQueryData<Chat | undefined>( 38 + ["chat", updatedChat.id], 39 + (chat) => (chat ? { ...chat, modelId: updatedChat.modelId } : chat), 40 + ); 41 + }, 42 + }); 43 + }
+11 -1
apps/web/src/lib/types.ts
··· 1 - import type { InferResponseType } from "hono/client"; 1 + import type { InferRequestType, InferResponseType } from "hono/client"; 2 2 import type { client } from "./api"; 3 3 4 4 type GetCharacters = typeof client.api.characters.$get; 5 + type GetModels = typeof client.api.models.$get; 5 6 type GetChats = typeof client.api.chats.$get; 6 7 type GetChat = (typeof client.api.chats)[":id"]["$get"]; 8 + type PatchChatRequest = InferRequestType< 9 + (typeof client.api.chats)[":id"]["$patch"] 10 + >; 7 11 8 12 export type Character = InferResponseType<GetCharacters>[number]; 13 + export type CharacterId = Character["id"]; 14 + export type Model = InferResponseType<GetModels>[number]; 15 + export type ChatModelId = Model["id"]; 9 16 export type ChatSummary = InferResponseType<GetChats>[number]; 10 17 export type Chat = InferResponseType<GetChat, 200>; 11 18 export type ChatMessage = Chat["messages"][number]; 19 + export type PatchChat = PatchChatRequest; 20 + export type PatchChatParam = PatchChat["param"]; 21 + export type PatchChatJson = PatchChat["json"];