diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd210edad05..fc24a8ec7e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -548,6 +548,34 @@ importers: specifier: ^5.9.3 version: 5.9.3 + templates/llm-chat-ts: + dependencies: + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + spacetimedb: + specifier: workspace:* + version: link:../../crates/bindings-typescript + devDependencies: + '@types/react': + specifier: ^18.3.18 + version: 18.3.23 + '@types/react-dom': + specifier: ^18.3.5 + version: 18.3.7(@types/react@18.3.23) + '@vitejs/plugin-react': + specifier: ^5.0.2 + version: 5.0.2(vite@7.1.5(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + typescript: + specifier: ~5.6.2 + version: 5.6.3 + vite: + specifier: ^7.1.5 + version: 7.1.5(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + templates/nuxt-ts: dependencies: nuxt: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8cf1969dbb3..bf4efb69d90 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,6 +5,7 @@ packages: - 'templates/chat-react-ts' - 'templates/react-ts' - 'templates/basic-ts' + - 'templates/llm-chat-ts' - 'templates/vue-ts' - 'templates/tanstack-ts' - 'templates/browser-ts' diff --git a/templates/llm-chat-ts/.gitignore b/templates/llm-chat-ts/.gitignore new file mode 100644 index 00000000000..e496aa04d3a --- /dev/null +++ b/templates/llm-chat-ts/.gitignore @@ -0,0 +1,5 @@ +dist +node_modules +tsconfig.tsbuildinfo +.env +.env.local diff --git a/templates/llm-chat-ts/.template.json b/templates/llm-chat-ts/.template.json new file mode 100644 index 00000000000..d756c3a4da7 --- /dev/null +++ b/templates/llm-chat-ts/.template.json @@ -0,0 +1,14 @@ +{ + "description": "Simple TypeScript chat app that calls an LLM API from a SpacetimeDB module", + "client_framework": "React", + "client_lang": "typescript", + "server_lang": "typescript", + "builtWith": [ + "react", + "react-dom", + "vitejs", + "typescript", + "vite", + "spacetimedb" + ] +} diff --git a/templates/llm-chat-ts/README.md b/templates/llm-chat-ts/README.md new file mode 100644 index 00000000000..8be7edc1781 --- /dev/null +++ b/templates/llm-chat-ts/README.md @@ -0,0 +1,83 @@ +Get a SpacetimeDB-backed LLM chat app running in under 5 minutes. + +## Prerequisites + +- [Node.js](https://nodejs.org/) 18+ installed +- [SpacetimeDB CLI](https://spacetimedb.com/install) installed +- An OpenRouter or OpenAI API key + +Install the [SpacetimeDB CLI](https://spacetimedb.com/install) before continuing. + +--- + +## Create your project + +Run the `spacetime dev` command to create a new project with a SpacetimeDB +module and React client. + +This will start the local SpacetimeDB server, publish your module, generate +TypeScript bindings, and start the React development server. + +```bash +spacetime dev --template llm-chat-ts +``` + +## Open your app + +Navigate to [http://localhost:5173](http://localhost:5173) to see your app +running. + +Open the provider config modal, choose OpenRouter or OpenAI, enter an API key +and model, then start a new chat. + +## Explore the project structure + +Your project contains both server and client code. + +Edit `spacetimedb/src/index.ts` to change tables, views, reducers, and +procedures. Edit `src/App.tsx` to build the chat UI. + +``` +my-spacetime-app/ +├── spacetimedb/ # Your SpacetimeDB module +│ └── src/ +│ ├── index.ts # Server-side tables, views, and reducers +│ └── llm.ts # LLM provider request helpers +├── src/ +│ ├── App.tsx # React chat UI +│ └── module_bindings/ # Auto-generated types +└── package.json +``` + +## Understand the module + +The module stores private chat threads, private chat messages, and private LLM +configuration for each SpacetimeDB identity. + +The public `chat` and `message` views only expose rows owned by the connected +identity. The `llm_config` table is private, and the API key is never returned +through subscriptions or config status calls. + +The API key is still stored as module data. This template is not a secret +manager: database operators can inspect module data, so use keys that are +appropriate for your local or hackathon environment. + +## Configure models + +Defaults: + +- Provider: `openrouter` +- Model: `openai/gpt-4o-mini` +- Local database name: `llm-chat-ts` +- New chats start with a clean context. + +Leaving the API key field blank keeps the saved key when editing the same +provider. Switching providers requires entering a new key. + +Set `VITE_SPACETIMEDB_HOST` or `VITE_SPACETIMEDB_DB_NAME` if you publish to a +different host or database name. + +## Next steps + +- See the [Chat App Tutorial](https://spacetimedb.com/docs/intro/tutorials/chat-app) for a complete example +- Read the [TypeScript SDK Reference](https://spacetimedb.com/docs/intro/core-concepts/clients/typescript-reference) for detailed API docs diff --git a/templates/llm-chat-ts/index.html b/templates/llm-chat-ts/index.html new file mode 100644 index 00000000000..03d33f37afd --- /dev/null +++ b/templates/llm-chat-ts/index.html @@ -0,0 +1,12 @@ + + + + + + SpacetimeDB LLM Chat + + +
+ + + diff --git a/templates/llm-chat-ts/package.json b/templates/llm-chat-ts/package.json new file mode 100644 index 00000000000..9fa7e1887c3 --- /dev/null +++ b/templates/llm-chat-ts/package.json @@ -0,0 +1,26 @@ +{ + "name": "@clockworklabs/llm-chat-ts", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings --module-path spacetimedb", + "spacetime:publish:local": "spacetime publish --module-path spacetimedb --server local", + "spacetime:publish": "spacetime publish --module-path spacetimedb --server maincloud" + }, + "dependencies": { + "spacetimedb": "workspace:*", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^5.0.2", + "typescript": "~5.6.2", + "vite": "^7.1.5" + } +} diff --git a/templates/llm-chat-ts/spacetimedb/package.json b/templates/llm-chat-ts/spacetimedb/package.json new file mode 100644 index 00000000000..1276c0171e4 --- /dev/null +++ b/templates/llm-chat-ts/spacetimedb/package.json @@ -0,0 +1,19 @@ +{ + "name": "spacetime-module", + "version": "1.0.0", + "description": "", + "scripts": { + "build": "spacetime build", + "publish": "spacetime publish" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "typescript": "~5.6.2" + } +} + diff --git a/templates/llm-chat-ts/spacetimedb/src/index.ts b/templates/llm-chat-ts/spacetimedb/src/index.ts new file mode 100644 index 00000000000..d5732a22d4f --- /dev/null +++ b/templates/llm-chat-ts/spacetimedb/src/index.ts @@ -0,0 +1,294 @@ +import { + schema, + table, + t, + SenderError, + type ReducerCtx, +} from 'spacetimedb/server'; +import { + callChat, + formatChatError, + providers, + type ChatMessage, +} from './llm'; + +const MAX_USER_MESSAGE_LENGTH = 8_000; +const MAX_HISTORY_MESSAGES = 20; +const TITLE_PREVIEW_LENGTH = 48; + +const llmConfigRow = { + owner: t.identity().primaryKey(), + provider: t.string(), + apiKey: t.string(), + model: t.string(), + systemPrompt: t.string().optional(), + updatedAt: t.timestamp(), +}; + +const chatRow = { + id: t.u64().primaryKey().autoInc(), + owner: t.identity().index('btree'), + title: t.string(), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), +}; + +const messageRow = { + id: t.u64().primaryKey().autoInc(), + chatId: t.u64().index('btree'), + owner: t.identity().index('btree'), + role: t.string(), + content: t.string(), + isError: t.bool(), + createdAt: t.timestamp(), +}; + +const llmConfig = table({ name: 'llm_config', public: false }, llmConfigRow); +const chatThread = table({ name: 'chat_thread', public: false }, chatRow); +const chatMessage = table({ name: 'chat_message', public: false }, messageRow); + +const spacetimedb = schema({ + llmConfig, + chatThread, + chatMessage, +}); +export default spacetimedb; + +type ModuleCtx = ReducerCtx; + +function senderError(message: string): never { + throw new SenderError(message); +} + +function validateProvider(provider: string): void { + if (!Object.prototype.hasOwnProperty.call(providers, provider)) { + senderError(`llm.unknown_provider:${provider}`); + } +} + +function validateConfig(provider: string, model: string): void { + validateProvider(provider); + if (model.trim().length === 0) senderError('llm.model_required'); +} + +function resolveApiKey( + existingProvider: string | undefined, + existingApiKey: string | undefined, + provider: string, + apiKey: string | undefined +): string { + const nextApiKey = apiKey?.trim(); + if (nextApiKey && nextApiKey.length > 0) return nextApiKey; + if ( + existingProvider === provider && + existingApiKey && + existingApiKey.length > 0 + ) { + return existingApiKey; + } + senderError('llm.api_key_required'); +} + +function validateMessage(content: string): void { + if (content.trim().length === 0) senderError('llm.message_required'); + if (content.length > MAX_USER_MESSAGE_LENGTH) { + senderError(`llm.message_too_long:${MAX_USER_MESSAGE_LENGTH}`); + } +} + +function makeTitle(content: string): string { + const compact = content.trim().replace(/\s+/g, ' '); + if (compact.length <= TITLE_PREVIEW_LENGTH) return compact; + return `${compact.slice(0, TITLE_PREVIEW_LENGTH - 3)}...`; +} + +function requireOwnedChat(ctx: ModuleCtx, chatId: bigint) { + const chat = ctx.db.chatThread.id.find(chatId); + if (!chat) senderError(`llm.chat_not_found:${chatId}`); + if (!chat.owner.isEqual(ctx.sender)) senderError(`llm.not_chat_owner:${chatId}`); + return chat; +} + +function buildHistory(ctx: ModuleCtx, chatId: bigint, systemPrompt: string | undefined): ChatMessage[] { + const rows = [...ctx.db.chatMessage.iter()] + .filter(row => row.chatId === chatId) + .filter(row => !row.isError && (row.role === 'user' || row.role === 'assistant')) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + .slice(-MAX_HISTORY_MESSAGES); + + const history: ChatMessage[] = []; + if (systemPrompt && systemPrompt.trim().length > 0) { + history.push({ role: 'system', content: systemPrompt }); + } + + for (const row of rows) { + history.push({ + role: row.role === 'assistant' ? 'assistant' : 'user', + content: row.content, + }); + } + + return history; +} + +export const init = spacetimedb.init(_ctx => {}); + +export const onConnect = spacetimedb.clientConnected(_ctx => {}); + +export const onDisconnect = spacetimedb.clientDisconnected(_ctx => {}); + +export const chat = spacetimedb.view( + { name: 'chat', public: true }, + t.array(t.row('ChatViewRow', chatRow)), + ctx => ctx.from.chatThread.where(row => row.owner.eq(ctx.sender)) +); + +export const message = spacetimedb.view( + { name: 'message', public: true }, + t.array(t.row('MessageViewRow', messageRow)), + ctx => ctx.from.chatMessage.where(row => row.owner.eq(ctx.sender)) +); + +export const set_llm_config = spacetimedb.reducer( + { + provider: t.string(), + apiKey: t.string().optional(), + model: t.string(), + systemPrompt: t.string().optional(), + }, + (ctx, { provider, apiKey, model, systemPrompt }) => { + validateConfig(provider, model); + + const existing = ctx.db.llmConfig.owner.find(ctx.sender); + const nextApiKey = resolveApiKey(existing?.provider, existing?.apiKey, provider, apiKey); + + const row = { + owner: ctx.sender, + provider, + apiKey: nextApiKey, + model, + systemPrompt, + updatedAt: ctx.timestamp, + }; + + if (existing) { + ctx.db.llmConfig.owner.update(row); + } else { + ctx.db.llmConfig.insert(row); + } + } +); + +export const get_llm_config_status = spacetimedb.procedure( + {}, + t.object('LlmConfigStatus', { + configured: t.bool(), + provider: t.string().optional(), + model: t.string().optional(), + systemPrompt: t.string().optional(), + }), + ctx => ctx.withTx(tx => { + const config = tx.db.llmConfig.owner.find(tx.sender); + return { + configured: config != null, + provider: config?.provider, + model: config?.model, + systemPrompt: config?.systemPrompt, + }; + }) +); + +export const create_chat = spacetimedb.procedure( + {}, + t.u64(), + ctx => ctx.withTx(tx => { + const row = tx.db.chatThread.insert({ + id: 0n, + owner: tx.sender, + title: 'New chat', + createdAt: tx.timestamp, + updatedAt: tx.timestamp, + }); + return row.id; + }) +); + +export const delete_chat = spacetimedb.reducer( + { chatId: t.u64() }, + (ctx, { chatId }) => { + const chat = requireOwnedChat(ctx, chatId); + + for (const message of [...ctx.db.chatMessage.iter()].filter(row => row.chatId === chatId)) { + ctx.db.chatMessage.delete(message); + } + ctx.db.chatThread.delete(chat); + } +); + +export const send_message = spacetimedb.procedure( + { chatId: t.u64(), content: t.string() }, + t.unit(), + (ctx, { chatId, content }) => { + validateMessage(content); + + const setup = ctx.withTx(tx => { + const config = tx.db.llmConfig.owner.find(tx.sender); + if (!config) senderError('llm.not_configured'); + validateProvider(config.provider); + const chat = requireOwnedChat(tx, chatId); + + tx.db.chatMessage.insert({ + id: 0n, + chatId, + owner: tx.sender, + role: 'user', + content, + isError: false, + createdAt: tx.timestamp, + }); + + if (chat.title === 'New chat') { + tx.db.chatThread.id.update({ + ...chat, + title: makeTitle(content), + updatedAt: tx.timestamp, + }); + } else { + tx.db.chatThread.id.update({ ...chat, updatedAt: tx.timestamp }); + } + + return { + provider: config.provider, + apiKey: config.apiKey, + model: config.model, + messages: buildHistory(tx, chatId, config.systemPrompt), + }; + }); + + const provider = providers[setup.provider]; + if (!provider) senderError(`llm.unknown_provider:${setup.provider}`); + + const result = callChat(ctx.http, provider, { + apiKey: setup.apiKey, + model: setup.model, + messages: setup.messages, + }); + + ctx.withTx(tx => { + tx.db.chatMessage.insert({ + id: 0n, + chatId, + owner: tx.sender, + role: 'assistant', + content: result.ok ? result.response.text : formatChatError(result.error), + isError: !result.ok, + createdAt: tx.timestamp, + }); + + const chat = tx.db.chatThread.id.find(chatId); + if (chat) tx.db.chatThread.id.update({ ...chat, updatedAt: tx.timestamp }); + }); + + return {}; + } +); diff --git a/templates/llm-chat-ts/spacetimedb/src/llm.ts b/templates/llm-chat-ts/spacetimedb/src/llm.ts new file mode 100644 index 00000000000..d54b6a39b0b --- /dev/null +++ b/templates/llm-chat-ts/spacetimedb/src/llm.ts @@ -0,0 +1,146 @@ +export interface HttpLike { + fetch(url: string, init: { method: string; headers: Record; body?: string }): { + status: number; + text(): string; + }; +} + +export type ChatMessage = { + role: 'system' | 'user' | 'assistant'; + content: string; +}; + +export type ChatRequest = { + apiKey: string; + model: string; + messages: ChatMessage[]; +}; + +export type ChatResponse = { + text: string; + model: string; +}; + +export type ChatError = + | { kind: 'http'; status: number; body: string } + | { kind: 'transport'; message: string } + | { kind: 'parse'; message: string; body: string }; + +export type ChatResult = + | { ok: true; response: ChatResponse } + | { ok: false; error: ChatError }; + +export interface Provider { + name: string; + buildRequest(req: ChatRequest): { + url: string; + headers: Record; + body: string; + }; + parseResponse(text: string, requestedModel: string): ChatResponse; +} + +function buildOpenAiBody(req: ChatRequest): string { + return JSON.stringify({ + model: req.model, + messages: req.messages, + }); +} + +function parseOpenAiResponse(text: string, requestedModel: string): ChatResponse { + const parsed = JSON.parse(text); + const content = parsed?.choices?.[0]?.message?.content; + if (typeof content !== 'string' || content.length === 0) { + throw new Error('response did not include choices[0].message.content'); + } + return { + text: content, + model: String(parsed.model ?? requestedModel), + }; +} + +export const openRouterProvider: Provider = { + name: 'openrouter', + buildRequest(req) { + return { + url: 'https://openrouter.ai/api/v1/chat/completions', + headers: { + Authorization: `Bearer ${req.apiKey}`, + 'Content-Type': 'application/json', + }, + body: buildOpenAiBody(req), + }; + }, + parseResponse: parseOpenAiResponse, +}; + +export const openAiProvider: Provider = { + name: 'openai', + buildRequest(req) { + return { + url: 'https://api.openai.com/v1/chat/completions', + headers: { + Authorization: `Bearer ${req.apiKey}`, + 'Content-Type': 'application/json', + }, + body: buildOpenAiBody(req), + }; + }, + parseResponse: parseOpenAiResponse, +}; + +export const providers: Record = { + openrouter: openRouterProvider, + openai: openAiProvider, +}; + +export function callChat(http: HttpLike, provider: Provider, req: ChatRequest): ChatResult { + const { url, headers, body } = provider.buildRequest(req); + + let res: { status: number; text(): string }; + try { + res = http.fetch(url, { method: 'POST', headers, body }); + } catch (err) { + return { + ok: false, + error: { + kind: 'transport', + message: err instanceof Error ? err.message : String(err), + }, + }; + } + + const responseText = res.text(); + if (res.status < 200 || res.status >= 300) { + return { ok: false, error: { kind: 'http', status: res.status, body: responseText } }; + } + + try { + return { ok: true, response: provider.parseResponse(responseText, req.model) }; + } catch (err) { + return { + ok: false, + error: { + kind: 'parse', + message: err instanceof Error ? err.message : String(err), + body: responseText, + }, + }; + } +} + +export function formatChatError(err: ChatError): string { + switch (err.kind) { + case 'http': + return `LLM HTTP ${err.status}: ${truncate(err.body, 500)}`; + case 'transport': + return `LLM transport error: ${err.message}`; + case 'parse': + return `LLM parse error: ${err.message}`; + } +} + +function truncate(text: string, max: number): string { + return text.length <= max ? text : text.slice(0, max) + '...'; +} + diff --git a/templates/llm-chat-ts/spacetimedb/tsconfig.json b/templates/llm-chat-ts/spacetimedb/tsconfig.json new file mode 100644 index 00000000000..85b883efe32 --- /dev/null +++ b/templates/llm-chat-ts/spacetimedb/tsconfig.json @@ -0,0 +1,23 @@ +/* + * This tsconfig is used for TypeScript projects created with `spacetimedb init + * --lang typescript`. You can modify it as needed for your project, although + * some options are required by SpacetimeDB. + */ +{ + "compilerOptions": { + "strict": true, + "skipLibCheck": true, + "moduleResolution": "bundler", + + /* The following options are required by SpacetimeDB + * and should not be modified + */ + "target": "ESNext", + "lib": ["ES2021", "dom"], + "module": "ESNext", + "isolatedModules": true, + "noEmit": true + }, + "include": ["./**/*"] +} + diff --git a/templates/llm-chat-ts/src/App.css b/templates/llm-chat-ts/src/App.css new file mode 100644 index 00000000000..3882fa0aed7 --- /dev/null +++ b/templates/llm-chat-ts/src/App.css @@ -0,0 +1,410 @@ +* { + box-sizing: border-box; +} + +html, +body, +#root { + height: 100%; +} + +body { + margin: 0; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #111827; + background: #f6f7fb; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button { + border: 0; + border-radius: 7px; + padding: 0.65rem 0.8rem; + color: #fff; + background: #2563eb; + cursor: pointer; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +input, +select, +textarea { + width: 100%; + border: 1px solid #d1d5db; + border-radius: 7px; + padding: 0.7rem; + color: #111827; + background: #fff; +} + +textarea { + resize: vertical; +} + +label { + display: grid; + gap: 0.4rem; + font-size: 0.9rem; + font-weight: 650; + color: #374151; +} + +.app-shell { + display: grid; + grid-template-columns: 280px minmax(0, 1fr); + height: 100%; + min-height: 0; + overflow: hidden; +} + +.sidebar { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr) auto; + gap: 1rem; + min-height: 0; + border-right: 1px solid #d8dee9; + padding: 1rem; + background: #111827; + color: #f9fafb; +} + +.sidebar-header { + display: grid; + gap: 0.6rem; +} + +.sidebar h1 { + margin: 0; + font-size: 1.25rem; +} + +.status { + width: fit-content; + border-radius: 999px; + padding: 0.25rem 0.55rem; + font-size: 0.78rem; + font-weight: 700; +} + +.status.online { + color: #bbf7d0; + background: #14532d; +} + +.status.offline { + color: #fecaca; + background: #7f1d1d; +} + +.new-chat, +.sidebar-footer button { + width: 100%; + background: #374151; +} + +.chat-list { + display: flex; + flex-direction: column; + gap: 0.35rem; + overflow: auto; +} + +.chat-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 0.5rem; + width: 100%; +} + +.chat-select { + min-width: 0; + color: #d1d5db; + background: transparent; + text-align: left; +} + +.chat-select span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-row.active .chat-select, +.chat-row:hover .chat-select { + color: #fff; + background: #1f2937; +} + +.delete-chat { + display: grid; + place-items: center; + width: 1.5rem; + height: 1.5rem; + padding: 0; + border-radius: 999px; + color: #9ca3af; + background: transparent; +} + +.delete-chat:hover { + color: #fff; + background: #4b5563; +} + +.sidebar-footer { + display: grid; + gap: 0.6rem; +} + +.muted { + margin: 0; + color: #9ca3af; + font-size: 0.86rem; +} + +.chat-pane { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.chat-header { + border-bottom: 1px solid #d8dee9; + padding: 1rem 1.25rem; + background: #fff; +} + +.chat-header h2, +.chat-header p { + margin: 0; +} + +.chat-header p { + margin-top: 0.25rem; + color: #6b7280; +} + +.messages { + display: flex; + flex-direction: column; + gap: 1rem; + min-height: 0; + overflow: auto; + padding: 1.25rem; +} + +.empty-state { + margin: auto; + max-width: 28rem; + text-align: center; + color: #6b7280; +} + +.empty-state h3 { + margin: 0 0 0.5rem; + color: #111827; +} + +.message { + width: min(760px, 86%); + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 0.85rem; + background: #fff; + box-shadow: 0 4px 14px rgb(17 24 39 / 5%); +} + +.message.user { + align-self: flex-end; + background: #eaf2ff; +} + +.message.assistant { + align-self: flex-start; +} + +.message.error { + border-color: #fecaca; + background: #fff1f2; +} + +.message-role { + margin-bottom: 0.45rem; + color: #4b5563; + font-size: 0.78rem; + font-weight: 800; + text-transform: uppercase; +} + +.message-content { + line-height: 1.5; +} + +.message-content p { + margin: 0; + white-space: pre-wrap; +} + +.message-content strong { + font-weight: 800; +} + +.inline-code { + border: 1px solid #d8dee9; + border-radius: 5px; + padding: 0.08rem 0.3rem; + color: #1f2937; + background: #f3f4f6; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 0.88em; +} + +.message-content p + p, +.message-content p + .code-block, +.code-block + p, +.code-block + .code-block { + margin-top: 0.75rem; +} + +.code-block { + overflow: hidden; + border: 1px solid #243244; + border-radius: 7px; + background: #0f172a; +} + +.code-label { + border-bottom: 1px solid #243244; + padding: 0.35rem 0.65rem; + color: #cbd5e1; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 0.76rem; + background: #111827; +} + +.code-block pre { + margin: 0; + overflow-x: auto; + padding: 0.8rem; +} + +.code-block code { + color: #e5e7eb; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 0.86rem; + line-height: 1.55; + white-space: pre; +} + +.message time { + display: block; + margin-top: 0.5rem; + color: #9ca3af; + font-size: 0.78rem; +} + +.thinking { + align-self: flex-start; + color: #6b7280; + font-size: 0.9rem; +} + +.composer { + border-top: 1px solid #d8dee9; + padding: 1rem 1.25rem; + background: #fff; +} + +.composer-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.75rem; + align-items: end; +} + +.composer textarea { + min-height: 4.75rem; +} + +.status-line { + margin-bottom: 0.65rem; + color: #b45309; + font-size: 0.9rem; +} + +.modal-backdrop { + position: fixed; + inset: 0; + display: grid; + place-items: center; + padding: 1rem; + background: rgb(17 24 39 / 55%); +} + +.config-modal { + display: grid; + gap: 1rem; + width: min(520px, 100%); + border-radius: 8px; + padding: 1rem; + background: #fff; + box-shadow: 0 24px 80px rgb(17 24 39 / 30%); +} + +.config-modal header, +.config-modal footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.config-modal h2 { + margin: 0; +} + +.config-modal header button { + color: #374151; + background: #e5e7eb; +} + +@media (max-width: 760px) { + .app-shell { + grid-template-columns: 1fr; + grid-template-rows: auto minmax(0, 1fr); + overflow: hidden; + } + + .sidebar { + grid-template-rows: auto auto auto; + min-height: 0; + border-right: 0; + border-bottom: 1px solid #d8dee9; + } + + .chat-list { + max-height: 10rem; + } + + .composer-row { + grid-template-columns: 1fr; + } + + .message { + width: 100%; + } +} diff --git a/templates/llm-chat-ts/src/App.tsx b/templates/llm-chat-ts/src/App.tsx new file mode 100644 index 00000000000..14ad39c1e40 --- /dev/null +++ b/templates/llm-chat-ts/src/App.tsx @@ -0,0 +1,530 @@ +import { + useEffect, + useMemo, + useRef, + useState, + type FormEvent, + type KeyboardEvent, + type ReactNode, +} from 'react'; +import type { Infer } from 'spacetimedb'; +import { procedures, reducers, tables } from './module_bindings'; +import ChatRow from './module_bindings/chat_table'; +import MessageRow from './module_bindings/message_table'; +import { + useProcedure, + useReducer, + useSpacetimeDB, + useTable, +} from 'spacetimedb/react'; + +type Chat = Infer; +type Message = Infer; + +type ConfigStatus = { + configured: boolean; + provider?: string; + model?: string; + systemPrompt?: string; +}; + +type ConfigDraft = { + provider: string; + apiKey: string; + model: string; + systemPrompt: string; +}; + +const DEFAULT_OPENROUTER_MODEL = 'openai/gpt-4o-mini'; +const DEFAULT_OPENAI_MODEL = 'gpt-4o-mini'; + +function defaultModel(provider: string) { + return provider === 'openai' ? DEFAULT_OPENAI_MODEL : DEFAULT_OPENROUTER_MODEL; +} + +function chatUpdatedMicros(chat: Chat): bigint { + return chat.updatedAt.microsSinceUnixEpoch as bigint; +} + +function sortChats(chats: readonly Chat[]) { + return [...chats].sort((a, b) => { + const av = chatUpdatedMicros(a); + const bv = chatUpdatedMicros(b); + return av < bv ? 1 : av > bv ? -1 : 0; + }); +} + +function sortMessages(messages: Message[]) { + return [...messages].sort((a, b) => { + if (a.id === b.id) return 0; + return a.id < b.id ? -1 : 1; + }); +} + +type MessagePart = + | { kind: 'text'; text: string } + | { kind: 'code'; code: string; language: string }; + +type InlinePart = + | { kind: 'text'; text: string } + | { kind: 'bold'; text: string } + | { kind: 'inlineCode'; text: string }; + +function parseMessageContent(content: string): MessagePart[] { + const parts: MessagePart[] = []; + const fencePattern = /```([^\n`]*)\n?([\s\S]*?)```/g; + let cursor = 0; + let match: RegExpExecArray | null; + + while ((match = fencePattern.exec(content)) != null) { + if (match.index > cursor) { + parts.push({ kind: 'text', text: content.slice(cursor, match.index) }); + } + parts.push({ + kind: 'code', + language: match[1].trim(), + code: match[2].replace(/\n$/, ''), + }); + cursor = match.index + match[0].length; + } + + if (cursor < content.length) { + parts.push({ kind: 'text', text: content.slice(cursor) }); + } + + return parts.length === 0 ? [{ kind: 'text', text: content }] : parts; +} + +function renderInlineText(text: string): ReactNode[] { + const nodes: ReactNode[] = []; + const inlinePattern = /(`[^`\n]+`|\*\*[^*\n][\s\S]*?\*\*)/g; + let cursor = 0; + let match: RegExpExecArray | null; + + while ((match = inlinePattern.exec(text)) != null) { + if (match.index > cursor) { + nodes.push(text.slice(cursor, match.index)); + } + + const token = match[0]; + const part: InlinePart = token.startsWith('`') + ? { kind: 'inlineCode', text: token.slice(1, -1) } + : { kind: 'bold', text: token.slice(2, -2) }; + + nodes.push( + part.kind === 'inlineCode' ? ( + + {part.text} + + ) : ( + {part.text} + ) + ); + cursor = match.index + token.length; + } + + if (cursor < text.length) nodes.push(text.slice(cursor)); + return nodes; +} + +function MessageContent({ content }: { content: string }) { + return ( +
+ {parseMessageContent(content).map((part, index) => { + if (part.kind === 'code') { + return ( +
+ {part.language &&
{part.language}
} +
+                {part.code}
+              
+
+ ); + } + + return part.text + .split(/\n{2,}/) + .filter(paragraph => paragraph.length > 0) + .map((paragraph, paragraphIndex) => ( +

+ {renderInlineText(paragraph)} +

+ )); + })} +
+ ); +} + +function App() { + const { isActive: connected } = useSpacetimeDB(); + const [chats] = useTable(tables.chat); + const [messages] = useTable(tables.message); + + const createChat = useProcedure(procedures.createChat); + const getConfigStatus = useProcedure(procedures.getLlmConfigStatus); + const sendMessage = useProcedure(procedures.sendMessage); + const setConfig = useReducer(reducers.setLlmConfig); + const deleteChatReducer = useReducer(reducers.deleteChat); + + const [activeChatId, setActiveChatId] = useState(null); + const [configStatus, setConfigStatus] = useState({ + configured: false, + }); + const [configOpen, setConfigOpen] = useState(false); + const [configDraft, setConfigDraft] = useState({ + provider: 'openrouter', + apiKey: '', + model: DEFAULT_OPENROUTER_MODEL, + systemPrompt: 'You are a concise, helpful assistant.', + }); + const [modelEdited, setModelEdited] = useState(false); + const [composerText, setComposerText] = useState(''); + const [statusText, setStatusText] = useState(''); + const [sending, setSending] = useState(false); + const messageEndRef = useRef(null); + const composerRef = useRef(null); + const focusComposerAfterSend = useRef(false); + + const sortedChats = useMemo(() => sortChats(chats), [chats]); + const activeChat = sortedChats.find(chat => chat.id === activeChatId); + const activeMessages = useMemo( + () => + sortMessages( + activeChatId == null + ? [] + : messages.filter(message => message.chatId === activeChatId) + ), + [activeChatId, messages] + ); + + useEffect(() => { + if (!connected) return; + getConfigStatus() + .then(status => { + setConfigStatus(status); + setConfigDraft(draft => ({ + ...draft, + provider: status.provider ?? draft.provider, + model: status.model ?? draft.model, + systemPrompt: status.systemPrompt ?? draft.systemPrompt, + })); + if (!status.configured) setConfigOpen(true); + }) + .catch(err => { + setStatusText(err instanceof Error ? err.message : String(err)); + }); + }, [connected, getConfigStatus]); + + useEffect(() => { + if (activeChatId == null && sortedChats.length > 0) { + setActiveChatId(sortedChats[0].id); + return; + } + if ( + activeChatId != null && + !sortedChats.some(chat => chat.id === activeChatId) + ) { + setActiveChatId(sortedChats[0]?.id ?? null); + } + }, [activeChatId, sortedChats]); + + useEffect(() => { + messageEndRef.current?.scrollIntoView({ block: 'end' }); + }, [activeMessages.length, sending]); + + useEffect(() => { + if (sending || !focusComposerAfterSend.current) return; + focusComposerAfterSend.current = false; + composerRef.current?.focus(); + }, [sending]); + + const updateProvider = (provider: string) => { + setConfigDraft(draft => ({ + ...draft, + provider, + model: modelEdited ? draft.model : defaultModel(provider), + })); + }; + + const onNewChat = async () => { + if (!connected) return; + setStatusText(''); + try { + const chatId = await createChat(); + setActiveChatId(chatId); + } catch (err) { + setStatusText(err instanceof Error ? err.message : String(err)); + } + }; + + const onDeleteChat = async (chatId: bigint) => { + setStatusText(''); + try { + await deleteChatReducer({ chatId }); + if (activeChatId === chatId) setActiveChatId(null); + } catch (err) { + setStatusText(err instanceof Error ? err.message : String(err)); + } + }; + + const onSaveConfig = async (event: FormEvent) => { + event.preventDefault(); + const providerChanged = + configStatus.configured && configStatus.provider !== configDraft.provider; + if (providerChanged && configDraft.apiKey.trim().length === 0) { + setStatusText('Enter an API key when switching providers.'); + return; + } + + setStatusText('Saving config...'); + try { + await setConfig({ + provider: configDraft.provider, + apiKey: configDraft.apiKey.trim() || undefined, + model: configDraft.model.trim(), + systemPrompt: configDraft.systemPrompt.trim() || undefined, + }); + const status = await getConfigStatus(); + setConfigStatus(status); + setConfigDraft(draft => ({ ...draft, apiKey: '' })); + setConfigOpen(false); + setStatusText('Config saved.'); + } catch (err) { + setStatusText(err instanceof Error ? err.message : String(err)); + } + }; + + const onSend = async (event: FormEvent) => { + event.preventDefault(); + if (!connected || sending) return; + if (!configStatus.configured) { + setConfigOpen(true); + return; + } + + const content = composerText.trim(); + if (!content) return; + + setSending(true); + setStatusText(''); + setComposerText(''); + + try { + const chatId = activeChatId ?? (await createChat()); + if (activeChatId == null) setActiveChatId(chatId); + await sendMessage({ chatId, content }); + } catch (err) { + setComposerText(content); + setStatusText(err instanceof Error ? err.message : String(err)); + } finally { + focusComposerAfterSend.current = true; + setSending(false); + } + }; + + const onComposerKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) { + return; + } + event.preventDefault(); + event.currentTarget.form?.requestSubmit(); + }; + + return ( +
+ + +
+
+
+

{activeChat?.title ?? 'New chat'}

+

Each chat has its own context. Your config is private to this identity.

+
+
+ +
+ {activeMessages.length === 0 ? ( +
+

Start a clean chat

+

Ask a question and the module will call your configured model.

+
+ ) : ( + activeMessages.map(message => ( +
+
+ {message.role === 'assistant' ? 'Assistant' : 'You'} +
+ + +
+ )) + )} + {sending &&
Assistant is thinking...
} +
+
+ +
+ {statusText &&
{statusText}
} +
+