Created
January 15, 2026 19:17
-
-
Save park-brian/ecbc843c859085d991811290a0106391 to your computer and use it in GitHub Desktop.
AI Chat
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>AWS Bedrock Chat</title> | |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5/dist/css/bootstrap.min.css" /> | |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1/font/bootstrap-icons.min.css" /> | |
| <style> | |
| :root { | |
| --bs-light-rgb: 250, 249, 245; | |
| --bs-secondary-bg-subtle: #f0eee6; | |
| --bs-border-radius: 0.75rem; | |
| --bs-border-radius-sm: 0.5rem; | |
| } | |
| input, button, label, select, .btn, .form-control[type="file"], .form-select, .form-check-label { font-weight: 500; } | |
| .form-select, .form-check-input, .form-check-label { cursor: pointer; } | |
| .text-pre { white-space: pre-wrap !important; } | |
| .message-wrapper:hover .message-actions { opacity: 1; } | |
| .message-actions { opacity: 0; transition: opacity 0.15s ease-in-out; } | |
| .message-block:hover .message-controls { visibility: visible !important; } | |
| .branch-counter { min-width: 2.5rem; font-variant-numeric: tabular-nums; } | |
| .edit-form textarea { resize: vertical; } | |
| </style> | |
| <script type="importmap"> | |
| { | |
| "imports": { | |
| "@aws-sdk/client-bedrock-runtime": "https://cdn.jsdelivr.net/npm/@aws-sdk/client-bedrock-runtime@3/+esm", | |
| "@google/genai": "https://cdn.jsdelivr.net/npm/@google/genai@1/+esm", | |
| "idb": "https://cdn.jsdelivr.net/npm/idb@8/+esm", | |
| "solid-js": "https://cdn.jsdelivr.net/npm/solid-js@1/dist/solid.min.js", | |
| "solid-js/html": "https://cdn.jsdelivr.net/npm/solid-js@1/html/dist/html.min.js", | |
| "solid-js/store": "https://cdn.jsdelivr.net/npm/solid-js@1/store/dist/store.min.js", | |
| "solid-js/web": "https://cdn.jsdelivr.net/npm/solid-js@1/web/dist/web.min.js" | |
| } | |
| } | |
| </script> | |
| <!-- | |
| CHAT.HTML - AI Chat Interface | |
| ============================== | |
| Buildless SolidJS chat with AWS Bedrock & Google Gemini. | |
| Features: branching conversations, IndexedDB storage, file uploads, tool use. | |
| DEVELOP: Open chat.html in browser. No build. Dependencies via CDN import maps. | |
| TEST: Open ?test=1 and check console, or run headless: | |
| npm install playwright && node -e "const {chromium}=require('playwright');(async()=>{const b=await chromium.launch(),p=await b.newPage({ignoreHTTPSErrors:true});p.on('console',m=>console.log(m.text()));p.on('pageerror',e=>console.error('PAGE ERROR:',e.message));await p.goto('file://'+process.cwd()+'/chat.html?test=1');await p.waitForFunction(()=>window.TESTS_DONE,{timeout:30000});await b.close()})()" | |
| SOLIDJS: COMPONENTS RUN ONCE | |
| ---------------------------- | |
| No re-renders. Reactivity = 0-arg functions, auto-wrapped and re-executed on change. | |
| ${() => val} → reactive ${val} → static (evaluated once) | |
| Signals: ${count} ✓ (signals are 0-arg functions) | |
| ${count()} ✗ (called immediately, static) | |
| Stores: ${() => store.prop} ✓ (wrap store access) | |
| Props: ${() => props.title} ✓ (props have getters, but templates need () => for tracking) | |
| Never destructure - loses the getter | |
| Handlers: onClick=${e => fn()} ✓ always use param (avoids 0-arg auto-wrap issues) | |
| SYNTAX | |
| ------ | |
| <${Comp} prop=${val} /> Self-closing | |
| <${Comp}>children<//> With children | |
| <${Show} when=${cond}>...<//> Conditional | |
| <${For} each=${items}>${x=>...}<//> Loop | |
| class=${() => `a ${b() ? 'c' : ''}`} | |
| classList=${{ active: isActiveSignal, disabled: () => store.x }} | |
| style=${{ width: () => w() + 'px' }} | |
| --> | |
| </head> | |
| <body class="vh-100 bg-light d-flex flex-column"> | |
| <div id="app" class="h-100"></div> | |
| <script type="module"> | |
| // #region 1. IMPORTS & INITIALIZATION | |
| // ============================================================================= | |
| // External dependencies, database setup, and app bootstrap. | |
| // ============================================================================= | |
| import { BedrockRuntimeClient, ConverseStreamCommand } from "@aws-sdk/client-bedrock-runtime"; | |
| import { GoogleGenAI } from "@google/genai"; | |
| import { openDB } from "idb"; | |
| import { render } from "solid-js/web"; | |
| import { For, Switch, Match, Show, createEffect, createSignal } from "solid-js"; | |
| import { createStore, produce, unwrap } from "solid-js/store"; | |
| import html from "solid-js/html"; | |
| const db = await initDB(); | |
| await ensureDefaultAgent(db); | |
| // TOOLS must be defined before useAgent which references it as default parameter | |
| const TOOLS = [ | |
| { | |
| fn: code, | |
| toolSpec: { | |
| name: "code", | |
| description: "Execute JavaScript or HTML code snippets.", | |
| inputSchema: { | |
| json: { | |
| type: "object", | |
| properties: { | |
| source: { type: "string", description: "The JavaScript code to execute. Eg: 2+2" }, | |
| language: { type: "string", description: "The programming language of the code (e.g., 'javascript', 'html').", default: "javascript" }, | |
| }, | |
| required: ["source"], | |
| }, | |
| }, | |
| }, | |
| }, | |
| { | |
| fn: think, | |
| toolSpec: { | |
| name: "think", | |
| description: "Use this tool to create a dedicated thinking space for complex reasoning. Use it when you need to analyze information, plan steps, or work through problems before providing a final answer.", | |
| inputSchema: { | |
| json: { | |
| type: "object", | |
| properties: { | |
| thought: { type: "string", description: "Your detailed thought process, analysis, or reasoning steps." }, | |
| }, | |
| required: ["thought"], | |
| }, | |
| }, | |
| }, | |
| }, | |
| ]; | |
| render(App, document.getElementById("app")); | |
| async function initDB() { | |
| return openDB("bedrock-messages", 1, { | |
| upgrade(db) { | |
| const tables = {}; | |
| for (const name of ["agents", "threads", "messages", "resources"]) { | |
| if (!db.objectStoreNames.contains(name)) { | |
| const store = db.createObjectStore(name, { keyPath: "id", autoIncrement: true }); | |
| store.createIndex("id", "id", { unique: true }); | |
| tables[name] = store; | |
| } | |
| } | |
| tables.messages.createIndex("agentId", "agentId"); | |
| tables.messages.createIndex("threadId", "threadId"); | |
| tables.messages.createIndex("parentId", "parentId"); | |
| tables.threads.createIndex("agentId", "agentId"); | |
| tables.resources.createIndex("agentId", "agentId"); | |
| tables.resources.createIndex("threadId", "threadId"); | |
| tables.resources.createIndex("messageId", "messageId"); | |
| }, | |
| }); | |
| } | |
| async function ensureDefaultAgent(db) { | |
| if (!(await db.count("agents"))) { | |
| await db.add("agents", { | |
| name: "default", | |
| systemPrompt: "You are honest. When you are uncertain about something, or if your tools aren't working right, you let the user know. You write in brief, artistic prose, without any *markdown*, lists, bullet points, emojis or em-dashes.", | |
| tools: ["code", "think"], | |
| resources: [], | |
| }); | |
| } | |
| } | |
| // #endregion | |
| // #region 2. APP & MAIN STATE | |
| // ============================================================================= | |
| // Main App component and useAgent hook for state management. | |
| // ============================================================================= | |
| function App() { | |
| const searchParams = new URLSearchParams(window.location.search); | |
| const urlParams = Object.fromEntries(searchParams.entries()); | |
| const { agent, sendMessage, switchBranch, params } = useAgent(urlParams, db); | |
| createEffect(() => setSearchParams(params)); | |
| const [editingMessageId, setEditingMessageId] = createSignal(null); | |
| // Event handlers | |
| function handleKeyDown(event) { | |
| if (event.key === "Enter" && !event.shiftKey && !agent.loading) { | |
| event.preventDefault(); | |
| event.target?.closest("form")?.requestSubmit(); | |
| } | |
| } | |
| async function handleSubmit(event) { | |
| event.preventDefault(); | |
| const form = event.target; | |
| const text = form.userMessage.value; | |
| const files = Array.from(form.userFiles.files || []); | |
| const modelId = form.modelId.value; | |
| const reasoningMode = form.reasoningMode.checked; | |
| form.userMessage.value = ""; | |
| form.userFiles.value = ""; | |
| await sendMessage(text, files, modelId, reasoningMode); | |
| } | |
| async function saveEdit(messageId, newText) { | |
| const msg = agent.messageTree?.nodes.get(messageId)?.message; | |
| const originalText = msg?.content.find(c => c.text)?.text; | |
| if (msg && newText && newText !== originalText) { | |
| const form = document.getElementById("inputForm"); | |
| await sendMessage(newText, [], form?.modelId?.value || "us.anthropic.claude-haiku-4-5-20251001-v1:0", form?.reasoningMode?.checked || false, messageId); | |
| } | |
| setEditingMessageId(null); | |
| } | |
| return html` | |
| <div class="container my-5"> | |
| <${For} each=${() => agent.messages}> | |
| ${message => html` | |
| <div class="message-block mb-2"> | |
| <${For} each=${() => message.content}> | |
| ${content => html` | |
| <${MessageContent} | |
| message=${message} | |
| content=${content} | |
| allMessages=${() => agent.messages} | |
| editingId=${editingMessageId} | |
| onSave=${saveEdit} | |
| onCancel=${_ => setEditingMessageId(null)} /> | |
| `} | |
| <//> | |
| <${Show} when=${() => message.id && agent.messageTree && editingMessageId() !== message.id}> | |
| <div class="message-controls invisible d-flex align-items-center gap-2"> | |
| <${BranchNav} tree=${() => agent.messageTree} messageId=${message.id} onSwitch=${switchBranch} /> | |
| <${Show} when=${() => message.role === "user"}> | |
| <button type="button" class="btn btn-sm btn-link text-decoration-none p-0 text-muted" onClick=${e => setEditingMessageId(message.id)} title="Edit message"> | |
| <i class="bi bi-pencil"></i> | |
| </button> | |
| <//> | |
| </div> | |
| <//> | |
| </div> | |
| `} | |
| <//> | |
| <${InputForm} onSubmit=${handleSubmit} onKeyDown=${handleKeyDown} /> | |
| </div> | |
| `; | |
| } | |
| function useAgent({ agentId, threadId }, db, tools = TOOLS) { | |
| agentId = +agentId || 1; | |
| threadId = +threadId || null; | |
| const [params, setParams] = createStore({ agentId, threadId }); | |
| const [agent, setAgent] = createStore({ | |
| id: null, | |
| name: null, | |
| thread: { id: null, name: null }, | |
| modelId: null, | |
| reasoningMode: false, | |
| systemPrompt: null, | |
| loading: false, | |
| tools: [], | |
| messages: [], | |
| messageTree: null, | |
| activePath: [], | |
| activeLeafId: null, | |
| }); | |
| // Effects: Load history | |
| createEffect(async () => { | |
| if (!params.threadId) return; | |
| const history = await db.getAllFromIndex("messages", "threadId", params.threadId); | |
| if (!history?.length) return; | |
| const thread = await db.get("threads", params.threadId); | |
| const tree = buildMessageTree(history); | |
| const path = getMostRecentPath(tree); | |
| const leafId = path.length > 0 ? path[path.length - 1] : null; | |
| const messages = path.map(id => tree.nodes.get(id)?.message).filter(Boolean); | |
| setAgent({ messages, thread: { name: thread?.name || "Untitled" }, messageTree: tree, activePath: path, activeLeafId: leafId }); | |
| }); | |
| // Effects: Save changes | |
| createEffect(async () => { | |
| if (!params.agentId || !agent.id) return; | |
| await upsert(db, "agents", { id: params.agentId, name: agent.name, systemPrompt: agent.systemPrompt, tools: agent.tools.map(t => t.toolSpec.name) }); | |
| if (!params.threadId || !agent.thread.id) return; | |
| await upsert(db, "threads", { id: params.threadId, agentId: params.agentId, name: agent.thread.name }); | |
| }); | |
| // Actions | |
| async function sendMessage(text, files = [], modelId, reasoningMode, forkFromId = null) { | |
| setAgent("loading", true); | |
| if (!params.threadId) { | |
| setAgent("thread", "name", "Untitled"); | |
| const thread = { agentId, name: agent.thread.name }; | |
| const threadId = await db.add("threads", thread); | |
| setParams("threadId", threadId); | |
| } | |
| const record = await db.get("agents", +params.agentId); | |
| const agentTools = tools.filter(t => record.tools.includes(t.toolSpec.name)); | |
| const client = await getConverseClient(modelId.includes("gemini") ? "google" : "aws"); | |
| const content = await getMessageContent(text, files); | |
| const parentId = forkFromId !== null | |
| ? agent.messageTree?.nodes.get(forkFromId)?.message?.parentId ?? null | |
| : agent.activeLeafId ?? null; | |
| const userMessage = { role: "user", content, parentId, createdAt: Date.now() }; | |
| const savedId = await db.add("messages", { ...userMessage, agentId: params.agentId, threadId: params.threadId }); | |
| userMessage.id = savedId; | |
| const allMessages = await db.getAllFromIndex("messages", "threadId", params.threadId); | |
| const tree = buildMessageTree(allMessages); | |
| const newPath = extendPath(tree, getPathToMessage(tree, userMessage.id)); | |
| const pathMessages = newPath.map(id => tree.nodes.get(id)?.message).filter(Boolean); | |
| setAgent({ | |
| id: record.id, | |
| thread: { id: params.threadId }, | |
| modelId, | |
| reasoningMode, | |
| name: record.name, | |
| systemPrompt: record.systemPrompt, | |
| resources: record.resources, | |
| tools: agentTools, | |
| messages: pathMessages, | |
| messageTree: tree, | |
| activePath: newPath, | |
| activeLeafId: userMessage.id, | |
| }); | |
| await runAgentWithBranching(agent, setAgent, client, userMessage.id, params, db); | |
| setAgent("loading", false); | |
| } | |
| function switchBranch(newMessageId) { | |
| const tree = agent.messageTree; | |
| if (!tree) return; | |
| const pathToMessage = getPathToMessage(tree, newMessageId); | |
| const fullPath = extendPath(tree, pathToMessage); | |
| const newLeafId = fullPath.length > 0 ? fullPath[fullPath.length - 1] : null; | |
| const pathMessages = fullPath.map(id => tree.nodes.get(id)?.message).filter(Boolean); | |
| setAgent({ activePath: fullPath, activeLeafId: newLeafId, messages: pathMessages }); | |
| } | |
| return { agent, params, setAgent, sendMessage, switchBranch }; | |
| } | |
| function setSearchParams(obj) { | |
| const params = new URLSearchParams(window.location.search); | |
| Object.entries(obj).forEach(([k, v]) => v != null && params.set(k, v)); | |
| window.history.replaceState({}, "", `${window.location.pathname}?${params}`); | |
| } | |
| async function upsert(db, table, obj, key = "id") { | |
| const existing = await db.get(table, obj[key]); | |
| if (existing) { | |
| const updated = { ...existing, ...obj }; | |
| await db.put(table, updated); | |
| return updated; | |
| } else { | |
| const id = await db.add(table, obj); | |
| return { ...obj, [key]: id }; | |
| } | |
| } | |
| // #endregion | |
| // #region 3. UI COMPONENTS | |
| // ============================================================================= | |
| // Presentational components for messages, branching, and input. | |
| // ============================================================================= | |
| function InputForm(props) { | |
| return html` | |
| <form id="inputForm" onSubmit=${props.onSubmit} class="shadow-sm bg-white border rounded"> | |
| <textarea | |
| id="userMessage" | |
| class="form-control form-control-sm rounded p-2 border-0 shadow-none" | |
| rows="3" | |
| placeholder="Enter message" | |
| aria-label="User message" | |
| onKeyDown=${props.onKeyDown} | |
| required></textarea> | |
| <div class="d-flex align-items-center justify-content-between gap-1 p-2"> | |
| <div class="d-flex flex-grow-1 gap-2 align-items-center"> | |
| <input type="file" name="userFiles" id="userFiles" class="visually-hidden" accept=".png,.jpg,.jpeg,.gif,.webp,.pdf,.doc,.docx,.xls,.xlsx,.tsv,.md,.json,text/*" multiple /> | |
| <label for="userFiles" class="btn btn-sm btn-secondary"> | |
| <span class="visually-hidden">Attach Files</span> | |
| <i class="bi bi-paperclip"></i> | |
| </label> | |
| <div class="form-check form-switch form-control-sm"> | |
| <input class="form-check-input" type="checkbox" role="switch" id="reasoningMode" name="reasoningMode" title="Enable extended reasoning mode" /> | |
| <label class="form-check-label" for="reasoningMode"> | |
| <span class="visually-hidden">Reasoning Mode</span> | |
| <i class="bi bi-lightbulb-fill text-secondary"></i> | |
| </label> | |
| </div> | |
| </div> | |
| <div class="d-flex align-items-center gap-2"> | |
| <select id="modelId" class="form-select form-select-sm w-auto border-0 shadow-none" required> | |
| <optgroup label="AWS Bedrock"> | |
| <option value="global.anthropic.claude-opus-4-5-20251101-v1:0">Opus 4.5</option> | |
| <option value="us.anthropic.claude-sonnet-4-5-20250929-v1:0">Sonnet 4.5</option> | |
| <option value="us.anthropic.claude-haiku-4-5-20251001-v1:0" selected>Haiku 4.5</option> | |
| </optgroup> | |
| <optgroup label="Google Vertex"> | |
| <option value="gemini-3-pro-preview">Gemini 3 Pro</option> | |
| <option value="gemini-3-flash-preview">Gemini 3 Flash</option> | |
| <option value="imagen-4.0-generate-001" hidden>Imagen 4.0</option> | |
| <option value="veo-3.0-generate-preview" hidden>Veo 3.0</option> | |
| </optgroup> | |
| </select> | |
| <button type="submit" class="btn btn-sm btn-dark">Send</button> | |
| </div> | |
| </div> | |
| </form> | |
| `; | |
| } | |
| function MessageContent(props) { | |
| const findToolResult = toolUseId => { | |
| for (const msg of (props.allMessages?.() || []).slice().reverse()) { | |
| const result = msg.content.find(c => c.toolResult?.toolUseId === toolUseId); | |
| if (result) return result.toolResult; | |
| } | |
| }; | |
| const c = () => props.content; | |
| const m = () => props.message; | |
| const isText = () => c().text !== undefined; | |
| return html` | |
| <${Switch}> | |
| <${Match} when=${() => isText() && m().role === "user"}> | |
| <${Show} | |
| when=${() => props.editingId === m().id} | |
| fallback=${html`<div class="small rounded p-2 text-dark bg-secondary-subtle d-inline-block text-pre">${() => c().text}</div>`}> | |
| <${EditableMessage} text=${() => c().text} onSave=${text => props.onSave?.(m().id, text)} onCancel=${props.onCancel} /> | |
| <//> | |
| <//> | |
| <${Match} when=${() => isText() && m().role === "assistant"}> | |
| <div class="small p-2 text-pre">${() => c().text}</div> | |
| <//> | |
| <${Match} when=${() => c().reasoningContent || c().toolUse?.name === "think"}> | |
| <details class="small rounded border p-2"> | |
| <summary class="cursor-pointer text-dark">View Reasoning</summary> | |
| <p class="my-2 text-muted">${() => c().reasoningContent?.reasoningText?.text || parseJSON(c().toolUse?.input)?.thought}</p> | |
| </details> | |
| <//> | |
| <${Match} when=${() => c().toolUse && c().toolUse?.name !== "think"}> | |
| <details class="small rounded border p-2"> | |
| <summary class="cursor-pointer text-dark">${() => c().toolUse.name}</summary> | |
| <div class="my-2 text-muted text-pre"> | |
| ${() => JSON.stringify(parseJSON(c().toolUse.input || "{}"), null, 2)} | |
| <hr /> | |
| ${() => JSON.stringify(findToolResult(c().toolUse.toolUseId) || {}, null, 2)} | |
| </div> | |
| </details> | |
| <//> | |
| <//> | |
| `; | |
| } | |
| function BranchNav(props) { | |
| const siblings = () => (props.tree && props.messageId ? getSiblings(props.tree, props.messageId) : [props.messageId]); | |
| const index = () => siblings().indexOf(props.messageId); | |
| return html` | |
| <${Show} when=${() => siblings().length > 1}> | |
| <span class="branch-nav d-inline-flex align-items-center gap-1 small text-muted ms-2"> | |
| <button type="button" class="btn btn-sm btn-link text-decoration-none p-0 text-muted" disabled=${() => index() <= 0} onClick=${e => props.onSwitch(siblings()[index() - 1])} title="Previous version"> | |
| <i class="bi bi-chevron-left"></i> | |
| </button> | |
| <span class="branch-counter text-center">${() => index() + 1}/${() => siblings().length}</span> | |
| <button type="button" class="btn btn-sm btn-link text-decoration-none p-0 text-muted" disabled=${() => index() >= siblings().length - 1} onClick=${e => props.onSwitch(siblings()[index() + 1])} title="Next version"> | |
| <i class="bi bi-chevron-right"></i> | |
| </button> | |
| </span> | |
| <//> | |
| `; | |
| } | |
| function EditableMessage(props) { | |
| let textareaRef; | |
| return html` | |
| <div class="edit-message-container"> | |
| <textarea | |
| ref=${el => { textareaRef = el; el.focus(); el.select(); }} | |
| class="form-control form-control-sm mb-2" | |
| rows="3" | |
| onKeyDown=${e => { | |
| if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); props.onSave(textareaRef.value); } | |
| else if (e.key === "Escape") props.onCancel(); | |
| }}>${() => props.text}</textarea> | |
| <div class="d-flex gap-2"> | |
| <button type="button" class="btn btn-sm btn-primary" onClick=${e => props.onSave(textareaRef.value)}>Save & Submit</button> | |
| <button type="button" class="btn btn-sm btn-secondary" onClick=${props.onCancel}>Cancel</button> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| // #endregion | |
| // #region 4. AGENT LOOP & STREAMING | |
| // ============================================================================= | |
| // Core agent loop, streaming response processing, and command construction. | |
| // ============================================================================= | |
| async function runAgentWithBranching(store, setStore, client, lastMessageId, params, db) { | |
| const saveMsg = msg => db.add("messages", { ...unwrap(msg), agentId: params.agentId, threadId: params.threadId }); | |
| const toolMap = Object.fromEntries(store.tools.map(t => [t.toolSpec.name, t.fn])); | |
| let parentId = lastMessageId; | |
| while (true) { | |
| const input = getConverseCommand({ ...store, messages: store.messages.map(({ role, content }) => ({ role, content })) }); | |
| const output = await client.send(input); | |
| setStore("messages", store.messages.length, { role: "assistant", content: [], parentId, createdAt: Date.now() }); | |
| let stopReason = null; | |
| for await (const msg of output.stream) { | |
| setStore(produce(s => processContentBlock(s, msg))); | |
| if (msg.messageStop) stopReason = msg.messageStop.stopReason; | |
| } | |
| const savedId = await saveMsg(store.messages.at(-1)); | |
| setStore("messages", store.messages.length - 1, "id", savedId); | |
| setStore("activeLeafId", savedId); | |
| parentId = savedId; | |
| if (stopReason !== "tool_use") break; | |
| const toolResults = { ...(await getToolResults(store.messages.at(-1).content, toolMap)), parentId: savedId, createdAt: Date.now() }; | |
| setStore("messages", store.messages.length, toolResults); | |
| parentId = await saveMsg(toolResults); | |
| setStore("messages", store.messages.length - 1, "id", parentId); | |
| } | |
| const allMessages = await db.getAllFromIndex("messages", "threadId", params.threadId); | |
| const tree = buildMessageTree(allMessages); | |
| setStore("messageTree", tree); | |
| setStore("activePath", getMostRecentPath(tree)); | |
| } | |
| function getConverseCommand(config) { | |
| const cachePoint = { type: "default" }; | |
| const additionalModelRequestFields = {}; | |
| if (config.reasoningMode) { | |
| additionalModelRequestFields.thinking = { type: "enabled", budget_tokens: +32_000 }; | |
| } | |
| const tools = config.tools.map(({ toolSpec }) => ({ toolSpec })).filter(Boolean); | |
| return { | |
| modelId: config.modelId, | |
| messages: config.messages, | |
| system: [{ text: config.systemPrompt }, { cachePoint }], | |
| toolConfig: { tools: [...tools, { cachePoint }] }, | |
| additionalModelRequestFields, | |
| }; | |
| } | |
| function processContentBlock(s, message) { | |
| const { contentBlockStart, contentBlockDelta, contentBlockStop } = message; | |
| const content = s.messages.at(-1).content; | |
| if (contentBlockStart?.start?.toolUse) { | |
| content[contentBlockStart.contentBlockIndex] = { toolUse: contentBlockStart.start.toolUse }; | |
| return; | |
| } | |
| if (contentBlockStop) { | |
| const block = content[contentBlockStop.contentBlockIndex]; | |
| if (block?.toolUse) block.toolUse.input = parseJSON(block.toolUse.input); | |
| return; | |
| } | |
| if (!contentBlockDelta) return; | |
| const { contentBlockIndex, delta } = contentBlockDelta; | |
| content[contentBlockIndex] ||= {}; | |
| const block = content[contentBlockIndex]; | |
| if (delta.reasoningContent) appendReasoning(block, delta.reasoningContent); | |
| else if (delta.text) block.text = (block.text || "") + delta.text; | |
| else if (delta.toolUse) block.toolUse.input = (block.toolUse.input || "") + delta.toolUse.input; | |
| } | |
| function appendReasoning(block, rc) { | |
| block.reasoningContent ||= { reasoningText: {} }; | |
| const t = block.reasoningContent; | |
| if (rc.text) t.reasoningText.text = (t.reasoningText.text || "") + rc.text; | |
| else if (rc.signature) t.reasoningText.signature = (t.reasoningText.signature || "") + rc.signature; | |
| else if (rc.redactedContent) t.redactedContent = (t.redactedContent || "") + rc.redactedContent; | |
| } | |
| // #endregion | |
| // #region 5. PROVIDERS & CLIENTS | |
| // ============================================================================= | |
| // AI provider clients (AWS Bedrock, Google Gemini) and configuration. | |
| // ============================================================================= | |
| async function getConverseClient(type = "aws") { | |
| if (type === "aws") { | |
| const config = await withStorage(getAwsConfig, localStorage, "aws-config"); | |
| const client = new BedrockRuntimeClient(config); | |
| const send = input => client.send(new ConverseStreamCommand(input)); | |
| return { client, send }; | |
| } | |
| if (type === "google") { | |
| const config = await withStorage(getGoogleConfig, localStorage, "google-config"); | |
| return getGeminiClient(new GoogleGenAI(config)); | |
| } | |
| } | |
| export function getGeminiClient(client) { | |
| const toB64 = b => btoa(b.reduce((s, x) => s + String.fromCharCode(x), "")); | |
| const MIME = { png: "image/png", jpeg: "image/jpeg", jpg: "image/jpeg", gif: "image/gif", webp: "image/webp", pdf: "application/pdf" }; | |
| async function* stream(input) { | |
| const toolNames = new Map(); | |
| const toGeminiPart = b => { | |
| if (b.text) return { text: b.text }; | |
| if (b.image) return { inlineData: { data: toB64(b.image.source.bytes), mimeType: MIME[b.image.format] } }; | |
| if (b.document) return { inlineData: { data: toB64(b.document.source.bytes), mimeType: MIME[b.document.format] || "application/octet-stream" } }; | |
| if (b.toolUse) { | |
| toolNames.set(b.toolUse.toolUseId, b.toolUse.name); | |
| return { functionCall: { name: b.toolUse.name, args: typeof b.toolUse.input === "string" ? JSON.parse(b.toolUse.input) : b.toolUse.input, id: b.toolUse.toolUseId } }; | |
| } | |
| if (b.toolResult) { | |
| const c = b.toolResult.content?.[0]; | |
| return { functionResponse: { id: b.toolResult.toolUseId, name: toolNames.get(b.toolResult.toolUseId) || b.toolResult.toolUseId, response: c?.json ?? { output: c?.text } } }; | |
| } | |
| }; | |
| const geminiReq = { | |
| model: input.modelId, | |
| contents: input.messages.map(m => ({ role: m.role === "assistant" ? "model" : "user", parts: m.content.map(toGeminiPart).filter(Boolean) })), | |
| config: { | |
| systemInstruction: input.system?.find(s => s.text)?.text, | |
| tools: input.toolConfig?.tools | |
| ?.filter(t => t.toolSpec) | |
| .map(t => ({ | |
| functionDeclarations: [{ | |
| name: t.toolSpec.name, | |
| description: t.toolSpec.description, | |
| parametersJsonSchema: t.toolSpec.inputSchema?.json, | |
| }], | |
| })), | |
| ...(input.additionalModelRequestFields?.thinking && { | |
| thinkingConfig: { thinkingBudget: input.additionalModelRequestFields.thinking.budget_tokens }, | |
| }), | |
| }, | |
| }; | |
| const response = await client.models.generateContentStream(geminiReq); | |
| yield { messageStart: { role: "assistant" } }; | |
| let idx = 0, active = null, toolNum = 0; | |
| for await (const chunk of response) { | |
| const parts = chunk.candidates?.[0]?.content?.parts ?? []; | |
| const done = chunk.candidates?.[0]?.finishReason; | |
| for (const p of parts) { | |
| let type, start, delta; | |
| if (p.thought && p.text != null) { | |
| type = "reasoning"; | |
| delta = { reasoningContent: { text: p.text, ...(p.thoughtSignature && { signature: p.thoughtSignature }) } }; | |
| } else if (p.text != null) { | |
| type = "text"; | |
| delta = { text: p.text }; | |
| } else if (p.functionCall) { | |
| type = "tool"; | |
| const id = `gemini_${toolNum++}`; | |
| start = { toolUse: { toolUseId: id, name: p.functionCall.name } }; | |
| delta = { toolUse: { input: JSON.stringify(p.functionCall.args ?? {}) } }; | |
| } else continue; | |
| if (type !== active) { | |
| if (active) yield { contentBlockStop: { contentBlockIndex: idx++ } }; | |
| if (start) yield { contentBlockStart: { contentBlockIndex: idx, start } }; | |
| active = type; | |
| } | |
| yield { contentBlockDelta: { contentBlockIndex: idx, delta } }; | |
| } | |
| if (done) { | |
| if (active) yield { contentBlockStop: { contentBlockIndex: idx } }; | |
| yield { messageStop: { stopReason: parts.some(p => p.functionCall) ? "tool_use" : "end_turn" } }; | |
| } | |
| } | |
| } | |
| return { client, send: input => Promise.resolve({ stream: stream(input) }) }; | |
| } | |
| async function withStorage(getConfig, storage, key) { | |
| const cached = storage.getItem(key); | |
| if (cached) return JSON.parse(cached); | |
| const config = await getConfig(); | |
| storage.setItem(key, JSON.stringify(config)); | |
| return config; | |
| } | |
| function getGoogleConfig(prompt = window.prompt) { | |
| const apiKey = prompt("Google GenAI API Key:"); | |
| return { apiKey }; | |
| } | |
| function getAwsConfig(prompt = window.prompt) { | |
| const region = prompt("AWS region [us-east-1]:"); | |
| const accessKeyId = prompt("AWS Access Key ID:"); | |
| const secretAccessKey = prompt("AWS Secret Access Key:"); | |
| const sessionToken = prompt("AWS Session Token [optional]:"); | |
| return { | |
| region: region || "us-east-1", | |
| credentials: { accessKeyId, secretAccessKey, sessionToken: sessionToken || undefined }, | |
| }; | |
| } | |
| // #endregion | |
| // #region 6. TOOLS | |
| // ============================================================================= | |
| // Tool function implementations and file/message content handling. | |
| // ============================================================================= | |
| function code({ language = "js", source, timeout = 5000 }) { | |
| return new Promise(resolve => { | |
| const logs = []; | |
| const frame = document.createElement("iframe"); | |
| frame.sandbox = "allow-scripts allow-same-origin"; | |
| frame.style.cssText = "position:absolute;left:-9999px;top:-9999px;width:0;height:0;border:0"; | |
| document.body.appendChild(frame); | |
| const onMsg = e => { | |
| if (e.source !== frame.contentWindow) return; | |
| const d = e.data || {}; | |
| if (d.type === "log") logs.push(String(d.msg)); | |
| if (d.type === "done") cleanup(); | |
| }; | |
| window.addEventListener("message", onMsg); | |
| const cleanup = () => { | |
| clearTimeout(kill); | |
| window.removeEventListener("message", onMsg); | |
| const doc = frame.contentDocument || frame.contentWindow.document; | |
| const b = doc.body, de = doc.documentElement; | |
| const height = Math.max(b?.scrollHeight || 0, b?.offsetHeight || 0, de?.clientHeight || 0, de?.scrollHeight || 0, de?.offsetHeight || 0); | |
| const html = de?.outerHTML || ""; | |
| frame.remove(); | |
| resolve({ logs, height, html }); | |
| }; | |
| const bridge = ` | |
| (()=>{ | |
| const send=(t,m)=>parent.postMessage({type:t,msg:m},"*"); | |
| ["log","warn","error","info","debug"].forEach(k=>{ | |
| const o=console[k]; console[k]=(...a)=>{try{send("log",a.join(" "))}catch{}; o&&o.apply(console,a);}; | |
| }); | |
| addEventListener("error",e=>send("log",String(e.message||e.error||"error"))); | |
| addEventListener("unhandledrejection",e=>send("log","UnhandledRejection: "+(e?.reason?.message||e?.reason||""))); | |
| })(); | |
| `; | |
| const jsDoc = `<!doctype html><meta charset=utf-8><scr` + `ipt>${bridge}</scr` + `ipt><scr` + `ipt type="module">${source || ""};parent.postMessage({type:"done"},"*");</scr` + `ipt>`; | |
| const htmlDoc = `<!doctype html><meta charset=utf-8><scr` + `ipt>${bridge}</scr` + `ipt>${source || ""}<scr` + `ipt async>addEventListener("load",()=>parent.postMessage({type:"done"},"*"));</scr` + `ipt>`; | |
| const kill = setTimeout(cleanup, timeout); | |
| frame.srcdoc = !language || ["js", "javascript"].includes(language) ? jsDoc : htmlDoc; | |
| }); | |
| } | |
| function think(input) { | |
| return "Thinking complete."; | |
| } | |
| async function getToolResults(toolUseContent, tools) { | |
| const content = await Promise.all( | |
| toolUseContent | |
| .filter(c => c.toolUse) | |
| .map(async ({ toolUse }) => { | |
| const { toolUseId, name, input } = toolUse; | |
| try { | |
| const result = await tools?.[name]?.(input); | |
| return { toolResult: { toolUseId, content: [{ json: { result } }] } }; | |
| } catch (error) { | |
| console.error("Tool error:", error); | |
| return { toolResult: { toolUseId, content: [{ json: { result: error.stack || error.message || String(error) } }] } }; | |
| } | |
| }) | |
| ); | |
| return { role: "user", content }; | |
| } | |
| async function getMessageContent(text, files) { | |
| const blocks = await Promise.all(files.map(getContentBlock)); | |
| return [{ text }, ...blocks.filter(Boolean)]; | |
| } | |
| async function getContentBlock(file) { | |
| const DOC_TYPES = ["pdf", "csv", "doc", "docx", "xls", "xlsx", "html", "txt", "md"]; | |
| const IMG_TYPES = ["png", "jpg", "jpeg", "gif", "webp"]; | |
| let ext = file.name.split(".").pop().toLowerCase(); | |
| const isText = file.type.startsWith("text/") || /json|xml/.test(file.type); | |
| if (isText && !DOC_TYPES.includes(ext)) ext = "txt"; | |
| if (ext === "htm") ext = "html"; | |
| if (ext === "jpeg") ext = "jpg"; | |
| const type = IMG_TYPES.includes(ext) ? "image" : DOC_TYPES.includes(ext) ? "document" : null; | |
| if (!type) return null; | |
| const bytes = new Uint8Array(await file.arrayBuffer()); | |
| const name = file.name.replace(/[^A-Z0-9 _\-\(\)\[\]]/gi, "_").replace(/\s+/g, " ").trim(); | |
| return { [type]: { format: ext, name, source: { bytes } } }; | |
| } | |
| // #endregion | |
| // #region 7. UTILITIES | |
| // ============================================================================= | |
| // Incremental JSON parser and tree/branching utilities. | |
| // ============================================================================= | |
| /** | |
| * Parses a JSON string incrementally, returning partial results for incomplete inputs. | |
| * Essential for streaming responses where JSON arrives in chunks. | |
| */ | |
| function parseJSON(input) { | |
| if (typeof input !== "string") return input; | |
| const jsonString = input.trim(); | |
| if (jsonString === "") return null; | |
| let index = 0; | |
| const LITERALS = { true: true, false: false, null: null, NaN: NaN, Infinity: Infinity, "-Infinity": -Infinity }; | |
| function skipWhitespace() { | |
| while (index < jsonString.length && " \n\r\t".includes(jsonString[index])) index++; | |
| } | |
| function parseValue() { | |
| skipWhitespace(); | |
| if (index >= jsonString.length) throw new Error("Unexpected end of input"); | |
| const char = jsonString[index]; | |
| if (char === "{") return parseObject(); | |
| if (char === "[") return parseArray(); | |
| if (char === '"') return parseString(); | |
| const remainingText = jsonString.substring(index); | |
| for (const [key, value] of Object.entries(LITERALS)) { | |
| if (jsonString.startsWith(key, index)) { | |
| const endPos = index + key.length; | |
| if (endPos === jsonString.length || ",]} \n\r\t".includes(jsonString[endPos])) { | |
| index = endPos; | |
| return value; | |
| } | |
| } | |
| if (key.startsWith(remainingText)) { | |
| index = jsonString.length; | |
| return value; | |
| } | |
| } | |
| if (char === "-" || (char >= "0" && char <= "9")) return parseNumber(); | |
| throw new Error(`Unexpected token '${char}' at position ${index}`); | |
| } | |
| function parseArray() { | |
| index++; | |
| const arr = []; | |
| while (index < jsonString.length && jsonString[index] !== "]") { | |
| try { | |
| arr.push(parseValue()); | |
| skipWhitespace(); | |
| if (jsonString[index] === ",") index++; | |
| else if (jsonString[index] !== "]") break; | |
| } catch (e) { return arr; } | |
| } | |
| if (index < jsonString.length && jsonString[index] === "]") index++; | |
| return arr; | |
| } | |
| function parseObject() { | |
| index++; | |
| const obj = {}; | |
| while (index < jsonString.length && jsonString[index] !== "}") { | |
| try { | |
| skipWhitespace(); | |
| if (jsonString[index] !== '"') break; | |
| const key = parseString(); | |
| skipWhitespace(); | |
| if (index >= jsonString.length || jsonString[index] !== ":") break; | |
| index++; | |
| obj[key] = parseValue(); | |
| skipWhitespace(); | |
| if (jsonString[index] === ",") index++; | |
| else if (jsonString[index] !== "}") break; | |
| } catch (e) { return obj; } | |
| } | |
| if (index < jsonString.length && jsonString[index] === "}") index++; | |
| return obj; | |
| } | |
| function parseString() { | |
| if (jsonString[index] !== '"') throw new Error("Expected '\"' to start a string"); | |
| const startIndex = index; | |
| index++; | |
| let escape = false; | |
| while (index < jsonString.length) { | |
| if (jsonString[index] === '"' && !escape) { | |
| const fullString = jsonString.substring(startIndex, ++index); | |
| return JSON.parse(fullString); | |
| } | |
| escape = jsonString[index] === "\\" ? !escape : false; | |
| index++; | |
| } | |
| const partialStr = jsonString.substring(startIndex); | |
| try { | |
| return JSON.parse(partialStr + '"'); | |
| } catch (e) { | |
| const lastBackslash = partialStr.lastIndexOf("\\"); | |
| if (lastBackslash > 0) return JSON.parse(partialStr.substring(0, lastBackslash) + '"'); | |
| return partialStr.substring(1); | |
| } | |
| } | |
| function parseNumber() { | |
| const startIndex = index; | |
| const numberChars = "0123456789eE.+-"; | |
| while (index < jsonString.length && numberChars.includes(jsonString[index])) index++; | |
| const numStr = jsonString.substring(startIndex, index); | |
| if (!numStr) throw new Error("Empty number literal"); | |
| try { return parseFloat(numStr); } | |
| catch (e) { | |
| if (numStr.length > 1) return parseFloat(numStr.slice(0, -1)); | |
| throw e; | |
| } | |
| } | |
| return parseValue(); | |
| } | |
| // Tree/Branching utilities | |
| export function buildMessageTree(messages) { | |
| const nodes = new Map(); | |
| const rootIds = []; | |
| for (const msg of messages) { | |
| nodes.set(msg.id, { message: msg, childIds: [] }); | |
| } | |
| for (const msg of messages) { | |
| if (msg.parentId === null || msg.parentId === undefined) { | |
| rootIds.push(msg.id); | |
| } else if (nodes.has(msg.parentId)) { | |
| nodes.get(msg.parentId).childIds.push(msg.id); | |
| } | |
| } | |
| for (const [_, node] of nodes) { | |
| node.childIds.sort((a, b) => nodes.get(a).message.createdAt - nodes.get(b).message.createdAt); | |
| } | |
| rootIds.sort((a, b) => nodes.get(a).message.createdAt - nodes.get(b).message.createdAt); | |
| return { rootIds, nodes }; | |
| } | |
| export function getMostRecentPath(tree) { | |
| if (tree.rootIds.length === 0) return []; | |
| const path = []; | |
| let currentId = tree.rootIds[tree.rootIds.length - 1]; | |
| while (currentId !== undefined) { | |
| path.push(currentId); | |
| const node = tree.nodes.get(currentId); | |
| if (!node || node.childIds.length === 0) break; | |
| currentId = node.childIds[node.childIds.length - 1]; | |
| } | |
| return path; | |
| } | |
| export function getSiblings(tree, messageId) { | |
| const node = tree.nodes.get(messageId); | |
| if (!node) return [messageId]; | |
| const parentId = node.message.parentId; | |
| if (parentId === null || parentId === undefined) return tree.rootIds; | |
| const parentNode = tree.nodes.get(parentId); | |
| return parentNode ? parentNode.childIds : [messageId]; | |
| } | |
| export function getPathToMessage(tree, messageId) { | |
| const path = []; | |
| let currentId = messageId; | |
| while (currentId !== null && currentId !== undefined) { | |
| path.unshift(currentId); | |
| const node = tree.nodes.get(currentId); | |
| if (!node) break; | |
| currentId = node.message.parentId; | |
| } | |
| return path; | |
| } | |
| export function extendPath(tree, path) { | |
| if (path.length === 0) return []; | |
| const extended = [...path]; | |
| let currentId = extended[extended.length - 1]; | |
| while (true) { | |
| const node = tree.nodes.get(currentId); | |
| if (!node || node.childIds.length === 0) break; | |
| currentId = node.childIds[node.childIds.length - 1]; | |
| extended.push(currentId); | |
| } | |
| return extended; | |
| } | |
| // #endregion | |
| // #region 8. TESTS | |
| // ============================================================================= | |
| // Test suite activated with ?test=1 query parameter. | |
| // ============================================================================= | |
| if (new URLSearchParams(window.location.search).get("test") === "1") { | |
| const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b); | |
| const assert = (cond, msg) => { if (!cond) throw new Error(msg || "Assertion failed"); }; | |
| async function runTests(tests) { | |
| let passed = 0, failed = 0; | |
| for (const test of tests) { | |
| try { | |
| await test(); | |
| console.log(`PASS: ${test.name}`); | |
| passed++; | |
| } catch (e) { | |
| console.error(`FAIL: ${test.name}\n ${e.message}`); | |
| failed++; | |
| } | |
| } | |
| console.log(`Results: ${passed} passed, ${failed} failed`); | |
| return { passed, failed }; | |
| } | |
| async function testBranchingAndParseJSON() { | |
| // Test branching: create tree with branches, verify navigation | |
| const messages = [ | |
| { id: 1, parentId: null, createdAt: 1000, role: "user", content: [] }, | |
| { id: 2, parentId: 1, createdAt: 2000, role: "assistant", content: [] }, | |
| { id: 3, parentId: 1, createdAt: 3000, role: "assistant", content: [] }, | |
| { id: 4, parentId: 3, createdAt: 4000, role: "user", content: [] }, | |
| ]; | |
| const tree = buildMessageTree(messages); | |
| assert(eq(tree.rootIds, [1]), "rootIds should be [1]"); | |
| assert(eq(tree.nodes.get(1).childIds, [2, 3]), "children sorted by createdAt"); | |
| assert(eq(getMostRecentPath(tree), [1, 3, 4]), "most recent path follows newest branch"); | |
| assert(eq(getSiblings(tree, 2), [2, 3]), "siblings include both branches"); | |
| assert(eq(getPathToMessage(tree, 4), [1, 3, 4]), "path from root to message"); | |
| assert(eq(extendPath(tree, [1]), [1, 3, 4]), "extend path to leaf"); | |
| const empty = buildMessageTree([]); | |
| assert(eq(empty.rootIds, []), "empty tree has no roots"); | |
| assert(eq(getMostRecentPath(empty), []), "empty tree has no path"); | |
| // Test parseJSON | |
| assert(eq(parseJSON('{"a": 1}'), { a: 1 }), "complete object"); | |
| assert(eq(parseJSON("[1, 2, 3]"), [1, 2, 3]), "complete array"); | |
| assert(parseJSON('{"name": "test", "val').name === "test", "partial object"); | |
| assert(eq(parseJSON("[1, 2, 3"), [1, 2, 3]), "partial array"); | |
| assert(eq(parseJSON("true"), true), "literal true"); | |
| assert(eq(parseJSON("null"), null), "literal null"); | |
| assert(eq(parseJSON(""), null), "empty string"); | |
| assert(eq(parseJSON({ x: 1 }), { x: 1 }), "non-string passthrough"); | |
| } | |
| async function testUIRendering() { | |
| await new Promise(r => setTimeout(r, 100)); | |
| const app = document.getElementById("app"); | |
| assert(app, "App container exists"); | |
| assert(app.querySelector("form#inputForm"), "Input form renders"); | |
| assert(app.querySelector("textarea#userMessage"), "Message textarea renders"); | |
| assert(app.querySelector("button[type='submit']"), "Send button renders"); | |
| const modelSelect = app.querySelector("#modelId"); | |
| assert(modelSelect?.options?.length > 0, "Model select has options"); | |
| assert(app.querySelector("#reasoningMode")?.type === "checkbox", "Reasoning toggle is checkbox"); | |
| assert(app.querySelector("#userFiles")?.type === "file", "File input exists"); | |
| } | |
| runTests([testBranchingAndParseJSON, testUIRendering]).then(({ failed }) => { | |
| window.TESTS_DONE = true; | |
| if (failed > 0) console.error(`\n${failed} test(s) failed!`); | |
| }); | |
| } | |
| // #endregion | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment