Studio: HTML canvas cards in chat with auto-render, a Code view, and visible diffusion code (#6374)
* Studio: auto-render fenced HTML in chat replies as canvas cards After an assistant reply finishes, append a clickable canvas card for each fenced html block in its text, with no render_html tool call and no extra message. Fragments and non-collapsed documents are covered; full documents already collapsed in place and blocks rendered by the render_html tool are skipped so nothing shows twice. Hoists the fence helpers out of markdown-text.tsx into a shared module (html-fences.ts) and adds a line-based multi-fence scanner so several html blocks in one reply are all found. * Studio: add HTML Code button to canvas cards and keep diffusion code visible When Canvas mode is on or a diffusion model is loaded, the canvas card shows a Preview and an HTML Code button side by side; Code opens the panel source view. The requested view is threaded through openArtifact so the surface opens to preview or code. Diffusion no longer collapses its full HTML answer, so the raw code stays in the message and the trailing canvas card is appended next to it. * Studio: drop the Code button on diffusion cards since their code is already inline * Studio: address review feedback on HTML canvas auto-cards - Build the fence-body indent regex once per fence instead of per line. - Only skip full-doc fences the in-place collapse can render (plain unindented triple-backtick), so 4-backtick or indented docs still get a card. - Scan each text part on its own so a fence cannot stitch across a tool, source, or reasoning part. - Exclude diffusion replies from the collapse/skip gates and the card Code button, since diffusion keeps its HTML inline. --------- Co-authored-by: Daniel Han <michaelhan2050@gmail.com>
This commit is contained in:
parent
14188d6f45
commit
e1eaf6e202
7 changed files with 238 additions and 114 deletions
|
|
@ -4,6 +4,13 @@
|
|||
"use client";
|
||||
|
||||
import { ArtifactCard, useChatRuntimeStore } from "@/features/chat";
|
||||
import {
|
||||
getCodeFence,
|
||||
isFullHtmlDocument,
|
||||
isHtmlFence,
|
||||
isRenderableRenderHtmlToolPart,
|
||||
isSvgFence,
|
||||
} from "@/features/chat/artifacts/html-fences";
|
||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
import { preprocessLaTeX } from "@/lib/latex";
|
||||
import { openLink } from "@/lib/open-link";
|
||||
|
|
@ -45,62 +52,16 @@ const STREAMDOWN_COMPONENTS = {
|
|||
};
|
||||
const COPY_RESET_MS = 2000;
|
||||
const MERMAID_SOURCE_RE = /```mermaid\s*([\s\S]*?)```/i;
|
||||
const CODE_FENCE_RE = /^```([^\r\n`]*)\r?\n([\s\S]*?)\r?\n?```$/;
|
||||
const ACTION_PANEL_CLASS =
|
||||
"pointer-events-auto flex shrink-0 items-center gap-1";
|
||||
const ACTION_BUTTON_CLASS =
|
||||
"flex size-8 cursor-pointer items-center justify-center rounded-[10px] text-chat-icon-fg transition-all hover:bg-chat-icon-bg-hover hover:text-chat-icon-fg-hover disabled:cursor-not-allowed disabled:opacity-50";
|
||||
|
||||
type CodeFence = {
|
||||
language: string | null;
|
||||
source: string;
|
||||
};
|
||||
|
||||
type ToolCallPartLike = {
|
||||
type?: string;
|
||||
toolName?: string;
|
||||
args?: unknown;
|
||||
result?: unknown;
|
||||
};
|
||||
|
||||
function isRenderableRenderHtmlToolPart(part: unknown): boolean {
|
||||
const toolPart = part as ToolCallPartLike;
|
||||
if (toolPart.type !== "tool-call" || toolPart.toolName !== "render_html") {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
typeof toolPart.result === "string" &&
|
||||
toolPart.result.startsWith("Error:")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
typeof toolPart.result === "string" &&
|
||||
toolPart.result.startsWith("Rendered HTML canvas")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const args = toolPart.args as { code?: unknown } | undefined;
|
||||
return typeof args?.code === "string" && args.code.trim().length > 0;
|
||||
}
|
||||
|
||||
function getMermaidSource(blockContent: string): string | null {
|
||||
const source = blockContent.match(MERMAID_SOURCE_RE)?.[1]?.trim();
|
||||
return source && source.length > 0 ? source : null;
|
||||
}
|
||||
|
||||
function getCodeFence(blockContent: string): CodeFence | null {
|
||||
const match = blockContent.trimEnd().match(CODE_FENCE_RE);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
language: match[1]?.trim() || null,
|
||||
source: match[2],
|
||||
};
|
||||
}
|
||||
|
||||
function getCodeFilename(language: string | null) {
|
||||
const extByLanguage: Record<string, string> = {
|
||||
bash: "sh",
|
||||
|
|
@ -131,28 +92,6 @@ function getCodeFilename(language: string | null) {
|
|||
return `snippet.${ext}`;
|
||||
}
|
||||
|
||||
function isSvgFence(codeFence: CodeFence): boolean {
|
||||
const lang = codeFence.language?.toLowerCase() ?? "";
|
||||
if (lang === "svg") return true;
|
||||
if (lang === "xml" || lang === "html") {
|
||||
const trimmed = codeFence.source.trimStart();
|
||||
// Match <svg directly or <?xml ...?> followed by <svg
|
||||
if (trimmed.startsWith("<svg")) return true;
|
||||
if (trimmed.startsWith("<?xml") && trimmed.includes("<svg")) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isHtmlFence(codeFence: CodeFence): boolean {
|
||||
const lang = codeFence.language?.toLowerCase() ?? "";
|
||||
return lang === "html" && !isSvgFence(codeFence);
|
||||
}
|
||||
|
||||
function isFullHtmlDocument(source: string): boolean {
|
||||
const trimmed = source.trimStart();
|
||||
return /^<!doctype\s+html\b/i.test(trimmed) || /^<html[\s>]/i.test(trimmed);
|
||||
}
|
||||
|
||||
const UNSAFE_SVG_RE =
|
||||
/<script[\s>]|on\w+\s*=|javascript:|<foreignObject[\s>]|<iframe[\s>]|<embed[\s>]|<object[\s>]/i;
|
||||
|
||||
|
|
@ -285,15 +224,13 @@ function CodeBlockActions({
|
|||
);
|
||||
}
|
||||
|
||||
// DiffusionGemma renders its denoising live in the bubble (see DiffusionCanvas in
|
||||
// thread.tsx) and has the HTML canvas feature on by default, so a full-HTML answer
|
||||
// (e.g. a playable game) renders as an interactive card without the global toggle.
|
||||
// Collapse a full-HTML answer in place into an artifact card. Diffusion keeps the
|
||||
// raw code visible instead (the trailing MessageHtmlArtifacts appends its card).
|
||||
function StreamdownBlock(props: BlockProps) {
|
||||
const shouldCollapseHtmlArtifacts = useChatRuntimeStore(
|
||||
(state) =>
|
||||
state.artifactsEnabled ||
|
||||
state.collapseHtmlArtifacts ||
|
||||
state.loadedIsDiffusion,
|
||||
(state.artifactsEnabled || state.collapseHtmlArtifacts) &&
|
||||
!state.loadedIsDiffusion,
|
||||
);
|
||||
const messageHasRenderableRenderHtmlTool = useAuiState(({ message }) =>
|
||||
message.parts.some(isRenderableRenderHtmlToolPart),
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from "@/components/assistant-ui/generated-image-overlay-context";
|
||||
import { downloadImagePart } from "@/components/assistant-ui/image";
|
||||
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
||||
import { MessageHtmlArtifacts } from "@/components/assistant-ui/message-html-artifacts";
|
||||
import { MessageTiming } from "@/components/assistant-ui/message-timing";
|
||||
import { Reasoning, ReasoningGroup } from "@/components/assistant-ui/reasoning";
|
||||
import { RagSourcesGroup } from "@/components/assistant-ui/rag-sources";
|
||||
|
|
@ -3227,6 +3228,7 @@ const AssistantMessage: FC = () => {
|
|||
/>
|
||||
<SourcesGroup />
|
||||
<RagSourcesGroup />
|
||||
<MessageHtmlArtifacts />
|
||||
<MessageError />
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,12 +3,14 @@
|
|||
|
||||
"use client";
|
||||
|
||||
import { CodeToggleIcon } from "@/components/assistant-ui/code-toggle-icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuiState } from "@assistant-ui/react";
|
||||
import { LayoutTwoColumnIcon as Layout2ColumnIcon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useLayoutEffect, useMemo } from "react";
|
||||
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
|
||||
import type { ArtifactViewMode } from "./html-frame";
|
||||
import {
|
||||
hasAutoOpenedArtifact,
|
||||
rememberAutoOpenedArtifact,
|
||||
|
|
@ -20,6 +22,9 @@ import {
|
|||
createChatArtifact,
|
||||
} from "./types";
|
||||
|
||||
const CARD_BASE =
|
||||
"group/artifact-card relative flex min-h-[52px] cursor-pointer items-center overflow-hidden rounded-lg border border-border/70 bg-muted/15 px-3 py-2 text-left transition-colors hover:bg-muted/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 dark:bg-muted/10 dark:hover:bg-muted/20";
|
||||
|
||||
export function ArtifactCard({
|
||||
code,
|
||||
title,
|
||||
|
|
@ -40,6 +45,11 @@ export function ArtifactCard({
|
|||
isStreaming?: boolean;
|
||||
}) {
|
||||
const activeThreadId = useChatRuntimeStore((state) => state.activeThreadId);
|
||||
// Canvas mode collapses the raw code in place, so offer a Code button too.
|
||||
// Diffusion keeps its code inline, so it needs no Code button.
|
||||
const showCodeButton = useChatRuntimeStore(
|
||||
(state) => state.artifactsEnabled && !state.loadedIsDiffusion,
|
||||
);
|
||||
const messageIdFromContext = useAuiState(({ message }) => message.id);
|
||||
const threadIdFromContext = useAuiState(
|
||||
({ threads }) => threads.mainThreadId,
|
||||
|
|
@ -87,7 +97,7 @@ export function ArtifactCard({
|
|||
}
|
||||
|
||||
rememberAutoOpenedArtifact(artifact.id);
|
||||
openArtifact(artifact, { surface });
|
||||
openArtifact(artifact, { surface, view: "preview" });
|
||||
}, [
|
||||
artifact,
|
||||
autoOpen,
|
||||
|
|
@ -97,45 +107,63 @@ export function ArtifactCard({
|
|||
updateArtifact,
|
||||
]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"group/artifact-card relative my-2 flex min-h-[52px] w-full max-w-md cursor-pointer items-center overflow-hidden rounded-lg border border-border/70 bg-muted/15 px-3 py-2 text-left transition-colors hover:bg-muted/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"dark:bg-muted/10 dark:hover:bg-muted/20",
|
||||
isStreaming &&
|
||||
"border-border/80 bg-muted/20 dark:border-border/70 dark:bg-muted/15",
|
||||
className,
|
||||
)}
|
||||
onClick={() => openArtifact(artifact, { surface })}
|
||||
aria-label={`Open ${artifact.title}`}
|
||||
>
|
||||
{isStreaming ? (
|
||||
<span
|
||||
aria-hidden={true}
|
||||
className="artifact-card-shimmer pointer-events-none absolute inset-0 z-0 motion-reduce:hidden"
|
||||
/>
|
||||
) : null}
|
||||
<div className="relative z-10 flex min-w-0 flex-1 items-center gap-2.5">
|
||||
<HugeiconsIcon
|
||||
icon={Layout2ColumnIcon}
|
||||
strokeWidth={1.75}
|
||||
className="size-5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<span className="grid min-w-0 flex-1 gap-1">
|
||||
<span className="truncate text-sm font-medium leading-tight text-foreground">
|
||||
{artifact.title}
|
||||
</span>
|
||||
<span className="truncate text-[11px] leading-none text-muted-foreground">
|
||||
HTML canvas
|
||||
</span>
|
||||
</span>
|
||||
const renderButton = (view: ArtifactViewMode) => {
|
||||
const isCode = view === "source";
|
||||
return (
|
||||
<button
|
||||
key={view}
|
||||
type="button"
|
||||
className={cn(
|
||||
CARD_BASE,
|
||||
showCodeButton ? "min-w-0 flex-1" : "w-full max-w-md",
|
||||
isStreaming &&
|
||||
"border-border/80 bg-muted/20 dark:border-border/70 dark:bg-muted/15",
|
||||
)}
|
||||
onClick={() => openArtifact(artifact, { surface, view })}
|
||||
aria-label={`Open ${artifact.title} ${isCode ? "code" : "preview"}`}
|
||||
>
|
||||
{isStreaming ? (
|
||||
<span className="shimmer shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary motion-reduce:animate-none">
|
||||
Generating
|
||||
</span>
|
||||
<span
|
||||
aria-hidden={true}
|
||||
className="artifact-card-shimmer pointer-events-none absolute inset-0 z-0 motion-reduce:hidden"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
<div className="relative z-10 flex min-w-0 flex-1 items-center gap-2.5">
|
||||
{isCode ? (
|
||||
<CodeToggleIcon className="size-5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<HugeiconsIcon
|
||||
icon={Layout2ColumnIcon}
|
||||
strokeWidth={1.75}
|
||||
className="size-5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
<span className="grid min-w-0 flex-1 gap-1">
|
||||
<span className="truncate text-sm font-medium leading-tight text-foreground">
|
||||
{isCode ? "HTML Code" : artifact.title}
|
||||
</span>
|
||||
<span className="truncate text-[11px] leading-none text-muted-foreground">
|
||||
HTML canvas
|
||||
</span>
|
||||
</span>
|
||||
{isStreaming && !isCode ? (
|
||||
<span className="shimmer shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary motion-reduce:animate-none">
|
||||
Generating
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
if (!showCodeButton) {
|
||||
return <div className={cn("my-2", className)}>{renderButton("preview")}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("my-2 flex w-full max-w-xl gap-2", className)}>
|
||||
{renderButton("preview")}
|
||||
{renderButton("source")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
} from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
import { ArtifactHtmlFrame, type ArtifactViewMode } from "./html-frame";
|
||||
import { useChatArtifactsStore } from "./store";
|
||||
import type { ChatArtifact } from "./types";
|
||||
import { getArtifactFilename } from "./types";
|
||||
|
||||
|
|
@ -119,6 +120,8 @@ export function ArtifactSurface({
|
|||
onOpenFullscreen?: () => void;
|
||||
}) {
|
||||
const [viewMode, setViewMode] = useState<ArtifactViewMode>("preview");
|
||||
// Follow the view the opener asked for (Preview vs Code button), per artifact.
|
||||
const requestedView = useChatArtifactsStore((state) => state.requestedView);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyResetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const surfaceRef = useRef<HTMLElement>(null);
|
||||
|
|
@ -138,6 +141,10 @@ export function ArtifactSurface({
|
|||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setViewMode(requestedView);
|
||||
}, [artifact.id, requestedView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (variant !== "overlay") return;
|
||||
previousFocusRef.current = document.activeElement;
|
||||
|
|
|
|||
145
studio/frontend/src/features/chat/artifacts/html-fences.ts
Normal file
145
studio/frontend/src/features/chat/artifacts/html-fences.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
// Shared fenced-code helpers for the HTML-artifact render paths, hoisted from
|
||||
// markdown-text.tsx so the in-place collapse and the post-message auto-render
|
||||
// agree on what counts as a renderable HTML fence.
|
||||
|
||||
export type CodeFence = {
|
||||
language: string | null;
|
||||
source: string;
|
||||
};
|
||||
|
||||
// Matches one fenced block spanning the whole string (one pre-split block).
|
||||
export const CODE_FENCE_RE = /^```([^\r\n`]*)\r?\n([\s\S]*?)\r?\n?```$/;
|
||||
|
||||
export type ToolCallPartLike = {
|
||||
type?: string;
|
||||
toolName?: string;
|
||||
args?: unknown;
|
||||
result?: unknown;
|
||||
};
|
||||
|
||||
// True when a part is a render_html tool call with usable code or a non-error result.
|
||||
export function isRenderableRenderHtmlToolPart(part: unknown): boolean {
|
||||
const toolPart = part as ToolCallPartLike;
|
||||
if (toolPart.type !== "tool-call" || toolPart.toolName !== "render_html") {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
typeof toolPart.result === "string" &&
|
||||
toolPart.result.startsWith("Error:")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
typeof toolPart.result === "string" &&
|
||||
toolPart.result.startsWith("Rendered HTML canvas")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const args = toolPart.args as { code?: unknown } | undefined;
|
||||
return typeof args?.code === "string" && args.code.trim().length > 0;
|
||||
}
|
||||
|
||||
export function getCodeFence(blockContent: string): CodeFence | null {
|
||||
const match = blockContent.trimEnd().match(CODE_FENCE_RE);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
language: match[1]?.trim() || null,
|
||||
source: match[2],
|
||||
};
|
||||
}
|
||||
|
||||
export function isSvgFence(codeFence: CodeFence): boolean {
|
||||
const lang = codeFence.language?.toLowerCase() ?? "";
|
||||
if (lang === "svg") return true;
|
||||
if (lang === "xml" || lang === "html") {
|
||||
const trimmed = codeFence.source.trimStart();
|
||||
// <svg directly, or <?xml ...?> then <svg
|
||||
if (trimmed.startsWith("<svg")) return true;
|
||||
if (trimmed.startsWith("<?xml") && trimmed.includes("<svg")) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isHtmlFence(codeFence: CodeFence): boolean {
|
||||
const lang = codeFence.language?.toLowerCase() ?? "";
|
||||
return lang === "html" && !isSvgFence(codeFence);
|
||||
}
|
||||
|
||||
export function isFullHtmlDocument(source: string): boolean {
|
||||
const trimmed = source.trimStart();
|
||||
return /^<!doctype\s+html\b/i.test(trimmed) || /^<html[\s>]/i.test(trimmed);
|
||||
}
|
||||
|
||||
export interface HtmlFence {
|
||||
source: string;
|
||||
isFullDocument: boolean;
|
||||
// Plain 3-backtick unindented fence: the only form the in-place collapser
|
||||
// (CODE_FENCE_RE) recognizes, so only these may be skipped as already shown.
|
||||
isPlainFence: boolean;
|
||||
index: number;
|
||||
}
|
||||
|
||||
// Opening fence: up to 3 leading spaces, >=3 backticks, then a backtick-free info string.
|
||||
const FENCE_OPEN_RE = /^( {0,3})(`{3,})([^`\r\n]*)$/;
|
||||
|
||||
// Scan a full message for every closed ```html fence. Line-based so multiple
|
||||
// fences are found and backticks inside a <script> string never split a block
|
||||
// (a close must be its own fence line). Drops unterminated/SVG fences.
|
||||
export function extractHtmlFences(text: string): HtmlFence[] {
|
||||
const lines = text.split(/\r?\n/);
|
||||
const fences: HtmlFence[] = [];
|
||||
let i = 0;
|
||||
let index = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const open = lines[i].match(FENCE_OPEN_RE);
|
||||
if (!open) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const indent = open[1].length;
|
||||
const ticks = open[2].length;
|
||||
const lang = open[3].trim().split(/\s+/)[0]?.toLowerCase() ?? "";
|
||||
|
||||
const closeRe = new RegExp(`^ {0,3}\`{${ticks},}\\s*$`);
|
||||
// Strip up to `indent` leading spaces (CommonMark fence indentation).
|
||||
const indentRe = indent > 0 ? new RegExp(`^ {0,${indent}}`) : null;
|
||||
let j = i + 1;
|
||||
const body: string[] = [];
|
||||
let closed = false;
|
||||
while (j < lines.length) {
|
||||
if (closeRe.test(lines[j])) {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
body.push(indentRe ? lines[j].replace(indentRe, "") : lines[j]);
|
||||
j++;
|
||||
}
|
||||
|
||||
if (!closed) {
|
||||
break; // everything after an unterminated open fence is inside it
|
||||
}
|
||||
|
||||
if (lang === "html") {
|
||||
const source = body.join("\n");
|
||||
if (!isSvgFence({ language: "html", source })) {
|
||||
fences.push({
|
||||
source,
|
||||
isFullDocument: isFullHtmlDocument(source),
|
||||
isPlainFence: indent === 0 && ticks === 3,
|
||||
index: index++,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
i = j + 1;
|
||||
}
|
||||
|
||||
return fences;
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { ArtifactViewMode } from "./html-frame";
|
||||
import type { ChatArtifact, ChatArtifactSurface } from "./types";
|
||||
|
||||
const autoOpenedArtifactIds = new Set<string>();
|
||||
|
|
@ -22,9 +23,11 @@ type ChatArtifactsState = {
|
|||
artifactsById: Record<string, ChatArtifact>;
|
||||
selectedArtifactId: string | null;
|
||||
surface: ChatArtifactSurface;
|
||||
// View the surface should show on the next open (Preview vs Code button).
|
||||
requestedView: ArtifactViewMode;
|
||||
openArtifact: (
|
||||
artifact: ChatArtifact,
|
||||
options?: { surface?: ChatArtifactSurface },
|
||||
options?: { surface?: ChatArtifactSurface; view?: ArtifactViewMode },
|
||||
) => void;
|
||||
updateArtifact: (artifact: ChatArtifact) => void;
|
||||
closeArtifactSurface: () => void;
|
||||
|
|
@ -37,6 +40,7 @@ export const useChatArtifactsStore = create<ChatArtifactsState>((set) => ({
|
|||
artifactsById: {},
|
||||
selectedArtifactId: null,
|
||||
surface: "panel",
|
||||
requestedView: "preview",
|
||||
openArtifact: (artifact, options) =>
|
||||
set((state) => ({
|
||||
artifactsById: {
|
||||
|
|
@ -45,6 +49,7 @@ export const useChatArtifactsStore = create<ChatArtifactsState>((set) => ({
|
|||
},
|
||||
selectedArtifactId: artifact.id,
|
||||
surface: options?.surface ?? state.surface,
|
||||
requestedView: options?.view ?? "preview",
|
||||
})),
|
||||
updateArtifact: (artifact) =>
|
||||
set((state) =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue