diff --git a/.changeset/chat-phase-one.md b/.changeset/chat-phase-one.md new file mode 100644 index 0000000..196cfd9 --- /dev/null +++ b/.changeset/chat-phase-one.md @@ -0,0 +1,5 @@ +--- +'contextvm-site': minor +--- + +Add the phase-one chat workspace with OpenRouter auto mode, provider and model configuration, streaming chat, and persistent conversations. diff --git a/bun.lock b/bun.lock index 90831c8..616eeac 100644 --- a/bun.lock +++ b/bun.lock @@ -22,6 +22,7 @@ "marked": "^17.0.5", "mode-watcher": "^1.1.0", "nostr-tools": "^2.23.3", + "openai": "^6.38.0", "rxjs": "^7.8.2", "zod": "^4.3.6", }, @@ -751,6 +752,8 @@ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "openai": ["openai@6.38.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-AoMplt2UalrpgUDMh3L09QWjNRlgJPipclQvA6sYAaeF6nHNBMgmikAZGmcYLn8on4d9sQY9Q8bOLfrBS7Lc8g=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "outdent": ["outdent@0.5.0", "", {}, "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q=="], diff --git a/package.json b/package.json index 2da256c..285a63d 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "marked": "^17.0.5", "mode-watcher": "^1.1.0", "nostr-tools": "^2.23.3", + "openai": "^6.38.0", "rxjs": "^7.8.2", "zod": "^4.3.6" } diff --git a/src/app.css b/src/app.css index e623f11..84ef932 100644 --- a/src/app.css +++ b/src/app.css @@ -132,6 +132,7 @@ pre { @apply overflow-x-auto rounded-lg bg-muted p-4; + max-width: 100%; } blockquote { @@ -435,6 +436,28 @@ animation: fade-in-up 0.8s ease-out; } +/* ============================================ + Chat Bubble Overflow Containment + ============================================ */ + +/* Force code blocks inside prose containers to respect parent width. +
has an intrinsic min-width equal to content; this resets it. */
+.prose pre,
+.prose .code-block {
+ min-width: 0;
+ max-width: 100%;
+}
+
+.prose .code-block > div {
+ min-width: 0;
+ max-width: 100%;
+}
+
+/* Code blocks inside chat bubbles: force the scrollable container
+ to compute its width from the bubble, not from its content.
+ The key trick is width:0 + min-width:100% on the scroll wrapper,
+ applied via inline styles in ChatBubble.svelte's renderer. */
+
/* ============================================
Mobile Optimizations
============================================ */
diff --git a/src/lib/components/chat/AutoModeBanner.svelte b/src/lib/components/chat/AutoModeBanner.svelte
new file mode 100644
index 0000000..af6c45f
--- /dev/null
+++ b/src/lib/components/chat/AutoModeBanner.svelte
@@ -0,0 +1,32 @@
+
+
+
diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte
new file mode 100644
index 0000000..b979948
--- /dev/null
+++ b/src/lib/components/chat/Chat.svelte
@@ -0,0 +1,434 @@
+
+
+
+ {#if autoModeEnabled}
+
+
+
+ {/if}
+
+ {#if !conversationId}
+
+
+ Choose a conversation or start a new one to begin chatting.
+
+
+ {:else if messages.length === 0}
+
+
+
+
+ CV
+
+
+
+ What can I help you build?
+
+ Orchestrate MCP servers, explore tools, and manage workflows — all from this chat.
+
+
+
+ {#each starterPrompts as prompt (prompt.text)}
+ {@const Icon = prompt.icon}
+
+ {/each}
+
+
+ Press Enter to send · Shift+Enter for new line
+
+
+ {:else}
+
+ {#each messages as message (message.id)}
+
+ {/each}
+
+ {/if}
+
+
+
+ {#if errorMessage}
+
+ {errorMessage}
+
+ {/if}
+
+
+
+
diff --git a/src/lib/components/chat/ChatBubble.svelte b/src/lib/components/chat/ChatBubble.svelte
new file mode 100644
index 0000000..2769b02
--- /dev/null
+++ b/src/lib/components/chat/ChatBubble.svelte
@@ -0,0 +1,124 @@
+
+
+
+
+
+ {#if message.role !== 'user'}
+
+ CV
+
+ {/if}
+
+
+
+ {#if message.role === 'user'}
+ {message.content}
+ {:else if message.role === 'assistant' && !message.content}
+
+
+
+
+
+ {:else}
+
+ {@html html}
+ {/if}
+
+ {#if timestampLabel}
+ {timestampLabel}
+ {/if}
+
+
diff --git a/src/lib/components/chat/ChatInput.svelte b/src/lib/components/chat/ChatInput.svelte
new file mode 100644
index 0000000..163195c
--- /dev/null
+++ b/src/lib/components/chat/ChatInput.svelte
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+
+ Enter to send
+ Shift+Enter for new line
+
+
diff --git a/src/lib/components/chat/ChatSidebar.svelte b/src/lib/components/chat/ChatSidebar.svelte
new file mode 100644
index 0000000..008001b
--- /dev/null
+++ b/src/lib/components/chat/ChatSidebar.svelte
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+ Conversations
+
+
+
+
+
+
+
+
+
diff --git a/src/lib/components/chat/ConversationList.svelte b/src/lib/components/chat/ConversationList.svelte
new file mode 100644
index 0000000..49b7b9a
--- /dev/null
+++ b/src/lib/components/chat/ConversationList.svelte
@@ -0,0 +1,299 @@
+
+
+
+ Conversations
+
+
+
+
+ {#if conversationStore.loading}
+
+ Loading conversations...
+
+ {:else if conversationStore.conversations.length === 0}
+
+
+
+
+
+ No conversations yet
+ Start a new chat to save it here.
+
+
+
+ {:else}
+
+ {#each conversationStore.conversations as conversation (conversation.id)}
+
+ {#if editingId === conversation.id}
+
+
+
+
+
+ {:else}
+
+
+
+
+
+ {/if}
+
+ {/each}
+
+ {/if}
+
+
+ {
+ deleteOpen = open;
+ if (!open) {
+ deleteTarget = null;
+ }
+ }}
+>
+
+
+ Delete conversation
+
+ This will permanently remove
+ {deleteTarget?.title ?? 'this conversation'} .
+
+
+
+
+
+
+
+
diff --git a/src/lib/components/chat/LLMConfig.svelte b/src/lib/components/chat/LLMConfig.svelte
new file mode 100644
index 0000000..f687289
--- /dev/null
+++ b/src/lib/components/chat/LLMConfig.svelte
@@ -0,0 +1,223 @@
+
+
+
+
+
+ Open settings
+
+
+
+ Chat settings
+
+ Choose the provider, model, and key used for this browser.
+
+
+
+
+
+
+ Provider
+ {selectedProvider?.label ?? config.provider}
+
+
+ Model
+ {modelStatus}
+
+
+ Key
+
+
+ {keyStatus}
+
+
+
+
+
+
+
+
+
+
+
+
+ Pasted completion endpoints are normalized automatically.
+
+
+
+
+
+ {#if config.provider === 'openrouter' && !usingDefaultKey}
+
+ {:else if usingDefaultKey}
+
+ The default key is public and limited to free-model usage.
+
+ {:else if selectedProvider?.requiresKey && !config.apiKey.trim()}
+ This provider needs your API key.
+ {/if}
+
+
+
+
+
+
+ Provider and model persist on this device.
+
+
+
+
+
diff --git a/src/lib/components/chat/ModelCombobox.svelte b/src/lib/components/chat/ModelCombobox.svelte
new file mode 100644
index 0000000..2f0da20
--- /dev/null
+++ b/src/lib/components/chat/ModelCombobox.svelte
@@ -0,0 +1,194 @@
+
+
+{#if status === 'error'}
+
+
+
+ {errorMessage ?? 'Model list unavailable. Enter the model id manually.'}
+
+
+{:else}
+
+
+ {selectedLabel}
+
+
+
+
+
+
+ {#if status === 'loading'}
+ Loading models...
+ {:else}
+ No models found.
+ {#if showAuto}
+
+ Auto
+
+ handleSelect('auto')}>
+
+ Auto (free models)
+
+
+
+ {/if}
+ {#if freeModels.length}
+
+ Free models
+
+ {#each freeModels as model (model)}
+ handleSelect(model)}>
+
+ {model}
+
+ {/each}
+
+
+ {/if}
+ {#if paidModels.length}
+
+ All models
+
+ {#each paidModels as model (model)}
+ handleSelect(model)}>
+
+ {model}
+
+ {/each}
+
+
+ {/if}
+ {/if}
+
+
+
+
+{/if}
diff --git a/src/lib/components/chat/ProviderCombobox.svelte b/src/lib/components/chat/ProviderCombobox.svelte
new file mode 100644
index 0000000..5ad1562
--- /dev/null
+++ b/src/lib/components/chat/ProviderCombobox.svelte
@@ -0,0 +1,62 @@
+
+
+
+
+ {selectedPreset?.label ?? 'Select provider'}
+
+
+
+
+
+
+ No providers found.
+
+
+ {#each PROVIDER_PRESETS as preset (preset.key)}
+ handleSelect(preset)}>
+
+
+ {preset.label}
+
+ {preset.baseURL || 'Custom base URL'}
+
+
+
+ {/each}
+
+
+
+
+
+
diff --git a/src/lib/components/header.svelte b/src/lib/components/header.svelte
index 32d0f71..65dbdd3 100644
--- a/src/lib/components/header.svelte
+++ b/src/lib/components/header.svelte
@@ -11,6 +11,7 @@
import * as Sheet from './ui/sheet/index.js';
const homeHref = $derived<`/`>('/');
+ const chatHref = $derived<`/chat`>('/chat');
const serversHref = $derived<`/servers`>('/servers');
const blogHref = $derived<`/blog`>('/blog');
const slidesHref = $derived<`/slides`>('/slides');
@@ -47,6 +48,14 @@