Procházet zdrojové kódy

feat: add assistant

Thomas Zhang před 3 měsíci
rodič
revize
7f2a37578d

+ 141 - 0
src/lib/components/layout/assistant/assistant.svelte

@@ -0,0 +1,141 @@
+<script lang="ts">
+	import {
+		BotIcon,
+		MessageSquareOffIcon,
+		MessagesSquareIcon,
+		SendIcon,
+		UserIcon,
+	} from '@lucide/svelte';
+	import { tick } from 'svelte';
+	import { fly } from 'svelte/transition';
+
+	import Button from '$lib/components/controls/button/button.svelte';
+	import Textarea from '$lib/components/controls/textarea/textarea.svelte';
+
+	type Message = {
+		role: 'user' | 'assistant';
+		content: string;
+	};
+
+	let messages = $state<Message[]>([]);
+	let input = $state('');
+	let viewport = $state<HTMLDivElement | null>(null);
+
+	const scrollToBottom = async () => {
+		await tick();
+		if (viewport) viewport.scrollTop = viewport.scrollHeight;
+	};
+
+	const send = () => {
+		const content = input.trim();
+		if (!content) return;
+		messages.push({ role: 'user', content });
+		input = '';
+		scrollToBottom();
+		setTimeout(() => {
+			messages.push({
+				role: 'assistant',
+				content: 'This is a placeholder reply — the assistant is not wired to a backend yet.',
+			});
+			scrollToBottom();
+		}, 300);
+	};
+
+	const clear = () => {
+		messages = [];
+	};
+
+	const onKeydown = (e: KeyboardEvent) => {
+		if (e.key === 'Enter' && !e.shiftKey) {
+			e.preventDefault();
+			send();
+		}
+	};
+</script>
+
+<aside
+	class="flex h-svh w-full flex-col overflow-hidden border-l border-white/10 bg-background/20 backdrop-blur-lg"
+>
+	<!-- Header -->
+	<div class="flex h-12 shrink-0 items-center gap-2 border-b border-white/10 bg-white/5 px-4">
+		<MessagesSquareIcon class="shrink-0 text-primary" size={20} strokeWidth={3} />
+		<span class="flex-1 truncate text-sm font-medium text-white/90">Assistant</span>
+		<Button
+			disabled={messages.length === 0}
+			size="icon-sm"
+			title="Clear"
+			variant="ghost"
+			onclick={clear}
+		>
+			<MessageSquareOffIcon class="text-white/50" size={14} />
+		</Button>
+	</div>
+
+	<!-- Messages -->
+	<div bind:this={viewport} class="flex-1 overflow-y-auto p-4">
+		{#if messages.length === 0}
+			<div class="flex h-full flex-col items-center justify-center gap-2 text-center">
+				<MessagesSquareIcon class="text-white/20" size={28} />
+				<p class="truncate text-xs text-white/40">Ask the assistant anything.</p>
+			</div>
+		{:else}
+			<ul class="flex flex-col gap-3">
+				{#each messages as message, i (i)}
+					<li
+						class="flex gap-2"
+						class:flex-row-reverse={message.role === 'user'}
+						in:fly={{ y: 8, duration: 150 }}
+					>
+						<div
+							class={[
+								'flex size-6 shrink-0 items-center justify-center rounded-full',
+								message.role === 'user'
+									? 'text-primary-300 bg-primary/20'
+									: 'bg-white/10 text-white/70',
+							]}
+						>
+							{#if message.role === 'user'}
+								<UserIcon size={12} />
+							{:else}
+								<BotIcon size={12} />
+							{/if}
+						</div>
+						<div
+							class={[
+								'max-w-[80%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap',
+								message.role === 'user'
+									? 'bg-primary/20 text-white/90'
+									: 'bg-white/5 text-white/80',
+							]}
+						>
+							{message.content}
+						</div>
+					</li>
+				{/each}
+			</ul>
+		{/if}
+	</div>
+
+	<!-- Composer -->
+	<div class="shrink-0 border-t border-white/10 bg-white/5 p-3">
+		<div class="relative">
+			<Textarea
+				class="max-h-40 min-h-16 resize-none border-white/10 bg-white/5 pr-10 text-sm text-white/90 placeholder:text-white/30"
+				rows={2}
+				bind:value={input}
+				onkeydown={onKeydown}
+			/>
+			<Button
+				class="absolute right-1 bottom-1"
+				disabled={input.trim().length === 0}
+				size="icon-sm"
+				title="Send"
+				variant="ghost"
+				onclick={send}
+			>
+				<SendIcon class="text-primary" size={14} />
+			</Button>
+		</div>
+		<p class="mt-1 truncate text-[10px] text-white/30">Enter to send, Shift+Enter for newline.</p>
+	</div>
+</aside>

+ 3 - 0
src/lib/components/layout/assistant/index.ts

@@ -0,0 +1,3 @@
+import Assistant from './assistant.svelte';
+
+export { Assistant };

+ 17 - 0
src/lib/components/layout/top/bar.svelte

@@ -1,13 +1,17 @@
 <script lang="ts">
+	import { MessagesSquare } from '@lucide/svelte';
 	import type { Snippet } from 'svelte';
 
 	import { resolve } from '$app/paths';
 
 	import logo from '$lib/assets/logo.svg';
 	import * as Breadcrumb from '$lib/components/controls/breadcrumb';
+	import { Button } from '$lib/components/controls/button';
+	import { getAssistantContext } from '$lib/stores/assistant.svelte';
 	import { getBreadcrumbContext } from '$lib/stores/breadcrumb.svelte';
 
 	const breadcrumb = getBreadcrumbContext();
+	const assistant = getAssistantContext();
 
 	let { side }: { side: Snippet } = $props();
 	let userMenuOpen = $state(false);
@@ -61,4 +65,17 @@
 			</Breadcrumb.List>
 		</Breadcrumb.Root>
 	</div>
+
+	<!-- Right: assistant toggle -->
+	<!-- disabled for now -->
+	<div class="mr-2 hidden shrink-0">
+		<Button
+			size="icon-sm"
+			title="Assistant"
+			variant="ghost"
+			onclick={() => (assistant.current.shown = !assistant.current.shown)}
+		>
+			<MessagesSquare class="text-white/50" size={14} />
+		</Button>
+	</div>
 </header>

+ 33 - 0
src/lib/stores/assistant.svelte.ts

@@ -0,0 +1,33 @@
+import { getContext, setContext } from 'svelte';
+
+type Assistant = {
+	shown: boolean;
+	messages: string[];
+};
+
+const KEY = Symbol('assistant');
+
+const createAssistantState = () => {
+	let current = $state<Assistant>({ shown: false, messages: [] });
+	return {
+		get current() {
+			return current;
+		},
+		set: (value: Assistant) => {
+			current = value;
+		},
+	};
+};
+
+type AssistantState = ReturnType<typeof createAssistantState>;
+
+const setAssistantContext = (): AssistantState => {
+	const state = createAssistantState();
+	setContext(KEY, state);
+	return state;
+};
+
+const getAssistantContext = (): AssistantState => getContext<AssistantState>(KEY);
+
+export type { Assistant, AssistantState };
+export { getAssistantContext, setAssistantContext };

+ 13 - 0
src/routes/+layout.svelte

@@ -5,8 +5,10 @@
 
 	import logo from '$lib/assets/logo.svg';
 	import * as Sidebar from '$lib/components/controls/sidebar';
+	import { Assistant } from '$lib/components/layout/assistant';
 	import { Sidebar as AppSidebar } from '$lib/components/layout/side';
 	import { TopBar } from '$lib/components/layout/top';
+	import { getAssistantContext, setAssistantContext } from '$lib/stores/assistant.svelte';
 	import { setBreadcrumbContext } from '$lib/stores/breadcrumb.svelte';
 
 	import type { LayoutData } from './$types';
@@ -17,6 +19,9 @@
 	const isAuthPage = $derived(page.url.pathname.startsWith('/auth'));
 
 	setBreadcrumbContext();
+	setAssistantContext();
+
+	let assistant = getAssistantContext();
 </script>
 
 <svelte:head>
@@ -60,6 +65,14 @@
 						{@render children()}
 					</div>
 				</main>
+				<div
+					class={[
+						'relative flex-none overflow-hidden transition-all duration-300',
+						assistant.current.shown ? 'w-80' : 'w-0',
+					]}
+				>
+					<Assistant />
+				</div>
 			</div>
 		</Sidebar.Provider>
 	{/if}