feat: one gateway port with path-mounted frontends, markdown chat, collapsible sidebars
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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,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…
|
||||
|
||||
Reference in New Issue
Block a user