diff --git a/.gitignore b/.gitignore index aee8305..65ee0c1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules/ # statically served FE dependencies static/htmx.min.js static/htmx-ext-sse.js +static/idiomorph-ext.min.js # output out diff --git a/Dockerfile b/Dockerfile index c5ac7e1..873e2ff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,7 +32,7 @@ COPY --from=deps /app/node_modules ./node_modules COPY . . # Then overlay the generated htmx files from deps stage (after postinstall) -COPY --from=deps /app/static/htmx.min.js /app/static/htmx-ext-sse.js ./static/ +COPY --from=deps /app/static/htmx.min.js /app/static/htmx-ext-sse.js /app/static/idiomorph-ext.min.js ./static/ ENV NODE_ENV=production \ PORT=3000 diff --git a/bun.lock b/bun.lock index b1320de..f3df0ab 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,7 @@ "hono": "^4.9.6", "htmx-ext-sse": "^2.2.4", "htmx.org": "^2.0.8", + "idiomorph": "^0.7.4", "marked": "^16.4.1", "nodemailer": "^7.0.10", "ulid": "^3.0.1", @@ -599,6 +600,8 @@ "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "idiomorph": ["idiomorph@0.7.4", "", {}, "sha512-uCdSpLo3uMfqOmrwXTpR1k/sq4sSmKC7l4o/LdJOEU+MMMq+wkevRqOQYn3lP7vfz9Mv+USBEqPvi0XhdL9ENw=="], + "ignore-walk": ["ignore-walk@6.0.5", "", { "dependencies": { "minimatch": "^9.0.0" } }, "sha512-VuuG0wCnjhnylG1ABXT3dAuIpTNDs/G8jlpmwXY03fXoXy/8ZK8/T+hMzt8L4WnrLCJgdybqgPagnF/f97cg3A=="], "import-in-the-middle": ["import-in-the-middle@1.15.0", "", { "dependencies": { "acorn": "^8.14.0", "acorn-import-attributes": "^1.9.5", "cjs-module-lexer": "^1.2.2", "module-details-from-path": "^1.0.3" } }, "sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA=="], diff --git a/db/schema.sql b/db/schema.sql index 5fde2e3..695bdb0 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,7 +1,7 @@ -\restrict pvjOXl9YLIvZIpwIY4SpsjlLg6SdnGsubpkeBvXexBkbo3eJ75tXDZIElIyU7Rk +\restrict Ah1gzcmwaXniQw2dzIyVlrPF4EVIv28DgaOjh5TDOwpcDPd3dZKeX8QreYsxoVW -- Dumped from database version 16.10 --- Dumped by pg_dump version 17.6 +-- Dumped by pg_dump version 18.0 SET statement_timeout = 0; SET lock_timeout = 0; @@ -296,6 +296,8 @@ CREATE TABLE public.chat_messages ( tool_results jsonb, created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, chat_id character varying(26) NOT NULL, + error jsonb, + usage jsonb, CONSTRAINT chat_messages_role_check CHECK ((role = ANY (ARRAY['user'::text, 'assistant'::text, 'system'::text]))) ); @@ -1102,7 +1104,7 @@ ALTER TABLE ONLY public.items -- PostgreSQL database dump complete -- -\unrestrict pvjOXl9YLIvZIpwIY4SpsjlLg6SdnGsubpkeBvXexBkbo3eJ75tXDZIElIyU7Rk +\unrestrict Ah1gzcmwaXniQw2dzIyVlrPF4EVIv28DgaOjh5TDOwpcDPd3dZKeX8QreYsxoVW -- @@ -1139,4 +1141,7 @@ INSERT INTO public.schema_migrations (version) VALUES ('20251027195737'), ('20251028010531'), ('20251029183629'), - ('20251030232843'); + ('20251030232843'), + ('20251111071524'), + ('20251111085621'), + ('20251111221748'); diff --git a/migrations/20251111071524_add_error_field_to_chat_messages.sql b/migrations/20251111071524_add_error_field_to_chat_messages.sql new file mode 100644 index 0000000..830f0d7 --- /dev/null +++ b/migrations/20251111071524_add_error_field_to_chat_messages.sql @@ -0,0 +1,7 @@ +-- migrate:up +ALTER TABLE chat_messages + ADD COLUMN error JSONB; + +-- migrate:down +ALTER TABLE chat_messages + DROP COLUMN error; diff --git a/migrations/20251111085621_fix_double_encoded_jsonb_fields.sql b/migrations/20251111085621_fix_double_encoded_jsonb_fields.sql new file mode 100644 index 0000000..e7f9e12 --- /dev/null +++ b/migrations/20251111085621_fix_double_encoded_jsonb_fields.sql @@ -0,0 +1,21 @@ +-- migrate:up +-- Fix double-encoded JSONB fields in chat_messages +-- Converts string-encoded JSON back to proper JSONB objects + +UPDATE chat_messages +SET tool_calls = (tool_calls#>>'{}')::jsonb +WHERE tool_calls IS NOT NULL + AND jsonb_typeof(tool_calls) = 'string'; + +UPDATE chat_messages +SET tool_results = (tool_results#>>'{}')::jsonb +WHERE tool_results IS NOT NULL + AND jsonb_typeof(tool_results) = 'string'; + +UPDATE chat_messages +SET error = (error#>>'{}')::jsonb +WHERE error IS NOT NULL + AND jsonb_typeof(error) = 'string'; + +-- migrate:down +-- No down migration - this is a data fix diff --git a/migrations/20251111221748_add_usage_to_chat_messages.sql b/migrations/20251111221748_add_usage_to_chat_messages.sql new file mode 100644 index 0000000..4df23d1 --- /dev/null +++ b/migrations/20251111221748_add_usage_to_chat_messages.sql @@ -0,0 +1,6 @@ +-- migrate:up +ALTER TABLE chat_messages ADD COLUMN usage jsonb; + +-- migrate:down +ALTER TABLE chat_messages DROP COLUMN usage; + diff --git a/mise.toml b/mise.toml index da32e11..9fc3dec 100644 --- a/mise.toml +++ b/mise.toml @@ -79,6 +79,33 @@ description = "Remove the Cloud Run service and migration job" run = "gcloud beta run services logs tail prod-app --project=csheet-475917 --region=us-central1" description = "Tail production Cloud Run logs" +[tasks."db:prod:psql"] +run = "gcloud sql connect app-db-7205418 --project=csheet-475917 --database=csheet --user=app" +description = "Connect to production database (interactive psql)" + +[tasks."db:prod:proxy"] +run = """ + echo "Starting Cloud SQL Proxy for production database..." + echo "Connection will be available at: localhost:5433" + echo "Database: csheet" + echo "User: app" + echo "" + echo "Connect with DBeaver using:" + echo " Host: localhost" + echo " Port: 5433" + echo " Database: csheet" + echo " Username: app" + echo " Password: (retrieve from Secret Manager)" + echo "" + echo "To get the password, run:" + echo " gcloud secrets versions access latest --secret=prod-postgres-password --project=csheet-475917" + echo "" + echo "Press Ctrl+C to stop the proxy" + echo "" + gcloud sql instances describe app-db-7205418 --project=csheet-475917 --format="value(connectionName)" | xargs -I {} cloud-sql-proxy {} --port 5433 +""" +description = "Start Cloud SQL Proxy tunnel to production database (localhost:5433)" + [tasks.install] run = "bun install" description = "Install dependencies" diff --git a/package.json b/package.json index 2bd1f4e..563b1df 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "type": "module", "private": true, "scripts": { - "postinstall": "mkdir -p static && cp node_modules/htmx.org/dist/htmx.min.js static/ && cp node_modules/htmx-ext-sse/sse.js static/htmx-ext-sse.js" + "postinstall": "mkdir -p static && cp node_modules/htmx.org/dist/htmx.min.js static/ && cp node_modules/htmx-ext-sse/sse.js static/htmx-ext-sse.js && cp node_modules/idiomorph/dist/idiomorph-ext.min.js static/idiomorph-ext.min.js" }, "devDependencies": { "@biomejs/biome": "^2.2.6", @@ -40,6 +40,7 @@ "hono": "^4.9.6", "htmx-ext-sse": "^2.2.4", "htmx.org": "^2.0.8", + "idiomorph": "^0.7.4", "marked": "^16.4.1", "nodemailer": "^7.0.10", "ulid": "^3.0.1", diff --git a/public/static/htmx-auth.js b/public/static/htmx-auth.js new file mode 100644 index 0000000..a032628 --- /dev/null +++ b/public/static/htmx-auth.js @@ -0,0 +1,17 @@ +// Handle HTMX error responses with redirects +// Allows 401/403/404 responses to trigger redirects when HX-Redirect header is present +document.body.addEventListener("htmx:beforeSwap", function (evt) { + // Check if response has HX-Redirect header + const hxRedirect = evt.detail.xhr.getResponseHeader("HX-Redirect") + if (hxRedirect) { + // Allow error status codes (401, 403, 404) to swap when redirect is present + if ( + evt.detail.xhr.status === 401 || + evt.detail.xhr.status === 403 || + evt.detail.xhr.status === 404 + ) { + evt.detail.shouldSwap = true + evt.detail.isError = false + } + } +}) diff --git a/pulumi/infra/index.ts b/pulumi/infra/index.ts index 503cc98..56e45db 100644 --- a/pulumi/infra/index.ts +++ b/pulumi/infra/index.ts @@ -230,13 +230,15 @@ const sqlInstance = new gcp.sql.DatabaseInstance( databaseVersion: "POSTGRES_16", project, region, + deletionProtection: stack === "prod" ? true : undefined, settings: { tier: dbTier, availabilityType: stack === "prod" ? "REGIONAL" : undefined, edition: stack === "prod" ? "ENTERPRISE" : undefined, ipConfiguration: { privateNetwork: network.id, - ipv4Enabled: false, + ipv4Enabled: stack === "prod", + authorizedNetworks: stack === "prod" ? [] : undefined, }, backupConfiguration: { enabled: true, @@ -245,7 +247,8 @@ const sqlInstance = new gcp.sql.DatabaseInstance( location: "us", transactionLogRetentionDays: 7, backupRetentionSettings: { - retainedBackups: stack === "prod" ? 90 : 7, + retainedBackups: stack === "prod" ? 30 : 7, + retentionUnit: stack === "prod" ? "COUNT" : undefined, }, }, diskAutoresize: true, diff --git a/src/ai/chat.ts b/src/ai/chat.ts index ee9f26f..18f3741 100644 --- a/src/ai/chat.ts +++ b/src/ai/chat.ts @@ -1,11 +1,11 @@ -import { type ChatMessage, create as saveChatMessage } from "@src/db/chat_messages" +import { type ChatMessage, create as saveChatMessage, type Usage } from "@src/db/chat_messages" import { getChatModel } from "@src/lib/ai" import { logger } from "@src/lib/logger" import type { ComputedCharacter } from "@src/services/computeCharacter" import type { ComputedChat } from "@src/services/computeChat" import { executeTool } from "@src/services/toolExecution" import { TOOL_DEFINITIONS, TOOLS } from "@src/tools" -import { streamText } from "ai" +import { type LanguageModel, streamText } from "ai" import type { SQL } from "bun" import { ulid } from "ulid" import { buildSystemPrompt } from "./prompts" @@ -40,6 +40,8 @@ export async function prepareChatRequest( content: systemPrompt, tool_calls: null, tool_results: null, + error: null, + usage: null, }) } @@ -51,6 +53,8 @@ export async function prepareChatRequest( content: userMessage, tool_calls: null, tool_results: null, + error: null, + usage: null, }) return { chatId: finalChatId } @@ -136,8 +140,8 @@ async function validateApprovalTools( } /** - * Execute a chat request by streaming AI response and creating assistant message after completion - * Returns the ID of the newly created assistant message + * Execute a chat request by streaming AI response and creating assistant message + * Always returns the ID of the newly created assistant message (with content or error) */ export async function executeChatRequest( db: SQL, @@ -150,23 +154,47 @@ export async function executeChatRequest( throw new Error("Chat is not ready to stream - shouldStream flag is false") } - const model = getChatModel() - - // Wrap streamText in a Promise that resolves when streaming completes - const requestBody = { - model, - maxOutputTokens: 1024, - messages: computedChat.llmMessages, - tools: TOOL_DEFINITIONS, - onError: ({ error }: { error: unknown }) => { - // Handle errors that occur during streaming (before onFinish) - logger.error("AI streaming error", error as Error, { - character_id: character.id, - }) - }, + // First try-catch: Prep phase (building request) + let requestBody: Parameters[0] + let model: LanguageModel + try { + model = getChatModel() + requestBody = { + model, + maxOutputTokens: 1024, + messages: computedChat.llmMessages, + tools: TOOL_DEFINITIONS, + onError: ({ error }: { error: unknown }) => { + logger.error("AI streaming error", error as Error, { + character_id: character.id, + }) + }, + } + } catch (err) { + logger.error("AI prep error", err as Error, { + chatId: computedChat.chatId, + character_id: character.id, + }) + + // Create assistant message with prep error + const assistantMsg = await saveChatMessage(db, { + character_id: character.id, + chat_id: computedChat.chatId, + role: "assistant", + content: "", + tool_calls: null, + tool_results: null, + error: { + type: "prep", + message: err instanceof Error ? err.message : "Unknown prep error", + }, + usage: null, + }) + + return assistantMsg.id } - // Start streaming - result is returned synchronously, streaming happens via callbacks + // Second try-catch: Streaming phase const messageAggregator: string[] = [] try { const result = streamText(requestBody) @@ -190,6 +218,19 @@ export async function executeChatRequest( toolResults[id] = null } + // Capture usage data from AI SDK + const usageData = await result.usage + const usage: Usage | null = usageData + ? { + provider: model.provider, + modelId: model.modelId, + inputTokens: usageData.inputTokens || 0, + outputTokens: usageData.outputTokens || 0, + cachedInputTokens: usageData.cachedInputTokens, + totalTokens: usageData.totalTokens || 0, + } + : null + // Create assistant message with final content after streaming completes const assistantMsg = await saveChatMessage(db, { character_id: character.id, @@ -198,6 +239,8 @@ export async function executeChatRequest( content: messageAggregator.join(""), tool_calls: Object.keys(toolCalls).length > 0 ? toolCalls : null, tool_results: Object.keys(toolResults).length > 0 ? toolResults : null, + error: null, + usage, }) // Auto-execute any read-only tools immediately @@ -212,6 +255,22 @@ export async function executeChatRequest( chatId: computedChat.chatId, character_id: character.id, }) - throw err + + // Create assistant message with stream error + const assistantMsg = await saveChatMessage(db, { + character_id: character.id, + chat_id: computedChat.chatId, + role: "assistant", + content: "", + tool_calls: null, + tool_results: null, + usage: null, + error: { + type: "stream", + message: err instanceof Error ? err.message : "Unknown stream error", + }, + }) + + return assistantMsg.id } } diff --git a/src/components/AbilitiesEditForm.tsx b/src/components/AbilitiesEditForm.tsx index 04026bd..d8a597a 100644 --- a/src/components/AbilitiesEditForm.tsx +++ b/src/components/AbilitiesEditForm.tsx @@ -133,9 +133,9 @@ export const AbilitiesEditForm = ({ character, values, errors = {} }: AbilitiesE id="abilities-edit-form" hx-post={`/characters/${character.id}/edit/abilities`} hx-vals='{"is_check": "true"}' - hx-trigger="change delay:300ms" + hx-trigger="change" hx-target="#editModalContent" - hx-swap="innerHTML" + hx-swap="morph:innerHTML" class="needs-validation" novalidate > @@ -183,7 +183,7 @@ export const AbilitiesEditForm = ({ character, values, errors = {} }: AbilitiesE hx-post={`/characters/${character.id}/edit/abilities`} hx-vals='{"is_check": "false"}' hx-target="#editModalContent" - hx-swap="innerHTML" + hx-swap="morph:innerHTML" > Update Abilities diff --git a/src/components/CastSpellForm.tsx b/src/components/CastSpellForm.tsx index 8cad22c..f2e8da8 100644 --- a/src/components/CastSpellForm.tsx +++ b/src/components/CastSpellForm.tsx @@ -88,9 +88,9 @@ export const CastSpellForm = ({ character, values = {}, errors = {} }: CastSpell id="cast-spell-form" hx-post={`/characters/${character.id}/castspell`} hx-vals='{"is_check": "true"}' - hx-trigger="change delay:300ms" + hx-trigger="change" hx-target="#editModalContent" - hx-swap="innerHTML" + hx-swap="morph:innerHTML" class="needs-validation" novalidate > @@ -175,7 +175,7 @@ export const CastSpellForm = ({ character, values = {}, errors = {} }: CastSpell hx-post={`/characters/${character.id}/castspell`} hx-vals='{"is_check": "false"}' hx-target="#editModalContent" - hx-swap="innerHTML" + hx-swap="morph:innerHTML" > Cast {spell.name} diff --git a/src/components/Character.tsx b/src/components/Character.tsx index 020686c..7872676 100644 --- a/src/components/Character.tsx +++ b/src/components/Character.tsx @@ -21,6 +21,7 @@ export const Character = ({ character, currentNote }: CharacterProps) => { llmMessages: [], shouldStream: false, unresolvedToolCalls: [], + consecutiveErrorCount: 0, } return ( diff --git a/src/components/CharacterImport.tsx b/src/components/CharacterImport.tsx index 7affb31..ef4787f 100644 --- a/src/components/CharacterImport.tsx +++ b/src/components/CharacterImport.tsx @@ -74,7 +74,7 @@ const MultiClassSelector = ({ values = {}, errors = {} }: MultiClassSelectorProp @@ -85,7 +85,7 @@ const MultiClassSelector = ({ values = {}, errors = {} }: MultiClassSelectorProp {isSelected && ( @@ -197,7 +197,7 @@ const MaxHPInput = ({ values = {}, errors = {} }: MaxHPInputProps) => { @@ -514,6 +514,7 @@ export const CharacterImport = ({ values = {}, errors = {} }: CharacterImportPro
diff --git a/src/components/ChatBox.tsx b/src/components/ChatBox.tsx index 15668db..4103fd6 100644 --- a/src/components/ChatBox.tsx +++ b/src/components/ChatBox.tsx @@ -51,6 +51,18 @@ export const ChatBox = ({ character, computedChat, swapOob = false }: ChatBoxPro return null } + let inputPlaceholder = "e.g. I spent 50 gold on a sword" + if (computedChat.unresolvedToolCalls.length > 0) { + inputPlaceholder = "Accept or decline Reed's edit" + } else if (computedChat.erroredMessage || computedChat.shouldStream) { + inputPlaceholder = "Hold on a second..." + } + + const inputDisabled = + computedChat.shouldStream || + computedChat.unresolvedToolCalls.length > 0 || + !!computedChat.erroredMessage + return (
@@ -115,6 +127,16 @@ export const ChatBox = ({ character, computedChat, swapOob = false }: ChatBoxPro )) )} + {/* Show error retry UI if last message has unretried error */} + {computedChat.erroredMessage?.error && ( + + )} + {/* Response box - shown when waiting for AI response */} {computedChat.shouldStream && (
@@ -126,6 +148,7 @@ export const ChatBox = ({ character, computedChat, swapOob = false }: ChatBoxPro hx-ext="sse" sse-connect={`/characters/${character.id}/chat/${computedChat.chatId}/stream`} sse-swap="message" + sse-close="close" > Thinking... @@ -151,15 +174,19 @@ export const ChatBox = ({ character, computedChat, swapOob = false }: ChatBoxPro name="message" id="chat-input" class="form-control" - placeholder="e.g., I spent 50 gold on a sword" + placeholder={inputPlaceholder} required autocomplete="off" - {...(computedChat.shouldStream ? { disabled: true } : {})} + disabled={inputDisabled} /> + )} + +
+
+
+ + ) +} + /** * Inline tool call approval component * Shows a pending tool call with approve/reject buttons @@ -229,33 +322,34 @@ export const ToolCallApproval = ({ characterId, chatId, toolCall }: ToolCallAppr
-
-
- -
- {approvalMessage} -
-
- - -
-
+
+
+ + Accept Reed's Action +
+
+
{approvalMessage}
+
diff --git a/src/components/ChatHistory.tsx b/src/components/ChatHistory.tsx index 67ddf4f..b90cf27 100644 --- a/src/components/ChatHistory.tsx +++ b/src/components/ChatHistory.tsx @@ -30,6 +30,16 @@ export const ChatHistory = ({ character, chats }: ChatHistoryProps) => { return `${message.substring(0, maxLength)}...` } + const formatTokens = (tokens: number | null) => { + if (tokens === null) { + return null + } + return tokens.toLocaleString() + } + + // Calculate total tokens across all chats + const totalTokens = chats.reduce((sum, chat) => sum + (chat.total_tokens || 0), 0) + return ( <> @@ -719,11 +720,13 @@ export const CreateItemForm = ({ character, values, errors }: CreateItemFormProp diff --git a/src/components/EditItemForm.tsx b/src/components/EditItemForm.tsx index edfecfb..4a21e3a 100644 --- a/src/components/EditItemForm.tsx +++ b/src/components/EditItemForm.tsx @@ -150,12 +150,12 @@ export const EditItemForm = ({ return (