New tools rendering (#2005)
Browse files* Optimize message update handling and rendering
Refactors ChatMessage.svelte to interleave tool and stream updates for more accurate message rendering, consolidates consecutive stream tokens, and updates ToolUpdate.svelte styles for improved spacing. Also optimizes update handling in both the client and server to merge consecutive stream updates, reducing unnecessary array growth and improving performance.
* Improve message updates reactivity and storage efficiency
In the Svelte page, message update merging now creates new objects and arrays to ensure UI reactivity when streaming tokens are merged. On the server, ephemeral stream tokens and keep-alive updates are filtered out before persisting messages to the database, reducing payload size and improving rehydration performance.
* Improve chat message block handling and update persistence
Refactors ChatMessage.svelte to better handle interleaved tool and stream updates, ensuring correct text chunking and fallback behavior. Updates the conversation server logic to persist lightweight stream update markers (with token length only) instead of full tokens, preserving update order without duplicating content and improving rehydration performance.
* Improve type safety and token handling in chat messages
Adds explicit 'as const' type annotations for message block types in ChatMessage.svelte to improve type safety. Fixes token merging in conversation page to handle undefined tokens safely. Updates server logic to preserve existing token length if already present, preventing recomputation and ensuring correct ordering.
* Update ChatMessage.svelte
* Refactor chat block UI and improve tool/result handling
Introduces BlockWrapper.svelte for consistent block layout and connectors in chat UI. Refactors ToolUpdate and OpenReasoningResults to use BlockWrapper, improving expand/collapse behavior and visual feedback for loading, errors, and results. Updates message streaming logic to support compressed tokens with length markers, and cleans up related code in ChatMessage and server logic for more reliable rendering and update merging.
* Refine chat block styling and tool update UI
Improves visual spacing and block linking in chat components, updates icon and ring color logic for tool updates, and refines expandable content UI. Removes unused imports and adjusts styles for better consistency and clarity.
* Update tool status icon and loading color
Replaces the hammer icon with a check icon when a tool update is successful in ToolUpdate.svelte. Adjusts the loading path color opacity in BlockWrapper.svelte for improved visual feedback.
* Update error color classes for consistency
Adjusted Tailwind CSS classes for error backgrounds and borders to use more consistent red shades and opacity in both light and dark modes. This improves visual clarity and maintains a uniform appearance for error states.
* Update +page.svelte
* Update +page.svelte
* Refactor chat components for improved reasoning UI
Replaces global <think> block regex usage with a non-global version to avoid lastIndex side effects. Updates OpenReasoningResults to auto-expand on initial loading and improves collapsed preview by stripping markdown. Removes unused navigation props and UI from ToolUpdate for cleaner code.
* Update OpenReasoningResults.svelte
* Refine chat UI sizing and color styles
Adjusted icon and spacing sizes in BlockWrapper.svelte for improved alignment. Updated ToolUpdate.svelte to use more consistent gray text colors. Increased heading font sizes for .prose-sm in main.css from 55% to 75% of original for better readability.
* Update BlockWrapper.svelte
- src/lib/components/chat/BlockWrapper.svelte +72 -0
- src/lib/components/chat/ChatMessage.svelte +122 -76
- src/lib/components/chat/OpenReasoningResults.svelte +63 -68
- src/lib/components/chat/ToolUpdate.svelte +161 -216
- src/lib/types/MessageUpdate.ts +2 -0
- src/routes/conversation/[id]/+page.svelte +24 -10
- src/routes/conversation/[id]/+server.ts +24 -4
- src/styles/main.css +7 -7
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script lang="ts">
|
| 2 |
+
import type { Snippet } from "svelte";
|
| 3 |
+
|
| 4 |
+
interface Props {
|
| 5 |
+
icon: Snippet;
|
| 6 |
+
iconBg?: string;
|
| 7 |
+
iconRing?: string;
|
| 8 |
+
hasNext?: boolean;
|
| 9 |
+
loading?: boolean;
|
| 10 |
+
children: Snippet;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
let {
|
| 14 |
+
icon,
|
| 15 |
+
iconBg = "bg-gray-50 dark:bg-gray-800",
|
| 16 |
+
iconRing = "ring-gray-100 dark:ring-gray-700",
|
| 17 |
+
hasNext = false,
|
| 18 |
+
loading = false,
|
| 19 |
+
children,
|
| 20 |
+
}: Props = $props();
|
| 21 |
+
</script>
|
| 22 |
+
|
| 23 |
+
<div class="group flex gap-2 has-[+.prose]:mb-1.5 [.prose+&]:mt-3">
|
| 24 |
+
<!-- Left column: icon + connector line -->
|
| 25 |
+
<div class="flex w-[22px] flex-shrink-0 flex-col items-center">
|
| 26 |
+
<div
|
| 27 |
+
class="relative z-0 flex h-[22px] w-[22px] items-center justify-center rounded-md ring-1 {iconBg} {iconRing}"
|
| 28 |
+
>
|
| 29 |
+
{@render icon()}
|
| 30 |
+
{#if loading}
|
| 31 |
+
<svg
|
| 32 |
+
class="pointer-events-none absolute inset-0 h-[22px] w-[22px]"
|
| 33 |
+
viewBox="0 0 22 22"
|
| 34 |
+
fill="none"
|
| 35 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 36 |
+
>
|
| 37 |
+
<rect
|
| 38 |
+
x="0.5"
|
| 39 |
+
y="0.5"
|
| 40 |
+
width="21"
|
| 41 |
+
height="21"
|
| 42 |
+
rx="5.5"
|
| 43 |
+
class="loading-path stroke-current text-purple-500/20"
|
| 44 |
+
stroke-width="1"
|
| 45 |
+
fill="none"
|
| 46 |
+
/>
|
| 47 |
+
</svg>
|
| 48 |
+
{/if}
|
| 49 |
+
</div>
|
| 50 |
+
{#if hasNext}
|
| 51 |
+
<div class="my-1 w-px flex-1 bg-gray-200 dark:bg-gray-700"></div>
|
| 52 |
+
{/if}
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
<!-- Right column: content -->
|
| 56 |
+
<div class="min-w-0 flex-1 pb-2 pt-px">
|
| 57 |
+
{@render children()}
|
| 58 |
+
</div>
|
| 59 |
+
</div>
|
| 60 |
+
|
| 61 |
+
<style>
|
| 62 |
+
@keyframes loading {
|
| 63 |
+
to {
|
| 64 |
+
stroke-dashoffset: -100;
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
.loading-path {
|
| 69 |
+
stroke-dasharray: 60 40;
|
| 70 |
+
animation: loading 2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
| 71 |
+
}
|
| 72 |
+
</style>
|
|
@@ -20,6 +20,7 @@
|
|
| 20 |
import { requireAuthUser } from "$lib/utils/auth";
|
| 21 |
import ToolUpdate from "./ToolUpdate.svelte";
|
| 22 |
import { isMessageToolUpdate } from "$lib/utils/messageUpdates";
|
|
|
|
| 23 |
|
| 24 |
interface Props {
|
| 25 |
message: Message;
|
|
@@ -83,6 +84,10 @@
|
|
| 83 |
const wrapper = document.createElement("div");
|
| 84 |
wrapper.appendChild(range.cloneContents());
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
wrapper.querySelectorAll("*").forEach((el) => {
|
| 87 |
el.removeAttribute("style");
|
| 88 |
el.removeAttribute("class");
|
|
@@ -110,6 +115,8 @@
|
|
| 110 |
|
| 111 |
// Zero-config reasoning autodetection: detect <think> blocks in content
|
| 112 |
const THINK_BLOCK_REGEX = /(<think>[\s\S]*?(?:<\/think>|$))/gi;
|
|
|
|
|
|
|
| 113 |
let hasClientThink = $derived(message.content.split(THINK_BLOCK_REGEX).length > 1);
|
| 114 |
|
| 115 |
// Strip think blocks for clipboard copy (always, regardless of detection)
|
|
@@ -117,39 +124,85 @@
|
|
| 117 |
message.content.replace(THINK_BLOCK_REGEX, "").trim()
|
| 118 |
);
|
| 119 |
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
}
|
| 127 |
-
return groups;
|
| 128 |
-
});
|
| 129 |
-
let hasToolUpdates = $derived(Object.keys(toolUpdateGroups).length > 0);
|
| 130 |
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
}
|
| 142 |
-
// Clamp if groups shrink or grow
|
| 143 |
-
if (toolNavIndex > len - 1) toolNavIndex = len - 1;
|
| 144 |
-
// While streaming, default to most recent group unless user navigated away
|
| 145 |
-
if (isLast && loading && toolAutoFollowLatest) toolNavIndex = len - 1;
|
| 146 |
-
});
|
| 147 |
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
if (!
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
}
|
|
|
|
|
|
|
| 153 |
});
|
| 154 |
|
| 155 |
$effect(() => {
|
|
@@ -200,63 +253,56 @@
|
|
| 200 |
</div>
|
| 201 |
{/if}
|
| 202 |
|
| 203 |
-
{#if hasToolUpdates}
|
| 204 |
-
{#if toolGroups.length}
|
| 205 |
-
{@const group = toolGroups[toolNavIndex]}
|
| 206 |
-
<ToolUpdate
|
| 207 |
-
tool={group}
|
| 208 |
-
{loading}
|
| 209 |
-
index={toolNavIndex}
|
| 210 |
-
total={toolGroups.length}
|
| 211 |
-
onprev={() => {
|
| 212 |
-
toolAutoFollowLatest = false;
|
| 213 |
-
toolNavIndex = Math.max(0, toolNavIndex - 1);
|
| 214 |
-
}}
|
| 215 |
-
onnext={() => {
|
| 216 |
-
toolNavIndex = Math.min(toolGroups.length - 1, toolNavIndex + 1);
|
| 217 |
-
// If user moves back to the newest group, resume auto-follow
|
| 218 |
-
toolAutoFollowLatest = toolNavIndex === toolGroups.length - 1;
|
| 219 |
-
}}
|
| 220 |
-
/>
|
| 221 |
-
{/if}
|
| 222 |
-
{/if}
|
| 223 |
-
|
| 224 |
<div bind:this={contentEl} oncopy={handleCopy}>
|
| 225 |
-
{#if isLast && loading &&
|
| 226 |
<IconLoading classNames="loading inline ml-2 first:ml-0" />
|
| 227 |
{/if}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
<div
|
| 247 |
class="prose max-w-none dark:prose-invert max-sm:prose-sm prose-headings:font-semibold prose-h1:text-lg prose-h2:text-base prose-h3:text-base prose-pre:bg-gray-800 prose-img:my-0 prose-img:rounded-lg dark:prose-pre:bg-gray-900"
|
| 248 |
>
|
| 249 |
-
<MarkdownRenderer content={
|
| 250 |
</div>
|
| 251 |
{/if}
|
| 252 |
-
{/
|
| 253 |
-
{
|
| 254 |
-
<div
|
| 255 |
-
class="prose max-w-none dark:prose-invert max-sm:prose-sm prose-headings:font-semibold prose-h1:text-lg prose-h2:text-base prose-h3:text-base prose-pre:bg-gray-800 prose-img:my-0 prose-img:rounded-lg dark:prose-pre:bg-gray-900"
|
| 256 |
-
>
|
| 257 |
-
<MarkdownRenderer content={message.content} loading={isLast && loading} />
|
| 258 |
-
</div>
|
| 259 |
-
{/if}
|
| 260 |
</div>
|
| 261 |
</div>
|
| 262 |
|
|
|
|
| 20 |
import { requireAuthUser } from "$lib/utils/auth";
|
| 21 |
import ToolUpdate from "./ToolUpdate.svelte";
|
| 22 |
import { isMessageToolUpdate } from "$lib/utils/messageUpdates";
|
| 23 |
+
import { MessageUpdateType, type MessageToolUpdate } from "$lib/types/MessageUpdate";
|
| 24 |
|
| 25 |
interface Props {
|
| 26 |
message: Message;
|
|
|
|
| 84 |
const wrapper = document.createElement("div");
|
| 85 |
wrapper.appendChild(range.cloneContents());
|
| 86 |
|
| 87 |
+
wrapper.querySelectorAll("[data-exclude-from-copy]").forEach((el) => {
|
| 88 |
+
el.remove();
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
wrapper.querySelectorAll("*").forEach((el) => {
|
| 92 |
el.removeAttribute("style");
|
| 93 |
el.removeAttribute("class");
|
|
|
|
| 115 |
|
| 116 |
// Zero-config reasoning autodetection: detect <think> blocks in content
|
| 117 |
const THINK_BLOCK_REGEX = /(<think>[\s\S]*?(?:<\/think>|$))/gi;
|
| 118 |
+
// Non-global version for .test() calls to avoid lastIndex side effects
|
| 119 |
+
const THINK_BLOCK_TEST_REGEX = /(<think>[\s\S]*?(?:<\/think>|$))/i;
|
| 120 |
let hasClientThink = $derived(message.content.split(THINK_BLOCK_REGEX).length > 1);
|
| 121 |
|
| 122 |
// Strip think blocks for clipboard copy (always, regardless of detection)
|
|
|
|
| 124 |
message.content.replace(THINK_BLOCK_REGEX, "").trim()
|
| 125 |
);
|
| 126 |
|
| 127 |
+
type Block =
|
| 128 |
+
| { type: "text"; content: string }
|
| 129 |
+
| { type: "tool"; uuid: string; updates: MessageToolUpdate[] };
|
| 130 |
+
|
| 131 |
+
let blocks = $derived.by(() => {
|
| 132 |
+
const updates = message.updates ?? [];
|
| 133 |
+
const res: Block[] = [];
|
| 134 |
+
const hasTools = updates.some(isMessageToolUpdate);
|
| 135 |
+
let contentCursor = 0;
|
| 136 |
+
let sawFinalAnswer = false;
|
| 137 |
+
|
| 138 |
+
// Fast path: no tool updates at all
|
| 139 |
+
if (!hasTools && updates.length === 0) {
|
| 140 |
+
if (message.content) return [{ type: "text" as const, content: message.content }];
|
| 141 |
+
return [];
|
| 142 |
}
|
|
|
|
|
|
|
|
|
|
| 143 |
|
| 144 |
+
for (const update of updates) {
|
| 145 |
+
if (update.type === MessageUpdateType.Stream) {
|
| 146 |
+
const token =
|
| 147 |
+
typeof update.token === "string" && update.token.length > 0 ? update.token : null;
|
| 148 |
+
const len = token !== null ? token.length : (update.len ?? 0);
|
| 149 |
+
const chunk =
|
| 150 |
+
token ??
|
| 151 |
+
(message.content ? message.content.slice(contentCursor, contentCursor + len) : "");
|
| 152 |
+
contentCursor += len;
|
| 153 |
+
if (!chunk) continue;
|
| 154 |
+
const last = res.at(-1);
|
| 155 |
+
if (last?.type === "text") last.content += chunk;
|
| 156 |
+
else res.push({ type: "text" as const, content: chunk });
|
| 157 |
+
} else if (isMessageToolUpdate(update)) {
|
| 158 |
+
const last = res.at(-1);
|
| 159 |
+
if (last?.type === "tool" && last.uuid === update.uuid) {
|
| 160 |
+
last.updates.push(update);
|
| 161 |
+
} else {
|
| 162 |
+
res.push({ type: "tool" as const, uuid: update.uuid, updates: [update] });
|
| 163 |
+
}
|
| 164 |
+
} else if (update.type === MessageUpdateType.FinalAnswer) {
|
| 165 |
+
sawFinalAnswer = true;
|
| 166 |
+
const finalText = update.text ?? "";
|
| 167 |
+
const currentText = res
|
| 168 |
+
.filter((b) => b.type === "text")
|
| 169 |
+
.map((b) => (b as { type: "text"; content: string }).content)
|
| 170 |
+
.join("");
|
| 171 |
+
|
| 172 |
+
let addedText = "";
|
| 173 |
+
if (finalText.startsWith(currentText)) {
|
| 174 |
+
addedText = finalText.slice(currentText.length);
|
| 175 |
+
} else if (!currentText.endsWith(finalText)) {
|
| 176 |
+
const needsGap = !/\n\n$/.test(currentText) && !/^\n/.test(finalText);
|
| 177 |
+
addedText = (needsGap ? "\n\n" : "") + finalText;
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
if (addedText) {
|
| 181 |
+
const last = res.at(-1);
|
| 182 |
+
if (last?.type === "text") {
|
| 183 |
+
last.content += addedText;
|
| 184 |
+
} else {
|
| 185 |
+
res.push({ type: "text" as const, content: addedText });
|
| 186 |
+
}
|
| 187 |
+
}
|
| 188 |
+
}
|
| 189 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
|
| 191 |
+
// If content remains unmatched (e.g., persisted stream markers), append the remainder
|
| 192 |
+
// Skip when a FinalAnswer already provided the authoritative text.
|
| 193 |
+
if (!sawFinalAnswer && message.content && contentCursor < message.content.length) {
|
| 194 |
+
const remaining = message.content.slice(contentCursor);
|
| 195 |
+
if (remaining.length > 0) {
|
| 196 |
+
const last = res.at(-1);
|
| 197 |
+
if (last?.type === "text") last.content += remaining;
|
| 198 |
+
else res.push({ type: "text" as const, content: remaining });
|
| 199 |
+
}
|
| 200 |
+
} else if (!res.some((b) => b.type === "text") && message.content) {
|
| 201 |
+
// Fallback: no text produced at all
|
| 202 |
+
res.push({ type: "text" as const, content: message.content });
|
| 203 |
}
|
| 204 |
+
|
| 205 |
+
return res;
|
| 206 |
});
|
| 207 |
|
| 208 |
$effect(() => {
|
|
|
|
| 253 |
</div>
|
| 254 |
{/if}
|
| 255 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
<div bind:this={contentEl} oncopy={handleCopy}>
|
| 257 |
+
{#if isLast && loading && blocks.length === 0}
|
| 258 |
<IconLoading classNames="loading inline ml-2 first:ml-0" />
|
| 259 |
{/if}
|
| 260 |
+
{#each blocks as block, blockIndex (block.type === "tool" ? `${block.uuid}-${blockIndex}` : `text-${blockIndex}`)}
|
| 261 |
+
{@const nextBlock = blocks[blockIndex + 1]}
|
| 262 |
+
{@const nextBlockHasThink =
|
| 263 |
+
nextBlock?.type === "text" && THINK_BLOCK_TEST_REGEX.test(nextBlock.content)}
|
| 264 |
+
{@const nextIsLinkable = nextBlock?.type === "tool" || nextBlockHasThink}
|
| 265 |
+
{#if block.type === "tool"}
|
| 266 |
+
<div data-exclude-from-copy class="has-[+.prose]:mb-3 [.prose+&]:mt-4">
|
| 267 |
+
<ToolUpdate tool={block.updates} {loading} hasNext={nextIsLinkable} />
|
| 268 |
+
</div>
|
| 269 |
+
{:else if block.type === "text"}
|
| 270 |
+
{#if isLast && loading && block.content.length === 0}
|
| 271 |
+
<IconLoading classNames="loading inline ml-2 first:ml-0" />
|
| 272 |
+
{/if}
|
| 273 |
|
| 274 |
+
{#if hasClientThink}
|
| 275 |
+
{@const parts = block.content.split(THINK_BLOCK_REGEX)}
|
| 276 |
+
{#each parts as part, partIndex}
|
| 277 |
+
{@const remainingParts = parts.slice(partIndex + 1)}
|
| 278 |
+
{@const hasMoreLinkable =
|
| 279 |
+
remainingParts.some((p) => p && THINK_BLOCK_TEST_REGEX.test(p)) || nextIsLinkable}
|
| 280 |
+
{#if part && part.startsWith("<think>")}
|
| 281 |
+
{@const isClosed = part.endsWith("</think>")}
|
| 282 |
+
{@const thinkContent = part.slice(7, isClosed ? -8 : undefined)}
|
| 283 |
+
|
| 284 |
+
<OpenReasoningResults
|
| 285 |
+
content={thinkContent}
|
| 286 |
+
loading={isLast && loading && !isClosed}
|
| 287 |
+
hasNext={hasMoreLinkable}
|
| 288 |
+
/>
|
| 289 |
+
{:else if part && part.trim().length > 0}
|
| 290 |
+
<div
|
| 291 |
+
class="prose max-w-none dark:prose-invert max-sm:prose-sm prose-headings:font-semibold prose-h1:text-lg prose-h2:text-base prose-h3:text-base prose-pre:bg-gray-800 prose-img:my-0 prose-img:rounded-lg dark:prose-pre:bg-gray-900"
|
| 292 |
+
>
|
| 293 |
+
<MarkdownRenderer content={part} loading={isLast && loading} />
|
| 294 |
+
</div>
|
| 295 |
+
{/if}
|
| 296 |
+
{/each}
|
| 297 |
+
{:else}
|
| 298 |
<div
|
| 299 |
class="prose max-w-none dark:prose-invert max-sm:prose-sm prose-headings:font-semibold prose-h1:text-lg prose-h2:text-base prose-h3:text-base prose-pre:bg-gray-800 prose-img:my-0 prose-img:rounded-lg dark:prose-pre:bg-gray-900"
|
| 300 |
>
|
| 301 |
+
<MarkdownRenderer content={block.content} loading={isLast && loading} />
|
| 302 |
</div>
|
| 303 |
{/if}
|
| 304 |
+
{/if}
|
| 305 |
+
{/each}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
</div>
|
| 307 |
</div>
|
| 308 |
|
|
@@ -1,86 +1,81 @@
|
|
| 1 |
<script lang="ts">
|
| 2 |
import MarkdownRenderer from "./MarkdownRenderer.svelte";
|
| 3 |
-
import
|
| 4 |
|
| 5 |
interface Props {
|
| 6 |
-
summary: string;
|
| 7 |
content: string;
|
| 8 |
loading?: boolean;
|
|
|
|
| 9 |
}
|
| 10 |
|
| 11 |
-
let {
|
| 12 |
-
let isOpen = $state(
|
|
|
|
|
|
|
| 13 |
|
|
|
|
| 14 |
$effect(() => {
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
});
|
| 17 |
</script>
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
>
|
| 23 |
-
<
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
| 26 |
>
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
class="stroke-gray-600 dark:stroke-gray-400"
|
| 34 |
-
style="stroke-width: 1.9; fill: none; stroke-linecap: round; stroke-linejoin: round;"
|
| 35 |
-
d="M16 6v3.33M16 6c0-2.65 3.25-4.3 5.4-2.62 1.2.95 1.6 2.65.95 4.04a3.63 3.63 0 0 1 4.61.16 3.45 3.45 0 0 1 .46 4.37 5.32 5.32 0 0 1 1.87 4.75c-.22 1.66-1.39 3.6-3.07 4.14M16 6c0-2.65-3.25-4.3-5.4-2.62a3.37 3.37 0 0 0-.95 4.04 3.65 3.65 0 0 0-4.6.16 3.37 3.37 0 0 0-.49 4.27 5.57 5.57 0 0 0-1.85 4.85 5.3 5.3 0 0 0 3.07 4.15M16 9.33v17.34m0-17.34c0 2.18 1.82 4 4 4m6.22 7.5c.67 1.3.56 2.91-.27 4.11a4.05 4.05 0 0 1-4.62 1.5c0 1.53-1.05 2.9-2.66 2.9A2.7 2.7 0 0 1 16 26.66m10.22-5.83a4.05 4.05 0 0 0-3.55-2.17m-16.9 2.18a4.05 4.05 0 0 0 .28 4.1c1 1.44 2.92 2.09 4.59 1.5 0 1.52 1.12 2.88 2.7 2.88A2.7 2.7 0 0 0 16 26.67M5.78 20.85a4.04 4.04 0 0 1 3.55-2.18"
|
| 36 |
-
/>
|
| 37 |
-
|
| 38 |
-
{#if loading}
|
| 39 |
-
<path
|
| 40 |
-
class="animate-pulse stroke-white"
|
| 41 |
-
style="stroke-width: 2; fill: none; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 50;"
|
| 42 |
-
d="M16 6v3.33M16 6c0-2.65 3.25-4.3 5.4-2.62 1.2.95 1.6 2.65.95 4.04a3.63 3.63 0 0 1 4.61.16 3.45 3.45 0 0 1 .46 4.37 5.32 5.32 0 0 1 1.87 4.75c-.22 1.66-1.39 3.6-3.07 4.14M16 6c0-2.65-3.25-4.3-5.4-2.62a3.37 3.37 0 0 0-.95 4.04 3.65 3.65 0 0 0-4.6.16 3.37 3.37 0 0 0-.49 4.27 5.57 5.57 0 0 0-1.85 4.85 5.3 5.3 0 0 0 3.07 4.15M16 9.33v17.34m0-17.34c0 2.18 1.82 4 4 4m6.22 7.5c.67 1.3.56 2.91-.27 4.11a4.05 4.05 0 0 1-4.62 1.5c0 1.53-1.05 2.9-2.66 2.9A2.7 2.7 0 0 1 16 26.66m10.22-5.83a4.05 4.05 0 0 0-3.55-2.17m-16.9 2.18a4.05 4.05 0 0 0 .28 4.1c1 1.44 2.92 2.09 4.59 1.5 0 1.52 1.12 2.88 2.7 2.88A2.7 2.7 0 0 0 16 26.67M5.78 20.85a4.04 4.04 0 0 1 3.55-2.18"
|
| 43 |
-
>
|
| 44 |
-
<animate
|
| 45 |
-
attributeName="stroke-dashoffset"
|
| 46 |
-
values="0;500"
|
| 47 |
-
dur="12s"
|
| 48 |
-
repeatCount="indefinite"
|
| 49 |
-
/>
|
| 50 |
-
</path>
|
| 51 |
-
{/if}
|
| 52 |
-
</svg>
|
| 53 |
</div>
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
<
|
| 57 |
-
|
| 58 |
-
class="flex items-center gap-1 truncate whitespace-nowrap text-[.82rem] text-gray-400"
|
| 59 |
class:animate-pulse={loading}
|
| 60 |
>
|
| 61 |
-
{
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
class="transition-rotate size-5 -rotate-90 text-gray-400 group-open:rotate-0"
|
| 70 |
-
/>
|
| 71 |
-
</summary>
|
| 72 |
-
|
| 73 |
-
<div
|
| 74 |
-
class="prose prose-sm !max-w-none space-y-4 border-t border-gray-200 p-3 text-sm text-gray-600 dark:prose-invert prose-img:my-0 prose-img:rounded-lg dark:border-gray-800 dark:text-gray-400"
|
| 75 |
-
>
|
| 76 |
-
{#key content}
|
| 77 |
-
<MarkdownRenderer {content} {loading} />
|
| 78 |
-
{/key}
|
| 79 |
-
</div>
|
| 80 |
-
</details>
|
| 81 |
-
|
| 82 |
-
<style>
|
| 83 |
-
details summary::-webkit-details-marker {
|
| 84 |
-
display: none;
|
| 85 |
-
}
|
| 86 |
-
</style>
|
|
|
|
| 1 |
<script lang="ts">
|
| 2 |
import MarkdownRenderer from "./MarkdownRenderer.svelte";
|
| 3 |
+
import BlockWrapper from "./BlockWrapper.svelte";
|
| 4 |
|
| 5 |
interface Props {
|
|
|
|
| 6 |
content: string;
|
| 7 |
loading?: boolean;
|
| 8 |
+
hasNext?: boolean;
|
| 9 |
}
|
| 10 |
|
| 11 |
+
let { content, loading = false, hasNext = false }: Props = $props();
|
| 12 |
+
let isOpen = $state(false);
|
| 13 |
+
let wasLoading = $state(false);
|
| 14 |
+
let initialized = $state(false);
|
| 15 |
|
| 16 |
+
// Track loading transitions to auto-expand/collapse
|
| 17 |
$effect(() => {
|
| 18 |
+
// Auto-expand on first render if already loading
|
| 19 |
+
if (!initialized) {
|
| 20 |
+
initialized = true;
|
| 21 |
+
if (loading) {
|
| 22 |
+
isOpen = true;
|
| 23 |
+
wasLoading = true;
|
| 24 |
+
return;
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
if (loading && !wasLoading) {
|
| 29 |
+
// Loading started - auto-expand
|
| 30 |
+
isOpen = true;
|
| 31 |
+
} else if (!loading && wasLoading) {
|
| 32 |
+
// Loading finished - auto-collapse
|
| 33 |
+
isOpen = false;
|
| 34 |
+
}
|
| 35 |
+
wasLoading = loading;
|
| 36 |
});
|
| 37 |
</script>
|
| 38 |
|
| 39 |
+
{#snippet icon()}
|
| 40 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32">
|
| 41 |
+
<path
|
| 42 |
+
class="stroke-gray-500 dark:stroke-gray-400"
|
| 43 |
+
style="stroke-width: 1.9; fill: none; stroke-linecap: round; stroke-linejoin: round;"
|
| 44 |
+
d="M16 6v3.33M16 6c0-2.65 3.25-4.3 5.4-2.62 1.2.95 1.6 2.65.95 4.04a3.63 3.63 0 0 1 4.61.16 3.45 3.45 0 0 1 .46 4.37 5.32 5.32 0 0 1 1.87 4.75c-.22 1.66-1.39 3.6-3.07 4.14M16 6c0-2.65-3.25-4.3-5.4-2.62a3.37 3.37 0 0 0-.95 4.04 3.65 3.65 0 0 0-4.6.16 3.37 3.37 0 0 0-.49 4.27 5.57 5.57 0 0 0-1.85 4.85 5.3 5.3 0 0 0 3.07 4.15M16 9.33v17.34m0-17.34c0 2.18 1.82 4 4 4m6.22 7.5c.67 1.3.56 2.91-.27 4.11a4.05 4.05 0 0 1-4.62 1.5c0 1.53-1.05 2.9-2.66 2.9A2.7 2.7 0 0 1 16 26.66m10.22-5.83a4.05 4.05 0 0 0-3.55-2.17m-16.9 2.18a4.05 4.05 0 0 0 .28 4.1c1 1.44 2.92 2.09 4.59 1.5 0 1.52 1.12 2.88 2.7 2.88A2.7 2.7 0 0 0 16 26.67M5.78 20.85a4.04 4.04 0 0 1 3.55-2.18"
|
| 45 |
+
/>
|
| 46 |
+
</svg>
|
| 47 |
+
{/snippet}
|
| 48 |
+
|
| 49 |
+
<BlockWrapper
|
| 50 |
+
{icon}
|
| 51 |
+
{hasNext}
|
| 52 |
+
iconBg="bg-gray-100 dark:bg-gray-700"
|
| 53 |
+
iconRing="ring-gray-200 dark:ring-gray-600"
|
| 54 |
>
|
| 55 |
+
<!-- Collapsed view (clickable to expand) -->
|
| 56 |
+
<button
|
| 57 |
+
type="button"
|
| 58 |
+
class="group/text w-full cursor-pointer text-left"
|
| 59 |
+
onclick={() => (isOpen = !isOpen)}
|
| 60 |
>
|
| 61 |
+
{#if isOpen}
|
| 62 |
+
<!-- Expanded: show full content -->
|
| 63 |
+
<div
|
| 64 |
+
class="prose prose-sm max-w-none text-sm leading-relaxed text-gray-500 dark:prose-invert dark:text-gray-400"
|
| 65 |
+
>
|
| 66 |
+
<MarkdownRenderer {content} {loading} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
</div>
|
| 68 |
+
{:else}
|
| 69 |
+
<!-- Collapsed: 2-line preview (plain text, strip markdown) -->
|
| 70 |
+
<div
|
| 71 |
+
class="line-clamp-2 text-sm leading-relaxed text-gray-500 dark:text-gray-400"
|
|
|
|
| 72 |
class:animate-pulse={loading}
|
| 73 |
>
|
| 74 |
+
{content
|
| 75 |
+
.replace(/[#*_`~[\]]/g, "")
|
| 76 |
+
.replace(/\n+/g, " ")
|
| 77 |
+
.trim()}
|
| 78 |
+
</div>
|
| 79 |
+
{/if}
|
| 80 |
+
</button>
|
| 81 |
+
</BlockWrapper>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -6,38 +6,32 @@
|
|
| 6 |
isMessageToolResultUpdate,
|
| 7 |
} from "$lib/utils/messageUpdates";
|
| 8 |
import LucideHammer from "~icons/lucide/hammer";
|
|
|
|
| 9 |
import { ToolResultStatus, type ToolFront } from "$lib/types/Tool";
|
| 10 |
import { page } from "$app/state";
|
| 11 |
-
import { onDestroy } from "svelte";
|
| 12 |
-
import { browser } from "$app/environment";
|
| 13 |
-
import CarbonChevronLeft from "~icons/carbon/chevron-left";
|
| 14 |
import CarbonChevronRight from "~icons/carbon/chevron-right";
|
|
|
|
| 15 |
|
| 16 |
interface Props {
|
| 17 |
tool: MessageToolUpdate[];
|
| 18 |
loading?: boolean;
|
| 19 |
-
|
| 20 |
-
index?: number;
|
| 21 |
-
total?: number;
|
| 22 |
-
onprev?: () => void;
|
| 23 |
-
onnext?: () => void;
|
| 24 |
}
|
| 25 |
|
| 26 |
-
let { tool, loading = false,
|
|
|
|
|
|
|
| 27 |
|
| 28 |
let toolFnName = $derived(tool.find(isMessageToolCallUpdate)?.call.name);
|
| 29 |
let toolError = $derived(tool.some(isMessageToolErrorUpdate));
|
| 30 |
let toolDone = $derived(tool.some(isMessageToolResultUpdate));
|
| 31 |
-
let
|
|
|
|
| 32 |
|
| 33 |
const availableTools: ToolFront[] = $derived.by(
|
| 34 |
() => (page.data as { tools?: ToolFront[] } | undefined)?.tools ?? []
|
| 35 |
);
|
| 36 |
|
| 37 |
-
let loadingBarEl: HTMLDivElement | undefined = $state(undefined);
|
| 38 |
-
let animation: Animation | undefined = $state(undefined);
|
| 39 |
-
let showingLoadingBar = $state(false);
|
| 40 |
-
|
| 41 |
type ToolOutput = Record<string, unknown>;
|
| 42 |
type McpImageContent = {
|
| 43 |
type: "image";
|
|
@@ -98,218 +92,169 @@
|
|
| 98 |
metadata: getMetadataEntries(output),
|
| 99 |
}));
|
| 100 |
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
animation = loadingBarEl.animate([{ width: "0%" }, { width: "calc(100%+1rem)" }], {
|
| 106 |
-
duration: (eta ?? 0) * 1000,
|
| 107 |
-
fill: "forwards",
|
| 108 |
-
});
|
| 109 |
-
}
|
| 110 |
-
});
|
| 111 |
-
|
| 112 |
-
onDestroy(() => {
|
| 113 |
-
animation?.cancel();
|
| 114 |
-
});
|
| 115 |
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
loadingBarEl.classList.remove("hidden");
|
| 120 |
-
animation?.cancel();
|
| 121 |
-
const fromWidth = getComputedStyle(loadingBarEl).width;
|
| 122 |
-
animation = loadingBarEl.animate([{ width: fromWidth }, { width: "calc(100%+1rem)" }], {
|
| 123 |
-
duration: 300,
|
| 124 |
-
fill: "forwards",
|
| 125 |
-
});
|
| 126 |
-
setTimeout(() => loadingBarEl?.classList.add("hidden"), 300);
|
| 127 |
-
}
|
| 128 |
-
});
|
| 129 |
</script>
|
| 130 |
|
| 131 |
-
{#
|
| 132 |
-
|
| 133 |
-
class="
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
bind:this={loadingBarEl}
|
| 143 |
-
class="absolute -m-1 hidden h-full w-full rounded-lg bg-purple-500/5 transition-all dark:bg-purple-500/10"
|
| 144 |
-
></div>
|
| 145 |
|
| 146 |
-
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
>
|
| 149 |
-
<
|
| 150 |
-
class="
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
fill="none"
|
| 156 |
-
xmlns="http://www.w3.org/2000/svg"
|
| 157 |
>
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
pathLength="100"
|
| 162 |
-
stroke="currentColor"
|
| 163 |
-
stroke-width="1"
|
| 164 |
-
stroke-linecap="round"
|
| 165 |
-
id="shape"
|
| 166 |
-
/>
|
| 167 |
-
</svg>
|
| 168 |
-
<LucideHammer class="size-3.5 text-purple-700 dark:text-purple-500" />
|
| 169 |
-
</div>
|
| 170 |
-
|
| 171 |
-
<span class="relative">
|
| 172 |
-
{toolError ? "Error calling" : toolDone ? "Called" : "Calling"} tool
|
| 173 |
-
<span class="font-semibold"
|
| 174 |
-
>{availableTools.find((entry) => entry.name === toolFnName)?.displayName ??
|
| 175 |
-
toolFnName}</span
|
| 176 |
-
>
|
| 177 |
-
</span>
|
| 178 |
-
|
| 179 |
-
{#if (total ?? 0) > 1}
|
| 180 |
-
<div class="relative ml-auto flex items-center gap-1.5">
|
| 181 |
-
<div
|
| 182 |
-
class="flex items-center divide-x rounded-md border border-gray-200 bg-gray-50 dark:divide-gray-700 dark:border-gray-800 dark:bg-gray-800"
|
| 183 |
>
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
aria-label="Previous tool"
|
| 189 |
-
disabled={(index ?? 0) <= 0}
|
| 190 |
-
onclick={(e) => {
|
| 191 |
-
e.preventDefault();
|
| 192 |
-
e.stopPropagation();
|
| 193 |
-
onprev?.();
|
| 194 |
-
}}
|
| 195 |
-
>
|
| 196 |
-
<CarbonChevronLeft />
|
| 197 |
-
</button>
|
| 198 |
-
|
| 199 |
-
<span
|
| 200 |
-
class="select-none px-1 text-center text-[10px] font-medium text-gray-500 dark:text-gray-400"
|
| 201 |
-
aria-live="polite"
|
| 202 |
-
>
|
| 203 |
-
{(index ?? 0) + 1} <span class="text-gray-300 dark:text-gray-500">/</span>
|
| 204 |
-
{total}
|
| 205 |
-
</span>
|
| 206 |
-
<button
|
| 207 |
-
type="button"
|
| 208 |
-
class="btn size-5 text-xs text-gray-500 hover:text-gray-700 focus:ring-0 disabled:opacity-40 dark:text-gray-400 dark:hover:text-gray-200"
|
| 209 |
-
title="Next tool"
|
| 210 |
-
aria-label="Next tool"
|
| 211 |
-
disabled={(index ?? 0) >= (total ?? 1) - 1}
|
| 212 |
-
onclick={(e) => {
|
| 213 |
-
e.preventDefault();
|
| 214 |
-
e.stopPropagation();
|
| 215 |
-
onnext?.();
|
| 216 |
-
}}
|
| 217 |
-
>
|
| 218 |
-
<CarbonChevronRight />
|
| 219 |
-
</button>
|
| 220 |
-
</div>
|
| 221 |
-
</div>
|
| 222 |
-
{/if}
|
| 223 |
-
</summary>
|
| 224 |
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
<
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
<span class="whitespace-pre-wrap">{formatValue(value)}</span>
|
| 237 |
-
</li>
|
| 238 |
-
{/if}
|
| 239 |
-
{/each}
|
| 240 |
-
</ul>
|
| 241 |
-
{:else if update.subtype === MessageToolUpdateType.Error}
|
| 242 |
-
<div class="mt-1 flex items-center gap-2 opacity-80">
|
| 243 |
-
<h3 class="text-sm">Error</h3>
|
| 244 |
-
<div class="h-px flex-1 bg-gradient-to-r from-gray-500/20"></div>
|
| 245 |
-
</div>
|
| 246 |
-
<p class="text-sm">{update.message}</p>
|
| 247 |
-
{:else if isMessageToolResultUpdate(update) && update.result.status === ToolResultStatus.Success && update.result.display}
|
| 248 |
-
<div class="mt-1 flex items-center gap-2 opacity-80">
|
| 249 |
-
<h3 class="text-sm">Result</h3>
|
| 250 |
-
<div class="h-px flex-1 bg-gradient-to-r from-gray-500/20"></div>
|
| 251 |
-
</div>
|
| 252 |
-
<div class="py-1 text-sm">
|
| 253 |
-
{#each parseToolOutputs(update.result.outputs) as parsedOutput}
|
| 254 |
-
<div class="space-y-2 py-2 first:pt-0 last:pb-0">
|
| 255 |
-
{#if parsedOutput.text}
|
| 256 |
-
<!-- prettier-ignore -->
|
| 257 |
-
<pre class="whitespace-pre-wrap break-all font-mono text-xs">{parsedOutput.text}</pre>
|
| 258 |
-
{/if}
|
| 259 |
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
</div>
|
| 270 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
</ul>
|
| 281 |
-
{/if}
|
| 282 |
</div>
|
| 283 |
-
{
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
{/if}
|
| 297 |
-
|
| 298 |
-
<style>
|
| 299 |
-
details summary::-webkit-details-marker {
|
| 300 |
-
display: none;
|
| 301 |
-
}
|
| 302 |
-
|
| 303 |
-
@keyframes loading {
|
| 304 |
-
to {
|
| 305 |
-
/* move one full perimeter, normalized via pathLength=100 */
|
| 306 |
-
stroke-dashoffset: -100;
|
| 307 |
-
}
|
| 308 |
-
}
|
| 309 |
-
|
| 310 |
-
.loading-path {
|
| 311 |
-
/* larger traveling gap for clearer motion */
|
| 312 |
-
stroke-dasharray: 80 20; /* 80% dash, 20% gap */
|
| 313 |
-
animation: loading 1.6s linear infinite;
|
| 314 |
-
}
|
| 315 |
-
</style>
|
|
|
|
| 6 |
isMessageToolResultUpdate,
|
| 7 |
} from "$lib/utils/messageUpdates";
|
| 8 |
import LucideHammer from "~icons/lucide/hammer";
|
| 9 |
+
import LucideCheck from "~icons/lucide/check";
|
| 10 |
import { ToolResultStatus, type ToolFront } from "$lib/types/Tool";
|
| 11 |
import { page } from "$app/state";
|
|
|
|
|
|
|
|
|
|
| 12 |
import CarbonChevronRight from "~icons/carbon/chevron-right";
|
| 13 |
+
import BlockWrapper from "./BlockWrapper.svelte";
|
| 14 |
|
| 15 |
interface Props {
|
| 16 |
tool: MessageToolUpdate[];
|
| 17 |
loading?: boolean;
|
| 18 |
+
hasNext?: boolean;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
}
|
| 20 |
|
| 21 |
+
let { tool, loading = false, hasNext = false }: Props = $props();
|
| 22 |
+
|
| 23 |
+
let isOpen = $state(false);
|
| 24 |
|
| 25 |
let toolFnName = $derived(tool.find(isMessageToolCallUpdate)?.call.name);
|
| 26 |
let toolError = $derived(tool.some(isMessageToolErrorUpdate));
|
| 27 |
let toolDone = $derived(tool.some(isMessageToolResultUpdate));
|
| 28 |
+
let isExecuting = $derived(!toolDone && !toolError && loading);
|
| 29 |
+
let toolSuccess = $derived(toolDone && !toolError);
|
| 30 |
|
| 31 |
const availableTools: ToolFront[] = $derived.by(
|
| 32 |
() => (page.data as { tools?: ToolFront[] } | undefined)?.tools ?? []
|
| 33 |
);
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
type ToolOutput = Record<string, unknown>;
|
| 36 |
type McpImageContent = {
|
| 37 |
type: "image";
|
|
|
|
| 92 |
metadata: getMetadataEntries(output),
|
| 93 |
}));
|
| 94 |
|
| 95 |
+
// Icon styling based on state
|
| 96 |
+
let iconBg = $derived(
|
| 97 |
+
toolError ? "bg-red-100 dark:bg-red-900/40" : "bg-purple-100 dark:bg-purple-900/40"
|
| 98 |
+
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
+
let iconRing = $derived(
|
| 101 |
+
toolError ? "ring-red-200 dark:ring-red-500/30" : "ring-purple-200 dark:ring-purple-500/30"
|
| 102 |
+
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
</script>
|
| 104 |
|
| 105 |
+
{#snippet icon()}
|
| 106 |
+
{#if toolSuccess}
|
| 107 |
+
<LucideCheck class="size-3.5 text-purple-600 dark:text-purple-400" />
|
| 108 |
+
{:else}
|
| 109 |
+
<LucideHammer
|
| 110 |
+
class="size-3.5 {toolError
|
| 111 |
+
? 'text-red-500 dark:text-red-400'
|
| 112 |
+
: 'text-purple-600 dark:text-purple-400'}"
|
| 113 |
+
/>
|
| 114 |
+
{/if}
|
| 115 |
+
{/snippet}
|
|
|
|
|
|
|
|
|
|
| 116 |
|
| 117 |
+
{#if toolFnName}
|
| 118 |
+
<BlockWrapper {icon} {iconBg} {iconRing} {hasNext} loading={isExecuting}>
|
| 119 |
+
<!-- Header row -->
|
| 120 |
+
<div class="flex w-full select-none items-center gap-2">
|
| 121 |
+
<button
|
| 122 |
+
type="button"
|
| 123 |
+
class="flex flex-1 cursor-pointer items-center gap-2 text-left"
|
| 124 |
+
onclick={() => (isOpen = !isOpen)}
|
| 125 |
>
|
| 126 |
+
<span
|
| 127 |
+
class="text-sm font-medium {isExecuting
|
| 128 |
+
? 'text-purple-700 dark:text-purple-300'
|
| 129 |
+
: toolError
|
| 130 |
+
? 'text-red-600 dark:text-red-400'
|
| 131 |
+
: 'text-gray-700 dark:text-gray-300'}"
|
|
|
|
|
|
|
| 132 |
>
|
| 133 |
+
{toolError ? "Error calling" : toolDone ? "Called" : "Calling"} tool
|
| 134 |
+
<code
|
| 135 |
+
class="rounded bg-gray-100 px-1.5 py-0.5 font-mono text-xs text-gray-500 opacity-90 dark:bg-gray-800 dark:text-gray-400"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
>
|
| 137 |
+
{availableTools.find((entry) => entry.name === toolFnName)?.displayName ?? toolFnName}
|
| 138 |
+
</code>
|
| 139 |
+
</span>
|
| 140 |
+
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
|
| 142 |
+
<button
|
| 143 |
+
type="button"
|
| 144 |
+
class="cursor-pointer"
|
| 145 |
+
onclick={() => (isOpen = !isOpen)}
|
| 146 |
+
aria-label={isOpen ? "Collapse" : "Expand"}
|
| 147 |
+
>
|
| 148 |
+
<CarbonChevronRight
|
| 149 |
+
class="size-4 text-gray-400 transition-transform duration-200 {isOpen ? 'rotate-90' : ''}"
|
| 150 |
+
/>
|
| 151 |
+
</button>
|
| 152 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
|
| 154 |
+
<!-- Expandable content -->
|
| 155 |
+
{#if isOpen}
|
| 156 |
+
<div class="mt-2 space-y-3">
|
| 157 |
+
{#each tool as update, i (`${update.subtype}-${i}`)}
|
| 158 |
+
{#if update.subtype === MessageToolUpdateType.Call}
|
| 159 |
+
<div class="space-y-1">
|
| 160 |
+
<div
|
| 161 |
+
class="text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500"
|
| 162 |
+
>
|
| 163 |
+
Input
|
| 164 |
+
</div>
|
| 165 |
+
<div
|
| 166 |
+
class="rounded-md border border-gray-100 bg-gray-50 p-2 text-gray-500 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-400"
|
| 167 |
+
>
|
| 168 |
+
<pre class="whitespace-pre-wrap break-all font-mono text-xs">{formatValue(
|
| 169 |
+
update.call.parameters
|
| 170 |
+
)}</pre>
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
{:else if update.subtype === MessageToolUpdateType.Error}
|
| 174 |
+
<div class="space-y-1">
|
| 175 |
+
<div
|
| 176 |
+
class="text-[10px] font-semibold uppercase tracking-wider text-red-500 dark:text-red-400"
|
| 177 |
+
>
|
| 178 |
+
Error
|
| 179 |
+
</div>
|
| 180 |
+
<div
|
| 181 |
+
class="rounded-md border border-red-200 bg-red-50 p-2 text-red-600 dark:border-red-500/30 dark:bg-red-900/20 dark:text-red-400"
|
| 182 |
+
>
|
| 183 |
+
<pre class="whitespace-pre-wrap break-all font-mono text-xs">{update.message}</pre>
|
| 184 |
+
</div>
|
| 185 |
+
</div>
|
| 186 |
+
{:else if isMessageToolResultUpdate(update) && update.result.status === ToolResultStatus.Success && update.result.display}
|
| 187 |
+
<div class="space-y-1">
|
| 188 |
+
<div class="flex items-center gap-2">
|
| 189 |
+
<div
|
| 190 |
+
class="text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500"
|
| 191 |
+
>
|
| 192 |
+
Output
|
| 193 |
</div>
|
| 194 |
+
<svg
|
| 195 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 196 |
+
width="12"
|
| 197 |
+
height="12"
|
| 198 |
+
viewBox="0 0 24 24"
|
| 199 |
+
fill="none"
|
| 200 |
+
stroke="currentColor"
|
| 201 |
+
stroke-width="2"
|
| 202 |
+
stroke-linecap="round"
|
| 203 |
+
stroke-linejoin="round"
|
| 204 |
+
class="text-emerald-500"
|
| 205 |
+
>
|
| 206 |
+
<circle cx="12" cy="12" r="10"></circle>
|
| 207 |
+
<path d="m9 12 2 2 4-4"></path>
|
| 208 |
+
</svg>
|
| 209 |
+
</div>
|
| 210 |
+
<div
|
| 211 |
+
class="scrollbar-custom rounded-md border border-gray-100 bg-white p-2 text-gray-500 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-400"
|
| 212 |
+
>
|
| 213 |
+
{#each parseToolOutputs(update.result.outputs) as parsedOutput}
|
| 214 |
+
<div class="space-y-2">
|
| 215 |
+
{#if parsedOutput.text}
|
| 216 |
+
<pre
|
| 217 |
+
class="scrollbar-custom max-h-60 overflow-y-auto whitespace-pre-wrap break-all font-mono text-xs">{parsedOutput.text}</pre>
|
| 218 |
+
{/if}
|
| 219 |
+
|
| 220 |
+
{#if parsedOutput.images.length > 0}
|
| 221 |
+
<div class="flex flex-wrap gap-2">
|
| 222 |
+
{#each parsedOutput.images as image, imageIndex}
|
| 223 |
+
<img
|
| 224 |
+
alt={`Tool result image ${imageIndex + 1}`}
|
| 225 |
+
class="max-h-60 rounded border border-gray-200 dark:border-gray-700"
|
| 226 |
+
src={`data:${image.mimeType};base64,${image.data}`}
|
| 227 |
+
/>
|
| 228 |
+
{/each}
|
| 229 |
+
</div>
|
| 230 |
+
{/if}
|
| 231 |
|
| 232 |
+
{#if parsedOutput.metadata.length > 0}
|
| 233 |
+
<pre class="whitespace-pre-wrap break-all font-mono text-xs">{formatValue(
|
| 234 |
+
Object.fromEntries(parsedOutput.metadata)
|
| 235 |
+
)}</pre>
|
| 236 |
+
{/if}
|
| 237 |
+
</div>
|
| 238 |
+
{/each}
|
| 239 |
+
</div>
|
|
|
|
|
|
|
| 240 |
</div>
|
| 241 |
+
{:else if isMessageToolResultUpdate(update) && update.result.status === ToolResultStatus.Error && update.result.display}
|
| 242 |
+
<div class="space-y-1">
|
| 243 |
+
<div
|
| 244 |
+
class="text-[10px] font-semibold uppercase tracking-wider text-red-500 dark:text-red-400"
|
| 245 |
+
>
|
| 246 |
+
Error
|
| 247 |
+
</div>
|
| 248 |
+
<div
|
| 249 |
+
class="rounded-md border border-red-200 bg-red-50 p-2 text-red-600 dark:border-red-500/30 dark:bg-red-900/20 dark:text-red-400"
|
| 250 |
+
>
|
| 251 |
+
<pre class="whitespace-pre-wrap break-all font-mono text-xs">{update.result
|
| 252 |
+
.message}</pre>
|
| 253 |
+
</div>
|
| 254 |
+
</div>
|
| 255 |
+
{/if}
|
| 256 |
+
{/each}
|
| 257 |
+
</div>
|
| 258 |
+
{/if}
|
| 259 |
+
</BlockWrapper>
|
| 260 |
{/if}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -44,6 +44,8 @@ export interface MessageTitleUpdate {
|
|
| 44 |
export interface MessageStreamUpdate {
|
| 45 |
type: MessageUpdateType.Stream;
|
| 46 |
token: string;
|
|
|
|
|
|
|
| 47 |
}
|
| 48 |
|
| 49 |
// Tool updates (for MCP and function calling)
|
|
|
|
| 44 |
export interface MessageStreamUpdate {
|
| 45 |
type: MessageUpdateType.Stream;
|
| 46 |
token: string;
|
| 47 |
+
/** Length of the original token. Used for compressed/persisted stream markers where token is empty. */
|
| 48 |
+
len?: number;
|
| 49 |
}
|
| 50 |
|
| 51 |
// Tool updates (for MCP and function calling)
|
|
@@ -250,13 +250,27 @@
|
|
| 250 |
update.token = update.token.replaceAll("\0", "");
|
| 251 |
}
|
| 252 |
|
| 253 |
-
const
|
| 254 |
-
update.type === MessageUpdateType.
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
}
|
| 261 |
const currentTime = new Date();
|
| 262 |
|
|
@@ -394,7 +408,7 @@
|
|
| 394 |
|
| 395 |
function handleKeydown(event: KeyboardEvent) {
|
| 396 |
// Stop generation on ESC key when loading
|
| 397 |
-
if (event.key === "Escape" && loading) {
|
| 398 |
event.preventDefault();
|
| 399 |
stopGeneration();
|
| 400 |
}
|
|
@@ -485,7 +499,7 @@
|
|
| 485 |
const navigatingAway =
|
| 486 |
navigation.to?.route.id !== page.route.id || navigation.to?.params?.id !== page.params.id;
|
| 487 |
|
| 488 |
-
if (loading && navigatingAway) {
|
| 489 |
addBackgroundGeneration({ id: page.params.id, startedAt: Date.now() });
|
| 490 |
}
|
| 491 |
|
|
@@ -499,7 +513,7 @@
|
|
| 499 |
});
|
| 500 |
</script>
|
| 501 |
|
| 502 |
-
<svelte:window
|
| 503 |
|
| 504 |
<svelte:head>
|
| 505 |
<title>{title}</title>
|
|
|
|
| 250 |
update.token = update.token.replaceAll("\0", "");
|
| 251 |
}
|
| 252 |
|
| 253 |
+
const isKeepAlive =
|
| 254 |
+
update.type === MessageUpdateType.Status &&
|
| 255 |
+
update.status === MessageUpdateStatus.KeepAlive;
|
| 256 |
+
|
| 257 |
+
if (!isKeepAlive) {
|
| 258 |
+
if (update.type === MessageUpdateType.Stream) {
|
| 259 |
+
const existingUpdates = messageToWriteTo.updates ?? [];
|
| 260 |
+
const lastUpdate = existingUpdates.at(-1);
|
| 261 |
+
if (lastUpdate?.type === MessageUpdateType.Stream) {
|
| 262 |
+
// Create fresh objects/arrays so the UI reacts to merged tokens
|
| 263 |
+
const merged = {
|
| 264 |
+
...lastUpdate,
|
| 265 |
+
token: (lastUpdate.token ?? "") + (update.token ?? ""),
|
| 266 |
+
};
|
| 267 |
+
messageToWriteTo.updates = [...existingUpdates.slice(0, -1), merged];
|
| 268 |
+
} else {
|
| 269 |
+
messageToWriteTo.updates = [...existingUpdates, update];
|
| 270 |
+
}
|
| 271 |
+
} else {
|
| 272 |
+
messageToWriteTo.updates = [...(messageToWriteTo.updates ?? []), update];
|
| 273 |
+
}
|
| 274 |
}
|
| 275 |
const currentTime = new Date();
|
| 276 |
|
|
|
|
| 408 |
|
| 409 |
function handleKeydown(event: KeyboardEvent) {
|
| 410 |
// Stop generation on ESC key when loading
|
| 411 |
+
if (event.key === "Escape" && $loading) {
|
| 412 |
event.preventDefault();
|
| 413 |
stopGeneration();
|
| 414 |
}
|
|
|
|
| 499 |
const navigatingAway =
|
| 500 |
navigation.to?.route.id !== page.route.id || navigation.to?.params?.id !== page.params.id;
|
| 501 |
|
| 502 |
+
if ($loading && navigatingAway) {
|
| 503 |
addBackgroundGeneration({ id: page.params.id, startedAt: Date.now() });
|
| 504 |
}
|
| 505 |
|
|
|
|
| 513 |
});
|
| 514 |
</script>
|
| 515 |
|
| 516 |
+
<svelte:window onkeydown={handleKeydown} />
|
| 517 |
|
| 518 |
<svelte:head>
|
| 519 |
<title>{title}</title>
|
|
@@ -11,6 +11,7 @@ import {
|
|
| 11 |
MessageUpdateType,
|
| 12 |
MessageReasoningUpdateType,
|
| 13 |
type MessageUpdate,
|
|
|
|
| 14 |
} from "$lib/types/MessageUpdate";
|
| 15 |
import { uploadFile } from "$lib/server/files/uploadFile";
|
| 16 |
import { convertLegacyConversation } from "$lib/utils/tree/convertLegacyConversation";
|
|
@@ -330,9 +331,27 @@ export async function POST({ request, locals, params, getClientAddress }) {
|
|
| 330 |
const metricsLabels = { model: metricsModelId };
|
| 331 |
|
| 332 |
const persistConversation = async () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
await collections.conversations.updateOne(
|
| 334 |
{ _id: convId },
|
| 335 |
-
{ $set: { messages:
|
| 336 |
);
|
| 337 |
};
|
| 338 |
|
|
@@ -470,15 +489,16 @@ export async function POST({ request, locals, params, getClientAddress }) {
|
|
| 470 |
}
|
| 471 |
}
|
| 472 |
|
| 473 |
-
// Append
|
| 474 |
if (
|
| 475 |
-
event.type !== MessageUpdateType.Stream &&
|
| 476 |
!(
|
| 477 |
event.type === MessageUpdateType.Status &&
|
| 478 |
event.status === MessageUpdateStatus.KeepAlive
|
| 479 |
)
|
| 480 |
) {
|
| 481 |
-
messageToWriteTo?.updates?.push(
|
|
|
|
|
|
|
| 482 |
}
|
| 483 |
|
| 484 |
// Avoid remote keylogging attack executed by watching packet lengths
|
|
|
|
| 11 |
MessageUpdateType,
|
| 12 |
MessageReasoningUpdateType,
|
| 13 |
type MessageUpdate,
|
| 14 |
+
type MessageStreamUpdate,
|
| 15 |
} from "$lib/types/MessageUpdate";
|
| 16 |
import { uploadFile } from "$lib/server/files/uploadFile";
|
| 17 |
import { convertLegacyConversation } from "$lib/utils/tree/convertLegacyConversation";
|
|
|
|
| 331 |
const metricsLabels = { model: metricsModelId };
|
| 332 |
|
| 333 |
const persistConversation = async () => {
|
| 334 |
+
const messagesForSave = conv.messages.map((msg) => {
|
| 335 |
+
const filteredUpdates =
|
| 336 |
+
msg.updates
|
| 337 |
+
?.filter(
|
| 338 |
+
(u) =>
|
| 339 |
+
!(u.type === MessageUpdateType.Status && u.status === MessageUpdateStatus.KeepAlive)
|
| 340 |
+
)
|
| 341 |
+
.map((u) => {
|
| 342 |
+
if (u.type !== MessageUpdateType.Stream) return u;
|
| 343 |
+
// Preserve existing len if already compressed, otherwise compute from token
|
| 344 |
+
const len = u.len ?? (u.token ?? "").length;
|
| 345 |
+
// store a lightweight marker to preserve ordering without duplicating content
|
| 346 |
+
return { type: MessageUpdateType.Stream, token: "", len } satisfies MessageStreamUpdate;
|
| 347 |
+
}) ?? [];
|
| 348 |
+
|
| 349 |
+
return { ...msg, updates: filteredUpdates };
|
| 350 |
+
});
|
| 351 |
+
|
| 352 |
await collections.conversations.updateOne(
|
| 353 |
{ _id: convId },
|
| 354 |
+
{ $set: { messages: messagesForSave, title: conv.title, updatedAt: new Date() } }
|
| 355 |
);
|
| 356 |
};
|
| 357 |
|
|
|
|
| 489 |
}
|
| 490 |
}
|
| 491 |
|
| 492 |
+
// Append updates for audit/replay (streams too, to preserve ordering)
|
| 493 |
if (
|
|
|
|
| 494 |
!(
|
| 495 |
event.type === MessageUpdateType.Status &&
|
| 496 |
event.status === MessageUpdateStatus.KeepAlive
|
| 497 |
)
|
| 498 |
) {
|
| 499 |
+
messageToWriteTo?.updates?.push(
|
| 500 |
+
event.type === MessageUpdateType.Stream ? { ...event } : event
|
| 501 |
+
);
|
| 502 |
}
|
| 503 |
|
| 504 |
// Avoid remote keylogging attack executed by watching packet lengths
|
|
@@ -88,34 +88,34 @@ body {
|
|
| 88 |
@apply border-[0.5px] bg-white text-gray-600 dark:border-gray-700 dark:!bg-gray-900 dark:bg-inherit dark:text-inherit;
|
| 89 |
}
|
| 90 |
|
| 91 |
-
/* Override prose-sm title sizes -
|
| 92 |
.prose-sm :where(h1):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
| 93 |
-
font-size: 1.
|
| 94 |
@apply font-semibold;
|
| 95 |
}
|
| 96 |
|
| 97 |
.prose-sm :where(h2):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
| 98 |
-
font-size:
|
| 99 |
@apply font-semibold;
|
| 100 |
}
|
| 101 |
|
| 102 |
.prose-sm :where(h3):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
| 103 |
-
font-size: 0.
|
| 104 |
@apply font-semibold;
|
| 105 |
}
|
| 106 |
|
| 107 |
.prose-sm :where(h4):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
| 108 |
-
font-size: 0.
|
| 109 |
@apply font-semibold;
|
| 110 |
}
|
| 111 |
|
| 112 |
.prose-sm :where(h5):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
| 113 |
-
font-size: 0.
|
| 114 |
@apply font-semibold;
|
| 115 |
}
|
| 116 |
|
| 117 |
.prose-sm :where(h6):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
| 118 |
-
font-size: 0.
|
| 119 |
@apply font-semibold;
|
| 120 |
}
|
| 121 |
}
|
|
|
|
| 88 |
@apply border-[0.5px] bg-white text-gray-600 dark:border-gray-700 dark:!bg-gray-900 dark:bg-inherit dark:text-inherit;
|
| 89 |
}
|
| 90 |
|
| 91 |
+
/* Override prose-sm title sizes - 75% of original */
|
| 92 |
.prose-sm :where(h1):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
| 93 |
+
font-size: 1.6em; /* 75% */
|
| 94 |
@apply font-semibold;
|
| 95 |
}
|
| 96 |
|
| 97 |
.prose-sm :where(h2):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
| 98 |
+
font-size: 1.07em; /* 75% */
|
| 99 |
@apply font-semibold;
|
| 100 |
}
|
| 101 |
|
| 102 |
.prose-sm :where(h3):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
| 103 |
+
font-size: 0.96em; /* 75% */
|
| 104 |
@apply font-semibold;
|
| 105 |
}
|
| 106 |
|
| 107 |
.prose-sm :where(h4):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
| 108 |
+
font-size: 0.8em; /* 75% */
|
| 109 |
@apply font-semibold;
|
| 110 |
}
|
| 111 |
|
| 112 |
.prose-sm :where(h5):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
| 113 |
+
font-size: 0.75em; /* 75% */
|
| 114 |
@apply font-semibold;
|
| 115 |
}
|
| 116 |
|
| 117 |
.prose-sm :where(h6):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
| 118 |
+
font-size: 0.7em; /* 75% */
|
| 119 |
@apply font-semibold;
|
| 120 |
}
|
| 121 |
}
|