Compare commits
16 commits
main
...
chat-artif
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcba6e785e | ||
|
|
388128ef45 | ||
|
|
9b4d6256d3 | ||
|
|
6ff7f42e07 | ||
|
|
9d50f5cd9c |
||
|
|
aaf28c0ede | ||
|
|
e8faa3d59b | ||
|
|
bc0ccc6768 | ||
|
|
b6faf7ffcd | ||
|
|
1aef0eca51 | ||
|
|
46adefa148 | ||
|
|
3dea07ed34 | ||
|
|
2c2f587346 | ||
|
|
1fd58891cd | ||
|
|
07972fd515 | ||
|
|
e0d3562c86 |
23 changed files with 1761 additions and 550 deletions
|
|
@ -4831,13 +4831,25 @@ class LlamaCppBackend:
|
|||
"content": _stripped,
|
||||
}
|
||||
)
|
||||
available_tool_names = [
|
||||
tool.get("function", {}).get("name")
|
||||
for tool in tools
|
||||
if isinstance(tool, dict)
|
||||
and isinstance(tool.get("function"), dict)
|
||||
]
|
||||
available_tool_names = [
|
||||
name for name in available_tool_names if name
|
||||
]
|
||||
tool_hint = (
|
||||
" or ".join(available_tool_names) or "an available tool"
|
||||
)
|
||||
conversation.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"STOP. Do NOT write code or explain. "
|
||||
"You MUST call a tool NOW. "
|
||||
"Call web_search or python immediately."
|
||||
f"Call {tool_hint} immediately."
|
||||
),
|
||||
}
|
||||
)
|
||||
|
|
@ -5009,7 +5021,12 @@ class LlamaCppBackend:
|
|||
arguments = json.loads(raw_args)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
if auto_heal_tool_calls:
|
||||
arguments = {"query": raw_args}
|
||||
heal_key = {
|
||||
"python": "code",
|
||||
"terminal": "command",
|
||||
"render_html": "code",
|
||||
}.get(tool_name, "query")
|
||||
arguments = {heal_key: raw_args}
|
||||
else:
|
||||
arguments = {"raw": raw_args}
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -66,7 +66,11 @@ def _status_for_tool(tool_name: str, arguments: dict) -> str:
|
|||
return f"Calling: {tool_name}"
|
||||
|
||||
|
||||
_CANONICAL_HEAL_ARG = {"python": "code", "terminal": "command"}
|
||||
_CANONICAL_HEAL_ARG = {
|
||||
"python": "code",
|
||||
"terminal": "command",
|
||||
"render_html": "code",
|
||||
}
|
||||
|
||||
|
||||
def _coerce_arguments(raw_args, *, heal: bool, tool_name: str = "") -> dict:
|
||||
|
|
|
|||
|
|
@ -502,12 +502,49 @@ TERMINAL_TOOL = {
|
|||
},
|
||||
}
|
||||
|
||||
ALL_TOOLS = [WEB_SEARCH_TOOL, PYTHON_TOOL, TERMINAL_TOOL]
|
||||
RENDER_HTML_TOOL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "render_html",
|
||||
"description": (
|
||||
"Render a self-contained HTML/CSS/JavaScript artifact for the user. "
|
||||
"Put the entire document in code, including any CSS in <style> tags "
|
||||
"and JavaScript in <script> tags."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string",
|
||||
"description": "A complete self-contained HTML document.",
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Short display title for the artifact.",
|
||||
},
|
||||
},
|
||||
"required": ["code"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ALL_TOOLS = [WEB_SEARCH_TOOL, PYTHON_TOOL, TERMINAL_TOOL, RENDER_HTML_TOOL]
|
||||
|
||||
|
||||
_TIMEOUT_UNSET = object()
|
||||
|
||||
|
||||
def _render_html_result(arguments: dict) -> str:
|
||||
code = arguments.get("code")
|
||||
if not isinstance(code, str) or not code.strip():
|
||||
return "Error: render_html requires a non-empty code string."
|
||||
title = arguments.get("title")
|
||||
if isinstance(title, str) and title.strip():
|
||||
safe_title = title.strip()[:120]
|
||||
return f"Rendered HTML artifact: {safe_title}"
|
||||
return "Rendered HTML artifact."
|
||||
|
||||
|
||||
def execute_tool(
|
||||
name: str,
|
||||
arguments: dict,
|
||||
|
|
@ -525,6 +562,8 @@ def execute_tool(
|
|||
f"execute_tool: name={name}, session_id={session_id}, timeout={timeout}"
|
||||
)
|
||||
effective_timeout = _EXEC_TIMEOUT if timeout is _TIMEOUT_UNSET else timeout
|
||||
if name == "render_html":
|
||||
return _render_html_result(arguments)
|
||||
if name == "web_search":
|
||||
return _web_search(
|
||||
arguments.get("query", ""),
|
||||
|
|
|
|||
|
|
@ -312,6 +312,7 @@ from starlette.requests import Request as _StarletteRequest # noqa: E402
|
|||
|
||||
|
||||
_CSP_SCRIPT_NONCE_HEADER = "x-internal-script-nonce"
|
||||
_ARTIFACT_PREVIEW_FRAME_PATH = "/api/inference/artifact-preview-frame"
|
||||
|
||||
|
||||
def _build_csp(script_nonce: "str | None" = None) -> str:
|
||||
|
|
@ -327,6 +328,7 @@ def _build_csp(script_nonce: "str | None" = None) -> str:
|
|||
"style-src 'self' 'unsafe-inline'; "
|
||||
f"{script_src}; "
|
||||
"font-src 'self' data:; "
|
||||
"frame-src 'self'; "
|
||||
"frame-ancestors 'none'; "
|
||||
"form-action 'self'; "
|
||||
"base-uri 'self'"
|
||||
|
|
@ -343,7 +345,8 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|||
if nonce is not None:
|
||||
del response.headers[_CSP_SCRIPT_NONCE_HEADER]
|
||||
response.headers.setdefault("Content-Security-Policy", _build_csp(nonce))
|
||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||
if request.url.path != _ARTIFACT_PREVIEW_FRAME_PATH:
|
||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("Referrer-Policy", "no-referrer")
|
||||
response.headers.setdefault(
|
||||
|
|
|
|||
|
|
@ -661,11 +661,12 @@ class ChatCompletionRequest(BaseModel):
|
|||
enabled_tools: Optional[list[str]] = Field(
|
||||
None,
|
||||
description = (
|
||||
"[x-unsloth] List of enabled tool names. Local GGUF models accept "
|
||||
"['web_search', 'python', 'terminal']. External providers accept "
|
||||
"['web_search', 'web_fetch', 'code_execution'] for Anthropic and "
|
||||
"['web_search', 'code_execution'] for OpenAI Responses. If None, "
|
||||
"all local tools are enabled and no server-side tools are forwarded."
|
||||
"[x-unsloth] List of enabled tool names. Local GGUF/safetensors models "
|
||||
"accept ['web_search', 'python', 'terminal', 'render_html']. External "
|
||||
"providers accept ['web_search', 'web_fetch', 'code_execution'] for "
|
||||
"Anthropic and ['web_search', 'code_execution', 'image_generation'] for "
|
||||
"OpenAI Responses. If None, all local tools are enabled and no "
|
||||
"server-side tools are forwarded."
|
||||
),
|
||||
)
|
||||
auto_heal_tool_calls: Optional[bool] = Field(
|
||||
|
|
|
|||
|
|
@ -239,6 +239,62 @@ router = APIRouter()
|
|||
studio_router = APIRouter()
|
||||
|
||||
|
||||
_ARTIFACT_PREVIEW_FRAME_CSP = (
|
||||
"default-src 'none'; "
|
||||
"script-src 'unsafe-inline'; "
|
||||
"style-src 'unsafe-inline'; "
|
||||
"img-src data: blob:; "
|
||||
"font-src data:; "
|
||||
"media-src data: blob:; "
|
||||
"connect-src 'none'; "
|
||||
"object-src 'none'; "
|
||||
"base-uri 'none'; "
|
||||
"form-action 'none'; "
|
||||
"frame-ancestors 'self'; "
|
||||
"sandbox allow-scripts"
|
||||
)
|
||||
_ARTIFACT_PREVIEW_FRAME_HTML = """<!doctype html>
|
||||
<html>
|
||||
<head><meta charset=\"utf-8\" /></head>
|
||||
<body>
|
||||
<script>
|
||||
(() => {
|
||||
const render = (html) => {
|
||||
document.open();
|
||||
document.write(html);
|
||||
document.close();
|
||||
};
|
||||
window.addEventListener("message", (event) => {
|
||||
const data = event.data;
|
||||
if (!data || data.type !== "unsloth:artifact-html" || typeof data.html !== "string") return;
|
||||
render(data.html);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
@studio_router.get("/artifact-preview-frame", include_in_schema = False)
|
||||
async def artifact_preview_frame():
|
||||
"""Serve the opaque sandbox shell used for client-side HTML artifacts."""
|
||||
|
||||
return Response(
|
||||
content = _ARTIFACT_PREVIEW_FRAME_HTML,
|
||||
media_type = "text/html; charset=utf-8",
|
||||
headers = {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Security-Policy": _ARTIFACT_PREVIEW_FRAME_CSP,
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
# SAMEORIGIN for browsers that ignore frame-ancestors; the
|
||||
# SecurityHeadersMiddleware uses setdefault so this takes
|
||||
# precedence over the global DENY.
|
||||
"X-Frame-Options": "SAMEORIGIN",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _detect_safetensors_features(backend, chat_template: Optional[str]) -> dict:
|
||||
"""Classify reasoning/tool capabilities via the GGUF classifier so
|
||||
flags match across backends. gpt-oss is overridden because Harmony
|
||||
|
|
@ -419,13 +475,23 @@ async def _await_cancel_then_close(cancel_event, resp) -> None:
|
|||
return
|
||||
|
||||
|
||||
# Appended to tool-use nudge to discourage plan-without-action
|
||||
# Appended to tool-use nudge to discourage plan-without-action.
|
||||
# Keep render_html guidance gated to turns where the artifact tool is actually
|
||||
# present in the tool schema; otherwise small local models can hallucinate a
|
||||
# missing tool call instead of following the fenced-HTML fallback prompt.
|
||||
_TOOL_ACTION_NUDGE = (
|
||||
" IMPORTANT: Always call tools directly -- never write code yourself."
|
||||
" Never describe what you plan to do -- just call the tool immediately."
|
||||
" For any code request, call the python tool. For any factual question, call web_search."
|
||||
" Do NOT output code blocks -- use the python tool instead."
|
||||
" For non-artifact code requests, call the python tool when it is available."
|
||||
" For factual questions that require current information, call web_search when it is available."
|
||||
" Do NOT output raw code blocks when an enabled tool can satisfy the request."
|
||||
)
|
||||
_ARTIFACT_TOOL_ACTION_NUDGE = " For HTML, CSS, or JavaScript artifact requests, call render_html when it is available."
|
||||
|
||||
|
||||
def _tool_action_nudge(has_artifact: bool) -> str:
|
||||
return _TOOL_ACTION_NUDGE + (_ARTIFACT_TOOL_ACTION_NUDGE if has_artifact else "")
|
||||
|
||||
|
||||
# Strip tool-call XML the speculative buffer in core/inference/llama_cpp.py
|
||||
# split across the visible/DRAIN boundary. Four leak shapes:
|
||||
|
|
@ -2399,6 +2465,7 @@ async def openai_chat_completions(
|
|||
_tool_names = {t["function"]["name"] for t in tools_to_use}
|
||||
_has_web = "web_search" in _tool_names
|
||||
_has_code = "python" in _tool_names or "terminal" in _tool_names
|
||||
_has_artifact = "render_html" in _tool_names
|
||||
|
||||
_date_line = f"The current date is {_date.today().isoformat()}."
|
||||
|
||||
|
|
@ -2420,34 +2487,31 @@ async def openai_chat_completions(
|
|||
"Use code execution for math, calculations, data processing, "
|
||||
"or to parse and analyze information from tool results."
|
||||
)
|
||||
_artifact_tips = (
|
||||
"Use render_html for HTML, CSS, or JavaScript artifact requests "
|
||||
"with one complete self-contained HTML document in the code argument."
|
||||
)
|
||||
|
||||
if _has_web and _has_code:
|
||||
_tool_tip_parts = []
|
||||
if _has_web:
|
||||
_tool_tip_parts.append(_web_tips)
|
||||
if _has_code:
|
||||
_tool_tip_parts.append(_code_tips)
|
||||
if _has_artifact:
|
||||
_tool_tip_parts.append(_artifact_tips)
|
||||
|
||||
if _tool_tip_parts:
|
||||
_nudge = (
|
||||
_date_line + " "
|
||||
"You have access to tools. When appropriate, prefer using "
|
||||
"tools rather than answering from memory. "
|
||||
+ _web_tips
|
||||
+ " "
|
||||
+ _code_tips
|
||||
)
|
||||
elif _has_code:
|
||||
_nudge = (
|
||||
_date_line + " "
|
||||
"You have access to tools. When appropriate, prefer using "
|
||||
"code execution rather than answering from memory. " + _code_tips
|
||||
)
|
||||
elif _has_web:
|
||||
_nudge = (
|
||||
_date_line + " "
|
||||
"You have access to tools. When appropriate, prefer using "
|
||||
"web search for up-to-date or uncertain factual "
|
||||
"information rather than answering from memory. " + _web_tips
|
||||
+ " ".join(_tool_tip_parts)
|
||||
)
|
||||
else:
|
||||
_nudge = ""
|
||||
|
||||
if _nudge:
|
||||
_nudge += _TOOL_ACTION_NUDGE
|
||||
_nudge += _tool_action_nudge(_has_artifact)
|
||||
# Append nudge to system prompt (preserve user's prompt)
|
||||
if system_prompt:
|
||||
system_prompt = system_prompt.rstrip() + "\n\n" + _nudge
|
||||
|
|
@ -2891,6 +2955,7 @@ async def openai_chat_completions(
|
|||
_sf_tool_names = {t["function"]["name"] for t in _sf_tools_to_use}
|
||||
_sf_has_web = "web_search" in _sf_tool_names
|
||||
_sf_has_code = "python" in _sf_tool_names or "terminal" in _sf_tool_names
|
||||
_sf_has_artifact = "render_html" in _sf_tool_names
|
||||
|
||||
_sf_date_line = f"The current date is {_date.today().isoformat()}."
|
||||
_sf_model_size_b = _extract_model_size_b(model_name)
|
||||
|
|
@ -2909,35 +2974,32 @@ async def openai_chat_completions(
|
|||
"Use code execution for math, calculations, data processing, "
|
||||
"or to parse and analyze information from tool results."
|
||||
)
|
||||
_sf_artifact_tips = (
|
||||
"Use render_html for HTML, CSS, or JavaScript artifact requests "
|
||||
"with one complete self-contained HTML document in the code argument."
|
||||
)
|
||||
|
||||
if _sf_has_web and _sf_has_code:
|
||||
_sf_tool_tip_parts = []
|
||||
if _sf_has_web:
|
||||
_sf_tool_tip_parts.append(_sf_web_tips)
|
||||
if _sf_has_code:
|
||||
_sf_tool_tip_parts.append(_sf_code_tips)
|
||||
if _sf_has_artifact:
|
||||
_sf_tool_tip_parts.append(_sf_artifact_tips)
|
||||
|
||||
if _sf_tool_tip_parts:
|
||||
_sf_nudge = (
|
||||
_sf_date_line + " "
|
||||
"You have access to tools. When appropriate, prefer using "
|
||||
"tools rather than answering from memory. "
|
||||
+ _sf_web_tips
|
||||
+ " "
|
||||
+ _sf_code_tips
|
||||
)
|
||||
elif _sf_has_code:
|
||||
_sf_nudge = (
|
||||
_sf_date_line + " "
|
||||
"You have access to tools. When appropriate, prefer using "
|
||||
"code execution rather than answering from memory. " + _sf_code_tips
|
||||
)
|
||||
elif _sf_has_web:
|
||||
_sf_nudge = (
|
||||
_sf_date_line + " "
|
||||
"You have access to tools. When appropriate, prefer using "
|
||||
"web search for up-to-date or uncertain factual "
|
||||
"information rather than answering from memory. " + _sf_web_tips
|
||||
+ " ".join(_sf_tool_tip_parts)
|
||||
)
|
||||
else:
|
||||
_sf_nudge = ""
|
||||
|
||||
_sf_system_prompt = system_prompt
|
||||
if _sf_nudge:
|
||||
_sf_nudge += _TOOL_ACTION_NUDGE
|
||||
_sf_nudge += _tool_action_nudge(_sf_has_artifact)
|
||||
if _sf_system_prompt:
|
||||
_sf_system_prompt = _sf_system_prompt.rstrip() + "\n\n" + _sf_nudge
|
||||
else:
|
||||
|
|
@ -4574,6 +4636,7 @@ async def anthropic_messages(
|
|||
_tool_names = {t["function"]["name"] for t in openai_tools}
|
||||
_has_web = "web_search" in _tool_names
|
||||
_has_code = "python" in _tool_names or "terminal" in _tool_names
|
||||
_has_artifact = "render_html" in _tool_names
|
||||
|
||||
_date_line = f"The current date is {_date.today().isoformat()}."
|
||||
_model_size_b = _extract_model_size_b(model_name)
|
||||
|
|
@ -4592,34 +4655,30 @@ async def anthropic_messages(
|
|||
"Use code execution for math, calculations, data processing, "
|
||||
"or to parse and analyze information from tool results."
|
||||
)
|
||||
_artifact_tips = (
|
||||
"Use render_html for HTML, CSS, or JavaScript artifact requests "
|
||||
"with one complete self-contained HTML document in the code argument."
|
||||
)
|
||||
|
||||
if _has_web and _has_code:
|
||||
_tool_tip_parts = []
|
||||
if _has_web:
|
||||
_tool_tip_parts.append(_web_tips)
|
||||
if _has_code:
|
||||
_tool_tip_parts.append(_code_tips)
|
||||
if _has_artifact:
|
||||
_tool_tip_parts.append(_artifact_tips)
|
||||
|
||||
if _tool_tip_parts:
|
||||
_nudge = (
|
||||
_date_line + " "
|
||||
"You have access to tools. When appropriate, prefer using "
|
||||
"tools rather than answering from memory. "
|
||||
+ _web_tips
|
||||
+ " "
|
||||
+ _code_tips
|
||||
)
|
||||
elif _has_code:
|
||||
_nudge = (
|
||||
_date_line + " "
|
||||
"You have access to tools. When appropriate, prefer using "
|
||||
"code execution rather than answering from memory. " + _code_tips
|
||||
)
|
||||
elif _has_web:
|
||||
_nudge = (
|
||||
_date_line + " "
|
||||
"You have access to tools. When appropriate, prefer using "
|
||||
"web search for up-to-date or uncertain factual "
|
||||
"information rather than answering from memory. " + _web_tips
|
||||
"tools rather than answering from memory. " + " ".join(_tool_tip_parts)
|
||||
)
|
||||
else:
|
||||
_nudge = ""
|
||||
|
||||
if _nudge:
|
||||
_nudge += _TOOL_ACTION_NUDGE
|
||||
_nudge += _tool_action_nudge(_has_artifact)
|
||||
# Inject into system prompt
|
||||
if openai_messages and openai_messages[0].get("role") == "system":
|
||||
openai_messages[0]["content"] = (
|
||||
|
|
|
|||
|
|
@ -3,18 +3,19 @@
|
|||
|
||||
"use client";
|
||||
|
||||
import { ArtifactCard } from "@/features/chat";
|
||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
import { preprocessLaTeX } from "@/lib/latex";
|
||||
import { openLink } from "@/lib/open-link";
|
||||
import { INTERNAL, useMessagePartText } from "@assistant-ui/react";
|
||||
import { Copy01Icon, Tick02Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { createCodePlugin } from "./code-plugin";
|
||||
import { createMathPlugin } from "@streamdown/math";
|
||||
import { mermaid } from "@streamdown/mermaid";
|
||||
import { DownloadIcon, Maximize2Icon, Minimize2Icon } from "lucide-react";
|
||||
import { DownloadIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Block, type BlockProps, Streamdown } from "streamdown";
|
||||
import { createCodePlugin } from "./code-plugin";
|
||||
import "katex/dist/katex.min.css";
|
||||
import { AudioPlayer } from "./audio-player";
|
||||
import { unslothDarkTheme, unslothLightTheme } from "./code-themes";
|
||||
|
|
@ -26,11 +27,7 @@ const code = createCodePlugin({
|
|||
const { withSmoothContextProvider } = INTERNAL;
|
||||
|
||||
const STREAMDOWN_COMPONENTS = {
|
||||
a: ({
|
||||
href,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"a">) => (
|
||||
a: ({ href, children, ...props }: React.ComponentProps<"a">) => (
|
||||
<a
|
||||
href={href}
|
||||
rel="noopener noreferrer"
|
||||
|
|
@ -123,7 +120,8 @@ function isHtmlFence(codeFence: CodeFence): boolean {
|
|||
return lang === "html" && !isSvgFence(codeFence);
|
||||
}
|
||||
|
||||
const UNSAFE_SVG_RE = /<script[\s>]|on\w+\s*=|javascript:|<foreignObject[\s>]|<iframe[\s>]|<embed[\s>]|<object[\s>]/i;
|
||||
const UNSAFE_SVG_RE =
|
||||
/<script[\s>]|on\w+\s*=|javascript:|<foreignObject[\s>]|<iframe[\s>]|<embed[\s>]|<object[\s>]/i;
|
||||
|
||||
function sanitizeSvg(source: string): string | null {
|
||||
if (UNSAFE_SVG_RE.test(source)) return null;
|
||||
|
|
@ -145,96 +143,6 @@ function SvgPreview({ source }: { source: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
const HTML_PREVIEW_DEFAULT_HEIGHT = 400;
|
||||
const HTML_PREVIEW_MAX_HEIGHT = 800;
|
||||
|
||||
function HtmlPreview({ source }: { source: string }) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [height, setHeight] = useState(HTML_PREVIEW_DEFAULT_HEIGHT);
|
||||
const [enlarged, setEnlarged] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MessageEvent) => {
|
||||
if (e.source !== iframeRef.current?.contentWindow) return;
|
||||
if (typeof e.data?.htmlPreviewHeight === "number") {
|
||||
setHeight(Math.min(Math.max(e.data.htmlPreviewHeight, 100), HTML_PREVIEW_MAX_HEIGHT));
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", handler);
|
||||
return () => window.removeEventListener("message", handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enlarged) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setEnlarged(false);
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [enlarged]);
|
||||
|
||||
const resizeScript = `<script>new ResizeObserver(()=>{
|
||||
parent.postMessage({htmlPreviewHeight:document.documentElement.scrollHeight},"*");
|
||||
}).observe(document.documentElement);</script>`;
|
||||
|
||||
const srcDoc = source + resizeScript;
|
||||
|
||||
if (enlarged) {
|
||||
return (
|
||||
<>
|
||||
<div className="mt-2 overflow-hidden rounded-lg border border-border" style={{ height }}>
|
||||
{/* Placeholder keeps layout stable while overlay is shown */}
|
||||
</div>
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex flex-col bg-background/80 backdrop-blur-sm"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setEnlarged(false); }}
|
||||
>
|
||||
<div className="flex items-center justify-end gap-2 px-4 py-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onClick={() => setEnlarged(false)}
|
||||
title="Exit fullscreen (Esc)"
|
||||
>
|
||||
<Minimize2Icon className="size-4" />
|
||||
Exit fullscreen
|
||||
</button>
|
||||
</div>
|
||||
<div className="mx-4 mb-4 flex-1 overflow-hidden rounded-lg border border-border bg-background">
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={srcDoc}
|
||||
sandbox="allow-scripts"
|
||||
style={{ width: "100%", height: "100%", border: "none", display: "block" }}
|
||||
title="HTML preview"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/html-preview relative mt-2 overflow-hidden rounded-lg border border-border">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-2 right-2 z-10 rounded-md border border-border bg-background/80 p-1.5 text-muted-foreground opacity-0 transition-all hover:bg-muted hover:text-foreground group-hover/html-preview:opacity-100 supports-[backdrop-filter]:backdrop-blur"
|
||||
onClick={() => setEnlarged(true)}
|
||||
title="Enlarge preview"
|
||||
>
|
||||
<Maximize2Icon className="size-4" />
|
||||
</button>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={srcDoc}
|
||||
sandbox="allow-scripts"
|
||||
style={{ width: "100%", height, border: "none", display: "block" }}
|
||||
title="HTML preview"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function downloadTextFile(filename: string, text: string): void {
|
||||
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
|
@ -362,7 +270,9 @@ function StreamdownBlock(props: BlockProps) {
|
|||
return (
|
||||
<div className="relative isolate">
|
||||
<div className="my-4 rounded-xl border border-border bg-muted/30 p-4">
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">svg</div>
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
svg
|
||||
</div>
|
||||
<pre className="overflow-x-auto text-xs text-muted-foreground whitespace-pre-wrap break-all">
|
||||
<code>{codeFence.source}</code>
|
||||
</pre>
|
||||
|
|
@ -374,7 +284,7 @@ function StreamdownBlock(props: BlockProps) {
|
|||
if (props.isIncomplete && codeFence && isHtmlFence(codeFence)) {
|
||||
return (
|
||||
<div className="my-4 flex h-48 items-center justify-center rounded-xl border border-border bg-muted/30 text-sm text-muted-foreground animate-pulse">
|
||||
Loading preview...
|
||||
Loading artifact preview...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -389,8 +299,18 @@ function StreamdownBlock(props: BlockProps) {
|
|||
}
|
||||
|
||||
if (codeFence) {
|
||||
const svgSource = !props.isIncomplete && isSvgFence(codeFence) ? sanitizeSvg(codeFence.source) : null;
|
||||
const htmlSource = !props.isIncomplete && isHtmlFence(codeFence) ? codeFence.source : null;
|
||||
const svgSource =
|
||||
!props.isIncomplete && isSvgFence(codeFence)
|
||||
? sanitizeSvg(codeFence.source)
|
||||
: null;
|
||||
const htmlSource =
|
||||
!props.isIncomplete && isHtmlFence(codeFence) ? codeFence.source : null;
|
||||
if (htmlSource) {
|
||||
return (
|
||||
<ArtifactCard code={htmlSource} title="HTML preview" source="fence" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative isolate">
|
||||
|
|
@ -402,7 +322,6 @@ function StreamdownBlock(props: BlockProps) {
|
|||
/>
|
||||
</div>
|
||||
{svgSource && <SvgPreview source={svgSource} />}
|
||||
{htmlSource && <HtmlPreview source={htmlSource} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
|
|||
import { ToolGroup } from "@/components/assistant-ui/tool-group";
|
||||
import { CodeExecutionToolUI } from "@/components/assistant-ui/tool-ui-code-execution";
|
||||
import { ImageGenerationToolUI } from "@/components/assistant-ui/tool-ui-image-generation";
|
||||
import { RenderHtmlToolUI } from "@/components/assistant-ui/tool-ui-render-html";
|
||||
import { PythonToolUI } from "@/components/assistant-ui/tool-ui-python";
|
||||
import { TerminalToolUI } from "@/components/assistant-ui/tool-ui-terminal";
|
||||
import { WebSearchToolUI } from "@/components/assistant-ui/tool-ui-web-search";
|
||||
|
|
@ -67,6 +68,7 @@ import {
|
|||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
DownloadIcon,
|
||||
FileTextIcon,
|
||||
GlobeIcon,
|
||||
HeadphonesIcon,
|
||||
ImageIcon,
|
||||
|
|
@ -79,7 +81,12 @@ import {
|
|||
TerminalIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { Copy01Icon, Delete02Icon, Edit03Icon, Tick02Icon } from "@hugeicons/core-free-icons";
|
||||
import {
|
||||
Copy01Icon,
|
||||
Delete02Icon,
|
||||
Edit03Icon,
|
||||
Tick02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
type ChangeEvent,
|
||||
|
|
@ -98,11 +105,7 @@ export const Thread: FC<{
|
|||
hideComposer?: boolean;
|
||||
hideWelcome?: boolean;
|
||||
targetThreadId?: string;
|
||||
}> = ({
|
||||
hideComposer,
|
||||
hideWelcome,
|
||||
targetThreadId,
|
||||
}) => {
|
||||
}> = ({ hideComposer, hideWelcome, targetThreadId }) => {
|
||||
// Intent-aware autoscroll: replaces assistant-ui's built-in autoscroll
|
||||
// to prevent the streaming-mutation race that makes the viewport snap
|
||||
// back to the bottom while the user is scrolling up (see the hook for
|
||||
|
|
@ -136,7 +139,9 @@ export const Thread: FC<{
|
|||
)}
|
||||
>
|
||||
{!hideWelcome && (
|
||||
<AuiIf condition={({ thread }) => thread.isEmpty && !thread.isLoading}>
|
||||
<AuiIf
|
||||
condition={({ thread }) => thread.isEmpty && !thread.isLoading}
|
||||
>
|
||||
<ThreadWelcome hideComposer={hideComposer} />
|
||||
</AuiIf>
|
||||
)}
|
||||
|
|
@ -225,7 +230,8 @@ const ThreadWelcome: FC<{ hideComposer?: boolean }> = ({ hideComposer }) => {
|
|||
useEffect(() => {
|
||||
const hour = new Date().getHours();
|
||||
if (hour >= 6 && hour < 12) setCurrentEmoji("large sloth drink.png");
|
||||
else if (hour >= 12 && hour < 17) setCurrentEmoji("sloth magnify final.png");
|
||||
else if (hour >= 12 && hour < 17)
|
||||
setCurrentEmoji("sloth magnify final.png");
|
||||
else if (hour >= 17 && hour < 21) setCurrentEmoji("sloth shy large.png");
|
||||
else setCurrentEmoji("unsloth-gem.png");
|
||||
}, []);
|
||||
|
|
@ -240,11 +246,7 @@ const ThreadWelcome: FC<{ hideComposer?: boolean }> = ({ hideComposer }) => {
|
|||
<div className="aui-thread-welcome-center flex w-full grow flex-col items-center justify-center pb-[48px]">
|
||||
<div className="aui-thread-welcome-message flex w-full flex-col justify-center gap-6 px-4">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<img
|
||||
src={currentEmojiSrc}
|
||||
alt="Sloth mascot"
|
||||
className="size-20"
|
||||
/>
|
||||
<img src={currentEmojiSrc} alt="Sloth mascot" className="size-20" />
|
||||
<h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in font-heading font-semibold text-2xl tracking-[-0.02em] duration-200">
|
||||
Chat with your model
|
||||
</h1>
|
||||
|
|
@ -294,7 +296,8 @@ const PendingAudioChip: FC = () => {
|
|||
};
|
||||
|
||||
const Composer: FC<{ disabled?: boolean }> = ({ disabled }) => {
|
||||
const { inputProps, isComposing, isComposingRef } = useImeComposerInputHandlers();
|
||||
const { inputProps, isComposing, isComposingRef } =
|
||||
useImeComposerInputHandlers();
|
||||
const composerText = useAuiState(({ composer }) => composer.text);
|
||||
const hasAttachments = useAuiState(
|
||||
({ composer }) => composer.attachments.length > 0,
|
||||
|
|
@ -304,7 +307,9 @@ const Composer: FC<{ disabled?: boolean }> = ({ disabled }) => {
|
|||
(attachment) => attachment.status.type === "running",
|
||||
),
|
||||
);
|
||||
const hasPendingAudio = useChatRuntimeStore((s) => Boolean(s.pendingAudioName));
|
||||
const hasPendingAudio = useChatRuntimeStore((s) =>
|
||||
Boolean(s.pendingAudioName),
|
||||
);
|
||||
const hasSendableContent =
|
||||
composerText.trim().length > 0 || hasAttachments || hasPendingAudio;
|
||||
|
||||
|
|
@ -342,7 +347,10 @@ const Composer: FC<{ disabled?: boolean }> = ({ disabled }) => {
|
|||
/>
|
||||
<ComposerAction
|
||||
disabled={
|
||||
disabled || !hasSendableContent || isComposing || hasPendingAttachments
|
||||
disabled ||
|
||||
!hasSendableContent ||
|
||||
isComposing ||
|
||||
hasPendingAttachments
|
||||
}
|
||||
blockSend={() =>
|
||||
!hasSendableContent || isComposingRef.current || hasPendingAttachments
|
||||
|
|
@ -553,7 +561,6 @@ const ComposerAudioUpload: FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
|
||||
const ReasoningToggle: FC = () => {
|
||||
const modelLoaded = useChatRuntimeStore(
|
||||
(s) => !!s.params.checkpoint && !s.modelLoading,
|
||||
|
|
@ -565,8 +572,12 @@ const ReasoningToggle: FC = () => {
|
|||
const setReasoningEnabled = useChatRuntimeStore((s) => s.setReasoningEnabled);
|
||||
const reasoningStyle = useChatRuntimeStore((s) => s.reasoningStyle);
|
||||
const reasoningEffort = useChatRuntimeStore((s) => s.reasoningEffort);
|
||||
const supportsReasoningOff = useChatRuntimeStore((s) => s.supportsReasoningOff);
|
||||
const reasoningEffortLevels = useChatRuntimeStore((s) => s.reasoningEffortLevels);
|
||||
const supportsReasoningOff = useChatRuntimeStore(
|
||||
(s) => s.supportsReasoningOff,
|
||||
);
|
||||
const reasoningEffortLevels = useChatRuntimeStore(
|
||||
(s) => s.reasoningEffortLevels,
|
||||
);
|
||||
const setReasoningEffort = useChatRuntimeStore((s) => s.setReasoningEffort);
|
||||
const lastOpenRouterChosenModel = useChatRuntimeStore(
|
||||
(s) => s.lastOpenRouterChosenModel,
|
||||
|
|
@ -619,7 +630,8 @@ const ReasoningToggle: FC = () => {
|
|||
effectiveReasoningEnabled && reasoningEffort !== "none";
|
||||
const disabled = !(modelLoaded && effectiveSupportsReasoning);
|
||||
const formatEffortLabel = (level: typeof reasoningEffort): string => {
|
||||
if (level !== "xhigh") return level.charAt(0).toUpperCase() + level.slice(1);
|
||||
if (level !== "xhigh")
|
||||
return level.charAt(0).toUpperCase() + level.slice(1);
|
||||
const normalized = externalSelection?.modelId?.trim().toLowerCase() ?? "";
|
||||
if (
|
||||
normalized.startsWith("claude-opus-4-6") ||
|
||||
|
|
@ -677,23 +689,25 @@ const ReasoningToggle: FC = () => {
|
|||
{effectiveReasoningEffortLevels
|
||||
.filter((level) => level !== "none")
|
||||
.map((level) => (
|
||||
<DropdownMenuItem
|
||||
key={level}
|
||||
onSelect={() => {
|
||||
setReasoningEffort(level);
|
||||
setReasoningEnabled(true);
|
||||
applyQwenThinkingParams(true);
|
||||
// Kimi's $web_search builtin forbids thinking, so
|
||||
// enabling thinking flips the Search pill off.
|
||||
if (isKimiExternal && toolsEnabled) {
|
||||
setToolsEnabled(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{formatEffortLabel(level)}
|
||||
{effectiveReasoningVisualEnabled && reasoningEffort === level ? " \u2713" : ""}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuItem
|
||||
key={level}
|
||||
onSelect={() => {
|
||||
setReasoningEffort(level);
|
||||
setReasoningEnabled(true);
|
||||
applyQwenThinkingParams(true);
|
||||
// Kimi's $web_search builtin forbids thinking, so
|
||||
// enabling thinking flips the Search pill off.
|
||||
if (isKimiExternal && toolsEnabled) {
|
||||
setToolsEnabled(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{formatEffortLabel(level)}
|
||||
{effectiveReasoningVisualEnabled && reasoningEffort === level
|
||||
? " \u2713"
|
||||
: ""}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
|
@ -808,8 +822,7 @@ const WebSearchToggle: FC = () => {
|
|||
? externalProviders.find((p) => p.id === externalSelection.providerId)
|
||||
: undefined;
|
||||
const isKimiExternal = selectedExternalProvider?.providerType === "kimi";
|
||||
const disabled =
|
||||
!modelLoaded || !(supportsTools || supportsBuiltinWebSearch);
|
||||
const disabled = !modelLoaded || !(supportsTools || supportsBuiltinWebSearch);
|
||||
|
||||
return (
|
||||
<button
|
||||
|
|
@ -899,7 +912,9 @@ const ImagesToggle: FC = () => {
|
|||
className="composer-pill-btn"
|
||||
data-active={imageToolsEnabled && !disabled ? "true" : "false"}
|
||||
aria-label={
|
||||
imageToolsEnabled ? "Disable image generation" : "Enable image generation"
|
||||
imageToolsEnabled
|
||||
? "Disable image generation"
|
||||
: "Enable image generation"
|
||||
}
|
||||
>
|
||||
<ImageIcon className="size-3.5" />
|
||||
|
|
@ -908,6 +923,29 @@ const ImagesToggle: FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const ArtifactsToggle: FC = () => {
|
||||
const modelLoaded = useChatRuntimeStore(
|
||||
(s) => !!s.params.checkpoint && !s.modelLoading,
|
||||
);
|
||||
const artifactsEnabled = useChatRuntimeStore((s) => s.artifactsEnabled);
|
||||
const setArtifactsEnabled = useChatRuntimeStore((s) => s.setArtifactsEnabled);
|
||||
const disabled = !modelLoaded;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setArtifactsEnabled(!artifactsEnabled)}
|
||||
className="composer-pill-btn"
|
||||
data-active={artifactsEnabled && !disabled ? "true" : "false"}
|
||||
aria-label={artifactsEnabled ? "Disable artifacts" : "Enable artifacts"}
|
||||
>
|
||||
<FileTextIcon className="size-3.5" />
|
||||
<span>Artifacts</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const ToolStatusDisplay: FC = () => {
|
||||
const toolStatus = useChatRuntimeStore((s) => s.toolStatus);
|
||||
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
|
||||
|
|
@ -980,6 +1018,7 @@ const ComposerAction: FC<{ disabled?: boolean; blockSend?: () => boolean }> = ({
|
|||
<WebSearchToggle />
|
||||
<CodeToolsToggle />
|
||||
<ImagesToggle />
|
||||
<ArtifactsToggle />
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<ComposerPrimitive.If dictation={false}>
|
||||
|
|
@ -1107,6 +1146,7 @@ const AssistantMessage: FC = () => {
|
|||
terminal: TerminalToolUI,
|
||||
code_execution: CodeExecutionToolUI,
|
||||
image_generation: ImageGenerationToolUI,
|
||||
render_html: RenderHtmlToolUI,
|
||||
},
|
||||
Fallback: ToolFallback,
|
||||
},
|
||||
|
|
@ -1284,7 +1324,11 @@ const UserActionBar: FC = () => {
|
|||
<CopyButton />
|
||||
<ActionBarPrimitive.Edit asChild={true}>
|
||||
<TooltipIconButton tooltip="Edit" className="aui-user-action-edit">
|
||||
<HugeiconsIcon icon={Edit03Icon} strokeWidth={1.75} className="size-icon" />
|
||||
<HugeiconsIcon
|
||||
icon={Edit03Icon}
|
||||
strokeWidth={1.75}
|
||||
className="size-icon"
|
||||
/>
|
||||
</TooltipIconButton>
|
||||
</ActionBarPrimitive.Edit>
|
||||
<DeleteMessageButton />
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
type FC,
|
||||
type PropsWithChildren,
|
||||
} from "react";
|
||||
import { useAuiState } from "@assistant-ui/react";
|
||||
import { ChevronDownIcon, LoaderIcon } from "lucide-react";
|
||||
import { Wrench01Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
|
|
@ -27,7 +28,8 @@ const toolGroupVariants = cva("aui-tool-group-root group/tool-group w-full", {
|
|||
variant: {
|
||||
outline: "corner-squircle rounded-lg border py-3",
|
||||
ghost: "rounded-lg bg-muted/10 py-2",
|
||||
muted: "corner-squircle rounded-lg border border-muted-foreground/30 bg-muted/30 py-3",
|
||||
muted:
|
||||
"corner-squircle rounded-lg border border-muted-foreground/30 bg-muted/30 py-3",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "ghost" },
|
||||
|
|
@ -209,9 +211,17 @@ const ToolGroupImpl: FC<
|
|||
PropsWithChildren<{ startIndex: number; endIndex: number }>
|
||||
> = ({ children, startIndex, endIndex }) => {
|
||||
const toolCount = endIndex - startIndex + 1;
|
||||
const containsArtifactTool = useAuiState(({ message }) =>
|
||||
message.parts
|
||||
.slice(startIndex, endIndex + 1)
|
||||
.some(
|
||||
(part) => part.type === "tool-call" && part.toolName === "render_html",
|
||||
),
|
||||
);
|
||||
|
||||
// Single tool call — render directly without wrapper
|
||||
if (toolCount <= 1) {
|
||||
// Single tool calls and artifacts render directly so cards never hide inside
|
||||
// a collapsed tool group.
|
||||
if (toolCount <= 1 || containsArtifactTool) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"use client";
|
||||
|
||||
import { ArtifactCard } from "@/features/chat";
|
||||
import { BrowserIcon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
type ToolCallMessagePartComponent,
|
||||
useToolArgsStatus,
|
||||
} from "@assistant-ui/react";
|
||||
import { memo } from "react";
|
||||
|
||||
// Context7 assistant-ui docs: tool UIs can read streaming args via
|
||||
// useToolArgsStatus, so render_html does not need to wait for tool completion.
|
||||
type RenderHtmlArgs = Record<string, unknown> & {
|
||||
code?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
const RenderHtmlToolUIImpl: ToolCallMessagePartComponent = ({
|
||||
args,
|
||||
result,
|
||||
status,
|
||||
toolCallId,
|
||||
}) => {
|
||||
const { propStatus } = useToolArgsStatus<RenderHtmlArgs>();
|
||||
const parsedArgs = (args as RenderHtmlArgs) ?? {};
|
||||
const code = typeof parsedArgs.code === "string" ? parsedArgs.code : "";
|
||||
const hasCode = code.trim().length > 0;
|
||||
const title =
|
||||
typeof parsedArgs.title === "string" ? parsedArgs.title : "HTML artifact";
|
||||
const isRunning = status?.type === "running";
|
||||
const codeIsStreaming = propStatus.code === "streaming";
|
||||
|
||||
if (hasCode) {
|
||||
return (
|
||||
<ArtifactCard
|
||||
code={code}
|
||||
title={title}
|
||||
source="tool"
|
||||
sourceToolCallId={toolCallId}
|
||||
autoOpen={true}
|
||||
isStreaming={isRunning || codeIsStreaming}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Surface the backend error when the tool call completed with invalid
|
||||
// args. Backend success results start with "Rendered HTML artifact";
|
||||
// error results start with "Error:".
|
||||
const errorText =
|
||||
status?.type === "complete" &&
|
||||
typeof result === "string" &&
|
||||
result.startsWith("Error:")
|
||||
? result
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="relative my-2 flex min-h-[52px] w-full max-w-md items-center overflow-hidden rounded-lg border border-border/70 bg-muted/15 px-3 py-2 text-left dark:bg-muted/10">
|
||||
<div className="relative z-10 flex min-w-0 flex-1 items-center gap-2.5">
|
||||
<HugeiconsIcon
|
||||
icon={BrowserIcon}
|
||||
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">
|
||||
{errorText ? "Artifact error" : "Generating artifact"}
|
||||
</span>
|
||||
<span className="truncate text-[11px] leading-none text-muted-foreground">
|
||||
{errorText ?? "HTML artifact"}
|
||||
</span>
|
||||
</span>
|
||||
{errorText ? null : (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const RenderHtmlToolUI = memo(
|
||||
RenderHtmlToolUIImpl,
|
||||
) as unknown as ToolCallMessagePartComponent;
|
||||
RenderHtmlToolUI.displayName = "RenderHtmlToolUI";
|
||||
|
|
@ -1,54 +1,54 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import type * as React from "react";
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Group>): React.ReactElement {
|
||||
return (
|
||||
<ResizablePrimitive.Group
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ResizablePanel({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Panel>): React.ReactElement {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
|
||||
withHandle?: boolean;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<ResizablePrimitive.Separator
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="bg-border h-6 w-1 rounded-lg z-10 flex shrink-0" />
|
||||
)}
|
||||
</ResizablePrimitive.Separator>
|
||||
);
|
||||
}
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||
import type * as React from "react";
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Group>): React.ReactElement {
|
||||
return (
|
||||
<ResizablePrimitive.Group
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ResizablePanel({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Panel>): React.ReactElement {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
|
||||
withHandle?: boolean;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<ResizablePrimitive.Separator
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"group bg-border/80 relative z-10 flex w-px cursor-col-resize items-center justify-center transition-[background-color,box-shadow] duration-150 ease-out after:absolute after:inset-y-0 after:left-1/2 after:w-2 after:-translate-x-1/2 hover:bg-primary/80 hover:shadow-[0_0_16px_rgba(23,184,139,0.55)] active:bg-primary/90 active:shadow-[0_0_18px_rgba(23,184,139,0.7)] focus-visible:bg-primary/80 focus-visible:ring-1 focus-visible:ring-primary/50 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:cursor-row-resize data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-2 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="z-10 flex h-6 w-1 shrink-0 rounded-lg bg-border transition-[background-color,box-shadow,transform] duration-150 ease-out group-hover:scale-y-110 group-hover:bg-primary/80 group-hover:shadow-[0_0_12px_rgba(23,184,139,0.65)] group-active:bg-primary" />
|
||||
)}
|
||||
</ResizablePrimitive.Separator>
|
||||
);
|
||||
}
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { getAuthToken } from "@/features/auth/session";
|
||||
import { getAuthToken } from "@/features/auth";
|
||||
import { apiUrl } from "@/lib/api-base";
|
||||
import { toast } from "@/lib/toast";
|
||||
import type { MessageTiming, ToolCallMessagePart } from "@assistant-ui/core";
|
||||
|
|
@ -37,12 +37,12 @@ import type {
|
|||
OpenAIMessageContent,
|
||||
} from "../types/api";
|
||||
import type { ChatModelSummary } from "../types/runtime";
|
||||
import { getImageInputUnavailableReason } from "../utils/image-input-support";
|
||||
import {
|
||||
getStoredChatThread,
|
||||
listStoredChatThreads,
|
||||
updateStoredChatThread,
|
||||
} from "../utils/chat-history-storage";
|
||||
import { getImageInputUnavailableReason } from "../utils/image-input-support";
|
||||
import {
|
||||
hasClosedThinkTag,
|
||||
parseAssistantContent,
|
||||
|
|
@ -833,7 +833,13 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
// Re-read store after potential auto-load / model ready wait
|
||||
runtime = useChatRuntimeStore.getState();
|
||||
const { params } = runtime;
|
||||
const { supportsTools, toolsEnabled, codeToolsEnabled, imageToolsEnabled } = runtime;
|
||||
const {
|
||||
supportsTools,
|
||||
toolsEnabled,
|
||||
codeToolsEnabled,
|
||||
imageToolsEnabled,
|
||||
artifactsEnabled,
|
||||
} = runtime;
|
||||
const externalSelection = parseExternalModelId(params.checkpoint);
|
||||
const isExternalRequest = externalSelection !== null;
|
||||
if (
|
||||
|
|
@ -872,33 +878,30 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
throw new Error("Missing connection API key.");
|
||||
}
|
||||
|
||||
const webSearchEnabledForThisTurn =
|
||||
Boolean(
|
||||
externalProvider &&
|
||||
toolsEnabled &&
|
||||
providerSupportsBuiltinWebSearch(externalProvider.providerType),
|
||||
);
|
||||
const codeExecEnabledForThisTurn =
|
||||
Boolean(
|
||||
externalProvider &&
|
||||
externalSelection &&
|
||||
codeToolsEnabled &&
|
||||
providerSupportsBuiltinCodeExecution(
|
||||
externalProvider.providerType,
|
||||
externalSelection.modelId,
|
||||
externalProvider.baseUrl,
|
||||
),
|
||||
);
|
||||
const webSearchEnabledForThisTurn = Boolean(
|
||||
externalProvider &&
|
||||
toolsEnabled &&
|
||||
providerSupportsBuiltinWebSearch(externalProvider.providerType),
|
||||
);
|
||||
const codeExecEnabledForThisTurn = Boolean(
|
||||
externalProvider &&
|
||||
externalSelection &&
|
||||
codeToolsEnabled &&
|
||||
providerSupportsBuiltinCodeExecution(
|
||||
externalProvider.providerType,
|
||||
externalSelection.modelId,
|
||||
externalProvider.baseUrl,
|
||||
),
|
||||
);
|
||||
// web_fetch shares the Search pill with web_search (no separate
|
||||
// UI toggle), so it follows toolsEnabled. Anthropic is the only
|
||||
// provider that ships it today; on others providerSupportsBuiltinWebFetch
|
||||
// returns false and this stays inert.
|
||||
const webFetchEnabledForThisTurn =
|
||||
Boolean(
|
||||
externalProvider &&
|
||||
toolsEnabled &&
|
||||
providerSupportsBuiltinWebFetch(externalProvider.providerType),
|
||||
);
|
||||
const webFetchEnabledForThisTurn = Boolean(
|
||||
externalProvider &&
|
||||
toolsEnabled &&
|
||||
providerSupportsBuiltinWebFetch(externalProvider.providerType),
|
||||
);
|
||||
const providerShipsWebFetch = Boolean(
|
||||
externalProvider &&
|
||||
providerSupportsBuiltinWebFetch(externalProvider.providerType),
|
||||
|
|
@ -964,32 +967,58 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
"Do not return tool-call syntax inside your response.";
|
||||
}
|
||||
}
|
||||
if (disabledToolGuard) {
|
||||
const firstMessage = outboundMessages[0];
|
||||
type OutboundMessage = (typeof outboundMessages)[number];
|
||||
function addSystemInstruction(
|
||||
targetMessages: OutboundMessage[],
|
||||
text: string | null,
|
||||
): void {
|
||||
if (!text) return;
|
||||
const firstMessage = targetMessages[0];
|
||||
if (firstMessage?.role === "system") {
|
||||
if (typeof firstMessage.content === "string") {
|
||||
outboundMessages[0] = {
|
||||
targetMessages[0] = {
|
||||
...firstMessage,
|
||||
content: `${firstMessage.content}\n\n${disabledToolGuard}`,
|
||||
content: `${firstMessage.content}\n\n${text}`,
|
||||
};
|
||||
} else {
|
||||
outboundMessages[0] = {
|
||||
targetMessages[0] = {
|
||||
...firstMessage,
|
||||
content: [
|
||||
...firstMessage.content,
|
||||
{ type: "text", text: `\n\n${disabledToolGuard}` },
|
||||
{ type: "text", text: `\n\n${text}` },
|
||||
],
|
||||
};
|
||||
}
|
||||
} else {
|
||||
outboundMessages.unshift({
|
||||
role: "system",
|
||||
content: disabledToolGuard,
|
||||
});
|
||||
return;
|
||||
}
|
||||
targetMessages.unshift({ role: "system", content: text });
|
||||
}
|
||||
|
||||
const imageBase64 = findLatestUserImageBase64(messages);
|
||||
const audioBase64 = findLatestUserAudioBase64(messages);
|
||||
const hasOutboundImage = Boolean(imageBase64);
|
||||
|
||||
// Keep render_html local-only and mirror the backend image-turn gate.
|
||||
// GGUF/safetensors disable tool execution when an image is present, so
|
||||
// image turns receive the fenced-html artifact fallback instead of being
|
||||
// prompted to call a tool the backend will not expose.
|
||||
const renderHtmlToolEnabledForThisTurn = Boolean(
|
||||
!isExternalRequest &&
|
||||
supportsTools &&
|
||||
artifactsEnabled &&
|
||||
!hasOutboundImage,
|
||||
);
|
||||
const artifactInstruction = artifactsEnabled
|
||||
? renderHtmlToolEnabledForThisTurn
|
||||
? "When the user asks for an HTML, CSS, or JavaScript artifact, use the render_html tool with one complete self-contained HTML document in the code argument. Embed CSS and JavaScript inside the document."
|
||||
: "When the user asks for an HTML, CSS, or JavaScript artifact, return one complete self-contained fenced html code block. Embed CSS and JavaScript inside the document. Do not emit tool-call syntax."
|
||||
: null;
|
||||
const effectiveDisabledToolGuard =
|
||||
disabledToolGuard && artifactsEnabled
|
||||
? `${disabledToolGuard} HTML, CSS, or JavaScript artifact requests can still be answered by following the artifact fallback instruction.`
|
||||
: disabledToolGuard;
|
||||
addSystemInstruction(outboundMessages, effectiveDisabledToolGuard);
|
||||
addSystemInstruction(outboundMessages, artifactInstruction);
|
||||
|
||||
// Block when ANY image is in the outbound payload (current or
|
||||
// prior turns) and the loaded model can't process images. Keeps
|
||||
|
|
@ -1314,8 +1343,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
) {
|
||||
void updateStoredChatThreadEventually(t.id, {
|
||||
openaiCodeExecContainerId: null,
|
||||
})
|
||||
.catch(() => {});
|
||||
}).catch(() => {});
|
||||
continue;
|
||||
}
|
||||
openaiCodeExecContainerId = t.openaiCodeExecContainerId;
|
||||
|
|
@ -1359,8 +1387,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
openaiCodeExecContainerId = created.id;
|
||||
void updateStoredChatThreadEventually(resolvedThreadId, {
|
||||
openaiCodeExecContainerId: created.id,
|
||||
})
|
||||
.catch(() => {});
|
||||
}).catch(() => {});
|
||||
} catch {
|
||||
// Fall back to backend's container_auto path on
|
||||
// failure — keeps the chat moving; the next turn
|
||||
|
|
@ -1473,7 +1500,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
// attaches `cache_control.ttl` when the value is one of
|
||||
// "5m" / "1h" (see external_provider.py near line 1375),
|
||||
// so unknown values are a no-op end-to-end.
|
||||
...(supportsProviderPromptCacheTtl(externalProvider.providerType) &&
|
||||
...(supportsProviderPromptCacheTtl(
|
||||
externalProvider.providerType,
|
||||
) &&
|
||||
(externalProvider.enablePromptCaching ?? true) &&
|
||||
isPromptCacheTtl(externalProvider.promptCacheTtl)
|
||||
? { prompt_cache_ttl: externalProvider.promptCacheTtl }
|
||||
|
|
@ -1518,12 +1547,18 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
...(supportsPreserveThinking
|
||||
? { preserve_thinking: preserveThinking }
|
||||
: {}),
|
||||
...(supportsTools && (toolsEnabled || codeToolsEnabled)
|
||||
...(supportsTools &&
|
||||
(toolsEnabled ||
|
||||
codeToolsEnabled ||
|
||||
renderHtmlToolEnabledForThisTurn)
|
||||
? {
|
||||
enable_tools: true,
|
||||
enabled_tools: [
|
||||
...(toolsEnabled ? ["web_search"] : []),
|
||||
...(codeToolsEnabled ? ["python", "terminal"] : []),
|
||||
...(renderHtmlToolEnabledForThisTurn
|
||||
? ["render_html"]
|
||||
: []),
|
||||
],
|
||||
auto_heal_tool_calls:
|
||||
useChatRuntimeStore.getState().autoHealToolCalls,
|
||||
|
|
@ -1591,8 +1626,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter {
|
|||
: "openaiCodeExecContainerId";
|
||||
void updateStoredChatThreadEventually(resolvedThreadId, {
|
||||
[field]: null,
|
||||
})
|
||||
.catch(() => {});
|
||||
}).catch(() => {});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
129
studio/frontend/src/features/chat/artifacts/artifact-card.tsx
Normal file
129
studio/frontend/src/features/chat/artifacts/artifact-card.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BrowserIcon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useAuiState } from "@assistant-ui/react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
|
||||
import {
|
||||
hasAutoOpenedArtifact,
|
||||
rememberAutoOpenedArtifact,
|
||||
useChatArtifactsStore,
|
||||
} from "./store";
|
||||
import {
|
||||
type ChatArtifact,
|
||||
type ChatArtifactSource,
|
||||
createChatArtifact,
|
||||
} from "./types";
|
||||
|
||||
export function ArtifactCard({
|
||||
code,
|
||||
title,
|
||||
source,
|
||||
sourceToolCallId,
|
||||
sourceMessageId,
|
||||
className,
|
||||
autoOpen = false,
|
||||
isStreaming = false,
|
||||
}: {
|
||||
code: string;
|
||||
title?: string | null;
|
||||
source: ChatArtifactSource;
|
||||
sourceToolCallId?: string | null;
|
||||
sourceMessageId?: string | null;
|
||||
className?: string;
|
||||
autoOpen?: boolean;
|
||||
isStreaming?: boolean;
|
||||
}) {
|
||||
const activeThreadId = useChatRuntimeStore((state) => state.activeThreadId);
|
||||
const messageIdFromContext = useAuiState(({ message }) => message.id);
|
||||
const threadIdFromContext = useAuiState(
|
||||
({ threads }) => threads.mainThreadId,
|
||||
);
|
||||
const artifactThreadId = threadIdFromContext ?? activeThreadId ?? null;
|
||||
const openArtifact = useChatArtifactsStore((state) => state.openArtifact);
|
||||
const updateArtifact = useChatArtifactsStore((state) => state.updateArtifact);
|
||||
const selectedArtifactId = useChatArtifactsStore(
|
||||
(state) => state.selectedArtifactId,
|
||||
);
|
||||
const artifact = useMemo<ChatArtifact>(
|
||||
() =>
|
||||
createChatArtifact({
|
||||
code,
|
||||
title,
|
||||
source,
|
||||
sourceMessageId: sourceMessageId ?? messageIdFromContext ?? null,
|
||||
sourceToolCallId: sourceToolCallId ?? null,
|
||||
threadId: artifactThreadId,
|
||||
isStreaming,
|
||||
}),
|
||||
[
|
||||
artifactThreadId,
|
||||
code,
|
||||
isStreaming,
|
||||
messageIdFromContext,
|
||||
source,
|
||||
sourceMessageId,
|
||||
sourceToolCallId,
|
||||
title,
|
||||
],
|
||||
);
|
||||
const surface = artifactThreadId ? "panel" : "overlay";
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoOpen) return;
|
||||
if (!hasAutoOpenedArtifact(artifact.id)) {
|
||||
rememberAutoOpenedArtifact(artifact.id);
|
||||
openArtifact(artifact, { surface });
|
||||
return;
|
||||
}
|
||||
if (selectedArtifactId === artifact.id) {
|
||||
updateArtifact(artifact);
|
||||
}
|
||||
}, [
|
||||
artifact,
|
||||
autoOpen,
|
||||
openArtifact,
|
||||
selectedArtifactId,
|
||||
surface,
|
||||
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",
|
||||
className,
|
||||
)}
|
||||
onClick={() => openArtifact(artifact, { surface })}
|
||||
aria-label={`Open ${artifact.title}`}
|
||||
>
|
||||
<div className="relative z-10 flex min-w-0 flex-1 items-center gap-2.5">
|
||||
<HugeiconsIcon
|
||||
icon={BrowserIcon}
|
||||
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 artifact
|
||||
</span>
|
||||
</span>
|
||||
{isStreaming ? (
|
||||
<span className="shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">
|
||||
Generating
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
306
studio/frontend/src/features/chat/artifacts/artifact-surface.tsx
Normal file
306
studio/frontend/src/features/chat/artifacts/artifact-surface.tsx
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"use client";
|
||||
|
||||
import { createCodePlugin } from "@/components/assistant-ui/code-plugin";
|
||||
import { CodeToggleIcon } from "@/components/assistant-ui/code-toggle-icon";
|
||||
import {
|
||||
unslothDarkTheme,
|
||||
unslothLightTheme,
|
||||
} from "@/components/assistant-ui/code-themes";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
EyeIcon,
|
||||
Maximize2Icon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
type KeyboardEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
import { ArtifactHtmlFrame, type ArtifactViewMode } from "./html-frame";
|
||||
import type { ChatArtifact } from "./types";
|
||||
import { getArtifactFilename } from "./types";
|
||||
|
||||
const COPY_RESET_MS = 2000;
|
||||
const artifactSourceCodePlugin = createCodePlugin({
|
||||
themes: [unslothLightTheme, unslothDarkTheme],
|
||||
});
|
||||
|
||||
function buildHtmlFence(source: string): string {
|
||||
const longestBacktickRun = Math.max(
|
||||
2,
|
||||
...(source.match(/`+/g) ?? []).map((match) => match.length),
|
||||
);
|
||||
const fence = "`".repeat(longestBacktickRun + 1);
|
||||
return `${fence}html\n${source}\n${fence}`;
|
||||
}
|
||||
// Sandboxed artifact iframes are intentionally excluded from the overlay focus
|
||||
// trap. Granting same-origin sandbox privileges would weaken isolation, so
|
||||
// keyboard users can reach Studio controls here while fully interactive artifact
|
||||
// content remains a known sandbox limitation.
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
function getFocusableElements(container: HTMLElement): HTMLElement[] {
|
||||
return Array.from(
|
||||
container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
|
||||
).filter(
|
||||
(element) =>
|
||||
!element.hasAttribute("disabled") &&
|
||||
element.getAttribute("aria-hidden") !== "true" &&
|
||||
element.tabIndex !== -1,
|
||||
);
|
||||
}
|
||||
|
||||
function downloadTextFile(filename: string, text: string): void {
|
||||
const blob = new Blob([text], { type: "text/html;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
|
||||
export function ArtifactSurface({
|
||||
artifact,
|
||||
variant,
|
||||
onClose,
|
||||
onOpenFullscreen,
|
||||
}: {
|
||||
artifact: ChatArtifact;
|
||||
variant: "panel" | "overlay";
|
||||
onClose: () => void;
|
||||
onOpenFullscreen?: () => void;
|
||||
}) {
|
||||
const [viewMode, setViewMode] = useState<ArtifactViewMode>(
|
||||
artifact.isStreaming ? "source" : "preview",
|
||||
);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyResetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const surfaceRef = useRef<HTMLElement>(null);
|
||||
const previousFocusRef = useRef<Element | null>(null);
|
||||
const filename = getArtifactFilename(artifact);
|
||||
const sourceMarkdown = useMemo(
|
||||
() => buildHtmlFence(artifact.code),
|
||||
[artifact.code],
|
||||
);
|
||||
const effectiveViewMode =
|
||||
artifact.isStreaming && viewMode === "preview" ? "source" : viewMode;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (copyResetRef.current) clearTimeout(copyResetRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (variant !== "overlay") return;
|
||||
previousFocusRef.current = document.activeElement;
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
const surface = surfaceRef.current;
|
||||
if (!surface) return;
|
||||
const firstFocusable = getFocusableElements(surface)[0];
|
||||
if (firstFocusable) {
|
||||
firstFocusable.focus();
|
||||
} else {
|
||||
surface.focus();
|
||||
}
|
||||
}, 0);
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
const previousFocus = previousFocusRef.current;
|
||||
if (previousFocus instanceof HTMLElement) previousFocus.focus();
|
||||
};
|
||||
}, [variant]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!(await copyToClipboard(artifact.code))) return;
|
||||
setCopied(true);
|
||||
if (copyResetRef.current) clearTimeout(copyResetRef.current);
|
||||
copyResetRef.current = setTimeout(() => {
|
||||
setCopied(false);
|
||||
copyResetRef.current = null;
|
||||
}, COPY_RESET_MS);
|
||||
};
|
||||
|
||||
const handleDialogKeyDown = (event: KeyboardEvent<HTMLElement>) => {
|
||||
if (variant !== "overlay") return;
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
const focusable = getFocusableElements(event.currentTarget);
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.focus();
|
||||
return;
|
||||
}
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const content = (
|
||||
<section
|
||||
ref={surfaceRef}
|
||||
role={variant === "overlay" ? "dialog" : undefined}
|
||||
aria-modal={variant === "overlay" ? true : undefined}
|
||||
tabIndex={variant === "overlay" ? -1 : undefined}
|
||||
onKeyDown={handleDialogKeyDown}
|
||||
className={cn(
|
||||
"flex min-h-0 flex-col overflow-hidden border border-border bg-background shadow-xl",
|
||||
variant === "panel"
|
||||
? "mt-[48px] h-[calc(100%_-_48px)] w-full rounded-none border-y-0 border-r-0"
|
||||
: "h-[min(92vh,900px)] w-[min(96vw,1200px)] rounded-2xl",
|
||||
)}
|
||||
aria-label={`${artifact.title} artifact`}
|
||||
>
|
||||
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-2.5 py-2">
|
||||
<div
|
||||
className="flex items-center gap-1 rounded-md bg-muted/40 p-0.5"
|
||||
role="tablist"
|
||||
aria-label="Artifact view"
|
||||
>
|
||||
{(["preview", "source"] as const).map((mode) => {
|
||||
const isPreview = mode === "preview";
|
||||
const Icon = isPreview ? EyeIcon : CodeToggleIcon;
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
role="tab"
|
||||
disabled={artifact.isStreaming && isPreview}
|
||||
onClick={() => setViewMode(mode)}
|
||||
className={cn(
|
||||
"flex size-8 items-center justify-center rounded-[4px] text-muted-foreground transition-colors",
|
||||
effectiveViewMode === mode
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "hover:bg-background/70 hover:text-foreground",
|
||||
artifact.isStreaming &&
|
||||
isPreview &&
|
||||
"cursor-not-allowed opacity-50",
|
||||
)}
|
||||
aria-label={
|
||||
isPreview ? "Preview artifact" : "View artifact source"
|
||||
}
|
||||
aria-selected={effectiveViewMode === mode}
|
||||
aria-pressed={effectiveViewMode === mode}
|
||||
title={isPreview ? "Preview" : "Source"}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={() => downloadTextFile(filename, artifact.code)}
|
||||
aria-label="Download artifact HTML"
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={handleCopy}
|
||||
aria-label="Copy artifact HTML"
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="size-4" />
|
||||
) : (
|
||||
<CopyIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
{variant === "panel" && onOpenFullscreen ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={onOpenFullscreen}
|
||||
aria-label="Open artifact fullscreen"
|
||||
>
|
||||
<Maximize2Icon className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={onClose}
|
||||
aria-label="Close artifact"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden bg-background">
|
||||
{effectiveViewMode === "preview" ? (
|
||||
<ArtifactHtmlFrame
|
||||
key={artifact.id}
|
||||
code={artifact.code}
|
||||
title={artifact.title}
|
||||
fill={true}
|
||||
className="h-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full overflow-auto text-xs leading-relaxed [&_[data-streamdown=code-block]]:!rounded-none [&_pre]:!m-0 [&_pre]:!bg-transparent [&_pre]:!p-0 [&_pre]:text-xs [&_pre]:leading-relaxed [&_code]:text-xs">
|
||||
<Streamdown
|
||||
mode="streaming"
|
||||
plugins={{ code: artifactSourceCodePlugin }}
|
||||
controls={{ code: false }}
|
||||
shikiTheme={[unslothLightTheme, unslothDarkTheme]}
|
||||
>
|
||||
{sourceMarkdown}
|
||||
</Streamdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
if (variant === "overlay") {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 p-4 backdrop-blur-sm"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
91
studio/frontend/src/features/chat/artifacts/html-frame.tsx
Normal file
91
studio/frontend/src/features/chat/artifacts/html-frame.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"use client";
|
||||
|
||||
import { apiUrl } from "@/lib/api-base";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { hashArtifactCode } from "./types";
|
||||
|
||||
const HTML_FRAME_DEFAULT_HEIGHT = 400;
|
||||
const HTML_FRAME_MAX_HEIGHT = 900;
|
||||
|
||||
export type ArtifactViewMode = "preview" | "source";
|
||||
export const ARTIFACT_VIEW_MODES: readonly ArtifactViewMode[] = [
|
||||
"preview",
|
||||
"source",
|
||||
];
|
||||
|
||||
export function isArtifactViewMode(value: string): value is ArtifactViewMode {
|
||||
return (ARTIFACT_VIEW_MODES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function buildArtifactSrcDoc(code: string): string {
|
||||
const resizeScript = `<script>(()=>{const post=()=>parent.postMessage({chatArtifactHeight:document.documentElement.scrollHeight},"*");new ResizeObserver(post).observe(document.documentElement);window.addEventListener("load",post);post();})();</script>`;
|
||||
return `${code}\n${resizeScript}`;
|
||||
}
|
||||
|
||||
// Preview iframes intentionally omit allow-downloads: generated artifacts can
|
||||
// offer their own UI, but downloads must go through Studio's explicit
|
||||
// copy/download controls outside the no-same-origin sandbox.
|
||||
export function ArtifactHtmlFrame({
|
||||
code,
|
||||
title = "HTML artifact preview",
|
||||
className,
|
||||
fill = false,
|
||||
}: {
|
||||
code: string;
|
||||
title?: string;
|
||||
className?: string;
|
||||
fill?: boolean;
|
||||
}) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [height, setHeight] = useState(HTML_FRAME_DEFAULT_HEIGHT);
|
||||
const artifactHtml = useMemo(() => buildArtifactSrcDoc(code), [code]);
|
||||
const src = useMemo(
|
||||
() =>
|
||||
apiUrl(
|
||||
`/api/inference/artifact-preview-frame?v=${encodeURIComponent(hashArtifactCode(code))}`,
|
||||
),
|
||||
[code],
|
||||
);
|
||||
const postArtifactHtml = useCallback(() => {
|
||||
// The sandboxed frame intentionally has an opaque origin ("null").
|
||||
// A wildcard target is required here;
|
||||
// the payload is sent only to this iframe's contentWindow.
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{ type: "unsloth:artifact-html", html: artifactHtml },
|
||||
"*",
|
||||
);
|
||||
}, [artifactHtml]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: MessageEvent) => {
|
||||
if (event.source !== iframeRef.current?.contentWindow) return;
|
||||
if (event.origin !== "null") return;
|
||||
if (typeof event.data?.chatArtifactHeight !== "number") return;
|
||||
setHeight(
|
||||
Math.min(
|
||||
Math.max(event.data.chatArtifactHeight, 160),
|
||||
HTML_FRAME_MAX_HEIGHT,
|
||||
),
|
||||
);
|
||||
};
|
||||
window.addEventListener("message", handler);
|
||||
return () => window.removeEventListener("message", handler);
|
||||
}, [postArtifactHtml]);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={src}
|
||||
sandbox="allow-scripts"
|
||||
referrerPolicy="no-referrer"
|
||||
onLoad={postArtifactHtml}
|
||||
className={cn("block w-full border-0 bg-background", className)}
|
||||
style={{ height: fill ? "100%" : height }}
|
||||
title={title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
85
studio/frontend/src/features/chat/artifacts/store.ts
Normal file
85
studio/frontend/src/features/chat/artifacts/store.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { ChatArtifact, ChatArtifactSurface } from "./types";
|
||||
|
||||
const autoOpenedArtifactIds = new Set<string>();
|
||||
|
||||
export function hasAutoOpenedArtifact(artifactId: string): boolean {
|
||||
return autoOpenedArtifactIds.has(artifactId);
|
||||
}
|
||||
|
||||
export function rememberAutoOpenedArtifact(artifactId: string): void {
|
||||
autoOpenedArtifactIds.add(artifactId);
|
||||
}
|
||||
|
||||
export function clearAutoOpenedArtifacts(): void {
|
||||
autoOpenedArtifactIds.clear();
|
||||
}
|
||||
|
||||
type ChatArtifactsState = {
|
||||
artifactsById: Record<string, ChatArtifact>;
|
||||
selectedArtifactId: string | null;
|
||||
surface: ChatArtifactSurface;
|
||||
openArtifact: (
|
||||
artifact: ChatArtifact,
|
||||
options?: { surface?: ChatArtifactSurface },
|
||||
) => void;
|
||||
updateArtifact: (artifact: ChatArtifact) => void;
|
||||
closeArtifactSurface: () => void;
|
||||
clearArtifactsForThread: (threadId: string | null | undefined) => void;
|
||||
resetArtifacts: () => void;
|
||||
};
|
||||
|
||||
export const useChatArtifactsStore = create<ChatArtifactsState>((set) => ({
|
||||
artifactsById: {},
|
||||
selectedArtifactId: null,
|
||||
surface: "panel",
|
||||
openArtifact: (artifact, options) =>
|
||||
set((state) => ({
|
||||
artifactsById: {
|
||||
[artifact.id]: artifact,
|
||||
},
|
||||
selectedArtifactId: artifact.id,
|
||||
surface: options?.surface ?? state.surface,
|
||||
})),
|
||||
updateArtifact: (artifact) =>
|
||||
set((state) =>
|
||||
state.artifactsById[artifact.id]
|
||||
? { artifactsById: { [artifact.id]: artifact } }
|
||||
: state,
|
||||
),
|
||||
closeArtifactSurface: () =>
|
||||
set({ artifactsById: {}, selectedArtifactId: null, surface: "panel" }),
|
||||
clearArtifactsForThread: (threadId) =>
|
||||
set((state) => {
|
||||
if (!threadId) return state;
|
||||
const artifactsById = Object.fromEntries(
|
||||
Object.entries(state.artifactsById).filter(
|
||||
([, artifact]) => artifact.threadId !== threadId,
|
||||
),
|
||||
);
|
||||
const selected = state.selectedArtifactId
|
||||
? artifactsById[state.selectedArtifactId]
|
||||
: null;
|
||||
return {
|
||||
artifactsById,
|
||||
selectedArtifactId: selected ? selected.id : null,
|
||||
};
|
||||
}),
|
||||
resetArtifacts: () =>
|
||||
set({
|
||||
artifactsById: {},
|
||||
selectedArtifactId: null,
|
||||
surface: "panel",
|
||||
}),
|
||||
}));
|
||||
|
||||
export function useSelectedChatArtifact(): ChatArtifact | null {
|
||||
return useChatArtifactsStore((state) =>
|
||||
state.selectedArtifactId
|
||||
? (state.artifactsById[state.selectedArtifactId] ?? null)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
84
studio/frontend/src/features/chat/artifacts/types.ts
Normal file
84
studio/frontend/src/features/chat/artifacts/types.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
export type ChatArtifactSource = "tool" | "fence";
|
||||
export type ChatArtifactSurface = "panel" | "overlay";
|
||||
|
||||
export interface ChatArtifact {
|
||||
id: string;
|
||||
title: string;
|
||||
code: string;
|
||||
source: ChatArtifactSource;
|
||||
sourceMessageId?: string | null;
|
||||
sourceToolCallId?: string | null;
|
||||
threadId?: string | null;
|
||||
isStreaming?: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface ChatArtifactInput {
|
||||
title?: string | null;
|
||||
code: string;
|
||||
source: ChatArtifactSource;
|
||||
sourceMessageId?: string | null;
|
||||
sourceToolCallId?: string | null;
|
||||
threadId?: string | null;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_ARTIFACT_TITLE = "HTML artifact";
|
||||
|
||||
export function normalizeArtifactTitle(title?: string | null): string {
|
||||
const trimmed = title?.trim();
|
||||
return trimmed && trimmed.length > 0 ? trimmed : DEFAULT_ARTIFACT_TITLE;
|
||||
}
|
||||
|
||||
export function hashArtifactCode(code: string): string {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < code.length; i += 1) {
|
||||
hash = ((hash << 5) + hash) ^ code.charCodeAt(i);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
export function createArtifactId(input: ChatArtifactInput): string {
|
||||
const threadSegment = input.threadId || "no-thread";
|
||||
const messageSegment = input.sourceMessageId || "transient";
|
||||
// Backend tool call IDs (call_0, call_1, …) reset per request, so
|
||||
// the message ID is needed to scope them to a specific turn.
|
||||
const parts = [input.source, threadSegment, messageSegment];
|
||||
|
||||
if (input.source === "tool" && input.sourceToolCallId) {
|
||||
parts.push(input.sourceToolCallId);
|
||||
} else {
|
||||
parts.push(hashArtifactCode(input.code));
|
||||
}
|
||||
|
||||
return parts.join(":");
|
||||
}
|
||||
|
||||
export function createChatArtifact(input: ChatArtifactInput): ChatArtifact {
|
||||
return {
|
||||
id: createArtifactId(input),
|
||||
title: normalizeArtifactTitle(input.title),
|
||||
code: input.code,
|
||||
source: input.source,
|
||||
sourceMessageId: input.sourceMessageId ?? null,
|
||||
sourceToolCallId: input.sourceToolCallId ?? null,
|
||||
threadId: input.threadId ?? null,
|
||||
isStreaming: input.isStreaming,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export function getArtifactFilename(
|
||||
artifact: Pick<ChatArtifact, "title">,
|
||||
): string {
|
||||
const slug = artifact.title
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 48);
|
||||
return `${slug || "artifact"}.html`;
|
||||
}
|
||||
|
|
@ -10,15 +10,22 @@ import {
|
|||
ModelSelector,
|
||||
} from "@/components/assistant-ui/model-selector";
|
||||
import { Thread } from "@/components/assistant-ui/thread";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { NativeModelChip } from "@/features/native-intents/components/native-model-chip";
|
||||
import { NativeModelDropOverlay } from "@/features/native-intents/components/native-model-drop-overlay";
|
||||
import { useNativeIntentStore } from "@/features/native-intents/store";
|
||||
import type { NativeIntent } from "@/features/native-intents/types";
|
||||
import { useChooseNativeModel } from "@/features/native-intents/use-native-dialogs";
|
||||
import { useNativeModelDrop } from "@/features/native-intents/use-native-drop";
|
||||
import { useNativePathLeasesSupported } from "@/features/native-intents/use-native-readiness";
|
||||
import {
|
||||
NativeModelChip,
|
||||
NativeModelDropOverlay,
|
||||
type NativeIntent,
|
||||
useChooseNativeModel,
|
||||
useNativeIntentStore,
|
||||
useNativeModelDrop,
|
||||
useNativePathLeasesSupported,
|
||||
} from "@/features/native-intents";
|
||||
import { GuidedTour, useGuidedTourController } from "@/features/tour";
|
||||
import { isTauri } from "@/lib/api-base";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -76,6 +83,13 @@ import {
|
|||
} from "./stores/chat-runtime-store";
|
||||
import { useExternalProvidersStore } from "./stores/external-providers-store";
|
||||
import { buildChatTourSteps } from "./tour";
|
||||
import { ArtifactSurface } from "./artifacts/artifact-surface";
|
||||
import {
|
||||
clearAutoOpenedArtifacts,
|
||||
useChatArtifactsStore,
|
||||
useSelectedChatArtifact,
|
||||
} from "./artifacts/store";
|
||||
import type { ChatArtifact, ChatArtifactSurface } from "./artifacts/types";
|
||||
import type { ChatView, MessageRecord } from "./types";
|
||||
import {
|
||||
getStoredChatThread,
|
||||
|
|
@ -132,6 +146,12 @@ function pickBestLoraForBase(
|
|||
return partial ?? sorted[0] ?? null;
|
||||
}
|
||||
|
||||
function isAssistantLocalThreadId(
|
||||
threadId: string | null | undefined,
|
||||
): boolean {
|
||||
return Boolean(threadId?.startsWith("__LOCALID_"));
|
||||
}
|
||||
|
||||
function messageHasImage(message: MessageRecord): boolean {
|
||||
const contentParts = Array.isArray(message.content) ? message.content : [];
|
||||
if (contentParts.some((part) => part.type === "image")) {
|
||||
|
|
@ -154,16 +174,77 @@ function messageHasImage(message: MessageRecord): boolean {
|
|||
const SingleContent = memo(function SingleContent({
|
||||
threadId,
|
||||
newThreadNonce,
|
||||
}: { threadId?: string; newThreadNonce?: string }): ReactElement {
|
||||
artifact,
|
||||
artifactSurface,
|
||||
onCloseArtifact,
|
||||
}: {
|
||||
threadId?: string;
|
||||
newThreadNonce?: string;
|
||||
artifact?: ChatArtifact | null;
|
||||
artifactSurface: ChatArtifactSurface;
|
||||
onCloseArtifact: () => void;
|
||||
}): ReactElement {
|
||||
const openArtifact = useChatArtifactsStore((state) => state.openArtifact);
|
||||
const activeThreadId = useChatRuntimeStore((state) => state.activeThreadId);
|
||||
const showArtifactPanel = Boolean(
|
||||
artifact &&
|
||||
artifactSurface === "panel" &&
|
||||
(threadId
|
||||
? !artifact.threadId || artifact.threadId === threadId
|
||||
: Boolean(newThreadNonce) ||
|
||||
Boolean(artifact.threadId && artifact.threadId === activeThreadId)),
|
||||
);
|
||||
|
||||
const threadPane = (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
|
||||
<Thread hideWelcome={Boolean(threadId)} targetThreadId={threadId} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<ChatRuntimeProvider
|
||||
modelType="base"
|
||||
initialThreadId={threadId}
|
||||
newThreadNonce={newThreadNonce}
|
||||
>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden">
|
||||
<Thread hideWelcome={Boolean(threadId)} targetThreadId={threadId} />
|
||||
</div>
|
||||
{showArtifactPanel && artifact ? (
|
||||
<ResizablePanelGroup
|
||||
orientation="horizontal"
|
||||
className="min-h-0 min-w-0 flex-1 basis-0 overflow-hidden"
|
||||
>
|
||||
<ResizablePanel
|
||||
id="chat-thread"
|
||||
defaultSize="62%"
|
||||
minSize="42%"
|
||||
className="h-full min-h-0 min-w-0 overflow-hidden"
|
||||
>
|
||||
<div className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
|
||||
{threadPane}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle={true} />
|
||||
<ResizablePanel
|
||||
id="chat-artifact"
|
||||
defaultSize="38%"
|
||||
minSize="30%"
|
||||
maxSize="58%"
|
||||
className="h-full min-h-0 min-w-0 overflow-hidden"
|
||||
>
|
||||
<div className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
|
||||
<ArtifactSurface
|
||||
artifact={artifact}
|
||||
variant="panel"
|
||||
onClose={onCloseArtifact}
|
||||
onOpenFullscreen={() =>
|
||||
openArtifact(artifact, { surface: "overlay" })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
) : (
|
||||
threadPane
|
||||
)}
|
||||
</ChatRuntimeProvider>
|
||||
);
|
||||
});
|
||||
|
|
@ -329,15 +410,17 @@ const LoraCompareContent = memo(function LoraCompareContent({
|
|||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
listStoredChatThreads({ pairId }).then((threads) => {
|
||||
if (!isActive) return;
|
||||
setBaseThreadId(threads.find((t) => t.modelType === "base")?.id);
|
||||
setLoraThreadId(threads.find((t) => t.modelType === "lora")?.id);
|
||||
}).catch((error) => {
|
||||
if (!isExpectedBackgroundChatStorageError(error)) {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
listStoredChatThreads({ pairId })
|
||||
.then((threads) => {
|
||||
if (!isActive) return;
|
||||
setBaseThreadId(threads.find((t) => t.modelType === "base")?.id);
|
||||
setLoraThreadId(threads.find((t) => t.modelType === "lora")?.id);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!isExpectedBackgroundChatStorageError(error)) {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
|
|
@ -478,21 +561,25 @@ const GeneralCompareContent = memo(function GeneralCompareContent({
|
|||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
listStoredChatThreads({ pairId }).then((threads) => {
|
||||
if (!isActive) return;
|
||||
setModel1ThreadId(
|
||||
threads.find((t) => t.modelType === "model1" || t.modelType === "base")
|
||||
?.id,
|
||||
);
|
||||
setModel2ThreadId(
|
||||
threads.find((t) => t.modelType === "model2" || t.modelType === "lora")
|
||||
?.id,
|
||||
);
|
||||
}).catch((error) => {
|
||||
if (!isExpectedBackgroundChatStorageError(error)) {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
listStoredChatThreads({ pairId })
|
||||
.then((threads) => {
|
||||
if (!isActive) return;
|
||||
setModel1ThreadId(
|
||||
threads.find(
|
||||
(t) => t.modelType === "model1" || t.modelType === "base",
|
||||
)?.id,
|
||||
);
|
||||
setModel2ThreadId(
|
||||
threads.find(
|
||||
(t) => t.modelType === "model2" || t.modelType === "lora",
|
||||
)?.id,
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!isExpectedBackgroundChatStorageError(error)) {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
|
|
@ -630,7 +717,11 @@ export function ChatPage(): ReactElement {
|
|||
const modelsError = useChatRuntimeStore((state) => state.modelsError);
|
||||
const modelLoading = useChatRuntimeStore((state) => state.modelLoading);
|
||||
const clearCheckpoint = useChatRuntimeStore((state) => state.clearCheckpoint);
|
||||
const resetArtifacts = useChatArtifactsStore((state) => state.resetArtifacts);
|
||||
const activeThreadId = useChatRuntimeStore((state) => state.activeThreadId);
|
||||
const persistedActiveThreadId = isAssistantLocalThreadId(activeThreadId)
|
||||
? null
|
||||
: activeThreadId;
|
||||
const modelOperationInProgress = useChatRuntimeStore(
|
||||
(state) => state.modelLoading,
|
||||
);
|
||||
|
|
@ -645,9 +736,9 @@ export function ChatPage(): ReactElement {
|
|||
} = useChatModelRuntime();
|
||||
const prevConnectionsEnabledRef = useRef(connectionsEnabled);
|
||||
useEffect(() => {
|
||||
const turnedOff =
|
||||
prevConnectionsEnabledRef.current && !connectionsEnabled;
|
||||
const turnedOff = prevConnectionsEnabledRef.current && !connectionsEnabled;
|
||||
if (!connectionsEnabled && isExternalModelId(inferenceParams.checkpoint)) {
|
||||
resetArtifacts();
|
||||
clearCheckpoint();
|
||||
if (turnedOff) {
|
||||
toast.info("Connections disabled", {
|
||||
|
|
@ -660,6 +751,7 @@ export function ChatPage(): ReactElement {
|
|||
clearCheckpoint,
|
||||
connectionsEnabled,
|
||||
inferenceParams.checkpoint,
|
||||
resetArtifacts,
|
||||
]);
|
||||
const pendingNativeModelIntent = useNativeIntentStore(
|
||||
(state) => state.pendingModelIntent,
|
||||
|
|
@ -679,17 +771,19 @@ export function ChatPage(): ReactElement {
|
|||
const reasoningEnabled = useChatRuntimeStore((s) => s.reasoningEnabled);
|
||||
const reasoningStyle = useChatRuntimeStore((s) => s.reasoningStyle);
|
||||
const reasoningEffort = useChatRuntimeStore((s) => s.reasoningEffort);
|
||||
const supportsReasoningOff = useChatRuntimeStore((s) => s.supportsReasoningOff);
|
||||
const supportsReasoningOff = useChatRuntimeStore(
|
||||
(s) => s.supportsReasoningOff,
|
||||
);
|
||||
const activeExternalProvider = useMemo(() => {
|
||||
const selection = parseExternalModelId(inferenceParams.checkpoint);
|
||||
if (!selection) return null;
|
||||
return (
|
||||
externalProvidersForChat.find(
|
||||
(p) => p.id === selection.providerId,
|
||||
) ?? null
|
||||
externalProvidersForChat.find((p) => p.id === selection.providerId) ??
|
||||
null
|
||||
);
|
||||
}, [externalProvidersForChat, inferenceParams.checkpoint]);
|
||||
const activeExternalProviderType = activeExternalProvider?.providerType ?? null;
|
||||
const activeExternalProviderType =
|
||||
activeExternalProvider?.providerType ?? null;
|
||||
const activeProviderCapabilities = useMemo(() => {
|
||||
const selection = parseExternalModelId(inferenceParams.checkpoint);
|
||||
if (!selection) return null;
|
||||
|
|
@ -797,7 +891,9 @@ export function ChatPage(): ReactElement {
|
|||
(provider?.providerType === "anthropic" ||
|
||||
provider?.providerType === "openai");
|
||||
const storedToolsEnabled = loadOptionalBool(CHAT_TOOLS_ENABLED_KEY);
|
||||
const storedCodeToolsEnabled = loadOptionalBool(CHAT_CODE_TOOLS_ENABLED_KEY);
|
||||
const storedCodeToolsEnabled = loadOptionalBool(
|
||||
CHAT_CODE_TOOLS_ENABLED_KEY,
|
||||
);
|
||||
const storedImageToolsEnabled = loadOptionalBool(
|
||||
CHAT_IMAGE_TOOLS_ENABLED_KEY,
|
||||
);
|
||||
|
|
@ -858,14 +954,43 @@ export function ChatPage(): ReactElement {
|
|||
if (search.thread) {
|
||||
return { mode: "single", threadId: search.thread };
|
||||
}
|
||||
if (activeThreadId && !activeThreadId.startsWith("__LOCALID_")) {
|
||||
return { mode: "single", threadId: activeThreadId };
|
||||
if (persistedActiveThreadId) {
|
||||
return { mode: "single", threadId: persistedActiveThreadId };
|
||||
}
|
||||
if (search.new) {
|
||||
return { mode: "single", newThreadNonce: search.new };
|
||||
}
|
||||
return { mode: "single" };
|
||||
}, [search.thread, search.compare, search.new, activeThreadId]);
|
||||
}, [search.thread, search.compare, search.new, persistedActiveThreadId]);
|
||||
|
||||
const selectedArtifact = useSelectedChatArtifact();
|
||||
const artifactSurface = useChatArtifactsStore((state) => state.surface);
|
||||
const closeArtifactSurface = useChatArtifactsStore(
|
||||
(state) => state.closeArtifactSurface,
|
||||
);
|
||||
const artifactViewKey =
|
||||
view.mode === "single"
|
||||
? `single:${view.threadId ?? view.newThreadNonce ?? "new"}`
|
||||
: `compare:${view.pairId}`;
|
||||
|
||||
useEffect(() => {
|
||||
clearAutoOpenedArtifacts();
|
||||
closeArtifactSurface();
|
||||
}, [artifactViewKey, closeArtifactSurface]);
|
||||
|
||||
useEffect(() => {
|
||||
if (view.mode !== "single") return;
|
||||
if (view.threadId || view.newThreadNonce || !selectedArtifact) return;
|
||||
// view intentionally excludes __LOCALID_ threads (they fall through to
|
||||
// { mode: "single" } with no threadId/nonce). Don't close an artifact
|
||||
// whose thread is the currently active local thread.
|
||||
if (
|
||||
selectedArtifact.threadId &&
|
||||
selectedArtifact.threadId === activeThreadId
|
||||
)
|
||||
return;
|
||||
closeArtifactSurface();
|
||||
}, [activeThreadId, closeArtifactSurface, selectedArtifact, view]);
|
||||
|
||||
const hasActiveModel = Boolean(inferenceParams.checkpoint);
|
||||
const loadNativeModelIntent = useCallback(
|
||||
|
|
@ -953,8 +1078,7 @@ export function ChatPage(): ReactElement {
|
|||
selectedProvider?.providerType,
|
||||
selectedExternal?.modelId,
|
||||
{
|
||||
isReasoningProvider:
|
||||
selectedProvider?.isReasoningModel === true,
|
||||
isReasoningProvider: selectedProvider?.isReasoningModel === true,
|
||||
},
|
||||
);
|
||||
const preferredEffort = store.reasoningEffort;
|
||||
|
|
@ -997,11 +1121,12 @@ export function ChatPage(): ReactElement {
|
|||
const supportsBuiltinWebSearch = providerSupportsBuiltinWebSearch(
|
||||
selectedProvider?.providerType,
|
||||
);
|
||||
const supportsBuiltinCodeExecution = providerSupportsBuiltinCodeExecution(
|
||||
selectedProvider?.providerType,
|
||||
selectedExternal?.modelId,
|
||||
selectedProvider?.baseUrl,
|
||||
);
|
||||
const supportsBuiltinCodeExecution =
|
||||
providerSupportsBuiltinCodeExecution(
|
||||
selectedProvider?.providerType,
|
||||
selectedExternal?.modelId,
|
||||
selectedProvider?.baseUrl,
|
||||
);
|
||||
const supportsBuiltinImageGeneration =
|
||||
providerSupportsBuiltinImageGeneration(
|
||||
selectedProvider?.providerType,
|
||||
|
|
@ -1118,8 +1243,9 @@ export function ChatPage(): ReactElement {
|
|||
],
|
||||
);
|
||||
const handleEject = useCallback(() => {
|
||||
resetArtifacts();
|
||||
void ejectModel();
|
||||
}, [ejectModel]);
|
||||
}, [ejectModel, resetArtifacts]);
|
||||
|
||||
const openModelSelector = useCallback(() => {
|
||||
setModelSelectorLocked(true);
|
||||
|
|
@ -1379,6 +1505,7 @@ export function ChatPage(): ReactElement {
|
|||
|
||||
const tourSteps = useMemo(
|
||||
() =>
|
||||
// eslint-disable-next-line react-hooks/refs -- buildChatTourSteps stores callbacks without invoking them during render.
|
||||
buildChatTourSteps({
|
||||
canCompare,
|
||||
openModelSelector,
|
||||
|
|
@ -1416,6 +1543,11 @@ export function ChatPage(): ReactElement {
|
|||
return () => window.clearTimeout(timeoutId);
|
||||
}, [modelSelectorLocked, tour.open]);
|
||||
|
||||
const showArtifactOverlay = Boolean(
|
||||
selectedArtifact &&
|
||||
(view.mode === "compare" || artifactSurface === "overlay"),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 basis-0 bg-background overflow-hidden">
|
||||
<GuidedTour {...tour.tourProps} />
|
||||
|
|
@ -1532,9 +1664,12 @@ export function ChatPage(): ReactElement {
|
|||
|
||||
{view.mode === "single" ? (
|
||||
<SingleContent
|
||||
key={view.threadId ?? "single"}
|
||||
key={view.threadId ?? view.newThreadNonce ?? "single"}
|
||||
threadId={view.threadId}
|
||||
newThreadNonce={view.newThreadNonce}
|
||||
artifact={selectedArtifact}
|
||||
artifactSurface={artifactSurface}
|
||||
onCloseArtifact={closeArtifactSurface}
|
||||
/>
|
||||
) : (
|
||||
<CompareContent
|
||||
|
|
@ -1547,6 +1682,14 @@ export function ChatPage(): ReactElement {
|
|||
deleteDisabled={modelOperationInProgress}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showArtifactOverlay && selectedArtifact ? (
|
||||
<ArtifactSurface
|
||||
artifact={selectedArtifact}
|
||||
variant="overlay"
|
||||
onClose={closeArtifactSurface}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<ChatSettingsPanel
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { CHAT_HISTORY_UPDATED_EVENT } from "../api/chat-api";
|
||||
import { useChatRuntimeStore } from "../stores/chat-runtime-store";
|
||||
import { useChatArtifactsStore } from "../artifacts/store";
|
||||
import type { ThreadRecord } from "../types";
|
||||
import {
|
||||
deleteStoredChatThreads,
|
||||
|
|
@ -157,6 +158,9 @@ export async function deleteChatItem(
|
|||
// generating against a thread that no longer exists.
|
||||
for (const id of threadIds) cancelIfRunning(id);
|
||||
|
||||
const artifactStore = useChatArtifactsStore.getState();
|
||||
for (const id of threadIds) artifactStore.clearArtifactsForThread(id);
|
||||
|
||||
// Optimistic tombstone: hide immediately; roll back on backend error.
|
||||
markChatThreadsDeleted(threadIds);
|
||||
notifyChatHistoryUpdated();
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export { useChatModelRuntime } from "./hooks/use-chat-model-runtime";
|
|||
export { ChatSearchDialog } from "./components/chat-search-dialog";
|
||||
export { setTrainingCompareHandoff } from "./lib/training-compare-handoff";
|
||||
export { clearAllChats, countAllChats } from "./utils/clear-all-chats";
|
||||
export { ArtifactCard } from "./artifacts/artifact-card";
|
||||
export { downloadChatExport } from "./utils/export-chat-history";
|
||||
export {
|
||||
deleteChatItem,
|
||||
|
|
|
|||
|
|
@ -21,10 +21,25 @@ import { isTauri } from "@/lib/api-base";
|
|||
import { isMultimodalResponse } from "./types/api";
|
||||
import { getImageInputUnavailableReason } from "./utils/image-input-support";
|
||||
import { useAui } from "@assistant-ui/react";
|
||||
import { ArrowUpIcon, GlobeIcon, HeadphonesIcon, ImageIcon, LightbulbIcon, LightbulbOffIcon, MicIcon, PlusIcon, SquareIcon, XIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowUpIcon,
|
||||
FileTextIcon,
|
||||
GlobeIcon,
|
||||
HeadphonesIcon,
|
||||
ImageIcon,
|
||||
LightbulbIcon,
|
||||
LightbulbOffIcon,
|
||||
MicIcon,
|
||||
PlusIcon,
|
||||
SquareIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { loadModel, validateModel } from "./api/chat-api";
|
||||
import { parseExternalModelId, providerTypeSupportsVision } from "./external-providers";
|
||||
import {
|
||||
parseExternalModelId,
|
||||
providerTypeSupportsVision,
|
||||
} from "./external-providers";
|
||||
import { useExternalProvidersStore } from "./stores/external-providers-store";
|
||||
import {
|
||||
type ReasoningEffort,
|
||||
|
|
@ -87,7 +102,10 @@ function fileToBase64DataURL(file: File): Promise<string> {
|
|||
});
|
||||
}
|
||||
|
||||
function formatReasoningEffortLabel(level: ReasoningEffort, modelId?: string): string {
|
||||
function formatReasoningEffortLabel(
|
||||
level: ReasoningEffort,
|
||||
modelId?: string,
|
||||
): string {
|
||||
if (level === "max") return "Max";
|
||||
if (level === "xhigh") {
|
||||
const normalized = modelId?.trim().toLowerCase() ?? "";
|
||||
|
|
@ -123,7 +141,12 @@ function useDictation(
|
|||
const start = useCallback(() => {
|
||||
const SpeechRecognitionAPI =
|
||||
typeof window !== "undefined" &&
|
||||
(window.SpeechRecognition ?? (window as unknown as { webkitSpeechRecognition?: typeof SpeechRecognition }).webkitSpeechRecognition);
|
||||
(window.SpeechRecognition ??
|
||||
(
|
||||
window as unknown as {
|
||||
webkitSpeechRecognition?: typeof SpeechRecognition;
|
||||
}
|
||||
).webkitSpeechRecognition);
|
||||
if (!SpeechRecognitionAPI) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -169,7 +192,11 @@ function useDictation(
|
|||
|
||||
const supported =
|
||||
typeof window !== "undefined" &&
|
||||
!!(window.SpeechRecognition ?? (window as unknown as { webkitSpeechRecognition?: unknown }).webkitSpeechRecognition);
|
||||
!!(
|
||||
window.SpeechRecognition ??
|
||||
(window as unknown as { webkitSpeechRecognition?: unknown })
|
||||
.webkitSpeechRecognition
|
||||
);
|
||||
|
||||
return { isDictating, start, stop, supported };
|
||||
}
|
||||
|
|
@ -208,9 +235,18 @@ export function RegisterCompareHandle({
|
|||
currentHandles[name] = {
|
||||
// fixes occasional reorder on reload.
|
||||
append: (content) =>
|
||||
aui.thread().append({ role: "user", content, createdAt: new Date() } as never),
|
||||
aui
|
||||
.thread()
|
||||
.append({ role: "user", content, createdAt: new Date() } as never),
|
||||
appendMessage: (content) =>
|
||||
aui.thread().append({ role: "user", content, createdAt: new Date(), startRun: false } as never),
|
||||
aui
|
||||
.thread()
|
||||
.append({
|
||||
role: "user",
|
||||
content,
|
||||
createdAt: new Date(),
|
||||
startRun: false,
|
||||
} as never),
|
||||
startRun: () => {
|
||||
const msgs = aui.thread().getState().messages;
|
||||
const lastId = msgs.length > 0 ? msgs[msgs.length - 1].id : null;
|
||||
|
|
@ -254,7 +290,8 @@ function PendingImageThumb({
|
|||
setSrc(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [file]);
|
||||
if (!src) return <div className="size-14 animate-pulse rounded-[14px] bg-muted" />;
|
||||
if (!src)
|
||||
return <div className="size-14 animate-pulse rounded-[14px] bg-muted" />;
|
||||
return (
|
||||
<div className="relative size-14 shrink-0 overflow-hidden rounded-[14px] border border-foreground/20 bg-muted">
|
||||
<img src={src} alt={file.name} className="h-full w-full object-cover" />
|
||||
|
|
@ -289,7 +326,10 @@ export function SharedComposer({
|
|||
const [running, setRunning] = useState(false);
|
||||
const [comparing, setComparing] = useState(false);
|
||||
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
|
||||
const [pendingAudio, setPendingAudio] = useState<{ name: string; base64: string } | null>(null);
|
||||
const [pendingAudio, setPendingAudio] = useState<{
|
||||
name: string;
|
||||
base64: string;
|
||||
} | null>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [isComposing, setIsComposing] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
|
@ -318,10 +358,16 @@ export function SharedComposer({
|
|||
const setReasoningEnabled = useChatRuntimeStore((s) => s.setReasoningEnabled);
|
||||
const reasoningStyle = useChatRuntimeStore((s) => s.reasoningStyle);
|
||||
const reasoningEffort = useChatRuntimeStore((s) => s.reasoningEffort);
|
||||
const supportsReasoningOff = useChatRuntimeStore((s) => s.supportsReasoningOff);
|
||||
const reasoningEffortLevels = useChatRuntimeStore((s) => s.reasoningEffortLevels);
|
||||
const supportsReasoningOff = useChatRuntimeStore(
|
||||
(s) => s.supportsReasoningOff,
|
||||
);
|
||||
const reasoningEffortLevels = useChatRuntimeStore(
|
||||
(s) => s.reasoningEffortLevels,
|
||||
);
|
||||
const setReasoningEffort = useChatRuntimeStore((s) => s.setReasoningEffort);
|
||||
const supportsPreserveThinking = useChatRuntimeStore((s) => s.supportsPreserveThinking);
|
||||
const supportsPreserveThinking = useChatRuntimeStore(
|
||||
(s) => s.supportsPreserveThinking,
|
||||
);
|
||||
const preserveThinking = useChatRuntimeStore((s) => s.preserveThinking);
|
||||
const setPreserveThinking = useChatRuntimeStore((s) => s.setPreserveThinking);
|
||||
const supportsTools = useChatRuntimeStore((s) => s.supportsTools);
|
||||
|
|
@ -336,6 +382,8 @@ export function SharedComposer({
|
|||
const setImageToolsEnabled = useChatRuntimeStore(
|
||||
(s) => s.setImageToolsEnabled,
|
||||
);
|
||||
const artifactsEnabled = useChatRuntimeStore((s) => s.artifactsEnabled);
|
||||
const setArtifactsEnabled = useChatRuntimeStore((s) => s.setArtifactsEnabled);
|
||||
const lastOpenRouterChosenModel = useChatRuntimeStore(
|
||||
(s) => s.lastOpenRouterChosenModel,
|
||||
);
|
||||
|
|
@ -437,16 +485,22 @@ export function SharedComposer({
|
|||
// the pill row stays compact for providers without the capability.
|
||||
const imageDisabled = !modelLoaded || !supportsBuiltinImageGeneration;
|
||||
const showImagePill = supportsBuiltinImageGeneration;
|
||||
const artifactDisabled = !modelLoaded;
|
||||
// Backwards-compatible alias for any other call site that may still
|
||||
// reference `toolsDisabled` (rare; both pills used it before).
|
||||
const toolsDisabled = codeDisabled;
|
||||
const setPendingAudioStore = useChatRuntimeStore((s) => s.setPendingAudio);
|
||||
const clearPendingAudioStore = useChatRuntimeStore((s) => s.clearPendingAudio);
|
||||
|
||||
const { isDictating, start: startDictation, stop: stopDictation, supported: dictationSupported } = useDictation(
|
||||
setText,
|
||||
const clearPendingAudioStore = useChatRuntimeStore(
|
||||
(s) => s.clearPendingAudio,
|
||||
);
|
||||
|
||||
const {
|
||||
isDictating,
|
||||
start: startDictation,
|
||||
stop: stopDictation,
|
||||
supported: dictationSupported,
|
||||
} = useDictation(setText);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
const handles = handlesRef.current;
|
||||
|
|
@ -463,43 +517,48 @@ export function SharedComposer({
|
|||
ta.style.height = "auto";
|
||||
const styles = window.getComputedStyle(ta);
|
||||
const lineHeight = parseFloat(styles.lineHeight) || 20;
|
||||
const paddingY = parseFloat(styles.paddingTop) + parseFloat(styles.paddingBottom);
|
||||
const borderY = parseFloat(styles.borderTopWidth) + parseFloat(styles.borderBottomWidth);
|
||||
const paddingY =
|
||||
parseFloat(styles.paddingTop) + parseFloat(styles.paddingBottom);
|
||||
const borderY =
|
||||
parseFloat(styles.borderTopWidth) + parseFloat(styles.borderBottomWidth);
|
||||
const maxHeight = lineHeight * 6 + paddingY + borderY;
|
||||
const next = Math.min(ta.scrollHeight, maxHeight);
|
||||
ta.style.height = `${next}px`;
|
||||
ta.style.overflowY = ta.scrollHeight > maxHeight ? "auto" : "hidden";
|
||||
}, [text]);
|
||||
|
||||
const addFiles = useCallback((files: FileList | null) => {
|
||||
if (!files?.length) return;
|
||||
const next: PendingImage[] = [];
|
||||
let droppedImageForUnavailable = false;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (!file) continue;
|
||||
// Handle audio files
|
||||
if (file.type.match(/^audio\//i) && file.size <= MAX_AUDIO_SIZE) {
|
||||
fileToBase64(file).then((base64) => {
|
||||
setPendingAudio({ name: file.name, base64 });
|
||||
setPendingAudioStore(base64, file.name);
|
||||
});
|
||||
continue;
|
||||
const addFiles = useCallback(
|
||||
(files: FileList | null) => {
|
||||
if (!files?.length) return;
|
||||
const next: PendingImage[] = [];
|
||||
let droppedImageForUnavailable = false;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (!file) continue;
|
||||
// Handle audio files
|
||||
if (file.type.match(/^audio\//i) && file.size <= MAX_AUDIO_SIZE) {
|
||||
fileToBase64(file).then((base64) => {
|
||||
setPendingAudio({ name: file.name, base64 });
|
||||
setPendingAudioStore(base64, file.name);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Handle image files
|
||||
if (!file.type.match(/^image\/(jpeg|png|webp|gif)$/i)) continue;
|
||||
if (file.size > MAX_IMAGE_SIZE) continue;
|
||||
if (attachUnavailableReason) {
|
||||
droppedImageForUnavailable = true;
|
||||
continue;
|
||||
}
|
||||
next.push({ id: crypto.randomUUID(), file });
|
||||
}
|
||||
// Handle image files
|
||||
if (!file.type.match(/^image\/(jpeg|png|webp|gif)$/i)) continue;
|
||||
if (file.size > MAX_IMAGE_SIZE) continue;
|
||||
if (attachUnavailableReason) {
|
||||
droppedImageForUnavailable = true;
|
||||
continue;
|
||||
if (droppedImageForUnavailable && attachUnavailableReason) {
|
||||
toast.error(attachUnavailableReason);
|
||||
}
|
||||
next.push({ id: crypto.randomUUID(), file });
|
||||
}
|
||||
if (droppedImageForUnavailable && attachUnavailableReason) {
|
||||
toast.error(attachUnavailableReason);
|
||||
}
|
||||
setPendingImages((prev) => [...prev, ...next]);
|
||||
}, [setPendingAudioStore, attachUnavailableReason]);
|
||||
setPendingImages((prev) => [...prev, ...next]);
|
||||
},
|
||||
[setPendingAudioStore, attachUnavailableReason],
|
||||
);
|
||||
|
||||
const removePendingImage = useCallback((id: string) => {
|
||||
setPendingImages((prev) => prev.filter((p) => p.id !== id));
|
||||
|
|
@ -557,12 +616,17 @@ export function SharedComposer({
|
|||
// LoraCompare and single-pane chats are unaffected.
|
||||
if (hasCompareHandles && !isGeneralizedCompare) {
|
||||
toast.error("Pick a model in each pane to compare", {
|
||||
description: "Use the model dropdown above each pane, then send your prompt.",
|
||||
description:
|
||||
"Use the model dropdown above each pane, then send your prompt.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingImages.length > 0 && !isGeneralizedCompare && imageUnavailableReason) {
|
||||
if (
|
||||
pendingImages.length > 0 &&
|
||||
!isGeneralizedCompare &&
|
||||
imageUnavailableReason
|
||||
) {
|
||||
// Single mode: the loaded model's runtime capability is known
|
||||
// here. Compare mode defers — each ensureModelLoaded below sets
|
||||
// loadedIsMultimodal for its side, and the chat-adapter's
|
||||
|
|
@ -600,8 +664,9 @@ export function SharedComposer({
|
|||
const maxSeqLength = store.params.maxSeqLength;
|
||||
const trustRemoteCode = store.params.trustRemoteCode ?? false;
|
||||
const chatTemplateOverride = store.chatTemplateOverride;
|
||||
const effectiveChatTemplateOverride =
|
||||
chatTemplateOverride?.trim() ? chatTemplateOverride : null;
|
||||
const effectiveChatTemplateOverride = chatTemplateOverride?.trim()
|
||||
? chatTemplateOverride
|
||||
: null;
|
||||
|
||||
function modelDisplayName(id: string): string {
|
||||
const parts = id.split("/");
|
||||
|
|
@ -609,11 +674,14 @@ export function SharedComposer({
|
|||
}
|
||||
|
||||
// Helper: load a model and update store checkpoint
|
||||
async function ensureModelLoaded(sel: CompareModelSelection): Promise<string> {
|
||||
async function ensureModelLoaded(
|
||||
sel: CompareModelSelection,
|
||||
): Promise<string> {
|
||||
const currentStore = useChatRuntimeStore.getState();
|
||||
const isAlreadyActive =
|
||||
currentStore.params.checkpoint === sel.id &&
|
||||
(currentStore.activeGgufVariant ?? null) === (sel.ggufVariant ?? null);
|
||||
(currentStore.activeGgufVariant ?? null) ===
|
||||
(sel.ggufVariant ?? null);
|
||||
if (!isAlreadyActive) {
|
||||
const validation = await validateModel({
|
||||
model_path: sel.id,
|
||||
|
|
@ -703,9 +771,17 @@ export function SharedComposer({
|
|||
try {
|
||||
// Side 1: load → generate → wait
|
||||
if (handle1 && model1?.id) {
|
||||
toast("Loading Model 1…", { id: toastId, description: name1, duration: Infinity });
|
||||
toast("Loading Model 1…", {
|
||||
id: toastId,
|
||||
description: name1,
|
||||
duration: Infinity,
|
||||
});
|
||||
const status1 = await ensureModelLoaded(model1);
|
||||
toast("Generating with Model 1…", { id: toastId, description: `${name1} (${status1})`, duration: Infinity });
|
||||
toast("Generating with Model 1…", {
|
||||
id: toastId,
|
||||
description: `${name1} (${status1})`,
|
||||
duration: Infinity,
|
||||
});
|
||||
const done = handle1.waitForRunEnd();
|
||||
handle1.startRun();
|
||||
await done;
|
||||
|
|
@ -713,13 +789,22 @@ export function SharedComposer({
|
|||
|
||||
// Side 2: load → generate → wait
|
||||
if (handle2 && model2?.id) {
|
||||
const needsLoad = model2.id.toLowerCase() !== (model1?.id || "").toLowerCase()
|
||||
|| (model2.ggufVariant ?? "") !== (model1?.ggufVariant ?? "");
|
||||
const needsLoad =
|
||||
model2.id.toLowerCase() !== (model1?.id || "").toLowerCase() ||
|
||||
(model2.ggufVariant ?? "") !== (model1?.ggufVariant ?? "");
|
||||
if (needsLoad) {
|
||||
toast("Loading Model 2…", { id: toastId, description: name2, duration: Infinity });
|
||||
toast("Loading Model 2…", {
|
||||
id: toastId,
|
||||
description: name2,
|
||||
duration: Infinity,
|
||||
});
|
||||
}
|
||||
const status2 = await ensureModelLoaded(model2);
|
||||
toast("Generating with Model 2…", { id: toastId, description: `${name2} (${status2})`, duration: Infinity });
|
||||
toast("Generating with Model 2…", {
|
||||
id: toastId,
|
||||
description: `${name2} (${status2})`,
|
||||
duration: Infinity,
|
||||
});
|
||||
const done = handle2.waitForRunEnd();
|
||||
handle2.startRun();
|
||||
await done;
|
||||
|
|
@ -773,7 +858,12 @@ export function SharedComposer({
|
|||
}
|
||||
}
|
||||
|
||||
const canSend = (text.trim().length > 0 || pendingImages.length > 0 || pendingAudio !== null) && !busy && !isComposing;
|
||||
const canSend =
|
||||
(text.trim().length > 0 ||
|
||||
pendingImages.length > 0 ||
|
||||
pendingAudio !== null) &&
|
||||
!busy &&
|
||||
!isComposing;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -808,7 +898,10 @@ export function SharedComposer({
|
|||
<span className="max-w-48 truncate">{pendingAudio.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setPendingAudio(null); clearPendingAudioStore(); }}
|
||||
onClick={() => {
|
||||
setPendingAudio(null);
|
||||
clearPendingAudioStore();
|
||||
}}
|
||||
className="flex size-4 items-center justify-center rounded-full hover:bg-destructive hover:text-destructive-foreground"
|
||||
aria-label="Remove audio"
|
||||
>
|
||||
|
|
@ -906,130 +999,136 @@ export function SharedComposer({
|
|||
)}
|
||||
{showReasoningControl ? (
|
||||
effectiveReasoningStyle === "reasoning_effort" ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={reasoningDisabled}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors",
|
||||
reasoningDisabled
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={reasoningDisabled}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors",
|
||||
reasoningDisabled
|
||||
? "cursor-not-allowed opacity-40"
|
||||
: effectiveReasoningVisualEnabled
|
||||
? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
|
||||
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
|
||||
)}
|
||||
aria-label={thinkEffortAriaLabel({
|
||||
modelLoaded,
|
||||
reasoningDisabled,
|
||||
reasoningEffort,
|
||||
})}
|
||||
>
|
||||
{effectiveReasoningVisualEnabled ? (
|
||||
<LightbulbIcon className="size-3.5" />
|
||||
) : (
|
||||
<LightbulbOffIcon className="size-3.5" />
|
||||
)}
|
||||
<span>
|
||||
Think:{" "}
|
||||
{effectiveReasoningVisualEnabled
|
||||
? formatReasoningEffortLabel(
|
||||
reasoningEffort,
|
||||
externalSelection?.modelId,
|
||||
)
|
||||
: formatReasoningDisabledLabel(
|
||||
effectiveSupportsReasoningOff,
|
||||
isExternalOpenAIReasoning,
|
||||
checkpoint,
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{effectiveSupportsReasoningOff && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setReasoningEnabled(false);
|
||||
applyQwenThinkingParams(false);
|
||||
}}
|
||||
>
|
||||
{formatReasoningDisabledLabel(
|
||||
effectiveSupportsReasoningOff,
|
||||
isExternalOpenAIReasoning,
|
||||
checkpoint,
|
||||
)}
|
||||
{!effectiveReasoningVisualEnabled ? " \u2713" : ""}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{effectiveReasoningEffortLevels
|
||||
.filter((level) => level !== "none")
|
||||
.map((level) => (
|
||||
<DropdownMenuItem
|
||||
key={level}
|
||||
onSelect={() => {
|
||||
setReasoningEffort(level);
|
||||
setReasoningEnabled(true);
|
||||
applyQwenThinkingParams(true);
|
||||
// Mutual exclusion: turning thinking on for a
|
||||
// Kimi model forces the web_search builtin off.
|
||||
if (isKimiExternal && toolsEnabled) {
|
||||
setToolsEnabled(false, { persist: false });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{formatReasoningEffortLabel(
|
||||
level,
|
||||
externalSelection?.modelId,
|
||||
)}
|
||||
{effectiveReasoningVisualEnabled &&
|
||||
reasoningEffort === level
|
||||
? " \u2713"
|
||||
: ""}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={reasoningDisabled || reasoningLockedOn}
|
||||
aria-disabled={reasoningDisabled || reasoningLockedOn}
|
||||
title={
|
||||
reasoningLockedOn
|
||||
? "This model requires reasoning to stay on."
|
||||
: undefined
|
||||
}
|
||||
onClick={() => {
|
||||
if (reasoningLockedOn) return;
|
||||
const next = !reasoningEnabled;
|
||||
setReasoningEnabled(next);
|
||||
applyQwenThinkingParams(next);
|
||||
// Mutual exclusion: Kimi's $web_search builtin
|
||||
// requires thinking off, so turning thinking on flips
|
||||
// the Search pill off (and vice versa).
|
||||
if (isKimiExternal && next && toolsEnabled) {
|
||||
setToolsEnabled(false, { persist: false });
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors",
|
||||
reasoningLockedOn
|
||||
? "cursor-not-allowed text-primary"
|
||||
: reasoningDisabled
|
||||
? "cursor-not-allowed opacity-40"
|
||||
: effectiveReasoningVisualEnabled
|
||||
: effectiveReasoningEnabled
|
||||
? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
|
||||
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
|
||||
)}
|
||||
aria-label={thinkEffortAriaLabel({
|
||||
modelLoaded,
|
||||
reasoningDisabled,
|
||||
reasoningEffort,
|
||||
})}
|
||||
>
|
||||
{effectiveReasoningVisualEnabled ? (
|
||||
<LightbulbIcon className="size-3.5" />
|
||||
) : (
|
||||
<LightbulbOffIcon className="size-3.5" />
|
||||
)}
|
||||
<span>
|
||||
Think:{" "}
|
||||
{effectiveReasoningVisualEnabled
|
||||
? formatReasoningEffortLabel(
|
||||
reasoningEffort,
|
||||
externalSelection?.modelId,
|
||||
)
|
||||
: formatReasoningDisabledLabel(
|
||||
effectiveSupportsReasoningOff,
|
||||
isExternalOpenAIReasoning,
|
||||
checkpoint,
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{effectiveSupportsReasoningOff && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setReasoningEnabled(false);
|
||||
applyQwenThinkingParams(false);
|
||||
}}
|
||||
>
|
||||
{formatReasoningDisabledLabel(
|
||||
effectiveSupportsReasoningOff,
|
||||
isExternalOpenAIReasoning,
|
||||
checkpoint,
|
||||
)}
|
||||
{!effectiveReasoningVisualEnabled ? " \u2713" : ""}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{effectiveReasoningEffortLevels
|
||||
.filter((level) => level !== "none")
|
||||
.map((level) => (
|
||||
<DropdownMenuItem
|
||||
key={level}
|
||||
onSelect={() => {
|
||||
setReasoningEffort(level);
|
||||
setReasoningEnabled(true);
|
||||
applyQwenThinkingParams(true);
|
||||
// Mutual exclusion: turning thinking on for a
|
||||
// Kimi model forces the web_search builtin off.
|
||||
if (isKimiExternal && toolsEnabled) {
|
||||
setToolsEnabled(false, { persist: false });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{formatReasoningEffortLabel(level, externalSelection?.modelId)}
|
||||
{effectiveReasoningVisualEnabled && reasoningEffort === level ? " \u2713" : ""}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={reasoningDisabled || reasoningLockedOn}
|
||||
aria-disabled={reasoningDisabled || reasoningLockedOn}
|
||||
title={
|
||||
reasoningLockedOn
|
||||
? "This model requires reasoning to stay on."
|
||||
: undefined
|
||||
}
|
||||
onClick={() => {
|
||||
if (reasoningLockedOn) return;
|
||||
const next = !reasoningEnabled;
|
||||
setReasoningEnabled(next);
|
||||
applyQwenThinkingParams(next);
|
||||
// Mutual exclusion: Kimi's $web_search builtin
|
||||
// requires thinking off, so turning thinking on flips
|
||||
// the Search pill off (and vice versa).
|
||||
if (isKimiExternal && next && toolsEnabled) {
|
||||
setToolsEnabled(false, { persist: false });
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-full px-1.5 py-1.5 text-[13px] font-medium text-muted-foreground/70 transition-colors",
|
||||
reasoningLockedOn
|
||||
? "cursor-not-allowed text-primary"
|
||||
: reasoningDisabled
|
||||
? "cursor-not-allowed opacity-40"
|
||||
: effectiveReasoningEnabled
|
||||
? "text-primary hover:bg-primary/10 dark:hover:bg-white/[0.08]"
|
||||
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
|
||||
)}
|
||||
aria-label={thinkToggleAriaLabel({
|
||||
reasoningLockedOn,
|
||||
modelLoaded,
|
||||
reasoningDisabled,
|
||||
effectiveReasoningEnabled,
|
||||
})}
|
||||
>
|
||||
{reasoningLockedOn ||
|
||||
(effectiveReasoningEnabled && !reasoningDisabled) ? (
|
||||
<LightbulbIcon className="size-3.5" />
|
||||
) : (
|
||||
<LightbulbOffIcon className="size-3.5" />
|
||||
)}
|
||||
<span>Think</span>
|
||||
</button>
|
||||
aria-label={thinkToggleAriaLabel({
|
||||
reasoningLockedOn,
|
||||
modelLoaded,
|
||||
reasoningDisabled,
|
||||
effectiveReasoningEnabled,
|
||||
})}
|
||||
>
|
||||
{reasoningLockedOn ||
|
||||
(effectiveReasoningEnabled && !reasoningDisabled) ? (
|
||||
<LightbulbIcon className="size-3.5" />
|
||||
) : (
|
||||
<LightbulbOffIcon className="size-3.5" />
|
||||
)}
|
||||
<span>Think</span>
|
||||
</button>
|
||||
)
|
||||
) : null}
|
||||
{supportsPreserveThinking && (
|
||||
|
|
@ -1046,7 +1145,9 @@ export function SharedComposer({
|
|||
: "hover:bg-primary/10 dark:hover:bg-white/[0.08]",
|
||||
)}
|
||||
aria-label={
|
||||
preserveThinking ? "Disable preserve think" : "Enable preserve think"
|
||||
preserveThinking
|
||||
? "Disable preserve think"
|
||||
: "Enable preserve think"
|
||||
}
|
||||
>
|
||||
{preserveThinking && modelLoaded ? (
|
||||
|
|
@ -1075,7 +1176,9 @@ export function SharedComposer({
|
|||
}}
|
||||
className="composer-pill-btn"
|
||||
data-active={toolsEnabled && !searchDisabled ? "true" : "false"}
|
||||
aria-label={toolsEnabled ? "Disable web search" : "Enable web search"}
|
||||
aria-label={
|
||||
toolsEnabled ? "Disable web search" : "Enable web search"
|
||||
}
|
||||
>
|
||||
<GlobeIcon className="size-3.5" />
|
||||
<span>Search</span>
|
||||
|
|
@ -1086,7 +1189,11 @@ export function SharedComposer({
|
|||
onClick={() => setCodeToolsEnabled(!codeToolsEnabled)}
|
||||
className="composer-pill-btn"
|
||||
data-active={codeToolsEnabled && !codeDisabled ? "true" : "false"}
|
||||
aria-label={codeToolsEnabled ? "Disable code execution" : "Enable code execution"}
|
||||
aria-label={
|
||||
codeToolsEnabled
|
||||
? "Disable code execution"
|
||||
: "Enable code execution"
|
||||
}
|
||||
>
|
||||
<CodeToggleIcon className="size-3.5" />
|
||||
<span>Code</span>
|
||||
|
|
@ -1097,15 +1204,34 @@ export function SharedComposer({
|
|||
disabled={imageDisabled}
|
||||
onClick={() => setImageToolsEnabled(!imageToolsEnabled)}
|
||||
className="composer-pill-btn"
|
||||
data-active={imageToolsEnabled && !imageDisabled ? "true" : "false"}
|
||||
data-active={
|
||||
imageToolsEnabled && !imageDisabled ? "true" : "false"
|
||||
}
|
||||
aria-label={
|
||||
imageToolsEnabled ? "Disable image generation" : "Enable image generation"
|
||||
imageToolsEnabled
|
||||
? "Disable image generation"
|
||||
: "Enable image generation"
|
||||
}
|
||||
>
|
||||
<ImageIcon className="size-3.5" />
|
||||
<span>Images</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={artifactDisabled}
|
||||
onClick={() => setArtifactsEnabled(!artifactsEnabled)}
|
||||
className="composer-pill-btn"
|
||||
data-active={
|
||||
artifactsEnabled && !artifactDisabled ? "true" : "false"
|
||||
}
|
||||
aria-label={
|
||||
artifactsEnabled ? "Disable artifacts" : "Enable artifacts"
|
||||
}
|
||||
>
|
||||
<FileTextIcon className="size-3.5" />
|
||||
<span>Artifacts</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{dictationSupported && (
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ export const CHAT_REASONING_ENABLED_KEY = "unsloth_chat_reasoning_enabled";
|
|||
export const CHAT_TOOLS_ENABLED_KEY = "unsloth_chat_tools_enabled";
|
||||
export const CHAT_CODE_TOOLS_ENABLED_KEY = "unsloth_chat_code_tools_enabled";
|
||||
export const CHAT_IMAGE_TOOLS_ENABLED_KEY = "unsloth_chat_image_tools_enabled";
|
||||
export const CHAT_ARTIFACTS_ENABLED_KEY = "unsloth_chat_artifacts_enabled";
|
||||
|
||||
// External provider selection is encoded into `params.checkpoint` as
|
||||
// `external::<providerId>::<modelId>`. PersistedChatSettings deliberately
|
||||
|
|
@ -265,6 +266,7 @@ type ChatRuntimeStore = {
|
|||
toolsEnabled: boolean;
|
||||
codeToolsEnabled: boolean;
|
||||
imageToolsEnabled: boolean;
|
||||
artifactsEnabled: boolean;
|
||||
toolStatus: string | null;
|
||||
generatingStatus: string | null;
|
||||
autoHealToolCalls: boolean;
|
||||
|
|
@ -324,6 +326,7 @@ type ChatRuntimeStore = {
|
|||
setToolsEnabled: (enabled: boolean, options?: { persist?: boolean }) => void;
|
||||
setCodeToolsEnabled: (enabled: boolean) => void;
|
||||
setImageToolsEnabled: (enabled: boolean) => void;
|
||||
setArtifactsEnabled: (enabled: boolean, options?: { persist?: boolean }) => void;
|
||||
setToolStatus: (status: string | null) => void;
|
||||
setGeneratingStatus: (status: string | null) => void;
|
||||
setAutoHealToolCalls: (enabled: boolean) => void;
|
||||
|
|
@ -567,6 +570,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
|
|||
toolsEnabled: loadBool(CHAT_TOOLS_ENABLED_KEY, false),
|
||||
codeToolsEnabled: loadBool(CHAT_CODE_TOOLS_ENABLED_KEY, false),
|
||||
imageToolsEnabled: loadBool(CHAT_IMAGE_TOOLS_ENABLED_KEY, false),
|
||||
artifactsEnabled: loadBool(CHAT_ARTIFACTS_ENABLED_KEY, false),
|
||||
toolStatus: null,
|
||||
generatingStatus: null,
|
||||
autoHealToolCalls: true,
|
||||
|
|
@ -747,6 +751,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
|
|||
toolsEnabled: false,
|
||||
codeToolsEnabled: false,
|
||||
imageToolsEnabled: false,
|
||||
artifactsEnabled: false,
|
||||
toolStatus: null,
|
||||
kvCacheDtype: null,
|
||||
loadedKvCacheDtype: null,
|
||||
|
|
@ -806,6 +811,13 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
|
|||
saveBool(CHAT_IMAGE_TOOLS_ENABLED_KEY, imageToolsEnabled);
|
||||
return { imageToolsEnabled };
|
||||
}),
|
||||
setArtifactsEnabled: (artifactsEnabled, options) =>
|
||||
set(() => {
|
||||
if (options?.persist !== false) {
|
||||
saveBool(CHAT_ARTIFACTS_ENABLED_KEY, artifactsEnabled);
|
||||
}
|
||||
return { artifactsEnabled };
|
||||
}),
|
||||
setToolStatus: (toolStatus) => set({ toolStatus }),
|
||||
setGeneratingStatus: (generatingStatus) => set({ generatingStatus }),
|
||||
setAutoHealToolCalls: (autoHealToolCalls) =>
|
||||
|
|
|
|||
11
studio/frontend/src/features/native-intents/index.ts
Normal file
11
studio/frontend/src/features/native-intents/index.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
export { NativeModelChip } from "./components/native-model-chip";
|
||||
export { NativeModelDropOverlay } from "./components/native-model-drop-overlay";
|
||||
export { useNativeIntentStore } from "./store";
|
||||
export type { NativeIntent } from "./types";
|
||||
export { useChooseNativeModel } from "./use-native-dialogs";
|
||||
export { useNativeModelDrop } from "./use-native-drop";
|
||||
export type { NativeModelDropState } from "./use-native-drop";
|
||||
export { useNativePathLeasesSupported } from "./use-native-readiness";
|
||||
Loading…
Add table
Add a link
Reference in a new issue