Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"@inlang/paraglide-js": "^2.2.0",
"@internationalized/date": "^3.10.0",
"@langchain/core": "^1.1.31",
"@langchain/langgraph-sdk": "^1.6.5",
"@langchain/langgraph-sdk": "^1.7.2",
"@lucide/svelte": "^0.562.0",
"@playwright/test": "^1.49.1",
"@sveltejs/adapter-node": "^5.2.12",
Expand Down Expand Up @@ -64,6 +64,7 @@
"vitest": "^3.0.0"
},
"dependencies": {
"@langchain/svelte": "^0.1.3",
"@sentry/sveltekit": "^10.0.0",
"mode-watcher": "^1.1.0"
}
Expand Down
8 changes: 6 additions & 2 deletions apps/frontend/src/lib/components/AIMessageActions.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { CopyButton } from '$lib/components/ui/copy-button';
import { Button } from '$lib/components/ui/button';
import { RefreshCw } from '@lucide/svelte';
import type { BaseMessage } from '$lib/langgraph/types';
import type { BaseMessage } from '@langchain/core/messages';
import * as m from '$lib/paraglide/messages.js';
import { Tooltip, TooltipTrigger, TooltipContent } from '$lib/components/ui/tooltip/index.js';
import FeedbackButtons from './FeedbackButtons.svelte';
Expand All @@ -17,6 +17,10 @@
let { message, isHovered, onRegenerate, onFeedback }: Props = $props();
let copySuccess = $state(false);
let copyTimeoutId: ReturnType<typeof setTimeout> | null = null;

function getContent(msg: BaseMessage): string {
return typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
}
</script>

<div
Expand All @@ -26,7 +30,7 @@
<Tooltip disableCloseOnTriggerClick>
<TooltipTrigger>
<CopyButton
text={message.text}
text={getContent(message)}
variant="ghost"
size="icon-sm"
class="h-6 w-6"
Expand Down
186 changes: 51 additions & 135 deletions apps/frontend/src/lib/components/Chat.svelte
Original file line number Diff line number Diff line change
@@ -1,17 +1,11 @@
<script lang="ts">
import { Client, type HumanMessage, type Thread } from '@langchain/langgraph-sdk';
import { streamAnswer } from '$lib/langgraph/streamAnswer.js';
import { convertThreadMessages } from '$lib/langgraph/utils.js';
import { untrack } from 'svelte';
import { useStream } from '@langchain/svelte';
import type { Client, Thread } from '@langchain/langgraph-sdk';
import type { ThreadValues } from '$lib/langgraph/types';
import ChatInput from './ChatInput.svelte';
import ChatMessages from './ChatMessages.svelte';
import ChatSuggestions, { type ChatSuggestion } from './ChatSuggestions.svelte';
import type { Message, UserMessage, ThreadValues } from '$lib/langgraph/types';
import { error } from '@sveltejs/kit';
import { onMount } from 'svelte';

// Configuration: Keep this simple for now, will update for performance-oriented
// lazy loaded or context loaded messages
const MAX_MESSAGES_TO_LOAD = 100;

interface Props {
langGraphClient: Client;
Expand All @@ -32,135 +26,56 @@
}: Props = $props();

let current_input = $state('');
let is_streaming = $state(false);
let final_answer_started = $state(false);
let messages = $state<Array<Message>>([]);
let chat_started = $state(false);
let generationError = $state<Error | null>(null);
let last_user_message = $state<string>('');

let generateController: AbortController | null;

