|
@@ -0,0 +1,208 @@
|
|
|
|
|
+<script lang="ts">
|
|
|
|
|
+ import { MessageSquareOff, Send, Square } from '@lucide/svelte';
|
|
|
|
|
+
|
|
|
|
|
+ import * as agentsApi from '$lib/api/agents';
|
|
|
|
|
+ import { GlassPane } from '$lib/components/common/glass-pane';
|
|
|
|
|
+ import { Button } from '$lib/components/controls/button';
|
|
|
|
|
+ import { Textarea } from '$lib/components/controls/textarea';
|
|
|
|
|
+ import type { Agent } from '$lib/types/entities';
|
|
|
|
|
+
|
|
|
|
|
+ let { workspaceId, agent }: { workspaceId: string; agent: Agent } = $props();
|
|
|
|
|
+
|
|
|
|
|
+ let systemPrompt = $state('');
|
|
|
|
|
+ let messages = $state<agentsApi.ChatMessage[]>([]);
|
|
|
|
|
+ let inputText = $state('');
|
|
|
|
|
+ let chatLoading = $state(false);
|
|
|
|
|
+ let chatError = $state<string | null>(null);
|
|
|
|
|
+ let chatAbortController = $state<AbortController | null>(null);
|
|
|
|
|
+ let threadEndEl = $state<HTMLDivElement | undefined>(undefined);
|
|
|
|
|
+
|
|
|
|
|
+ $effect(() => {
|
|
|
|
|
+ messages = [];
|
|
|
|
|
+ chatError = null;
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ $effect(() => {
|
|
|
|
|
+ // Access every message's content so this re-runs during streaming.
|
|
|
|
|
+ for (const m of messages) void m.content;
|
|
|
|
|
+ threadEndEl?.scrollIntoView({ block: 'nearest' });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const sendChat = async () => {
|
|
|
|
|
+ if (!agent || !inputText.trim() || chatLoading) return;
|
|
|
|
|
+
|
|
|
|
|
+ const userText = inputText.trim();
|
|
|
|
|
+ inputText = '';
|
|
|
|
|
+ chatError = null;
|
|
|
|
|
+
|
|
|
|
|
+ const outMessages: agentsApi.ChatMessage[] = [
|
|
|
|
|
+ ...(systemPrompt.trim() ? [{ role: 'system' as const, content: systemPrompt.trim() }] : []),
|
|
|
|
|
+ ...messages,
|
|
|
|
|
+ { role: 'user', content: userText },
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ messages.push({ role: 'user', content: userText });
|
|
|
|
|
+ const assistantIdx = messages.length;
|
|
|
|
|
+ messages.push({ role: 'assistant', content: '' });
|
|
|
|
|
+
|
|
|
|
|
+ chatLoading = true;
|
|
|
|
|
+ const ctrl = new AbortController();
|
|
|
|
|
+ chatAbortController = ctrl;
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await agentsApi.testChatCompletion(
|
|
|
|
|
+ workspaceId,
|
|
|
|
|
+ agent.id,
|
|
|
|
|
+ {
|
|
|
|
|
+ messages: outMessages,
|
|
|
|
|
+ },
|
|
|
|
|
+ ctrl.signal,
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ if (!res.ok) {
|
|
|
|
|
+ const errBody = (await res.json().catch(() => ({ message: res.statusText }))) as {
|
|
|
|
|
+ message?: string;
|
|
|
|
|
+ };
|
|
|
|
|
+ throw new Error(errBody.message ?? res.statusText);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const reader = res.body!.getReader();
|
|
|
|
|
+ const decoder = new TextDecoder();
|
|
|
|
|
+ let buf = '';
|
|
|
|
|
+
|
|
|
|
|
+ for (;;) {
|
|
|
|
|
+ const { done, value } = await reader.read();
|
|
|
|
|
+ if (done) break;
|
|
|
|
|
+ buf += decoder.decode(value, { stream: true });
|
|
|
|
|
+ const lines = buf.split('\n');
|
|
|
|
|
+ buf = lines.pop() ?? '';
|
|
|
|
|
+
|
|
|
|
|
+ for (const line of lines) {
|
|
|
|
|
+ if (!line.startsWith('data: ')) continue;
|
|
|
|
|
+ const chunk = line.slice(6).trim();
|
|
|
|
|
+ if (chunk === '[DONE]') continue;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const parsed = JSON.parse(chunk) as {
|
|
|
|
|
+ choices?: [{ delta?: { content?: string } }];
|
|
|
|
|
+ };
|
|
|
|
|
+ const delta = parsed.choices?.[0]?.delta?.content ?? '';
|
|
|
|
|
+ if (delta) messages[assistantIdx].content += delta;
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // skip malformed chunk
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ if (err instanceof Error && err.name === 'AbortError') return;
|
|
|
|
|
+ messages.splice(assistantIdx, 1);
|
|
|
|
|
+ chatError = err instanceof Error ? err.message : 'An error occurred';
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ chatLoading = false;
|
|
|
|
|
+ chatAbortController = null;
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const stopChat = () => {
|
|
|
|
|
+ chatAbortController?.abort();
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const clearSession = () => {
|
|
|
|
|
+ messages = [];
|
|
|
|
|
+ systemPrompt = '';
|
|
|
|
|
+ chatError = null;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const onChatKeydown = (e: KeyboardEvent) => {
|
|
|
|
|
+ if (e.key === 'Enter' && !e.shiftKey) {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ sendChat();
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<GlassPane class="overflow-hidden bg-black/25">
|
|
|
|
|
+ <div class="bg-white/5 p-4">
|
|
|
|
|
+ <div class="flex items-center justify-between">
|
|
|
|
|
+ <p class="text-lg font-thin">Chat</p>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ class="text-white/50"
|
|
|
|
|
+ size="sm"
|
|
|
|
|
+ title="Clear session"
|
|
|
|
|
+ variant="ghost"
|
|
|
|
|
+ onclick={() => clearSession()}
|
|
|
|
|
+ >
|
|
|
|
|
+ <MessageSquareOff size={18} strokeWidth={2} />
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Message thread -->
|
|
|
|
|
+ <div class="h-96 overflow-y-auto rounded-lg p-4">
|
|
|
|
|
+ {#each messages as msg, i (i)}
|
|
|
|
|
+ {#if msg.role === 'user'}
|
|
|
|
|
+ <div class="mb-3 flex justify-end">
|
|
|
|
|
+ <div
|
|
|
|
|
+ class="max-w-[80%] rounded-2xl rounded-tr-sm bg-primary/40 px-4 py-2 text-sm text-white/90"
|
|
|
|
|
+ >
|
|
|
|
|
+ {msg.content}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {:else if msg.role === 'assistant'}
|
|
|
|
|
+ <div class="mb-3 flex justify-start">
|
|
|
|
|
+ <div
|
|
|
|
|
+ class="max-w-[80%] rounded-2xl rounded-tl-sm border border-white/10 bg-white/5 px-4 py-2 text-sm text-white/80"
|
|
|
|
|
+ >
|
|
|
|
|
+ {#if msg.content}
|
|
|
|
|
+ <pre class="font-sans whitespace-pre-wrap">{msg.content}</pre>
|
|
|
|
|
+ {:else}
|
|
|
|
|
+ <span class="animate-pulse text-white/30">▋</span>
|
|
|
|
|
+ {/if}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {/if}
|
|
|
|
|
+ {:else}
|
|
|
|
|
+ <div class="flex h-full items-center justify-center">
|
|
|
|
|
+ <p class="text-sm text-white/50">Send a message to start the conversation.</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {/each}
|
|
|
|
|
+
|
|
|
|
|
+ {#if chatError}
|
|
|
|
|
+ <div class="mt-2 rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-red-300">
|
|
|
|
|
+ {chatError}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {/if}
|
|
|
|
|
+
|
|
|
|
|
+ <div bind:this={threadEndEl}></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="shrink-0 bg-white/5 px-4 pt-4 pb-2">
|
|
|
|
|
+ <Textarea
|
|
|
|
|
+ class="max-h-40 min-h-16 border-white/10 bg-white/5 pr-10 text-sm text-white/90 placeholder:text-white/30"
|
|
|
|
|
+ rows={2}
|
|
|
|
|
+ bind:value={inputText}
|
|
|
|
|
+ onkeydown={(e) => onChatKeydown(e)}
|
|
|
|
|
+ />
|
|
|
|
|
+ <div class="mt-2 flex items-start justify-between">
|
|
|
|
|
+ <p class="truncate text-xs text-white/30">Enter to send, Shift+Enter for newline.</p>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ disabled={inputText.trim().length === 0}
|
|
|
|
|
+ size="icon"
|
|
|
|
|
+ title={chatLoading ? 'Stop' : 'Send'}
|
|
|
|
|
+ variant="ghost"
|
|
|
|
|
+ onclick={() => {
|
|
|
|
|
+ if (chatLoading) {
|
|
|
|
|
+ stopChat();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ sendChat();
|
|
|
|
|
+ }
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ {#if chatLoading}
|
|
|
|
|
+ <Square size={18} strokeWidth={2} />
|
|
|
|
|
+ {:else}
|
|
|
|
|
+ <Send class="text-primary" size={18} strokeWidth={2} />
|
|
|
|
|
+ {/if}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+</GlassPane>
|