Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 9 additions & 4 deletions db/schema.sql
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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])))
);

Expand Down Expand Up @@ -1102,7 +1104,7 @@ ALTER TABLE ONLY public.items
-- PostgreSQL database dump complete
--

\unrestrict pvjOXl9YLIvZIpwIY4SpsjlLg6SdnGsubpkeBvXexBkbo3eJ75tXDZIElIyU7Rk
\unrestrict Ah1gzcmwaXniQw2dzIyVlrPF4EVIv28DgaOjh5TDOwpcDPd3dZKeX8QreYsxoVW


--
Expand Down Expand Up @@ -1139,4 +1141,7 @@ INSERT INTO public.schema_migrations (version) VALUES
('20251027195737'),
('20251028010531'),
('20251029183629'),
('20251030232843');
('20251030232843'),
('20251111071524'),
('20251111085621'),
('20251111221748');
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- migrate:up
ALTER TABLE chat_messages
ADD COLUMN error JSONB;

-- migrate:down
ALTER TABLE chat_messages
DROP COLUMN error;
21 changes: 21 additions & 0 deletions migrations/20251111085621_fix_double_encoded_jsonb_fields.sql
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions migrations/20251111221748_add_usage_to_chat_messages.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- migrate:up
ALTER TABLE chat_messages ADD COLUMN usage jsonb;

-- migrate:down
ALTER TABLE chat_messages DROP COLUMN usage;

27 changes: 27 additions & 0 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions public/static/htmx-auth.js
Original file line number Diff line number Diff line change
@@ -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
}
}
})
7 changes: 5 additions & 2 deletions pulumi/infra/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
99 changes: 79 additions & 20 deletions src/ai/chat.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -40,6 +40,8 @@ export async function prepareChatRequest(
content: systemPrompt,
tool_calls: null,
tool_results: null,
error: null,
usage: null,
})
}

Expand All @@ -51,6 +53,8 @@ export async function prepareChatRequest(
content: userMessage,
tool_calls: null,
tool_results: null,
error: null,
usage: null,
})

return { chatId: finalChatId }
Expand Down Expand Up @@ -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,
Expand All @@ -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<typeof streamText>[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)
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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
}
}
6 changes: 3 additions & 3 deletions src/components/AbilitiesEditForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
>
Expand Down Expand Up @@ -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
</button>
Expand Down
Loading