├── .gitattributes ├── static ├── styles.css ├── favicon.ico ├── screenshot.png └── logo.svg ├── lib ├── kv.ts ├── markdown.ts ├── fetch.ts ├── workspace.ts └── oai.ts ├── .vscode ├── extensions.json ├── settings.json └── tailwind.json ├── fresh.config.ts ├── .gitignore ├── dev.ts ├── tailwind.config.ts ├── components └── Button.tsx ├── main.ts ├── routes ├── _app.tsx ├── index.tsx ├── _404.tsx ├── api │ └── workspace │ │ └── [workspaceId] │ │ ├── chat.ts │ │ ├── messages │ │ └── [messageId].ts │ │ └── chats │ │ └── [chatId].ts └── workspace │ ├── [workspaceId].tsx │ └── index.tsx ├── .github └── workflows │ └── ci.yml ├── README.md ├── deno.json ├── islands ├── ChatUI.tsx ├── Sidebar.tsx └── ChatContent.tsx └── fresh.gen.ts /.gitattributes: -------------------------------------------------------------------------------- 1 | *.png filter=lfs diff=lfs merge=lfs -text 2 | -------------------------------------------------------------------------------- /static/styles.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /lib/kv.ts: -------------------------------------------------------------------------------- 1 | export const kv = await Deno.openKv(Deno.env.get("KV_PATH") || undefined); 2 | -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denoland/chatspace/main/static/favicon.ico -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "denoland.vscode-deno", 4 | "bradlc.vscode-tailwindcss" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /static/screenshot.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:8e7cfbad7f9c57ae8ada1c25576fa54d19a0b784c0dc8f583d7964c13e76a0cb 3 | size 650233 4 | -------------------------------------------------------------------------------- /fresh.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "$fresh/server.ts"; 2 | import tailwind from "$fresh/plugins/tailwind.ts"; 3 | 4 | export default defineConfig({ 5 | plugins: [tailwind()], 6 | }); 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dotenv environment variable files 2 | .env 3 | .env.development.local 4 | .env.test.local 5 | .env.production.local 6 | .env.local 7 | 8 | # Fresh build directory 9 | _fresh/ 10 | # npm dependencies 11 | node_modules/ 12 | -------------------------------------------------------------------------------- /dev.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S deno run -A --watch=static/,routes/ 2 | 3 | import dev from "$fresh/dev.ts"; 4 | import config from "./fresh.config.ts"; 5 | 6 | import "$std/dotenv/load.ts"; 7 | 8 | await dev(import.meta.url, "./main.ts", config); 9 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import { type Config } from "tailwindcss"; 2 | import typography from "@tailwindcss/typography"; 3 | 4 | export default { 5 | content: [ 6 | "{routes,islands,components}/**/*.{ts,tsx}", 7 | ], 8 | plugins: [ 9 | typography, 10 | ], 11 | } satisfies Config; 12 | -------------------------------------------------------------------------------- /lib/markdown.ts: -------------------------------------------------------------------------------- 1 | import * as ammonia from "https://deno.land/x/ammonia@0.3.1/mod.ts"; 2 | import { marked } from "npm:marked@11.1.0"; 3 | 4 | const ammoniaInit = ammonia.init(); 5 | 6 | export async function safelyRenderMarkdown(input: string): Promise { 7 | await ammoniaInit; 8 | return ammonia.clean(await marked(input)); 9 | } 10 | -------------------------------------------------------------------------------- /components/Button.tsx: -------------------------------------------------------------------------------- 1 | import { JSX } from "preact"; 2 | import { IS_BROWSER } from "$fresh/runtime.ts"; 3 | 4 | export function Button(props: JSX.HTMLAttributes) { 5 | return ( 6 | 61 | 62 | 63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /islands/ChatUI.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState } from "preact/hooks"; 2 | import { 3 | createWorkspaceState, 4 | WorkspaceInfo, 5 | WorkspaceStateContext, 6 | } from "../lib/workspace.ts"; 7 | import { NewChat, SidebarContent } from "./Sidebar.tsx"; 8 | import { ChatContent } from "./ChatContent.tsx"; 9 | 10 | export function ChatUI( 11 | { workspaceId, workspaceInfo }: { 12 | workspaceId: string; 13 | workspaceInfo: WorkspaceInfo; 14 | }, 15 | ) { 16 | const [isSidebarOpen, setIsSidebarOpen] = useState(false); 17 | const toggleSidebar = () => setIsSidebarOpen(!isSidebarOpen); 18 | 19 | const state = useMemo( 20 | () => createWorkspaceState(workspaceId, workspaceInfo), 21 | [], 22 | ); 23 | 24 | useEffect(() => { 25 | const callback = () => { 26 | state.currentHead.value = location.hash.slice(1); 27 | setIsSidebarOpen(false); 28 | }; 29 | callback(); 30 | globalThis.addEventListener("hashchange", callback); 31 | return () => globalThis.removeEventListener("hashchange", callback); 32 | }, []); 33 | 34 | return ( 35 | 36 |
37 |
38 | 44 |
45 | 46 |
47 |
52 | 53 |
54 |
55 | 56 |
57 |
58 |
59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /fresh.gen.ts: -------------------------------------------------------------------------------- 1 | // DO NOT EDIT. This file is generated by Fresh. 2 | // This file SHOULD be checked into source version control. 3 | // This file is automatically updated during development when running `dev.ts`. 4 | 5 | import * as $_404 from "./routes/_404.tsx"; 6 | import * as $_app from "./routes/_app.tsx"; 7 | import * as $api_workspace_workspaceId_chat from "./routes/api/workspace/[workspaceId]/chat.ts"; 8 | import * as $api_workspace_workspaceId_chats_chatId_ from "./routes/api/workspace/[workspaceId]/chats/[chatId].ts"; 9 | import * as $api_workspace_workspaceId_messages_messageId_ from "./routes/api/workspace/[workspaceId]/messages/[messageId].ts"; 10 | import * as $index from "./routes/index.tsx"; 11 | import * as $workspace_workspaceId_ from "./routes/workspace/[workspaceId].tsx"; 12 | import * as $workspace_index from "./routes/workspace/index.tsx"; 13 | import * as $ChatContent from "./islands/ChatContent.tsx"; 14 | import * as $ChatUI from "./islands/ChatUI.tsx"; 15 | import * as $Sidebar from "./islands/Sidebar.tsx"; 16 | import { type Manifest } from "$fresh/server.ts"; 17 | 18 | const manifest = { 19 | routes: { 20 | "./routes/_404.tsx": $_404, 21 | "./routes/_app.tsx": $_app, 22 | "./routes/api/workspace/[workspaceId]/chat.ts": 23 | $api_workspace_workspaceId_chat, 24 | "./routes/api/workspace/[workspaceId]/chats/[chatId].ts": 25 | $api_workspace_workspaceId_chats_chatId_, 26 | "./routes/api/workspace/[workspaceId]/messages/[messageId].ts": 27 | $api_workspace_workspaceId_messages_messageId_, 28 | "./routes/index.tsx": $index, 29 | "./routes/workspace/[workspaceId].tsx": $workspace_workspaceId_, 30 | "./routes/workspace/index.tsx": $workspace_index, 31 | }, 32 | islands: { 33 | "./islands/ChatContent.tsx": $ChatContent, 34 | "./islands/ChatUI.tsx": $ChatUI, 35 | "./islands/Sidebar.tsx": $Sidebar, 36 | }, 37 | baseUrl: import.meta.url, 38 | } satisfies Manifest; 39 | 40 | export default manifest; 41 | -------------------------------------------------------------------------------- /lib/workspace.ts: -------------------------------------------------------------------------------- 1 | import { Signal, signal } from "@preact/signals"; 2 | import { createContext } from "preact"; 3 | 4 | export interface WorkspaceState { 5 | id: string; 6 | heads: Signal>; 7 | currentHead: Signal; 8 | } 9 | 10 | export interface WorkspaceInfo { 11 | heads: ChatHead[]; 12 | createdAt: number; 13 | } 14 | 15 | export interface ChatHead { 16 | id: string; 17 | backend?: string; 18 | title: string; 19 | systemPrompt: string; 20 | timestamp: number; 21 | messages?: string[]; 22 | } 23 | 24 | export interface ChatMessage { 25 | id: string; 26 | role: "user" | "assistant"; 27 | backend?: string; 28 | text: string; 29 | html?: string; 30 | timestamp: number; 31 | completed: boolean; 32 | interrupted?: boolean; 33 | } 34 | 35 | export const WorkspaceStateContext = createContext(null); 36 | 37 | export function createWorkspaceState( 38 | id: string, 39 | info: WorkspaceInfo, 40 | ): WorkspaceState { 41 | const state: WorkspaceState = { 42 | id, 43 | heads: signal(new Map(info.heads.map((head) => [head.id, head]))), 44 | currentHead: signal(""), 45 | }; 46 | 47 | return state; 48 | } 49 | 50 | export async function batchLoadMessages( 51 | kv: Deno.Kv, 52 | workspaceId: string, 53 | messageIds: string[], 54 | ): Promise { 55 | messageIds = messageIds.filter((x) => x); 56 | 57 | const batchSize = 10; 58 | 59 | const messages: ChatMessage[] = []; 60 | for (let i = 0; i < messageIds.length; i += batchSize) { 61 | const batch = messageIds.slice(i, i + batchSize); 62 | const batchMessages = await kv.getMany( 63 | batch.map((x) => ["messages", workspaceId, x]), 64 | ); 65 | messages.push(...batchMessages.map((m) => { 66 | if (m.value !== null) { 67 | return m.value; 68 | } else { 69 | throw new Error("Message not found"); 70 | } 71 | })); 72 | } 73 | 74 | return messages; 75 | } 76 | -------------------------------------------------------------------------------- /.vscode/tailwind.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1.1, 3 | "atDirectives": [ 4 | { 5 | "name": "@tailwind", 6 | "description": "Use the `@tailwind` directive to insert Tailwind's `base`, `components`, `utilities` and `screens` styles into your CSS.", 7 | "references": [ 8 | { 9 | "name": "Tailwind Documentation", 10 | "url": "https://tailwindcss.com/docs/functions-and-directives#tailwind" 11 | } 12 | ] 13 | }, 14 | { 15 | "name": "@apply", 16 | "description": "Use the `@apply` directive to inline any existing utility classes into your own custom CSS. This is useful when you find a common utility pattern in your HTML that you’d like to extract to a new component.", 17 | "references": [ 18 | { 19 | "name": "Tailwind Documentation", 20 | "url": "https://tailwindcss.com/docs/functions-and-directives#apply" 21 | } 22 | ] 23 | }, 24 | { 25 | "name": "@responsive", 26 | "description": "You can generate responsive variants of your own classes by wrapping their definitions in the `@responsive` directive:\n```css 27 | @responsive {\n .alert { 28 | background-color: #E53E3E;\n }\n}\n```\n", 29 | "references": [ 30 | { 31 | "name": "Tailwind Documentation", 32 | "url": "https://tailwindcss.com/docs/functions-and-directives#responsive" 33 | } 34 | ] 35 | }, 36 | { 37 | "name": "@screen", 38 | "description": "The `@screen` directive allows you to create media queries that reference your breakpoints by **name** instead of duplicating their values in your own CSS:\n```css 39 | @screen sm {\n /* ... */\n}\n```\n…gets transformed into this:\n```css 40 | @media (min-width: 640px) {\n /* ... */\n}\n```\n", 41 | "references": [ 42 | { 43 | "name": "Tailwind Documentation", 44 | "url": "https://tailwindcss.com/docs/functions-and-directives#screen" 45 | } 46 | ] 47 | }, 48 | { 49 | "name": "@variants", 50 | "description": "Generate `hover`, `focus`, `active` and other **variants** of your own utilities by wrapping their definitions in the `@variants` directive:\n```css 51 | @variants hover, focus {\n .btn-brand {\n background-color: #3182CE;\n }\n}\n```\n", 52 | "references": [ 53 | { 54 | "name": "Tailwind Documentation", 55 | "url": "https://tailwindcss.com/docs/functions-and-directives#variants" 56 | } 57 | ] 58 | } 59 | ] 60 | } 61 | -------------------------------------------------------------------------------- /lib/oai.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from "https://deno.land/x/openai@v4.28.0/mod.ts"; 2 | import { batchLoadMessages, ChatHead } from "./workspace.ts"; 3 | import { 4 | ChatCompletionChunk, 5 | ChatCompletionMessageParam, 6 | } from "https://deno.land/x/openai@v4.28.0/resources/mod.ts"; 7 | import { Stream } from "https://deno.land/x/openai@v4.28.0/streaming.ts"; 8 | 9 | interface Backend { 10 | oai: OpenAI; 11 | model: string; 12 | } 13 | 14 | const backends: Map = new Map(); 15 | const defaultBackendName = Deno.env.get("CHATSPACE_DEFAULT_BACKEND"); 16 | 17 | for (const [envName, envValue] of Object.entries(Deno.env.toObject())) { 18 | const prefix = "CHATSPACE_BACKEND_"; 19 | 20 | if (!envName.startsWith(prefix)) continue; 21 | const backendName = envName.slice(prefix.length).toLowerCase(); 22 | const [backendUrl, backendKey, model] = envValue.split(","); 23 | if (!backendUrl || !backendKey || !model) { 24 | console.warn(`Invalid backend "${backendName}"`); 25 | } 26 | 27 | const oai = new OpenAI({ 28 | baseURL: backendUrl, 29 | apiKey: backendKey, 30 | }); 31 | backends.set(backendName, { 32 | oai, 33 | model, 34 | }); 35 | } 36 | 37 | export async function generateChatCompletions( 38 | kv: Deno.Kv, 39 | workspaceId: string, 40 | head: ChatHead, 41 | ): Promise< 42 | { stream: Stream; backendName: string } | null 43 | > { 44 | const messages: ChatCompletionMessageParam[] = []; 45 | 46 | messages.push({ role: "system", content: head.systemPrompt }); 47 | 48 | const messageIds = head.messages ?? []; 49 | const boundary = messageIds.findLastIndex((x) => x === "") + 1; 50 | for ( 51 | const msg of await batchLoadMessages( 52 | kv, 53 | workspaceId, 54 | messageIds.slice(boundary), 55 | ) 56 | ) { 57 | if (msg.role === "user") { 58 | messages.push({ role: "user", content: msg.text }); 59 | } else if (msg.role === "assistant") { 60 | messages.push({ role: "assistant", content: msg.text }); 61 | } else { 62 | // ignore 63 | } 64 | } 65 | 66 | const backendName = head.backend ?? defaultBackendName; 67 | if (!backendName) return null; 68 | const backend = backends.get(backendName); 69 | if (!backend) return null; 70 | 71 | const stream = await backend.oai.chat.completions.create({ 72 | model: backend.model, 73 | messages, 74 | stream: true, 75 | }); 76 | return { stream, backendName }; 77 | } 78 | 79 | export function getAllBackendNames(): string[] { 80 | const res = Array.from(backends.keys()); 81 | 82 | // promote default backend to start of array 83 | if (defaultBackendName) { 84 | const idx = res.indexOf(defaultBackendName); 85 | if (idx !== -1) { 86 | res.splice(idx, 1); 87 | res.unshift(defaultBackendName); 88 | } 89 | } 90 | 91 | return res; 92 | } 93 | 94 | export function isValidBackendName(name: string): boolean { 95 | return backends.has(name); 96 | } 97 | -------------------------------------------------------------------------------- /islands/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from "preact/hooks"; 2 | import { fetchOrError } from "../lib/fetch.ts"; 3 | import { 4 | ChatHead, 5 | WorkspaceInfo, 6 | WorkspaceStateContext, 7 | } from "../lib/workspace.ts"; 8 | 9 | // A list of conversation titles. Clicking on one will open it in the content 10 | // area. 11 | export function SidebarContent() { 12 | const state = useContext(WorkspaceStateContext)!; 13 | const currentHead = state.currentHead.value; 14 | return ( 15 |
16 | 19 | 20 |
21 | {[...state.heads.value.values()].reverse().map((head) => ( 22 |
{ 28 | location.hash = `#${head.id}`; 29 | }} 30 | > 31 |
{head.title}
32 |
33 |
34 | {new Date(head.timestamp).toLocaleDateString()} 35 |
36 | 57 |
58 |
59 | ))} 60 |
61 |
62 | ); 63 | } 64 | 65 | export function NewChat({ white }: { white?: boolean }) { 66 | const state = useContext(WorkspaceStateContext)!; 67 | return ( 68 | 103 | ); 104 | } 105 | -------------------------------------------------------------------------------- /routes/api/workspace/[workspaceId]/chats/[chatId].ts: -------------------------------------------------------------------------------- 1 | import { FreshContext, Handlers } from "$fresh/server.ts"; 2 | import { ulid } from "https://deno.land/x/ulid@v0.3.0/mod.ts"; 3 | import { kv } from "../../../../../lib/kv.ts"; 4 | import { 5 | batchLoadMessages, 6 | ChatHead, 7 | ChatMessage, 8 | } from "../../../../../lib/workspace.ts"; 9 | import { 10 | generateChatCompletions, 11 | getAllBackendNames, 12 | isValidBackendName, 13 | } from "../../../../../lib/oai.ts"; 14 | import { safelyRenderMarkdown } from "../../../../../lib/markdown.ts"; 15 | 16 | export const handler: Handlers = { 17 | async GET(req: Request, ctx: FreshContext) { 18 | const key = [ 19 | "heads", 20 | ctx.params.workspaceId, 21 | ctx.params.chatId, 22 | ]; 23 | const availableBackends = getAllBackendNames(); 24 | 25 | if (req.headers.get("accept") === "text/event-stream") { 26 | const seenMessages: Set = new Set(); 27 | const encoder = new TextEncoder(); 28 | const encoderStream = new TransformStream({ 29 | transform: async ( 30 | [head]: [Deno.KvEntryMaybe], 31 | controller, 32 | ) => { 33 | const messages = await batchLoadMessages( 34 | kv, 35 | ctx.params.workspaceId, 36 | head.value?.messages?.filter((x) => !seenMessages.has(x)) ?? [], 37 | ); 38 | for (const m of messages) { 39 | seenMessages.add(m.id); 40 | m.html = await safelyRenderMarkdown(m.text); 41 | } 42 | controller.enqueue( 43 | encoder.encode(`data: ${ 44 | JSON.stringify({ 45 | head: head.value, 46 | messages, 47 | availableBackends, 48 | }) 49 | }\n\n`), 50 | ); 51 | }, 52 | }); 53 | kv.watch([key]).pipeTo(encoderStream.writable).catch((e) => { 54 | if ("" + e === "resource closed") { 55 | return; 56 | } 57 | console.log(`Error watching ${key}: ${e}`); 58 | }); 59 | return new Response(encoderStream.readable, { 60 | headers: { 61 | "content-type": "text/event-stream", 62 | }, 63 | }); 64 | } 65 | 66 | const head = await kv.get(key); 67 | if (!head.value) return ctx.renderNotFound(); 68 | 69 | const messages = await batchLoadMessages( 70 | kv, 71 | ctx.params.workspaceId, 72 | head.value.messages ?? [], 73 | ); 74 | 75 | return Response.json({ head: head.value, messages, availableBackends }); 76 | }, 77 | 78 | async POST(req: Request, ctx: FreshContext) { 79 | const { text, boundary } = await req.json(); 80 | if (typeof text !== "string" || text.length === 0 || text.length > 16384) { 81 | return Response.json({ error: "invalid text" }, { status: 400 }); 82 | } 83 | if (boundary !== undefined && typeof boundary !== "boolean") { 84 | return Response.json({ error: "invalid boundary" }, { status: 400 }); 85 | } 86 | 87 | const userMessageId = ulid(); 88 | const assistantMessageId = ulid(); 89 | const headKey = [ 90 | "heads", 91 | ctx.params.workspaceId, 92 | ctx.params.chatId, 93 | ]; 94 | const head = await kv.get(headKey); 95 | if (!head.value) return ctx.renderNotFound(); 96 | 97 | if (!head.value.messages) head.value.messages = []; 98 | 99 | if ( 100 | boundary && head.value.messages[head.value.messages.length - 1] !== "" 101 | ) head.value.messages.push(""); 102 | head.value.messages.push(userMessageId, assistantMessageId); 103 | 104 | const userMessage: ChatMessage = { 105 | id: userMessageId, 106 | role: "user", 107 | text, 108 | timestamp: Date.now(), 109 | completed: true, 110 | }; 111 | const assistantMessage: ChatMessage = { 112 | id: assistantMessageId, 113 | role: "assistant", 114 | text: "", 115 | timestamp: Date.now(), 116 | completed: false, 117 | }; 118 | 119 | const assistantMessageKey = [ 120 | "messages", 121 | ctx.params.workspaceId, 122 | assistantMessageId, 123 | ]; 124 | 125 | const { ok } = await kv.atomic().check(head).set(head.key, head.value).set([ 126 | "messages", 127 | ctx.params.workspaceId, 128 | userMessageId, 129 | ], userMessage).set(assistantMessageKey, assistantMessage).commit(); 130 | if (!ok) return Response.json({ error: "conflict" }, { status: 409 }); 131 | 132 | (async () => { 133 | head.value.messages?.pop(); // remove the incomplete assistant reply 134 | 135 | const streamWithInfo = await generateChatCompletions( 136 | kv, 137 | ctx.params.workspaceId, 138 | head.value, 139 | ); 140 | if (!streamWithInfo) { 141 | assistantMessage.interrupted = true; 142 | assistantMessage.completed = true; 143 | await kv.set(assistantMessageKey, assistantMessage); 144 | return; 145 | } 146 | const { stream, backendName } = streamWithInfo; 147 | assistantMessage.backend = backendName; 148 | 149 | let ongoingSet: Promise | null = null; 150 | let stop = false; 151 | 152 | for await (const chunk of stream) { 153 | if (stop) { 154 | stream.controller.abort(); 155 | break; 156 | } 157 | const content = chunk.choices[0]?.delta.content ?? ""; 158 | assistantMessage.text += content; 159 | assistantMessage.timestamp = Date.now(); 160 | assistantMessage.completed = !!chunk.choices[0]?.finish_reason; 161 | if (!ongoingSet) { 162 | ongoingSet = Promise.all([ 163 | kv.set(assistantMessageKey, assistantMessage), 164 | kv.get(headKey), 165 | ]).then(([_setRes, head]) => { 166 | ongoingSet = null; 167 | stop = !head.value?.messages?.find((x) => x === assistantMessageId); 168 | }); 169 | } 170 | } 171 | await ongoingSet; 172 | 173 | if (!assistantMessage.completed) { 174 | assistantMessage.interrupted = true; 175 | assistantMessage.completed = true; 176 | } 177 | await kv.set(assistantMessageKey, assistantMessage); 178 | })().catch((e) => { 179 | console.log(`generation failed (chat ${ctx.params.chatId}): ${e}`); 180 | }); 181 | 182 | return Response.json({ ok: true }); 183 | }, 184 | 185 | async DELETE(_req: Request, ctx: FreshContext) { 186 | const headKey = [ 187 | "heads", 188 | ctx.params.workspaceId, 189 | ctx.params.chatId, 190 | ]; 191 | 192 | await kv.delete(headKey); 193 | return Response.json({ ok: true }); 194 | }, 195 | 196 | async PATCH(req: Request, ctx: FreshContext) { 197 | const { title, systemPrompt, deletedMessages, backend } = await req.json(); 198 | if ( 199 | title !== undefined && (typeof title !== "string" || title.length === 0) 200 | ) { 201 | return Response.json({ error: "invalid title" }, { status: 400 }); 202 | } 203 | if ( 204 | systemPrompt !== undefined && 205 | (typeof systemPrompt !== "string" || systemPrompt.length === 0) 206 | ) { 207 | return Response.json({ error: "invalid systemPrompt" }, { status: 400 }); 208 | } 209 | if ( 210 | deletedMessages !== undefined && 211 | (!Array.isArray(deletedMessages) || 212 | deletedMessages.findIndex((x) => 213 | typeof x !== "string" || x.length === 0 214 | ) !== -1) 215 | ) { 216 | return Response.json({ error: "invalid deletedMessages" }, { 217 | status: 400, 218 | }); 219 | } 220 | if ( 221 | backend !== undefined && 222 | (typeof backend !== "string" || !isValidBackendName(backend)) 223 | ) { 224 | return Response.json({ error: "invalid backend" }, { status: 400 }); 225 | } 226 | 227 | const headKey = [ 228 | "heads", 229 | ctx.params.workspaceId, 230 | ctx.params.chatId, 231 | ]; 232 | const head = await kv.get(headKey); 233 | if (!head.value) return ctx.renderNotFound(); 234 | 235 | if (title !== undefined) head.value.title = title; 236 | if (systemPrompt !== undefined) head.value.systemPrompt = systemPrompt; 237 | if (deletedMessages !== undefined) { 238 | const deletedMessagesSet = new Set(deletedMessages); 239 | head.value.messages = head.value.messages?.filter((x) => 240 | !deletedMessagesSet.has(x) 241 | ); 242 | 243 | // 1. All consecutive empty messages (`""`) should be reduced to one 244 | let lastMessage = ""; 245 | head.value.messages = head.value.messages?.filter((x) => { 246 | const keep = x !== "" || lastMessage !== ""; 247 | lastMessage = x; 248 | return keep; 249 | }); 250 | // 2. Trailing empty messages must be removed 251 | while ( 252 | head.value.messages && 253 | head.value.messages[head.value.messages.length - 1] === "" 254 | ) { 255 | head.value.messages.pop(); 256 | } 257 | } 258 | if (backend !== undefined) head.value.backend = backend; 259 | 260 | const { ok } = await kv.atomic().check(head).set(headKey, head.value) 261 | .commit(); 262 | if (!ok) return Response.json({ error: "conflict" }, { status: 409 }); 263 | 264 | return Response.json({ ok: true }); 265 | }, 266 | }; 267 | -------------------------------------------------------------------------------- /islands/ChatContent.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | MutableRef, 3 | useContext, 4 | useEffect, 5 | useMemo, 6 | useRef, 7 | useState, 8 | } from "preact/hooks"; 9 | import { 10 | ChatHead, 11 | ChatMessage, 12 | WorkspaceStateContext, 13 | } from "../lib/workspace.ts"; 14 | import { fetchOrError } from "../lib/fetch.ts"; 15 | 16 | export function ChatContent() { 17 | const state = useContext(WorkspaceStateContext)!; 18 | const currentHead = state.currentHead.value; 19 | if (!currentHead) { 20 | return ( 21 |
22 | Select a chat 23 |
24 | ); 25 | } 26 | return ( 27 |
28 | 29 |
30 | ); 31 | } 32 | 33 | function ChatContentForId({ chatId }: { chatId: string }) { 34 | const state = useContext(WorkspaceStateContext)!; 35 | const [head, setHead] = useState(null as ChatHead | null); 36 | const messageCache: MutableRef> = useRef(new Map()); 37 | const inputRef = useRef(null as HTMLTextAreaElement | null); 38 | const boundaryCheckboxRef = useRef(null as HTMLInputElement | null); 39 | const [availableBackends, setAvailableBackends] = useState([] as string[]); 40 | 41 | useEffect(() => { 42 | const sse = new EventSource(`/api/workspace/${state.id}/chats/${chatId}`); 43 | sse.onerror = (err) => { 44 | console.log("Connection Error"); 45 | sse.close(); 46 | }; 47 | 48 | sse.onmessage = (event) => { 49 | const body: { 50 | head: ChatHead; 51 | messages: ChatMessage[]; 52 | availableBackends: string[]; 53 | } = JSON.parse( 54 | event.data, 55 | ); 56 | for (const m of body.messages) { 57 | messageCache.current.set(m.id, m); 58 | } 59 | if (!body.head.backend) { 60 | body.head.backend = availableBackends[0]; 61 | } 62 | setHead(body.head); 63 | setAvailableBackends(body.availableBackends); 64 | }; 65 | 66 | return () => { 67 | sse.close(); 68 | }; 69 | }, [state.id, chatId]); 70 | 71 | const [editingTitle, setEditingTitle] = useState(null as string | null); 72 | const [editingSystemPrompt, setEditingSystemPrompt] = useState( 73 | null as 74 | | string 75 | | null, 76 | ); 77 | const bottomRef = useRef(null as HTMLDivElement | null); 78 | 79 | useEffect(() => { 80 | bottomRef.current?.scrollIntoView({ behavior: "instant" }); 81 | }, [head?.messages ? head.messages[head.messages.length - 1] : ""]); 82 | 83 | const backendSelector = useRef(null as HTMLSelectElement | null); 84 | 85 | const sendInput = async () => { 86 | const content = inputRef.current?.value?.trim() ?? ""; 87 | if (!content) return; 88 | 89 | await fetchOrError( 90 | `/api/workspace/${state.id}/chats/${chatId}`, 91 | { 92 | method: "POST", 93 | body: { 94 | text: content, 95 | boundary: !!boundaryCheckboxRef.current?.checked, 96 | }, 97 | }, 98 | ); 99 | 100 | if (inputRef.current) inputRef.current.value = ""; 101 | if (boundaryCheckboxRef.current) { 102 | boundaryCheckboxRef.current.checked = false; 103 | } 104 | }; 105 | 106 | if (!head) return
; 107 | return ( 108 |
109 |
113 |
114 |
115 | {editingTitle !== null 116 | ? ( 117 | { 122 | setEditingTitle((e.target as HTMLInputElement).value); 123 | }} 124 | onBlur={async () => { 125 | await fetchOrError( 126 | `/api/workspace/${state.id}/chats/${chatId}`, 127 | { 128 | method: "PATCH", 129 | body: { 130 | title: editingTitle, 131 | }, 132 | }, 133 | ); 134 | setEditingTitle(null); 135 | }} 136 | /> 137 | ) 138 | : ( 139 | { 142 | setEditingTitle(head.title); 143 | }} 144 | > 145 | {head.title} 146 | 147 | )} 148 |
149 |
150 |
151 | {new Date(head.timestamp).toLocaleString()} 152 |
153 | 154 |
155 | 180 |
181 |
182 |
183 | {editingSystemPrompt !== null 184 | ? ( 185 | { 190 | setEditingSystemPrompt( 191 | (e.target as HTMLInputElement).value, 192 | ); 193 | }} 194 | onBlur={async () => { 195 | await fetchOrError( 196 | `/api/workspace/${state.id}/chats/${chatId}`, 197 | { 198 | method: "PATCH", 199 | body: { 200 | systemPrompt: editingSystemPrompt, 201 | }, 202 | }, 203 | ); 204 | setEditingSystemPrompt(null); 205 | }} 206 | /> 207 | ) 208 | : ( 209 | { 212 | setEditingSystemPrompt(head.systemPrompt); 213 | }} 214 | > 215 | {head.systemPrompt} 216 | 217 | )} 218 |
219 |
220 | 221 |
222 | {head.messages?.map((id, i) => { 223 | if (!id) { 224 | // boundary 225 | return ( 226 |
227 | New conversation 228 |
229 | ); 230 | } 231 | const message = messageCache.current.get(id); 232 | if (!message) return null; 233 | return ( 234 | 240 | ); 241 | })} 242 |
243 |
244 |
245 |
246 |
247 |