1<script lang="ts">
  2	import {
  3		ModelBadge,
  4		ChatMessageActions,
  5		ChatMessageStatistics,
  6		ChatMessageThinkingBlock,
  7		CopyToClipboardIcon,
  8		MarkdownContent,
  9		ModelsSelector
 10	} from '$lib/components/app';
 11	import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
 12	import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
 13	import { isLoading } from '$lib/stores/chat.svelte';
 14	import { autoResizeTextarea, copyToClipboard } from '$lib/utils';
 15	import { fade } from 'svelte/transition';
 16	import { Check, X, Wrench } from '@lucide/svelte';
 17	import { Button } from '$lib/components/ui/button';
 18	import { Checkbox } from '$lib/components/ui/checkbox';
 19	import { INPUT_CLASSES } from '$lib/constants/input-classes';
 20	import Label from '$lib/components/ui/label/label.svelte';
 21	import { config } from '$lib/stores/settings.svelte';
 22	import { conversationsStore } from '$lib/stores/conversations.svelte';
 23	import { isRouterMode } from '$lib/stores/server.svelte';
 24
 25	interface Props {
 26		class?: string;
 27		deletionInfo: {
 28			totalCount: number;
 29			userMessages: number;
 30			assistantMessages: number;
 31			messageTypes: string[];
 32		} | null;
 33		editedContent?: string;
 34		isEditing?: boolean;
 35		message: DatabaseMessage;
 36		messageContent: string | undefined;
 37		onCancelEdit?: () => void;
 38		onCopy: () => void;
 39		onConfirmDelete: () => void;
 40		onContinue?: () => void;
 41		onDelete: () => void;
 42		onEdit?: () => void;
 43		onEditKeydown?: (event: KeyboardEvent) => void;
 44		onEditedContentChange?: (content: string) => void;
 45		onNavigateToSibling?: (siblingId: string) => void;
 46		onRegenerate: (modelOverride?: string) => void;
 47		onSaveEdit?: () => void;
 48		onShowDeleteDialogChange: (show: boolean) => void;
 49		onShouldBranchAfterEditChange?: (value: boolean) => void;
 50		showDeleteDialog: boolean;
 51		shouldBranchAfterEdit?: boolean;
 52		siblingInfo?: ChatMessageSiblingInfo | null;
 53		textareaElement?: HTMLTextAreaElement;
 54		thinkingContent: string | null;
 55		toolCallContent: ApiChatCompletionToolCall[] | string | null;
 56	}
 57
 58	let {
 59		class: className = '',
 60		deletionInfo,
 61		editedContent = '',
 62		isEditing = false,
 63		message,
 64		messageContent,
 65		onCancelEdit,
 66		onConfirmDelete,
 67		onContinue,
 68		onCopy,
 69		onDelete,
 70		onEdit,
 71		onEditKeydown,
 72		onEditedContentChange,
 73		onNavigateToSibling,
 74		onRegenerate,
 75		onSaveEdit,
 76		onShowDeleteDialogChange,
 77		onShouldBranchAfterEditChange,
 78		showDeleteDialog,
 79		shouldBranchAfterEdit = false,
 80		siblingInfo = null,
 81		textareaElement = $bindable(),
 82		thinkingContent,
 83		toolCallContent = null
 84	}: Props = $props();
 85
 86	const toolCalls = $derived(
 87		Array.isArray(toolCallContent) ? (toolCallContent as ApiChatCompletionToolCall[]) : null
 88	);
 89	const fallbackToolCalls = $derived(typeof toolCallContent === 'string' ? toolCallContent : null);
 90
 91	const processingState = useProcessingState();
 92
 93	let currentConfig = $derived(config());
 94	let isRouter = $derived(isRouterMode());
 95	let displayedModel = $derived((): string | null => {
 96		if (message.model) {
 97			return message.model;
 98		}
 99
100		return null;
101	});
102
103	const { handleModelChange } = useModelChangeValidation({
104		getRequiredModalities: () => conversationsStore.getModalitiesUpToMessage(message.id),
105		onSuccess: (modelName) => onRegenerate(modelName)
106	});
107
108	function handleCopyModel() {
109		const model = displayedModel();
110
111		void copyToClipboard(model ?? '');
112	}
113
114	$effect(() => {
115		if (isEditing && textareaElement) {
116			autoResizeTextarea(textareaElement);
117		}
118	});
119
120	$effect(() => {
121		if (isLoading() && !message?.content?.trim()) {
122			processingState.startMonitoring();
123		}
124	});
125
126	function formatToolCallBadge(toolCall: ApiChatCompletionToolCall, index: number) {
127		const callNumber = index + 1;
128		const functionName = toolCall.function?.name?.trim();
129		const label = functionName || `Call #${callNumber}`;
130
131		const payload: Record<string, unknown> = {};
132
133		const id = toolCall.id?.trim();
134		if (id) {
135			payload.id = id;
136		}
137
138		const type = toolCall.type?.trim();
139		if (type) {
140			payload.type = type;
141		}
142
143		if (toolCall.function) {
144			const fnPayload: Record<string, unknown> = {};
145
146			const name = toolCall.function.name?.trim();
147			if (name) {
148				fnPayload.name = name;
149			}
150
151			const rawArguments = toolCall.function.arguments?.trim();
152			if (rawArguments) {
153				try {
154					fnPayload.arguments = JSON.parse(rawArguments);
155				} catch {
156					fnPayload.arguments = rawArguments;
157				}
158			}
159
160			if (Object.keys(fnPayload).length > 0) {
161				payload.function = fnPayload;
162			}
163		}
164
165		const formattedPayload = JSON.stringify(payload, null, 2);
166
167		return {
168			label,
169			tooltip: formattedPayload,
170			copyValue: formattedPayload
171		};
172	}
173
174	function handleCopyToolCall(payload: string) {
175		void copyToClipboard(payload, 'Tool call copied to clipboard');
176	}
177</script>
178
179<div
180	class="text-md group w-full leading-7.5 {className}"
181	role="group"
182	aria-label="Assistant message with actions"
183>
184	{#if thinkingContent}
185		<ChatMessageThinkingBlock
186			reasoningContent={thinkingContent}
187			isStreaming={!message.timestamp}
188			hasRegularContent={!!messageContent?.trim()}
189		/>
190	{/if}
191
192	{#if message?.role === 'assistant' && isLoading() && !message?.content?.trim()}
193		<div class="mt-6 w-full max-w-[48rem]" in:fade>
194			<div class="processing-container">
195				<span class="processing-text">
196					{processingState.getPromptProgressText() ?? processingState.getProcessingMessage()}
197				</span>
198			</div>
199		</div>
200	{/if}
201
202	{#if isEditing}
203		<div class="w-full">
204			<textarea
205				bind:this={textareaElement}
206				bind:value={editedContent}
207				class="min-h-[50vh] w-full resize-y rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}"
208				onkeydown={onEditKeydown}
209				oninput={(e) => {
210					autoResizeTextarea(e.currentTarget);
211					onEditedContentChange?.(e.currentTarget.value);
212				}}
213				placeholder="Edit assistant message..."
214			></textarea>
215
216			<div class="mt-2 flex items-center justify-between">
217				<div class="flex items-center space-x-2">
218					<Checkbox
219						id="branch-after-edit"
220						bind:checked={shouldBranchAfterEdit}
221						onCheckedChange={(checked) => onShouldBranchAfterEditChange?.(checked === true)}
222					/>
223					<Label for="branch-after-edit" class="cursor-pointer text-sm text-muted-foreground">
224						Branch conversation after edit
225					</Label>
226				</div>
227				<div class="flex gap-2">
228					<Button class="h-8 px-3" onclick={onCancelEdit} size="sm" variant="outline">
229						<X class="mr-1 h-3 w-3" />
230						Cancel
231					</Button>
232
233					<Button class="h-8 px-3" onclick={onSaveEdit} disabled={!editedContent?.trim()} size="sm">
234						<Check class="mr-1 h-3 w-3" />
235						Save
236					</Button>
237				</div>
238			</div>
239		</div>
240	{:else if message.role === 'assistant'}
241		{#if config().disableReasoningFormat}
242			<pre class="raw-output">{messageContent || ''}</pre>
243		{:else}
244			<MarkdownContent content={messageContent || ''} />
245		{/if}
246	{:else}
247		<div class="text-sm whitespace-pre-wrap">
248			{messageContent}
249		</div>
250	{/if}
251
252	<div class="info my-6 grid gap-4 tabular-nums">
253		{#if displayedModel()}
254			<div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground">
255				{#if isRouter}
256					<ModelsSelector
257						currentModel={displayedModel()}
258						onModelChange={handleModelChange}
259						disabled={isLoading()}
260						upToMessageId={message.id}
261					/>
262				{:else}
263					<ModelBadge model={displayedModel() || undefined} onclick={handleCopyModel} />
264				{/if}
265
266				{#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
267					<ChatMessageStatistics
268						promptTokens={message.timings.prompt_n}
269						promptMs={message.timings.prompt_ms}
270						predictedTokens={message.timings.predicted_n}
271						predictedMs={message.timings.predicted_ms}
272					/>
273				{:else if isLoading() && currentConfig.showMessageStats}
274					{@const liveStats = processingState.getLiveProcessingStats()}
275					{@const genStats = processingState.getLiveGenerationStats()}
276					{@const promptProgress = processingState.processingState?.promptProgress}
277					{@const isStillProcessingPrompt =
278						promptProgress && promptProgress.processed < promptProgress.total}
279
280					{#if liveStats || genStats}
281						<ChatMessageStatistics
282							isLive={true}
283							isProcessingPrompt={!!isStillProcessingPrompt}
284							promptTokens={liveStats?.tokensProcessed}
285							promptMs={liveStats?.timeMs}
286							predictedTokens={genStats?.tokensGenerated}
287							predictedMs={genStats?.timeMs}
288						/>
289					{/if}
290				{/if}
291			</div>
292		{/if}
293
294		{#if config().showToolCalls}
295			{#if (toolCalls && toolCalls.length > 0) || fallbackToolCalls}
296				<span class="inline-flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
297					<span class="inline-flex items-center gap-1">
298						<Wrench class="h-3.5 w-3.5" />
299
300						<span>Tool calls:</span>
301					</span>
302
303					{#if toolCalls && toolCalls.length > 0}
304						{#each toolCalls as toolCall, index (toolCall.id ?? `${index}`)}
305							{@const badge = formatToolCallBadge(toolCall, index)}
306							<button
307								type="button"
308								class="tool-call-badge inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75"
309								title={badge.tooltip}
310								aria-label={`Copy tool call ${badge.label}`}
311								onclick={() => handleCopyToolCall(badge.copyValue)}
312							>
313								{badge.label}
314								<CopyToClipboardIcon
315									text={badge.copyValue}
316									ariaLabel={`Copy tool call ${badge.label}`}
317								/>
318							</button>
319						{/each}
320					{:else if fallbackToolCalls}
321						<button
322							type="button"
323							class="tool-call-badge tool-call-badge--fallback inline-flex cursor-pointer items-center gap-1 rounded-sm bg-muted-foreground/15 px-1.5 py-0.75"
324							title={fallbackToolCalls}
325							aria-label="Copy tool call payload"
326							onclick={() => handleCopyToolCall(fallbackToolCalls)}
327						>
328							{fallbackToolCalls}
329							<CopyToClipboardIcon text={fallbackToolCalls} ariaLabel="Copy tool call payload" />
330						</button>
331					{/if}
332				</span>
333			{/if}
334		{/if}
335	</div>
336
337	{#if message.timestamp && !isEditing}
338		<ChatMessageActions
339			role="assistant"
340			justify="start"
341			actionsPosition="left"
342			{siblingInfo}
343			{showDeleteDialog}
344			{deletionInfo}
345			{onCopy}
346			{onEdit}
347			{onRegenerate}
348			onContinue={currentConfig.enableContinueGeneration && !thinkingContent
349				? onContinue
350				: undefined}
351			{onDelete}
352			{onConfirmDelete}
353			{onNavigateToSibling}
354			{onShowDeleteDialogChange}
355		/>
356	{/if}
357</div>
358
359<style>
360	.processing-container {
361		display: flex;
362		flex-direction: column;
363		align-items: flex-start;
364		gap: 0.5rem;
365	}
366
367	.processing-text {
368		background: linear-gradient(
369			90deg,
370			var(--muted-foreground),
371			var(--foreground),
372			var(--muted-foreground)
373		);
374		background-size: 200% 100%;
375		background-clip: text;
376		-webkit-background-clip: text;
377		-webkit-text-fill-color: transparent;
378		animation: shine 1s linear infinite;
379		font-weight: 500;
380		font-size: 0.875rem;
381	}
382
383	@keyframes shine {
384		to {
385			background-position: -200% 0;
386		}
387	}
388
389	.raw-output {
390		width: 100%;
391		max-width: 48rem;
392		margin-top: 1.5rem;
393		padding: 1rem 1.25rem;
394		border-radius: 1rem;
395		background: hsl(var(--muted) / 0.3);
396		color: var(--foreground);
397		font-family:
398			ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
399			'Liberation Mono', Menlo, monospace;
400		font-size: 0.875rem;
401		line-height: 1.6;
402		white-space: pre-wrap;
403		word-break: break-word;
404	}
405
406	.tool-call-badge {
407		max-width: 12rem;
408		white-space: nowrap;
409		overflow: hidden;
410		text-overflow: ellipsis;
411	}
412
413	.tool-call-badge--fallback {
414		max-width: 20rem;
415		white-space: normal;
416		word-break: break-word;
417	}
418</style>