// Load existing messages from thread on component initialization
onMount(() => {
if (thread?.values?.messages && thread.values.messages.length > 0) {
// Only load the last MAX_MESSAGES_TO_LOAD messages
const lastMessages = thread.values.messages.slice(-MAX_MESSAGES_TO_LOAD);
const loadedMessages = convertThreadMessages(lastMessages);

if (loadedMessages.length > 0) {
messages = loadedMessages;
chat_started = true;
// If we have existing messages, the final answer already started
final_answer_started = true;
}
// untrack: useStream is initialized once per component instance.
// Chat remounts when thread changes (route-level navigation), so capturing
// initial values here is intentional.
//
// initialValues pre-populates messages from the already-fetched thread state.
// useStream doesn't fetch history on mount, so we seed it with page-fetched data.
// After the first stream completes, mutate() is called internally to load real
// history for branching.
const { messages, isLoading, error, submit, stop, getMessagesMetadata, switchThread } = useStream(
{
client: untrack(() => langGraphClient),
assistantId: untrack(() => assistantId),
fetchStateHistory: true,
messagesKey: 'messages',
initialValues: untrack(() => (thread.values as Record<string, unknown>) ?? {})
}
});
);

function updateMessages(chunk: Message) {
console.debug('Processing chunk in inputSubmit:', chunk);
// useStream v0.1.3 ignores options.threadId — the internal store is always
// initialized as undefined. switchThread() is the only way to pre-set it so
// submit() uses the existing thread instead of creating a new one.
switchThread(untrack(() => thread.thread_id));

// Look for existing message with same id
const messageIndex = messages.findIndex((m) => m.id === chunk.id);
let chat_started = $derived($messages.length > 0 || $isLoading);

if (messageIndex == -1) {
// New message
messages.push(chunk);
} else {
// Update existing message
const existing = messages[messageIndex];
if (chunk.text) {
existing.text += chunk.text;
}

if (existing.type == 'tool' && 'status' in chunk) {
existing.status = chunk.status;
}
}

if (!final_answer_started && chunk.type == 'ai' && chunk.text) final_answer_started = true;

// Trigger reactivity
messages = [...messages];
async function submitInput() {
if (!current_input.trim()) return;
const text = current_input;
current_input = '';
await submit({ messages: [{ type: 'human', content: text, id: crypto.randomUUID() }] });
}

async function submitInputOrRetry(isRetry = false) {
if (current_input) {
chat_started = true;

let messageText: string;
let messageId: string;

if (!isRetry) {
// New message: create and push to messages array
const userMessage: UserMessage = {
type: 'user',
text: current_input,
id: crypto.randomUUID()
};
messages.push(userMessage);
last_user_message = current_input; // Store for retry
messageText = userMessage.text;
messageId = userMessage.id;
} else {
// Retry: reuse existing message
const lastUserMsg = messages.findLast((m) => m.type === 'user');
if (!lastUserMsg || !lastUserMsg.text || !lastUserMsg.id) {
error(500, {
message: 'Retry attempted but no or invalid user message found'
});
}
messageText = lastUserMsg.text;
messageId = lastUserMsg.id;
}
async function handleEditMessage(messageId: string, newText: string) {
const msg = $messages.find((m) => m.id === messageId);
if (!msg) return;

current_input = '';

is_streaming = true;
final_answer_started = false;
generationError = null; // Clear previous errors

generateController = new AbortController();
const signal = generateController.signal;

const inputMessage: HumanMessage = { type: 'human', content: messageText, id: messageId };

try {
for await (const chunk of streamAnswer(
langGraphClient,
thread.thread_id,
assistantId,
inputMessage,
signal
))
updateMessages(chunk);
} catch (e) {
// Aborted by user, ignore.
if (e instanceof DOMException && e.name === 'AbortError') return;

if (e instanceof Error) generationError = e;
error(500, {
message: 'Error during generation'
});
} finally {
is_streaming = false;
}
}
const metadata = getMessagesMetadata(msg);
await submit(
{ messages: [{ type: 'human', content: newText, id: crypto.randomUUID() }] },
{ checkpoint: metadata?.firstSeenState?.parent_checkpoint }
);
}

function retryGeneration() {
if (last_user_message) {
current_input = last_user_message;
submitInputOrRetry(true);
}
}
async function retryGeneration() {
const lastHuman = [...$messages].reverse().find((m) => m.getType() === 'human');
if (!lastHuman) return;

async function stopGeneration() {
if (!generateController)
throw Error(
'Unable to cancel null generateController. This is a bug! Was a generation running? Was an abort controller passed?'
);
generateController.abort();
const metadata = getMessagesMetadata(lastHuman);
await submit(undefined, { checkpoint: metadata?.firstSeenState?.parent_checkpoint });
}
</script>

Expand All @@ -173,22 +88,23 @@
{intro}
onSuggestionClick={(suggestedText) => {
current_input = suggestedText;
submitInputOrRetry();
submitInput();
}}
/>
{:else}
<ChatMessages
{messages}
finalAnswerStarted={final_answer_started}
{generationError}
messages={$messages}
generationError={$error instanceof Error ? $error : null}
onRetryError={retryGeneration}
onEditSave={handleEditMessage}
isLoading={$isLoading}
/>
{/if}
</div>
<ChatInput
bind:value={current_input}
isStreaming={is_streaming}
onSubmit={submitInputOrRetry}
onStop={() => stopGeneration()}
isStreaming={$isLoading}
onSubmit={submitInput}
onStop={() => stop()}
/>
</div>
67 changes: 55 additions & 12 deletions apps/frontend/src/lib/components/ChatMessage.svelte
Original file line number Diff line number Diff line change
@@ -1,29 +1,38 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Textarea } from '$lib/components/ui/textarea';
import { User } from '@lucide/svelte';
import type { BaseMessage } from '$lib/langgraph/types';
import type { BaseMessage } from '@langchain/core/messages';
import Markdown from 'svelte-exmarkdown';
import { gfmPlugin } from 'svelte-exmarkdown/gfm';
import AIMessageActions from './AIMessageActions.svelte';
import UserMessageActions from './UserMessageActions.svelte';

interface Props {
message: BaseMessage;
onEdit?: (message: BaseMessage) => void;
onRegenerate?: (message: BaseMessage) => void;
onFeedback?: (message: BaseMessage, type: 'up' | 'down') => void;
isLoading?: boolean;
onEditSave?: (messageId: string, newText: string) => void;
}

let { message, onEdit, onRegenerate, onFeedback }: Props = $props();
let { message, isLoading = false, onEditSave }: Props = $props();

const plugins = [gfmPlugin()];

function getContent(msg: BaseMessage): string {
return typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
}

let isHovered = $state(false);
let isEditing = $state(false);
let editText = $state(getContent(message));
</script>

<div class="mb-6 w-full {message.type === 'user' ? 'flex justify-end' : 'flex justify-start'}">
<div
class="mb-6 w-full {message.getType() === 'human' ? 'flex justify-end' : 'flex justify-start'}"
>
<div
class="flex items-start gap-3 {message.type === 'user'
class="flex items-start gap-3 {message.getType() === 'human'
? 'max-w-[70%] flex-row-reverse'
: 'max-w-[80%]'}"
>
Expand All @@ -37,22 +46,56 @@
onmouseleave={() => (isHovered = false)}
class="relative w-full"
>
{#if message.type === 'ai'}
{#if message.getType() === 'ai'}

Check failure on line 49 in apps/frontend/src/lib/components/ChatMessage.svelte

View workflow job for this annotation

GitHub Actions / CI Job

src/lib/components/ChatMessage.svelte.test.ts > ChatMessage > when rendering a user message > displays the message text

TypeError: $$props.message.getType is not a function in <unknown> in ChatMessage.svelte in tooltip-provider.svelte in tooltip-provider.svelte in TestProviders.svelte ❯ src/lib/components/ChatMessage.svelte:49:18 ❯ src/lib/components/ChatMessage.svelte:324:34 ❯ ChatMessage src/lib/components/ChatMessage.svelte:323:25

Check failure on line 49 in apps/frontend/src/lib/components/ChatMessage.svelte

View workflow job for this annotation

GitHub Actions / CI Job

src/lib/components/ChatMessage.svelte.test.ts > ChatMessage > when rendering a user message > renders the message group container

TypeError: $$props.message.getType is not a function in <unknown> in ChatMessage.svelte in tooltip-provider.svelte in tooltip-provider.svelte in TestProviders.svelte ❯ src/lib/components/ChatMessage.svelte:49:18 ❯ src/lib/components/ChatMessage.svelte:324:34 ❯ ChatMessage src/lib/components/ChatMessage.svelte:323:25

Check failure on line 49 in apps/frontend/src/lib/components/ChatMessage.svelte

View workflow job for this annotation

GitHub Actions / CI Job

src/lib/components/ChatMessage.svelte.test.ts > ChatMessage > when rendering an AI message > displays the message text

TypeError: $$props.message.getType is not a function in <unknown> in ChatMessage.svelte in tooltip-provider.svelte in tooltip-provider.svelte in TestProviders.svelte ❯ src/lib/components/ChatMessage.svelte:49:18 ❯ src/lib/components/ChatMessage.svelte:324:34 ❯ ChatMessage src/lib/components/ChatMessage.svelte:323:25

Check failure on line 49 in apps/frontend/src/lib/components/ChatMessage.svelte

View workflow job for this annotation

GitHub Actions / CI Job

src/lib/components/ChatMessage.svelte.test.ts > ChatMessage > when rendering an AI message > renders the message group container

TypeError: $$props.message.getType is not a function in <unknown> in ChatMessage.svelte in tooltip-provider.svelte in tooltip-provider.svelte in TestProviders.svelte ❯ src/lib/components/ChatMessage.svelte:49:18 ❯ src/lib/components/ChatMessage.svelte:324:34 ❯ ChatMessage src/lib/components/ChatMessage.svelte:323:25
<Card.Root class="border-border-card bg-muted border shadow-sm">
<Card.Content class="prose prose-gray dark:prose-invert max-w-none text-sm">
<Markdown md={message.text} {plugins} />
<Markdown md={getContent(message)} {plugins} />
</Card.Content>
</Card.Root>
<AIMessageActions {message} {isHovered} {onRegenerate} {onFeedback} />
<AIMessageActions {message} {isHovered} />
{:else if isEditing}
<div class="w-full space-y-2">
<Textarea bind:value={editText} class="min-h-24 text-sm" />
<div class="flex justify-end gap-2">
Comment on lines +57 to +59
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactor. long enough that it deserves it's own space

<Button
size="sm"
variant="outline"
onclick={() => {
isEditing = false;
editText = getContent(message);
}}
>
Cancel
</Button>
<Button
size="sm"
onclick={() => {
if (onEditSave && editText.trim() && message.id) {
onEditSave(message.id, editText);
isEditing = false;
}
}}
>
Save & Rerun
</Button>
</div>
</div>
{:else}
<Card.Root class="bg-foreground border-0 shadow-sm">
<Card.Content
class="prose prose-invert text-background max-w-none text-sm whitespace-pre-wrap"
>
{message.text}
{getContent(message)}
</Card.Content>
</Card.Root>
<UserMessageActions {message} {isHovered} {onEdit} />
<UserMessageActions
{isHovered}
{isLoading}
onEdit={() => {
isEditing = true;
editText = getContent(message);
}}
/>
{/if}
</div>
</div>
Expand Down
Loading
Loading