feat: one gateway port with path-mounted frontends, markdown chat, collapsible sidebars

This commit is contained in:
hh
2026-08-29 00:49:06 +02:00
parent 3ca5843868
commit 932a7a80af
32 changed files with 691 additions and 441 deletions
+8
View File
@@ -4,6 +4,10 @@
"workspaces": {
"": {
"name": "ui",
"dependencies": {
"dompurify": "^3.4.14",
"marked": "^18.0.11",
},
"devDependencies": {
"@biomejs/biome": "2.5.9",
"@fontsource-variable/inter": "^5.3.0",
@@ -230,6 +234,8 @@
"devalue": ["devalue@5.9.2", "", {}, "sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w=="],
"dompurify": ["dompurify@3.4.14", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg=="],
"empathic": ["empathic@2.0.1", "", {}, "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q=="],
"enhanced-resolve": ["enhanced-resolve@5.24.5", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A=="],
@@ -330,6 +336,8 @@
"magicast": ["magicast@0.5.4", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w=="],
"marked": ["marked@18.0.11", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
+4
View File
@@ -13,6 +13,10 @@
"prepare": "svelte-kit sync || echo ''",
"preview": "vite preview"
},
"dependencies": {
"dompurify": "^3.4.14",
"marked": "^18.0.11"
},
"devDependencies": {
"@biomejs/biome": "2.5.9",
"@fontsource-variable/inter": "^5.3.0",
+2 -2
View File
@@ -127,9 +127,9 @@ export interface FrontendInfo {
default_agents: Record<string, string>;
kinds: Kind[];
name: string;
port: number | null;
public_base_url: string | null;
path: string | null;
type: string;
url: string | null;
}
export interface AgentsResponse {
+89 -27
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import LogOutIcon from "@lucide/svelte/icons/log-out";
import PanelLeftCloseIcon from "@lucide/svelte/icons/panel-left-close";
import PanelLeftOpenIcon from "@lucide/svelte/icons/panel-left-open";
import { goto } from "$app/navigation";
import { base } from "$app/paths";
import { page } from "$app/state";
@@ -9,9 +11,11 @@
import { gateway } from "$lib/gateway.svelte";
import { isActive, NAV } from "$lib/nav";
import { session } from "$lib/session.svelte";
import { ui } from "$lib/ui.svelte";
import { cn } from "$lib/utils";
const running = $derived(gateway.running.length);
const open = $derived(ui.nav);
async function signOut() {
await session.logout();
@@ -20,56 +24,114 @@
</script>
<aside
class="hidden w-52 shrink-0 flex-col border-r bg-sidebar text-sidebar-foreground sm:flex"
class={cn(
"hidden shrink-0 flex-col border-r bg-sidebar text-sidebar-foreground transition-[width] duration-150 sm:flex",
open ? "w-52" : "w-12"
)}
>
<a
class="flex h-12 items-center gap-2 border-b px-4 font-semibold tracking-tight"
class={cn(
"flex h-12 items-center gap-2 border-b font-semibold tracking-tight",
open ? "px-4" : "justify-center"
)}
href="{base}/"
title="Beaver"
>
<span class="size-2.5 rounded-sm bg-primary"></span>
Beaver
<span class="size-2.5 shrink-0 rounded-sm bg-primary"></span>
{#if open}
Beaver
{/if}
</a>
<nav aria-label="Sections" class="flex flex-1 flex-col gap-0.5 p-2">
{#each NAV as item (item.href)}
{@const active = isActive(page.url.pathname, base, item.href)}
<a
aria-current={active ? "page" : undefined}
aria-label={item.label}
class={cn(
"flex h-8 items-center gap-2.5 rounded-md px-2.5 text-sm transition-colors",
"relative flex h-8 items-center gap-2.5 rounded-md text-sm transition-colors",
open ? "px-2.5" : "justify-center",
active
? "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
: "text-sidebar-foreground/80 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground"
)}
href="{base}{item.href}"
title={open ? undefined : item.label}
>
<item.icon class="size-4 shrink-0 text-icon" />
<span class="flex-1">{item.label}</span>
{#if open}
<span class="flex-1">{item.label}</span>
{/if}
{#if item.href === "/" && running > 0}
<span
class="tabular rounded-full bg-signal/12 px-1.5 font-medium text-signal text-xs"
>
{running}
</span>
{#if open}
<span
class="tabular rounded-full bg-signal/12 px-1.5 font-medium text-signal text-xs"
>
{running}
</span>
{:else}
<span
class="absolute top-1 right-1 size-1.5 rounded-full bg-signal"
></span>
{/if}
{/if}
</a>
{/each}
</nav>
<div class="flex flex-col gap-2 border-t p-3">
<LiveDot detail={gateway.live.detail} state={gateway.live.state} />
<div class="flex items-center justify-between gap-2">
<span class="truncate text-muted-foreground text-xs">{session.user}</span>
<div class="flex items-center">
<ThemeToggle />
<Button
aria-label="Sign out"
onclick={signOut}
size="icon-sm"
title="Sign out"
variant="ghost"
>
<LogOutIcon class="size-4" />
</Button>
<div
class={cn(
"flex flex-col gap-2 border-t",
open ? "p-3" : "items-center p-2"
)}
>
<LiveDot
detail={gateway.live.detail}
label={open}
state={gateway.live.state}
/>
{#if open}
<div class="flex items-center justify-between gap-2">
<span class="truncate text-muted-foreground text-xs">
{session.user}
</span>
<div class="flex items-center">
<ThemeToggle />
<Button
aria-label="Sign out"
onclick={signOut}
size="icon-sm"
title="Sign out"
variant="ghost"
>
<LogOutIcon class="size-4" />
</Button>
</div>
</div>
</div>
{:else}
<ThemeToggle />
<Button
aria-label="Sign out"
onclick={signOut}
size="icon-sm"
title="Sign out"
variant="ghost"
>
<LogOutIcon class="size-4" />
</Button>
{/if}
<Button
aria-label={open ? "Collapse sidebar" : "Expand sidebar"}
class={open ? "self-end" : ""}
onclick={() => ui.toggleNav()}
size="icon-sm"
title="{open ? 'Collapse' : 'Expand'} sidebar (⌘B)"
variant="ghost"
>
{#if open}
<PanelLeftCloseIcon class="size-4" />
{:else}
<PanelLeftOpenIcon class="size-4" />
{/if}
</Button>
</div>
</aside>
@@ -1,4 +1,5 @@
<script lang="ts">
import PanelLeftCloseIcon from "@lucide/svelte/icons/panel-left-close";
import PlusIcon from "@lucide/svelte/icons/plus";
import { toast } from "svelte-sonner";
import { goto } from "$app/navigation";
@@ -17,6 +18,7 @@
import { clip, fmtRelative, shortId } from "$lib/format";
import { gateway } from "$lib/gateway.svelte";
import { session } from "$lib/session.svelte";
import { ui } from "$lib/ui.svelte";
import { cn } from "$lib/utils";
let { selected = null }: { selected?: string | null } = $props();
@@ -162,6 +164,16 @@
<Button aria-label="New conversation" onclick={openCreate} size="icon-sm">
<PlusIcon class="size-4" />
</Button>
<Button
aria-label="Hide conversations"
class="hidden lg:inline-flex"
onclick={() => ui.toggleRail()}
size="icon-sm"
title="Hide conversations"
variant="ghost"
>
<PanelLeftCloseIcon class="size-4" />
</Button>
</div>
<div class="min-h-0 flex-1 overflow-y-auto">
{#if gateway.live.state === "failed"}
+6 -10
View File
@@ -22,12 +22,10 @@
}
function baseOf(fe: FrontendInfo): string {
if (fe.public_base_url) {
return fe.public_base_url;
if (fe.url) {
return fe.url;
}
return fe.port
? `${window.location.protocol}//${window.location.hostname}:${fe.port}`
: "";
return fe.path ? `${window.location.origin}${fe.path}` : "";
}
function describe(fe: FrontendInfo): Group {
@@ -92,14 +90,12 @@
{
hint: "Obsidian plugin → API origin; the admin uses it too. Routes live under /api/…",
label: "api origin",
url: fe.public_base_url
? fe.public_base_url.replace(API_SUFFIX, "")
: base,
url: base.replace(API_SUFFIX, ""),
},
{
hint: "SSE of the whole gateway; per conversation: /api/conversations/{id}/events",
label: "events",
url: `${fe.public_base_url ? fe.public_base_url.replace(API_SUFFIX, "") : base}/api/events`,
url: `${base}/events`,
},
],
frontend: fe,
@@ -114,7 +110,7 @@
case "AdminFrontend":
return {
endpoints: [
{ hint: "this console", label: "admin", url: `${base}/admin/` },
{ hint: "this console", label: "admin", url: `${base}/` },
],
frontend: fe,
note: "",
+5 -5
View File
@@ -10,6 +10,7 @@
import { cn } from "$lib/utils";
import type { ActivityModel } from "./activity.svelte";
import { summarizeInput, toolLabel } from "./activity.svelte";
import Markdown from "./markdown.svelte";
import QuestionCard from "./question-card.svelte";
import TurnCard from "./turn-card.svelte";
@@ -159,14 +160,13 @@
>
{#each parts as block, blockIndex (blockIndex)}
{#if block.type === "text" && block.text}
<p
<Markdown
class={cn(
"max-w-[75ch] whitespace-pre-wrap rounded-lg px-3 py-2 text-sm",
"max-w-[75ch] rounded-lg px-3 py-2",
message.role === "user" ? "bg-primary/10" : "bg-muted/50"
)}
>
{block.text}
</p>
text={block.text}
/>
{:else if block.type === "tool_use"}
<p class="flex items-baseline gap-2 px-1 text-xs">
<span class="font-medium">{toolLabel(block.name ?? "?")}</span>
+22 -1
View File
@@ -15,7 +15,7 @@
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import * as Select from "$lib/components/ui/select";
import { fmtRelative, shortId } from "$lib/format";
import { clip, fmtRelative, shortId } from "$lib/format";
let {
client,
@@ -43,6 +43,9 @@
let branchText = $state("");
let newTitle = $state("");
const WINDOW_MAX = 56;
const windows = $derived(info.bindings.filter((b) => b.visible));
const SEEDS = [
{ label: "clean - empty context", value: "clean" },
{ label: "copy - copy of this history", value: "copy" },
@@ -193,7 +196,25 @@
<span class="tabular">
last activity {fmtRelative(info.last_activity_at)}
</span>
{#if info.queue.length > 0}
<span class="tabular font-medium text-note">
{info.queue.length}
queued
</span>
{/if}
</div>
{#if windows.length > 0}
<div
class="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-muted-foreground text-xs"
>
{#each windows as window (window.frontend + window.external_id)}
<span class="truncate" title="{window.frontend}: {window.external_id}">
<span class="text-foreground/70">{window.frontend}</span>
{clip(window.external_id, WINDOW_MAX)}
</span>
{/each}
</div>
{/if}
</header>
<Dialog.Root bind:open={branchOpen}>
+46 -63
View File
@@ -18,13 +18,11 @@
id,
href,
onOpen,
compact = false,
}: {
client: ApiClient;
id: string;
href: (id: string) => string;
onOpen: (id: string) => void;
compact?: boolean;
} = $props();
const feed = new ConversationFeed(
@@ -74,73 +72,58 @@
onChanged={refresh}
{onOpen}
/>
<div
class="grid min-h-0 flex-1 grid-cols-1 {compact
? ''
: 'lg:grid-cols-[minmax(0,1fr)_18rem]'}"
>
<div class="flex min-h-0 flex-col">
<Tabs.Root class="flex min-h-0 flex-1 flex-col gap-0" bind:value={tab}>
<Tabs.List class="mx-4 mt-2 w-fit sm:mx-6">
<Tabs.Trigger value="chat">Chat</Tabs.Trigger>
<Tabs.Trigger value="activity">Activity</Tabs.Trigger>
<Tabs.Trigger value="raw">Raw</Tabs.Trigger>
<Tabs.Trigger class={compact ? "" : "lg:hidden"} value="meta">
Meta
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content class="flex min-h-0 flex-1 flex-col" value="chat">
<ChatView
{client}
conversationId={id}
model={feed.model}
refreshKey={historyKey}
/>
</Tabs.Content>
<Tabs.Content
class="min-h-0 flex-1 overflow-y-auto px-4 py-3 sm:px-6"
value="activity"
>
<ActivityFeed
{client}
connected={feed.live.state === "open"}
conversationId={id}
model={feed.model}
/>
</Tabs.Content>
<Tabs.Content
class="min-h-0 flex-1 overflow-y-auto px-4 py-3 sm:px-6"
value="raw"
>
<RawEntries {client} conversationId={id} />
</Tabs.Content>
<Tabs.Content
class="min-h-0 flex-1 overflow-y-auto px-4 py-3 sm:px-6"
value="meta"
>
{@render rail()}
</Tabs.Content>
</Tabs.Root>
<Composer
{client}
conversationId={id}
disabled={info.status !== "open"}
/>
</div>
{#if !compact}
<aside
class="hidden min-h-0 overflow-y-auto border-l bg-sidebar/50 p-4 lg:block"
<div class="flex min-h-0 flex-1 flex-col">
<Tabs.Root class="flex min-h-0 flex-1 flex-col gap-0" bind:value={tab}>
<Tabs.List class="mx-4 mt-2 w-fit sm:mx-6">
<Tabs.Trigger value="chat">Chat</Tabs.Trigger>
<Tabs.Trigger value="activity">Activity</Tabs.Trigger>
<Tabs.Trigger value="raw">Raw</Tabs.Trigger>
<Tabs.Trigger value="meta">Meta</Tabs.Trigger>
</Tabs.List>
<Tabs.Content class="flex min-h-0 flex-1 flex-col" value="chat">
<ChatView
{client}
conversationId={id}
model={feed.model}
refreshKey={historyKey}
/>
</Tabs.Content>
<Tabs.Content
class="min-h-0 flex-1 overflow-y-auto px-4 py-3 sm:px-6"
value="activity"
>
{@render rail()}
</aside>
{/if}
<ActivityFeed
{client}
connected={feed.live.state === "open"}
conversationId={id}
model={feed.model}
/>
</Tabs.Content>
<Tabs.Content
class="min-h-0 flex-1 overflow-y-auto px-4 py-3 sm:px-6"
value="raw"
>
<RawEntries {client} conversationId={id} />
</Tabs.Content>
<Tabs.Content
class="min-h-0 flex-1 overflow-y-auto px-4 py-3 sm:px-6"
value="meta"
>
{@render meta()}
</Tabs.Content>
</Tabs.Root>
<Composer
{client}
conversationId={id}
disabled={info.status !== "open"}
/>
</div>
{/if}
</div>
{#snippet rail()}
{#snippet meta()}
{#if info}
<div class="flex flex-col gap-5">
<div class="flex max-w-3xl flex-col gap-5">
<section class="flex flex-col gap-1.5">
<h2
class="font-medium text-muted-foreground text-xs uppercase tracking-wide"
+14
View File
@@ -0,0 +1,14 @@
<script lang="ts">
import { cn } from "$lib/utils";
import { renderMarkdown } from "./markdown";
let { text, class: className = "" }: { text: string; class?: string } =
$props();
const html = $derived(renderMarkdown(text));
</script>
<div class={cn("prose prose-sm max-w-none", className)}>
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized in renderMarkdown -->
{@html html}
</div>
+18
View File
@@ -0,0 +1,18 @@
import DOMPurify from "dompurify";
import { marked } from "marked";
marked.use({ breaks: true, gfm: true });
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
if (node.tagName === "A") {
node.setAttribute("target", "_blank");
node.setAttribute("rel", "noopener");
}
});
// The one place chat text becomes HTML. The Obsidian panel swaps this for
// Obsidian's own MarkdownRenderer; the admin uses marked + DOMPurify.
export function renderMarkdown(text: string): string {
const html = marked.parse(text, { async: false });
return DOMPurify.sanitize(html, { ADD_ATTR: ["target"] });
}
+10 -10
View File
@@ -10,6 +10,7 @@
} from "$lib/format";
import { cn } from "$lib/utils";
import type { Turn } from "./activity.svelte";
import Markdown from "./markdown.svelte";
import ToolNodeView from "./tool-node.svelte";
let { turn, now }: { turn: Turn; now: number } = $props();
@@ -65,11 +66,10 @@
{/if}
</header>
{#if turn.userText}
<p
class="mx-1 rounded-md bg-muted/50 px-2.5 py-1.5 text-sm whitespace-pre-wrap"
>
{turn.userText}
</p>
<Markdown
class="mx-1 rounded-md bg-muted/50 px-2.5 py-1.5"
text={turn.userText}
/>
{/if}
{#if turn.roots.length > 0}
<div class="flex flex-col">
@@ -81,13 +81,13 @@
</div>
{/if}
{#each turn.says as text, index (index)}
<p class="mx-1 flex gap-2 rounded-md bg-primary/8 px-2.5 py-1.5 text-sm">
<MessageSquareIcon class="mt-0.5 size-3.5 shrink-0 text-link" />
<span class="whitespace-pre-wrap">{text}</span>
</p>
<div class="mx-1 flex gap-2 rounded-md bg-primary/8 px-2.5 py-1.5">
<MessageSquareIcon class="mt-1 size-3.5 shrink-0 text-link" />
<Markdown class="min-w-0 flex-1" {text} />
</div>
{/each}
{#if turn.text}
<p class="mx-1 px-1 text-sm whitespace-pre-wrap">{turn.text}</p>
<Markdown class="mx-1 px-1" text={turn.text} />
{:else if turn.status === "running" && turn.roots.length === 0 && turn.thinking === 0}
<p class="mx-1 px-1 text-muted-foreground text-sm">
Waiting for the model…
+1 -2
View File
@@ -2,7 +2,6 @@ import { base } from "$app/paths";
import { ApiClient } from "./api/client";
interface SessionPayload {
api_base: string | null;
token: string;
user: string;
}
@@ -37,7 +36,7 @@ class Session {
return;
}
this.user = payload.user;
this.apiBase = payload.api_base ?? window.location.origin;
this.apiBase = window.location.origin;
this.client = new ApiClient(this.apiBase, payload.token, {
onUnauthorized: () => this.refreshToken(),
});
+44
View File
@@ -0,0 +1,44 @@
import { browser } from "$app/environment";
const NAV_KEY = "beaver.ui.nav";
const RAIL_KEY = "beaver.ui.rail";
function stored(key: string, fallback: boolean): boolean {
if (!browser) {
return fallback;
}
const raw = localStorage.getItem(key);
return raw === null ? fallback : raw === "1";
}
function store(key: string, value: boolean): void {
if (browser) {
localStorage.setItem(key, value ? "1" : "0");
}
}
// Chrome state: the section sidebar and the conversation rail. Explicit
// toggles persist; the automatic collapse on the conversations pages does
// not, so leaving them restores what the user had.
class Ui {
nav = $state(stored(NAV_KEY, true));
rail = $state(stored(RAIL_KEY, true));
setNav(open: boolean, persist = false): void {
this.nav = open;
if (persist) {
store(NAV_KEY, open);
}
}
toggleNav(): void {
this.setNav(!this.nav, true);
}
toggleRail(): void {
this.rail = !this.rail;
store(RAIL_KEY, this.rail);
}
}
export const ui = new Ui();
+29 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import "./layout.css";
import { ModeWatcher } from "mode-watcher";
import { onMount } from "svelte";
import { onMount, untrack } from "svelte";
import { goto } from "$app/navigation";
import { base } from "$app/paths";
import { page } from "$app/state";
@@ -12,10 +12,15 @@
import * as Tooltip from "$lib/components/ui/tooltip";
import { gateway } from "$lib/gateway.svelte";
import { session } from "$lib/session.svelte";
import { ui } from "$lib/ui.svelte";
let { children } = $props();
const isLogin = $derived(page.url.pathname === `${base}/login`);
const inConversations = $derived(
page.url.pathname.startsWith(`${base}/conversations`)
);
let navBefore: boolean | null = null;
onMount(() => {
session.check();
@@ -38,8 +43,31 @@
return () => gateway.stop();
}
});
$effect(() => {
if (inConversations) {
untrack(() => {
if (navBefore === null) {
navBefore = ui.nav;
ui.setNav(false);
}
});
} else if (navBefore !== null) {
ui.setNav(navBefore);
navBefore = null;
}
});
function onKeydown(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key === "b") {
event.preventDefault();
ui.toggleNav();
}
}
</script>
<svelte:window onkeydown={onKeydown} />
<svelte:head><link href={favicon} rel="icon"></svelte:head>
<ModeWatcher />
+43 -3
View File
@@ -1,26 +1,66 @@
<script lang="ts">
import PanelLeftOpenIcon from "@lucide/svelte/icons/panel-left-open";
import { page } from "$app/state";
import ConversationList from "$lib/components/conversation-list.svelte";
import { Button } from "$lib/components/ui/button";
import { gateway } from "$lib/gateway.svelte";
import { ui } from "$lib/ui.svelte";
import { cn } from "$lib/utils";
let { children } = $props();
const selected = $derived(page.params.id ?? null);
const running = $derived(gateway.running.length);
</script>
<svelte:head><title>Conversations · Beaver</title></svelte:head>
<div class="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[22rem_minmax(0,1fr)]">
<div
class={cn(
"grid min-h-0 flex-1 grid-cols-1",
ui.rail
? "lg:grid-cols-[22rem_minmax(0,1fr)]"
: "lg:grid-cols-[2.75rem_minmax(0,1fr)]"
)}
>
<div
class={cn(
"min-h-0 border-r bg-sidebar/40",
selected ? "hidden lg:block" : "block"
)}
>
<ConversationList {selected} />
{#if ui.rail}
<ConversationList {selected} />
{:else}
<div class="hidden h-full flex-col items-center gap-2 py-2 lg:flex">
<Button
aria-label="Show conversations"
onclick={() => ui.toggleRail()}
size="icon-sm"
title="Show conversations"
variant="ghost"
>
<PanelLeftOpenIcon class="size-4" />
</Button>
{#if running > 0}
<span
class="tabular rounded-full bg-signal/12 px-1.5 font-medium text-signal text-xs"
title="{running} running"
>
{running}
</span>
{/if}
</div>
<div class="h-full lg:hidden">
<ConversationList {selected} />
</div>
{/if}
</div>
<div
class={cn("min-h-0", selected ? "flex flex-col" : "hidden lg:flex lg:flex-col")}
class={cn(
"min-h-0",
selected ? "flex flex-col" : "hidden lg:flex lg:flex-col"
)}
>
{@render children()}
</div>
+57
View File
@@ -297,3 +297,60 @@
@utility hairline {
border-color: color-mix(in oklab, var(--border) 100%, transparent);
}
/* chat markdown: typography plugin on the theme tokens, compact rhythm */
.prose {
--tw-prose-body: var(--foreground);
--tw-prose-headings: var(--foreground);
--tw-prose-lead: var(--muted-foreground);
--tw-prose-links: var(--link);
--tw-prose-bold: var(--foreground);
--tw-prose-counters: var(--muted-foreground);
--tw-prose-bullets: var(--muted-foreground);
--tw-prose-hr: var(--border);
--tw-prose-quotes: var(--foreground);
--tw-prose-quote-borders: var(--border);
--tw-prose-captions: var(--muted-foreground);
--tw-prose-code: var(--foreground);
--tw-prose-pre-code: var(--foreground);
--tw-prose-pre-bg: var(--muted);
--tw-prose-th-borders: var(--border);
--tw-prose-td-borders: var(--border);
font-size: 0.875rem;
line-height: 1.5;
}
.prose
:where(p, ul, ol, pre, blockquote, table):not(
:where([class~="not-prose"] *)
) {
margin-block: 0.4em;
}
.prose :where(h1, h2, h3, h4):not(:where([class~="not-prose"] *)) {
margin-block: 0.8em 0.4em;
font-size: 1em;
font-weight: 600;
}
.prose :where(li):not(:where([class~="not-prose"] *)) {
margin-block: 0.15em;
}
.prose :where(code):not(:where([class~="not-prose"] *))::before,
.prose :where(code):not(:where([class~="not-prose"] *))::after {
content: none;
}
.prose :where(code):not(:where(pre *)):not(:where([class~="not-prose"] *)) {
padding: 0.1em 0.3em;
font-weight: 500;
background: color-mix(in oklch, var(--muted) 70%, transparent);
border-radius: 0.25rem;
}
.prose :where(pre):not(:where([class~="not-prose"] *)) {
padding: 0.6em 0.8em;
font-size: 0.8125rem;
border-radius: 0.375rem;
}
.prose > :first-child {
margin-top: 0;
}
.prose > :last-child {
margin-bottom: 0;
}
+3 -4
View File
@@ -3,8 +3,7 @@ import { sveltekit } from "@sveltejs/kit/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
const adminOrigin = process.env.ADMIN_ORIGIN ?? "http://127.0.0.1:62992";
const apiOrigin = process.env.API_ORIGIN ?? "http://127.0.0.1:62994";
const gatewayOrigin = process.env.GATEWAY_ORIGIN ?? "http://127.0.0.1:62990";
export default defineConfig({
plugins: [
@@ -22,8 +21,8 @@ export default defineConfig({
],
server: {
proxy: {
"/admin/auth": adminOrigin,
"/api": apiOrigin,
"/admin/auth": gatewayOrigin,
"/api": gatewayOrigin,
},
},
});