Merge remote-tracking branch 'origin/main' into r7256
This commit is contained in:
commit
96d8b99e65
24 changed files with 730 additions and 106 deletions
|
|
@ -307,6 +307,26 @@ def _native_linux_system_rocm_lib_dirs(binary_dir: str = "") -> "list[str]":
|
|||
_DEFAULT_MAX_TOKENS_FLOOR = 32768
|
||||
_DEFAULT_FIRST_TOKEN_TIMEOUT_S = 1200.0 # 20 min
|
||||
|
||||
|
||||
def _finalize_reasoning_only_cumulative(
|
||||
cumulative: str, reasoning_text: str, finish_reason: Optional[str], promote_reasoning_only: bool
|
||||
) -> str:
|
||||
"""Close a live thinking block and promote it only after a clean stop.
|
||||
|
||||
Local inference streams cumulative snapshots. Replacing ``<think>...`` with
|
||||
bare reasoning at EOF makes the final snapshot shorter, so suffix-based
|
||||
route consumers drop the intended fallback. Keep the snapshot append-only.
|
||||
A length-truncated thought is not a final answer, so close it without
|
||||
promotion and let the client surface the ``length`` terminal state. Raw
|
||||
consumers that do not split reasoning from visible content can disable the
|
||||
fallback to avoid returning the same reasoning twice.
|
||||
"""
|
||||
visible_fallback = (
|
||||
reasoning_text if promote_reasoning_only and finish_reason != "length" else ""
|
||||
)
|
||||
return cumulative + "</think>" + visible_fallback
|
||||
|
||||
|
||||
# Only large streamed tool payloads get an early provisional card; render_html
|
||||
# is exempt because it needs immediate artifact feedback.
|
||||
_PROVISIONAL_ARGS_MIN_CHARS = 256
|
||||
|
|
@ -10556,6 +10576,7 @@ class LlamaCppBackend:
|
|||
reasoning_effort: Optional[str] = None,
|
||||
preserve_thinking: Optional[bool] = None,
|
||||
seed: Optional[int] = None,
|
||||
promote_reasoning_only: bool = True,
|
||||
_allow_respawn_retry: bool = True,
|
||||
) -> Generator[Union[str, dict], None, None]:
|
||||
"""
|
||||
|
|
@ -10638,7 +10659,12 @@ class LlamaCppBackend:
|
|||
# model put its whole reply in reasoning
|
||||
# (e.g. Qwen3 always-think). Show it as
|
||||
# the main response, not a thinking block.
|
||||
cumulative = reasoning_text
|
||||
cumulative = _finalize_reasoning_only_cumulative(
|
||||
cumulative,
|
||||
reasoning_text,
|
||||
_metadata_finish_reason,
|
||||
promote_reasoning_only,
|
||||
)
|
||||
yield cumulative
|
||||
_stream_done = True
|
||||
break # exit inner while
|
||||
|
|
@ -10735,6 +10761,7 @@ class LlamaCppBackend:
|
|||
reasoning_effort = reasoning_effort,
|
||||
preserve_thinking = preserve_thinking,
|
||||
seed = seed,
|
||||
promote_reasoning_only = promote_reasoning_only,
|
||||
_allow_respawn_retry = False,
|
||||
)
|
||||
return
|
||||
|
|
@ -10776,6 +10803,7 @@ class LlamaCppBackend:
|
|||
confirm_tool_calls: bool = False,
|
||||
bypass_permissions: bool = False,
|
||||
permission_mode: Optional[str] = None,
|
||||
promote_reasoning_only: bool = True,
|
||||
) -> Generator[dict, None, None]:
|
||||
"""
|
||||
Agentic loop: let the model call tools, execute them, and continue.
|
||||
|
|
@ -11118,7 +11146,12 @@ class LlamaCppBackend:
|
|||
),
|
||||
}
|
||||
else:
|
||||
cumulative_display = reasoning_accum
|
||||
cumulative_display = _finalize_reasoning_only_cumulative(
|
||||
cumulative_display,
|
||||
reasoning_accum,
|
||||
_iter_finish_reason,
|
||||
promote_reasoning_only,
|
||||
)
|
||||
if not _suppress_visible_output:
|
||||
yield {
|
||||
"type": "content",
|
||||
|
|
@ -11582,7 +11615,12 @@ class LlamaCppBackend:
|
|||
if _reasoning_started_at is not None and not _reasoning_summary_emitted:
|
||||
_reasoning_summary_emitted = True
|
||||
yield _reasoning_summary_event(_reasoning_started_at)
|
||||
cumulative_display = reasoning_accum
|
||||
cumulative_display = _finalize_reasoning_only_cumulative(
|
||||
cumulative_display,
|
||||
reasoning_accum,
|
||||
_iter_finish_reason,
|
||||
promote_reasoning_only,
|
||||
)
|
||||
if not _suppress_visible_output:
|
||||
yield {
|
||||
"type": "content",
|
||||
|
|
@ -12146,7 +12184,12 @@ class LlamaCppBackend:
|
|||
"text": _strip_tool_markup(cumulative, final = True),
|
||||
}
|
||||
else:
|
||||
cumulative = reasoning_text
|
||||
cumulative = _finalize_reasoning_only_cumulative(
|
||||
cumulative,
|
||||
reasoning_text,
|
||||
_metadata_finish_reason,
|
||||
promote_reasoning_only,
|
||||
)
|
||||
yield {"type": "content", "text": cumulative}
|
||||
_stream_done = True
|
||||
break # exit inner while
|
||||
|
|
|
|||
|
|
@ -1794,7 +1794,16 @@ router = APIRouter()
|
|||
studio_router = APIRouter()
|
||||
|
||||
|
||||
_ARTIFACT_PREVIEW_FRAME_ANCESTORS = "'self' tauri://localhost http://tauri.localhost"
|
||||
# Packaged desktop runs at tauri://localhost (macOS/Linux) or http://tauri.localhost
|
||||
# (Windows WebView2); the web build is same-origin ('self'). The `tauri dev` shell,
|
||||
# however, serves the frontend from the Vite dev origin (http://localhost:5173),
|
||||
# so the packaged allowlist alone leaves the preview blocked in dev with an
|
||||
# "ancestor violates frame-ancestors" error. This shell exposes no server resource
|
||||
# (it only renders postMessage'd HTML in a no-same-origin sandbox), so also allowing
|
||||
# any localhost/127.0.0.1 dev origin to frame it is safe and unblocks the dev shell.
|
||||
_ARTIFACT_PREVIEW_FRAME_ANCESTORS = (
|
||||
"'self' tauri://localhost http://tauri.localhost http://localhost:* http://127.0.0.1:*"
|
||||
)
|
||||
_ARTIFACT_PREVIEW_FRAME_STRICT_CSP = (
|
||||
"default-src 'none'; "
|
||||
"script-src 'unsafe-inline'; "
|
||||
|
|
@ -13355,6 +13364,7 @@ async def anthropic_messages(
|
|||
disable_parallel_tool_use = _disable_parallel,
|
||||
bypass_permissions = bool(payload.bypass_permissions),
|
||||
permission_mode = getattr(payload, "permission_mode", None),
|
||||
promote_reasoning_only = False,
|
||||
)
|
||||
|
||||
if payload.stream:
|
||||
|
|
@ -13394,6 +13404,7 @@ async def anthropic_messages(
|
|||
max_tokens = payload.max_tokens,
|
||||
stop = stop,
|
||||
cancel_event = cancel_event,
|
||||
promote_reasoning_only = False,
|
||||
)
|
||||
|
||||
if payload.stream:
|
||||
|
|
|
|||
|
|
@ -68,16 +68,15 @@ def _emitter_client_text(events: list[str]) -> str:
|
|||
|
||||
|
||||
def test_anthropic_emitter_closes_reasoning_only_think_block():
|
||||
# A reasoning-only reply streams <think>X live then shrinks to bare X at EOF.
|
||||
# This emitter diffs cumulative snapshots and drops the shrink, so without a
|
||||
# closing pass the client text would end on an unclosed <think>. finish()
|
||||
# must balance it.
|
||||
# Anthropic asks the GGUF generator not to promote reasoning into a duplicate
|
||||
# visible fallback, so its final cumulative snapshot only balances the block.
|
||||
emitter = AnthropicStreamEmitter()
|
||||
events = emitter.start("msg_1", "m")
|
||||
events += emitter.feed({"type": "content", "text": "<think>The capital"})
|
||||
events += emitter.feed({"type": "content", "text": "<think>The capital of France is Paris."})
|
||||
# The generator's final bare-text shrink (dropped by the cumulative diff).
|
||||
events += emitter.feed({"type": "content", "text": "The capital of France is Paris."})
|
||||
events += emitter.feed(
|
||||
{"type": "content", "text": "<think>The capital of France is Paris.</think>"}
|
||||
)
|
||||
events += emitter.finish()
|
||||
|
||||
assert _emitter_client_text(events) == "<think>The capital of France is Paris.</think>"
|
||||
|
|
@ -1563,6 +1562,44 @@ class TestAnthropicMessagesToolRouting:
|
|||
assert entry["context_length"] == 2048
|
||||
assert monitor.active_count() == 0
|
||||
|
||||
@pytest.mark.parametrize("stream", [False, True])
|
||||
@pytest.mark.parametrize("with_tools", [False, True])
|
||||
def test_reasoning_only_output_is_not_duplicated(self, monkeypatch, stream, with_tools):
|
||||
reasoning = "The capital of France is Paris."
|
||||
|
||||
def _gen_plain(**kwargs):
|
||||
assert kwargs["promote_reasoning_only"] is False
|
||||
yield f"<think>{reasoning}"
|
||||
yield f"<think>{reasoning}</think>"
|
||||
|
||||
def _gen_tools(**kwargs):
|
||||
assert kwargs["promote_reasoning_only"] is False
|
||||
yield {"type": "content", "text": f"<think>{reasoning}"}
|
||||
yield {"type": "content", "text": f"<think>{reasoning}</think>"}
|
||||
|
||||
_mock_backend(
|
||||
monkeypatch,
|
||||
generate_chat_completion = _gen_plain,
|
||||
generate_chat_completion_with_tools = _gen_tools,
|
||||
)
|
||||
payload_fields = {"stream": stream}
|
||||
if with_tools:
|
||||
payload_fields.update(
|
||||
{
|
||||
"enable_tools": True,
|
||||
"tools": [{"type": "web_search_20250305", "name": "web_search"}],
|
||||
}
|
||||
)
|
||||
payload = _basic_payload(**payload_fields)
|
||||
|
||||
response = _drive(anthropic_messages(payload, request = self._Request(), current_subject = "t"))
|
||||
if stream:
|
||||
body = self._sse_blob(self._consume_response(response))
|
||||
assert body.count(reasoning) == 1
|
||||
else:
|
||||
body = json.loads(response.body)
|
||||
assert body["content"][0]["text"] == f"<think>{reasoning}</think>"
|
||||
|
||||
def test_tool_use_non_streaming_records_api_monitor_reply(self, monkeypatch):
|
||||
import routes.inference as inf_mod
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,24 @@ def _done() -> str:
|
|||
return "data: [DONE]\n"
|
||||
|
||||
|
||||
def _finish(reason: str) -> str:
|
||||
return (
|
||||
"data: "
|
||||
+ json.dumps(
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"delta": {},
|
||||
"finish_reason": reason,
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
|
||||
def _make_backend(monkeypatch, streams: list[list[str]], payloads: list[dict]):
|
||||
backend = LlamaCppBackend.__new__(LlamaCppBackend)
|
||||
backend._process = object()
|
||||
|
|
@ -299,9 +317,8 @@ def test_reasoning_streams_incrementally_with_tools(monkeypatch):
|
|||
def test_reasoning_only_reply_matches_no_tool_path_with_tools(monkeypatch):
|
||||
# A reasoning-only turn (whole answer in reasoning_content, no content, no
|
||||
# tool) with a tool active streams the reasoning live, then resolves to the
|
||||
# bare reasoning text -- identical to the no-tool generate_chat_completion
|
||||
# path -- so the non-streaming drain still returns it as `content`, not an
|
||||
# empty answer.
|
||||
# same text on the visible channel. The final cumulative snapshot stays
|
||||
# append-only so route suffix extraction cannot drop that fallback.
|
||||
stream = [
|
||||
_sse({"reasoning_content": "The capital of France is Paris."}),
|
||||
_done(),
|
||||
|
|
@ -321,8 +338,49 @@ def test_reasoning_only_reply_matches_no_tool_path_with_tools(monkeypatch):
|
|||
content_texts = [e["text"] for e in events if e["type"] == "content"]
|
||||
# Reasoning streamed live during BUFFERING (the fix).
|
||||
assert content_texts[0] == "<think>The capital of France is Paris."
|
||||
# Resolves to bare reasoning, matching the no-tool sibling.
|
||||
assert content_texts[-1] == "The capital of France is Paris."
|
||||
assert content_texts[-1] == (
|
||||
"<think>The capital of France is Paris.</think>The capital of France is Paris."
|
||||
)
|
||||
|
||||
|
||||
def _assert_reasoning_only_raw_consumer_gets_one_balanced_think_block(monkeypatch, with_tools):
|
||||
stream = [
|
||||
_sse({"reasoning_content": "The capital of France is Paris."}),
|
||||
_done(),
|
||||
]
|
||||
backend = _make_backend(monkeypatch, [stream], [])
|
||||
|
||||
if with_tools:
|
||||
items = list(
|
||||
backend.generate_chat_completion_with_tools(
|
||||
messages = [{"role": "user", "content": "capital of France?"}],
|
||||
tools = [{"type": "function", "function": {"name": "web_search"}}],
|
||||
max_tool_iterations = 1,
|
||||
promote_reasoning_only = False,
|
||||
)
|
||||
)
|
||||
cumulatives = [item["text"] for item in items if item.get("type") == "content"]
|
||||
else:
|
||||
items = list(
|
||||
backend.generate_chat_completion(
|
||||
messages = [{"role": "user", "content": "capital of France?"}],
|
||||
promote_reasoning_only = False,
|
||||
)
|
||||
)
|
||||
cumulatives = [item for item in items if isinstance(item, str)]
|
||||
|
||||
assert cumulatives[-1] == "<think>The capital of France is Paris.</think>"
|
||||
assert all(
|
||||
current.startswith(previous) for previous, current in zip([""] + cumulatives, cumulatives)
|
||||
)
|
||||
|
||||
|
||||
def test_reasoning_only_raw_consumer_without_tools_gets_one_balanced_think_block(monkeypatch):
|
||||
_assert_reasoning_only_raw_consumer_gets_one_balanced_think_block(monkeypatch, False)
|
||||
|
||||
|
||||
def test_reasoning_only_raw_consumer_with_tools_gets_one_balanced_think_block(monkeypatch):
|
||||
_assert_reasoning_only_raw_consumer_gets_one_balanced_think_block(monkeypatch, True)
|
||||
|
||||
|
||||
def test_reasoning_before_structured_tool_closes_think_block(monkeypatch):
|
||||
|
|
@ -392,8 +450,8 @@ def _replay_route_reasoning_extractor(cumulatives: list[str]) -> tuple[str, str]
|
|||
def test_reasoning_only_route_output_matches_no_tool_path(monkeypatch):
|
||||
# Parity contract: a reasoning-only reply must reach the client identically
|
||||
# whether tools are on or off. Both generators stream <think> live then
|
||||
# resolve to the bare reasoning text; the route's suffix-diff + extractor
|
||||
# must therefore produce the same (visible, reasoning) split for both.
|
||||
# append a balanced close plus visible fallback; the route's suffix-diff +
|
||||
# extractor must therefore produce the same split for both.
|
||||
stream = [
|
||||
_sse({"reasoning_content": "The capital"}),
|
||||
_sse({"reasoning_content": " of France is Paris."}),
|
||||
|
|
@ -430,10 +488,37 @@ def test_reasoning_only_route_output_matches_no_tool_path(monkeypatch):
|
|||
no_tool_out = _replay_route_reasoning_extractor(no_tool_cumulatives)
|
||||
assert tool_out == no_tool_out
|
||||
# Pin the shared contract so a change to either path shows up here.
|
||||
_visible, reasoning = tool_out
|
||||
visible, reasoning = tool_out
|
||||
assert visible == "The capital of France is Paris."
|
||||
assert reasoning == "The capital of France is Paris."
|
||||
|
||||
|
||||
def test_length_truncated_reasoning_stays_append_only_without_visible_promotion(monkeypatch):
|
||||
stream = [
|
||||
_sse({"reasoning_content": "The proof begins by assuming finitely many primes."}),
|
||||
_finish("length"),
|
||||
_done(),
|
||||
]
|
||||
backend = _make_backend(monkeypatch, [stream], [])
|
||||
|
||||
items = list(
|
||||
backend.generate_chat_completion(
|
||||
messages = [{"role": "user", "content": "Prove infinitely many primes"}],
|
||||
max_tokens = 16,
|
||||
)
|
||||
)
|
||||
cumulatives = [item for item in items if isinstance(item, str)]
|
||||
|
||||
assert all(
|
||||
current.startswith(previous) for previous, current in zip([""] + cumulatives, cumulatives)
|
||||
)
|
||||
assert cumulatives[-1] == ("<think>The proof begins by assuming finitely many primes.</think>")
|
||||
visible, reasoning = _replay_route_reasoning_extractor(cumulatives)
|
||||
assert visible == ""
|
||||
assert reasoning == "The proof begins by assuming finitely many primes."
|
||||
assert items[-1]["finish_reason"] == "length"
|
||||
|
||||
|
||||
def test_reasoning_before_bare_json_tool_closes_think_block(monkeypatch):
|
||||
# _drain_silently sibling of the structured-tool close: a bare-JSON tool call
|
||||
# with a live reasoning prefix must also close </think> before draining, and
|
||||
|
|
|
|||
|
|
@ -524,8 +524,10 @@ export function AppProvider({ children }: AppProviderProps) {
|
|||
visibleToasts={2}
|
||||
expand={true}
|
||||
closeButton={true}
|
||||
// Clear the chat header buttons on the right.
|
||||
offset={{ top: 12, right: 64 }}
|
||||
// Clear the chat header buttons on the right. On desktop, also drop
|
||||
// below the ~34px custom window titlebar so toasts don't cover the
|
||||
// minimize / maximize / close controls.
|
||||
offset={{ top: isTauri ? 46 : 12, right: 64 }}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</MotionConfig>
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ import {
|
|||
import { resolveLoadMaxSeqLength } from "../presets/preset-policy";
|
||||
import {
|
||||
generateAudio,
|
||||
GenerationLengthError,
|
||||
listCachedGguf,
|
||||
listCachedModels,
|
||||
listGgufVariants,
|
||||
|
|
@ -4093,7 +4094,15 @@ export function createOpenAIStreamAdapter(
|
|||
);
|
||||
if (!abortSignal.aborted) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (err instanceof StreamInterruptedError) {
|
||||
if (err instanceof GenerationLengthError) {
|
||||
toast.error("Response ran out of tokens", {
|
||||
description:
|
||||
"The model used the full Max Tokens budget while thinking " +
|
||||
"and did not produce a final answer. Increase Max Tokens in " +
|
||||
"chat Settings or turn off thinking, then retry.",
|
||||
duration: 8000,
|
||||
});
|
||||
} else if (err instanceof StreamInterruptedError) {
|
||||
// Connection dropped mid-turn: surface it explicitly (the rethrow
|
||||
// below also marks the message with an inline error + Retry).
|
||||
toast.error("Response interrupted", {
|
||||
|
|
|
|||
|
|
@ -50,6 +50,21 @@ export class StreamInterruptedError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a reasoning model consumes its output budget before emitting any
|
||||
* standard content. Keeping this distinct from a dropped connection lets the
|
||||
* chat UI explain why a completed stream contains only a thinking panel.
|
||||
*/
|
||||
export class GenerationLengthError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
"The model reached the Max Tokens limit before producing a final answer. " +
|
||||
"Increase Max Tokens or disable thinking, then retry.",
|
||||
);
|
||||
this.name = "GenerationLengthError";
|
||||
}
|
||||
}
|
||||
|
||||
export function notifyChatHistoryUpdated(): void {
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new Event(CHAT_HISTORY_UPDATED_EVENT));
|
||||
|
|
@ -982,6 +997,61 @@ function parseSseEvent(rawEvent: string): string[] {
|
|||
return dataLines;
|
||||
}
|
||||
|
||||
function hasNonWhitespaceText(value: unknown): boolean {
|
||||
if (typeof value === "string") {
|
||||
return value.trim().length > 0;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((item) => hasNonWhitespaceText(item));
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
return ["thinking", "text", "content", "reasoning", "summary"].some(
|
||||
(key) => key in record && hasNonWhitespaceText(record[key]),
|
||||
);
|
||||
}
|
||||
|
||||
function classifyStructuredDeltaContent(content: unknown): {
|
||||
hasAssistantContent: boolean;
|
||||
hasReasoningContent: boolean;
|
||||
} {
|
||||
if (typeof content === "string") {
|
||||
return {
|
||||
hasAssistantContent: hasNonWhitespaceText(content),
|
||||
hasReasoningContent: false,
|
||||
};
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return {
|
||||
hasAssistantContent: false,
|
||||
hasReasoningContent: false,
|
||||
};
|
||||
}
|
||||
|
||||
let hasAssistantContent = false;
|
||||
let hasReasoningContent = false;
|
||||
for (const part of content) {
|
||||
if (typeof part === "string") {
|
||||
hasAssistantContent ||= hasNonWhitespaceText(part);
|
||||
continue;
|
||||
}
|
||||
if (!part || typeof part !== "object") {
|
||||
continue;
|
||||
}
|
||||
const record = part as Record<string, unknown>;
|
||||
if (record.type === "thinking" || record.type === "reasoning") {
|
||||
hasReasoningContent ||= hasNonWhitespaceText(record);
|
||||
} else if (record.type === "text" || record.type === "output_text") {
|
||||
const text =
|
||||
typeof record.text === "string" ? record.text : record.content;
|
||||
hasAssistantContent ||= hasNonWhitespaceText(text);
|
||||
}
|
||||
}
|
||||
return { hasAssistantContent, hasReasoningContent };
|
||||
}
|
||||
|
||||
export async function* streamChatCompletions(
|
||||
payload: OpenAIChatCompletionsRequest,
|
||||
signal: AbortSignal,
|
||||
|
|
@ -1009,6 +1079,19 @@ export async function* streamChatCompletions(
|
|||
// EOF without `[DONE]` or a finish_reason chunk means the stream was cut
|
||||
// mid-generation: surface as interrupted, not silent success.
|
||||
let sawTerminalSignal = false;
|
||||
let terminalFinishReason: string | null = null;
|
||||
let sawAssistantContent = false;
|
||||
let sawReasoningContent = false;
|
||||
|
||||
const throwIfReasoningOnlyLength = () => {
|
||||
if (
|
||||
terminalFinishReason === "length" &&
|
||||
sawReasoningContent &&
|
||||
!sawAssistantContent
|
||||
) {
|
||||
throw new GenerationLengthError();
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
|
|
@ -1018,6 +1101,7 @@ export async function* streamChatCompletions(
|
|||
if (!sawTerminalSignal) {
|
||||
throw new StreamInterruptedError();
|
||||
}
|
||||
throwIfReasoningOnlyLength();
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -1039,6 +1123,7 @@ export async function* streamChatCompletions(
|
|||
if (dataText === "[DONE]") {
|
||||
completed = true;
|
||||
sawTerminalSignal = true;
|
||||
throwIfReasoningOnlyLength();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1094,11 +1179,31 @@ export async function* streamChatCompletions(
|
|||
}
|
||||
// finish_reason is a valid terminal signal for providers that close
|
||||
// the stream without an explicit [DONE] sentinel.
|
||||
const finishReason = (
|
||||
const parsedChoices = (
|
||||
parsed as {
|
||||
choices?: Array<{ finish_reason?: string | null }>;
|
||||
choices?: Array<{
|
||||
delta?: Record<string, unknown>;
|
||||
finish_reason?: string | null;
|
||||
}>;
|
||||
}
|
||||
).choices?.[0]?.finish_reason;
|
||||
).choices;
|
||||
for (const choice of parsedChoices ?? []) {
|
||||
const delta = choice.delta;
|
||||
if (delta) {
|
||||
const contentState = classifyStructuredDeltaContent(delta.content);
|
||||
sawAssistantContent ||= contentState.hasAssistantContent;
|
||||
sawReasoningContent ||= contentState.hasReasoningContent;
|
||||
const reasoning =
|
||||
delta.reasoning_content ??
|
||||
delta.reasoning ??
|
||||
delta.reasoning_details;
|
||||
sawReasoningContent ||= hasNonWhitespaceText(reasoning);
|
||||
}
|
||||
if (choice.finish_reason) {
|
||||
terminalFinishReason = choice.finish_reason;
|
||||
}
|
||||
}
|
||||
const finishReason = parsedChoices?.[0]?.finish_reason;
|
||||
if (finishReason) {
|
||||
sawTerminalSignal = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import {
|
|||
import { MascotImg } from "@/components/mascot-img";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
import { downloadFile, isDownloadCancelled } from "@/lib/native-files";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CopyIcon, EyeIcon, Maximize2Icon, XIcon } from "lucide-react";
|
||||
import { Download01Icon } from "@hugeicons/core-free-icons";
|
||||
|
|
@ -91,18 +93,6 @@ function ArtifactGeneratingPanel() {
|
|||
);
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
@ -205,7 +195,7 @@ export function ArtifactSurface({
|
|||
className={cn(
|
||||
"relative flex min-h-0 flex-col bg-background",
|
||||
variant === "panel"
|
||||
? "artifact-panel-shell mx-2 mt-[72px] mb-8 h-[calc(100%_-_104px)] overflow-visible rounded-[28px] border-t border-border/70 bg-card/95"
|
||||
? "artifact-panel-shell mx-2 mt-[90px] mb-8 h-[calc(100%_-_122px)] overflow-visible rounded-[28px] border-t border-border/70 bg-card/95"
|
||||
: "h-[min(92vh,900px)] w-[min(96vw,1200px)] overflow-hidden rounded-2xl border border-border shadow-xl",
|
||||
)}
|
||||
aria-label={`${artifact.title} canvas`}
|
||||
|
|
@ -265,7 +255,19 @@ export function ArtifactSurface({
|
|||
size="icon"
|
||||
className="size-8"
|
||||
disabled={isLoadingArtifact || !hasArtifactCode}
|
||||
onClick={() => downloadTextFile(filename, artifact.code)}
|
||||
onClick={() => {
|
||||
// Route through the native save dialog on desktop; the plain
|
||||
// blob-anchor download is silently dropped by the Tauri WebView2.
|
||||
void downloadFile(
|
||||
artifact.code,
|
||||
filename,
|
||||
"text/html;charset=utf-8",
|
||||
).catch((err) => {
|
||||
if (!isDownloadCancelled(err)) {
|
||||
toast.error("Failed to save canvas HTML");
|
||||
}
|
||||
});
|
||||
}}
|
||||
aria-label="Download canvas HTML"
|
||||
>
|
||||
<HugeiconsIcon icon={Download01Icon} className="size-4" />
|
||||
|
|
|
|||
|
|
@ -966,7 +966,7 @@ export function ChatSettingsPanel({
|
|||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
||||
<p className="text-ui-11 leading-relaxed text-muted-foreground">
|
||||
Saving a preset also stores current load settings (context length,
|
||||
KV cache dtype, speculative decoding, GPU layers).
|
||||
{currentLoadSummary ? (
|
||||
|
|
|
|||
|
|
@ -1271,12 +1271,19 @@ export function useChatModelRuntime() {
|
|||
prog.expected_bytes,
|
||||
dlSamples,
|
||||
);
|
||||
setLoadProgress({
|
||||
percent: pct,
|
||||
label: progressLabel,
|
||||
phase: "downloading",
|
||||
});
|
||||
if (loadToastDismissedRef.current) return;
|
||||
// loadProgress state is only read by the dismissed-toast inline
|
||||
// status. Writing it while the toast is visible re-renders the
|
||||
// whole chat page every poll — cheap in Chrome, janky in the
|
||||
// desktop WebView2 (laggy typing). Feed the toast directly and
|
||||
// only touch state when the inline view is actually live.
|
||||
if (loadToastDismissedRef.current) {
|
||||
setLoadProgress({
|
||||
percent: pct,
|
||||
label: progressLabel,
|
||||
phase: "downloading",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast(null, {
|
||||
id: toastId,
|
||||
...modelLoadToastOptions(
|
||||
|
|
@ -1298,19 +1305,23 @@ export function useChatModelRuntime() {
|
|||
const est = estimate(dlSamples, prog.downloaded_bytes, 0);
|
||||
const rateSuffix =
|
||||
est.stable ? ` • ${formatRate(est.rate)}` : "";
|
||||
setLoadProgress({
|
||||
percent: null,
|
||||
label: `${dlGb.toFixed(1)} GB downloaded${rateSuffix}`,
|
||||
phase: "downloading",
|
||||
});
|
||||
// Inline-status-only state; skip the chat-page re-render unless it's shown.
|
||||
if (loadToastDismissedRef.current) {
|
||||
setLoadProgress({
|
||||
percent: null,
|
||||
label: `${dlGb.toFixed(1)} GB downloaded${rateSuffix}`,
|
||||
phase: "downloading",
|
||||
});
|
||||
}
|
||||
} else if (prog.progress >= 1 && hasShownProgress) {
|
||||
downloadComplete = true;
|
||||
setLoadProgress({
|
||||
percent: 100,
|
||||
label: "Download complete",
|
||||
phase: "starting",
|
||||
});
|
||||
if (!loadToastDismissedRef.current) {
|
||||
if (loadToastDismissedRef.current) {
|
||||
setLoadProgress({
|
||||
percent: 100,
|
||||
label: "Download complete",
|
||||
phase: "starting",
|
||||
});
|
||||
} else {
|
||||
toast(null, {
|
||||
id: toastId,
|
||||
...modelLoadToastOptions(
|
||||
|
|
@ -1364,12 +1375,17 @@ export function useChatModelRuntime() {
|
|||
formatEta(est.eta) !== "--" ? ` • ${formatEta(est.eta)} left` : ""
|
||||
}`
|
||||
: base;
|
||||
setLoadProgress({
|
||||
percent: pct,
|
||||
label,
|
||||
phase: "starting",
|
||||
});
|
||||
if (loadToastDismissedRef.current) return;
|
||||
// Inline-status-only state (see pollDownload): while the toast is
|
||||
// up, skip the state write so the chat page doesn't re-render every
|
||||
// poll during "Starting model" — the desktop WebView2 typing-lag fix.
|
||||
if (loadToastDismissedRef.current) {
|
||||
setLoadProgress({
|
||||
percent: pct,
|
||||
label,
|
||||
phase: "starting",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast(null, {
|
||||
id: toastId,
|
||||
...modelLoadToastOptions(
|
||||
|
|
|
|||
|
|
@ -1,13 +1,8 @@
|
|||
// 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 {
|
||||
ChevronDown,
|
||||
CircleAlert,
|
||||
CircleOff,
|
||||
Hand,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import { ChevronDown, CircleAlert, Hand, ShieldCheck } from "lucide-react";
|
||||
import type { ComponentType } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
|
|
@ -29,6 +24,7 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
|
||||
import { SparklesGlyph } from "@/lib/sparkles-icon";
|
||||
import { Tick02Icon } from "@/lib/tick-icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
|
|
@ -45,7 +41,7 @@ export const PERMISSION_MODE_OPTIONS: readonly {
|
|||
value: PermissionMode;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: typeof Hand;
|
||||
icon: ComponentType<{ className?: string; strokeWidth?: number }>;
|
||||
}[] = [
|
||||
{
|
||||
value: "ask",
|
||||
|
|
@ -63,7 +59,7 @@ export const PERMISSION_MODE_OPTIONS: readonly {
|
|||
value: "off",
|
||||
label: "Run automatically",
|
||||
description: "Run tool calls without approval prompts inside the sandbox",
|
||||
icon: CircleOff,
|
||||
icon: SparklesGlyph,
|
||||
},
|
||||
{
|
||||
value: "full",
|
||||
|
|
|
|||
|
|
@ -301,12 +301,12 @@ const HUB_SECTION_TABS: { value: string; label: string; icon?: ReactNode }[] = [
|
|||
{
|
||||
value: "recommended",
|
||||
label: "Recommended",
|
||||
icon: <HugeiconsIcon icon={StarIcon} className="size-3.5" />,
|
||||
icon: <HugeiconsIcon icon={StarIcon} className="size-3.5 shrink-0" />,
|
||||
},
|
||||
{
|
||||
value: "downloaded",
|
||||
label: "On Device",
|
||||
icon: <HugeiconsIcon icon={Download01Icon} className="size-3.5" />,
|
||||
icon: <HugeiconsIcon icon={Download01Icon} className="size-3.5 shrink-0" />,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -384,7 +384,9 @@ function ModelSelectorContent({
|
|||
{
|
||||
value: "connected",
|
||||
label: "Connected",
|
||||
icon: <HugeiconsIcon icon={CloudIcon} className="size-3.5" />,
|
||||
icon: (
|
||||
<HugeiconsIcon icon={CloudIcon} className="size-3.5 shrink-0" />
|
||||
),
|
||||
},
|
||||
]
|
||||
: HUB_SECTION_TABS,
|
||||
|
|
|
|||
|
|
@ -3064,13 +3064,18 @@ export function HubModelPicker({
|
|||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Section tabs then the format and sort dropdowns, packed left with one
|
||||
uniform gap between every control. The box is sized so the last
|
||||
dropdown still lands on Search Hub's edge. Dropdowns hide on Connected. */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Keep the left-packed controls on one line while they fit, then wrap
|
||||
whole groups before their intrinsic widths cross the picker edge.
|
||||
Dropdowns hide on Connected. */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-2",
|
||||
hasConnected ? "-mr-4" : "-mr-2",
|
||||
)}
|
||||
>
|
||||
{sectionToggle}
|
||||
{showConnected ? null : (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex max-w-full min-w-0 flex-wrap items-center gap-2">
|
||||
<HubOptionMenu
|
||||
value={formatFilter}
|
||||
options={FORMAT_FILTER_OPTIONS}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@ export function PillTabs({
|
|||
className?: string;
|
||||
compact?: boolean;
|
||||
/** Size each tab to its label instead of equal widths. The active tab carries
|
||||
* the pill background directly (the toggle never animates). */
|
||||
* the pill background directly (the toggle never animates). Tabs only shrink
|
||||
* when their combined intrinsic width exceeds the available space. */
|
||||
fit?: boolean;
|
||||
}) {
|
||||
const activeIndex = Math.max(
|
||||
|
|
@ -84,7 +85,7 @@ export function PillTabs({
|
|||
onClick={() => onValueChange(tab.value)}
|
||||
className={cn(
|
||||
"relative z-10 inline-flex items-center justify-center gap-1.5 rounded-full transition-colors",
|
||||
fit ? "shrink-0" : "min-w-0 flex-1",
|
||||
fit ? "min-w-0 shrink" : "min-w-0 flex-1",
|
||||
compact ? "h-7 px-2.5 text-ui-11" : "h-9 px-3 text-ui-12p5",
|
||||
value === tab.value
|
||||
? "text-foreground"
|
||||
|
|
@ -97,7 +98,7 @@ export function PillTabs({
|
|||
)}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
<span className="min-w-0 truncate">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,18 @@
|
|||
// 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 ReactElement, useCallback } from "react";
|
||||
import { Lock, LockOpen, Maximize2, Minus, Plus } from "lucide-react";
|
||||
import { Panel, useReactFlow } from "@xyflow/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Panel, useReactFlow } from "@xyflow/react";
|
||||
import {
|
||||
Focus,
|
||||
Lock,
|
||||
LockOpen,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Minus,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { type ReactElement, useCallback } from "react";
|
||||
import { buildFitViewOptions } from "../../utils/graph/fit-view";
|
||||
import { RECIPE_FLOATING_ICON_BUTTON_CLASS } from "../recipe-floating-icon-button-class";
|
||||
|
||||
|
|
@ -12,12 +20,16 @@ type ViewportControlsProps = {
|
|||
interactive: boolean;
|
||||
lockDisabled?: boolean;
|
||||
onToggleInteractive: () => void;
|
||||
maximized: boolean;
|
||||
onToggleMaximize: () => void;
|
||||
};
|
||||
|
||||
export function ViewportControls({
|
||||
interactive,
|
||||
lockDisabled = false,
|
||||
onToggleInteractive,
|
||||
maximized,
|
||||
onToggleMaximize,
|
||||
}: ViewportControlsProps): ReactElement {
|
||||
const { zoomIn, zoomOut, fitView, getNodes } = useReactFlow();
|
||||
|
||||
|
|
@ -61,9 +73,23 @@ export function ViewportControls({
|
|||
size="icon"
|
||||
className={RECIPE_FLOATING_ICON_BUTTON_CLASS}
|
||||
onClick={handleFitView}
|
||||
aria-label="Fit view"
|
||||
aria-label="Center view"
|
||||
>
|
||||
<Maximize2 className="size-4" />
|
||||
<Focus className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={RECIPE_FLOATING_ICON_BUTTON_CLASS}
|
||||
onClick={onToggleMaximize}
|
||||
aria-label={maximized ? "Exit full view" : "Expand to full view"}
|
||||
>
|
||||
{maximized ? (
|
||||
<Minimize2 className="size-4" />
|
||||
) : (
|
||||
<Maximize2 className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -74,7 +100,11 @@ export function ViewportControls({
|
|||
onClick={onToggleInteractive}
|
||||
aria-label={interactive ? "Lock interaction" : "Unlock interaction"}
|
||||
>
|
||||
{interactive ? <LockOpen className="size-4" /> : <Lock className="size-4" />}
|
||||
{interactive ? (
|
||||
<LockOpen className="size-4" />
|
||||
) : (
|
||||
<Lock className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</Panel>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -237,6 +237,7 @@ export function RecipeStudioPage({
|
|||
}, [setActiveView]);
|
||||
const [processorsOpen, setProcessorsOpen] = useState(false);
|
||||
const [interactive, setInteractive] = useState(true);
|
||||
const [maximized, setMaximized] = useState(false);
|
||||
const [runtimeIslandMinimized, setRuntimeIslandMinimized] = useState(false);
|
||||
const [recentCompletedExecution, setRecentCompletedExecution] =
|
||||
useState<RecipeExecutionRecord | null>(null);
|
||||
|
|
@ -569,6 +570,16 @@ export function RecipeStudioPage({
|
|||
[reactFlowInstance],
|
||||
);
|
||||
|
||||
const toggleMaximize = useCallback(() => {
|
||||
// The maximized surface is a fixed z-50 overlay that already covers the
|
||||
// app sidebar (z-10/z-20), so we don't touch the sidebar's own state — that
|
||||
// state is persisted in pin mode and mutating it here would leak the
|
||||
// temporary collapse into the next page/session.
|
||||
setMaximized((prev) => !prev);
|
||||
// Container size changes; refit once the layout settles.
|
||||
scheduleFitView({ delayMs: TAB_SWITCH_FIT_DELAY_MS });
|
||||
}, [scheduleFitView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
previousActiveViewRef.current !== activeView &&
|
||||
|
|
@ -587,6 +598,15 @@ export function RecipeStudioPage({
|
|||
}
|
||||
}, [activeView, reactFlowInstance]);
|
||||
|
||||
// The "Exit full view" control lives inside the editor canvas, which unmounts
|
||||
// on other tabs. Drop full-view mode when leaving the editor so Easy/Runs
|
||||
// aren't left under the fixed overlay.
|
||||
useEffect(() => {
|
||||
if (activeView !== "editor" && maximized) {
|
||||
setMaximized(false);
|
||||
}
|
||||
}, [activeView, maximized]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!reactFlowInstance ||
|
||||
|
|
@ -732,6 +752,8 @@ export function RecipeStudioPage({
|
|||
interactive={canvasInteractive}
|
||||
lockDisabled={executionLocked}
|
||||
onToggleInteractive={toggleInteractive}
|
||||
maximized={maximized}
|
||||
onToggleMaximize={toggleMaximize}
|
||||
/>
|
||||
{islandExecution &&
|
||||
(isExecutionInProgress(islandExecution.status) ||
|
||||
|
|
@ -773,10 +795,25 @@ export function RecipeStudioPage({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100dvh-var(--studio-titlebar-height,0px))] bg-background">
|
||||
<main className="w-full px-6 py-8">
|
||||
<div
|
||||
className={
|
||||
maximized
|
||||
? "fixed inset-x-0 bottom-0 z-50 flex flex-col bg-background"
|
||||
: "flex h-full min-h-0 flex-1 flex-col bg-background"
|
||||
}
|
||||
style={
|
||||
maximized
|
||||
? {
|
||||
// Start below the custom/mac window titlebar so the header and
|
||||
// its controls aren't hidden under (or click-blocked by) it.
|
||||
top: "var(--studio-non-chat-content-top-inset, var(--studio-content-top-inset, 0px))",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<main className="flex min-h-0 w-full flex-1 flex-col">
|
||||
<div
|
||||
className="relative w-full overflow-hidden rounded-2xl corner-squircle border"
|
||||
className="relative flex min-h-0 w-full flex-1 flex-col overflow-hidden border"
|
||||
ref={setSheetContainer}
|
||||
>
|
||||
<RecipeStudioHeader
|
||||
|
|
@ -794,7 +831,7 @@ export function RecipeStudioPage({
|
|||
}}
|
||||
/>
|
||||
<div
|
||||
className="h-[75vh] w-full rounded-t-none"
|
||||
className="flex min-h-0 w-full flex-1 rounded-t-none"
|
||||
ref={flowContainerRef}
|
||||
>
|
||||
{activeView === "easy" ? (
|
||||
|
|
|
|||
|
|
@ -282,8 +282,13 @@
|
|||
/* Standard interactive-icon size for nav, menus, action bars, and
|
||||
in-message code-block actions. Sized one step above body text so
|
||||
icons read as minimally larger than adjacent labels (~14px text).
|
||||
Follows the UI font size preference; 18px at the default.
|
||||
Theme-independent — declared once in :root. */
|
||||
--icon-size: 18px;
|
||||
/* Standard icon size follows the UI font size itself: matches it below
|
||||
the 16px default, grows at half the change above it (setting 20 ->
|
||||
18px), so icons read slightly smaller than enlarged text. */
|
||||
--ui-icon-size: min(calc(1rem * var(--ui-font-scale, 1)), calc(0.5rem + 0.5rem * var(--ui-font-scale, 1)));
|
||||
--icon-size: var(--ui-icon-size);
|
||||
/* Inset of a centered .size-icon glyph within a 2rem (size-8) action
|
||||
button — i.e. (32px − icon-size) / 2. Use as a negative margin on a
|
||||
chat-message action bar so the leftmost icon's visual edge aligns
|
||||
|
|
@ -1265,8 +1270,8 @@ html[data-chat-font] .aui-root {
|
|||
}
|
||||
.app-user-menu [data-slot="dropdown-menu-item"] svg,
|
||||
.app-user-menu [data-slot="dropdown-menu-sub-trigger"] svg {
|
||||
width: 19px !important;
|
||||
height: 19px !important;
|
||||
width: var(--ui-icon-size) !important;
|
||||
height: var(--ui-icon-size) !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.app-user-menu [data-slot="dropdown-menu-item"]:focus,
|
||||
|
|
@ -1561,20 +1566,20 @@ html[data-chat-font] .aui-root {
|
|||
/* Fixed-width icon slot so every pill's icon occupies the same space and
|
||||
the labels line up on an even rhythm, regardless of icon size. */
|
||||
.composer-pill-glyph {
|
||||
@apply relative inline-flex w-[19px] shrink-0 items-center justify-center transition-opacity;
|
||||
@apply relative inline-flex w-[var(--ui-icon-size)] shrink-0 items-center justify-center transition-opacity;
|
||||
}
|
||||
|
||||
/* On hover the icon swaps for an X inside a soft circle (ChatGPT-style),
|
||||
filling the icon slot so every pill's X is identical and centered. */
|
||||
.composer-pill-x {
|
||||
@apply pointer-events-none absolute inset-0 m-auto size-[19px] rounded-full bg-primary/15 p-[3px] opacity-0 transition-opacity dark:bg-white/[0.14];
|
||||
@apply pointer-events-none absolute inset-0 m-auto size-[var(--ui-icon-size)] rounded-full bg-primary/15 p-[3px] opacity-0 transition-opacity dark:bg-white/[0.14];
|
||||
}
|
||||
|
||||
/* Icon-only (compact) pills are too small for the circle, so show a bare x. */
|
||||
[data-pill-compact="true"]
|
||||
.composer-pill-btn:not([data-keep-label])
|
||||
.composer-pill-x {
|
||||
@apply size-[15px] bg-transparent p-0 dark:bg-transparent;
|
||||
@apply size-[min(calc(15px*var(--ui-font-scale,1)),calc(7.5px+7.5px*var(--ui-font-scale,1)))] bg-transparent p-0 dark:bg-transparent;
|
||||
}
|
||||
|
||||
/* Compact pills hide their labels, so surface the name as a hover
|
||||
|
|
@ -1853,8 +1858,8 @@ html[data-chat-font] .aui-root {
|
|||
|
||||
/* Smaller tick for selected Thinking options. */
|
||||
.unsloth-tick {
|
||||
width: 0.8rem !important;
|
||||
height: 0.8rem !important;
|
||||
width: min(calc(0.8rem * var(--ui-font-scale, 1)), calc(0.4rem + 0.4rem * var(--ui-font-scale, 1))) !important;
|
||||
height: min(calc(0.8rem * var(--ui-font-scale, 1)), calc(0.4rem + 0.4rem * var(--ui-font-scale, 1))) !important;
|
||||
}
|
||||
|
||||
/* Soft elevation; [data-slot] outranks the component ring-1, dropping the border. */
|
||||
|
|
@ -1943,9 +1948,9 @@ html[data-chat-font] .aui-root {
|
|||
[data-slot="dropdown-menu-item"],
|
||||
[data-slot="dropdown-menu-sub-trigger"]
|
||||
)
|
||||
svg {
|
||||
width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
svg:not(.unsloth-tick) {
|
||||
width: var(--ui-icon-size) !important;
|
||||
height: var(--ui-icon-size) !important;
|
||||
}
|
||||
|
||||
/* Destructive items keep red text and a red-tinted hover, not the grey one. */
|
||||
|
|
@ -2770,3 +2775,96 @@ html[data-chat-font] .aui-root {
|
|||
display: block !important;
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
/* Icons that sit beside scaled labels follow the UI font size itself:
|
||||
glyphs at or above a 16px base render at --ui-icon-size (12 -> 12px,
|
||||
16 -> 16px, 20 -> 18px), so icons track the text below the default and
|
||||
read slightly smaller than it above. Sub-16px glyphs keep their
|
||||
proportions through the same curve as a factor. Menu, select and closed select trigger surfaces, popovers, toasts,
|
||||
the chat thread and both composers. Only glyphs scale; hit targets,
|
||||
paddings and surface geometry stay fixed. Identity at the default. */
|
||||
:is(
|
||||
[data-slot='dropdown-menu-content'],
|
||||
[data-slot='dropdown-menu-sub-content'],
|
||||
[data-slot='select-content'],
|
||||
[data-slot='select-trigger'],
|
||||
[data-slot='combobox-content'],
|
||||
[data-slot='combobox-trigger'],
|
||||
[data-slot='context-menu-content'],
|
||||
[data-slot='context-menu-sub-content'],
|
||||
[data-slot='menubar-content'],
|
||||
[data-slot='popover-content'],
|
||||
[data-slot='command'],
|
||||
[data-sonner-toast],
|
||||
.composer-action-wrapper,
|
||||
.aui-composer-action-wrapper,
|
||||
.aui-action-bar-more-content,
|
||||
.aui-root
|
||||
) {
|
||||
& svg.size-2\.5 { width: min(calc(0.625rem * var(--ui-font-scale, 1)), calc(0.3125rem + 0.3125rem * var(--ui-font-scale, 1))); height: min(calc(0.625rem * var(--ui-font-scale, 1)), calc(0.3125rem + 0.3125rem * var(--ui-font-scale, 1))); }
|
||||
& svg.size-3 { width: min(calc(0.75rem * var(--ui-font-scale, 1)), calc(0.375rem + 0.375rem * var(--ui-font-scale, 1))); height: min(calc(0.75rem * var(--ui-font-scale, 1)), calc(0.375rem + 0.375rem * var(--ui-font-scale, 1))); }
|
||||
& svg.size-3\.5 { width: min(calc(0.875rem * var(--ui-font-scale, 1)), calc(0.4375rem + 0.4375rem * var(--ui-font-scale, 1))); height: min(calc(0.875rem * var(--ui-font-scale, 1)), calc(0.4375rem + 0.4375rem * var(--ui-font-scale, 1))); }
|
||||
& svg.size-4 { width: var(--ui-icon-size); height: var(--ui-icon-size); }
|
||||
& svg.size-4\.5 { width: var(--ui-icon-size); height: var(--ui-icon-size); }
|
||||
& svg.size-5 { width: var(--ui-icon-size); height: var(--ui-icon-size); }
|
||||
& svg.size-6 { width: min(calc(1.5rem * var(--ui-font-scale, 1)), calc(0.75rem + 0.75rem * var(--ui-font-scale, 1))); height: min(calc(1.5rem * var(--ui-font-scale, 1)), calc(0.75rem + 0.75rem * var(--ui-font-scale, 1))); }
|
||||
& svg.size-\[5px\] { width: min(calc(5px * var(--ui-font-scale, 1)), calc(2.5px + 2.5px * var(--ui-font-scale, 1))); height: min(calc(5px * var(--ui-font-scale, 1)), calc(2.5px + 2.5px * var(--ui-font-scale, 1))); }
|
||||
& svg.size-\[6px\] { width: min(calc(6px * var(--ui-font-scale, 1)), calc(3px + 3px * var(--ui-font-scale, 1))); height: min(calc(6px * var(--ui-font-scale, 1)), calc(3px + 3px * var(--ui-font-scale, 1))); }
|
||||
& svg.size-\[10px\] { width: min(calc(10px * var(--ui-font-scale, 1)), calc(5px + 5px * var(--ui-font-scale, 1))); height: min(calc(10px * var(--ui-font-scale, 1)), calc(5px + 5px * var(--ui-font-scale, 1))); }
|
||||
& svg.size-\[11px\] { width: min(calc(11px * var(--ui-font-scale, 1)), calc(5.5px + 5.5px * var(--ui-font-scale, 1))); height: min(calc(11px * var(--ui-font-scale, 1)), calc(5.5px + 5.5px * var(--ui-font-scale, 1))); }
|
||||
& svg.size-\[12px\] { width: min(calc(12px * var(--ui-font-scale, 1)), calc(6px + 6px * var(--ui-font-scale, 1))); height: min(calc(12px * var(--ui-font-scale, 1)), calc(6px + 6px * var(--ui-font-scale, 1))); }
|
||||
& svg.size-\[13px\] { width: min(calc(13px * var(--ui-font-scale, 1)), calc(6.5px + 6.5px * var(--ui-font-scale, 1))); height: min(calc(13px * var(--ui-font-scale, 1)), calc(6.5px + 6.5px * var(--ui-font-scale, 1))); }
|
||||
& svg.size-\[14px\] { width: min(calc(14px * var(--ui-font-scale, 1)), calc(7px + 7px * var(--ui-font-scale, 1))); height: min(calc(14px * var(--ui-font-scale, 1)), calc(7px + 7px * var(--ui-font-scale, 1))); }
|
||||
& svg.size-\[15px\] { width: min(calc(15px * var(--ui-font-scale, 1)), calc(7.5px + 7.5px * var(--ui-font-scale, 1))); height: min(calc(15px * var(--ui-font-scale, 1)), calc(7.5px + 7.5px * var(--ui-font-scale, 1))); }
|
||||
& svg.size-\[15\.5px\] { width: min(calc(15.5px * var(--ui-font-scale, 1)), calc(7.75px + 7.75px * var(--ui-font-scale, 1))); height: min(calc(15.5px * var(--ui-font-scale, 1)), calc(7.75px + 7.75px * var(--ui-font-scale, 1))); }
|
||||
& svg.size-\[16px\] { width: var(--ui-icon-size); height: var(--ui-icon-size); }
|
||||
& svg.size-\[17px\] { width: var(--ui-icon-size); height: var(--ui-icon-size); }
|
||||
& svg.size-\[18px\] { width: var(--ui-icon-size); height: var(--ui-icon-size); }
|
||||
& svg.size-\[18\.5px\] { width: var(--ui-icon-size); height: var(--ui-icon-size); }
|
||||
& svg.size-\[20px\] { width: var(--ui-icon-size); height: var(--ui-icon-size); }
|
||||
& svg.size-\[21px\] { width: var(--ui-icon-size); height: var(--ui-icon-size); }
|
||||
& svg.size-\[22px\] { width: var(--ui-icon-size); height: var(--ui-icon-size); }
|
||||
& svg.size-\[36px\] { width: min(calc(36px * var(--ui-font-scale, 1)), calc(18px + 18px * var(--ui-font-scale, 1))); height: min(calc(36px * var(--ui-font-scale, 1)), calc(18px + 18px * var(--ui-font-scale, 1))); }
|
||||
& svg.w-3 { width: min(calc(0.75rem * var(--ui-font-scale, 1)), calc(0.375rem + 0.375rem * var(--ui-font-scale, 1))); }
|
||||
& svg.h-3 { height: min(calc(0.75rem * var(--ui-font-scale, 1)), calc(0.375rem + 0.375rem * var(--ui-font-scale, 1))); }
|
||||
& svg.w-3\.5 { width: min(calc(0.875rem * var(--ui-font-scale, 1)), calc(0.4375rem + 0.4375rem * var(--ui-font-scale, 1))); }
|
||||
& svg.h-3\.5 { height: min(calc(0.875rem * var(--ui-font-scale, 1)), calc(0.4375rem + 0.4375rem * var(--ui-font-scale, 1))); }
|
||||
& svg.w-4 { width: var(--ui-icon-size); }
|
||||
& svg.h-4 { height: var(--ui-icon-size); }
|
||||
& svg.w-5 { width: var(--ui-icon-size); }
|
||||
& svg.h-5 { height: var(--ui-icon-size); }
|
||||
/* Buttons default un-classed icons to size-4 the same way. Sonner's
|
||||
close button keeps its compact 12px glyph inside a fixed control. */
|
||||
& button:not([class*=':size-3'], [data-close-button]) svg:not([class*='size-'], [class*='w-'], [class*='h-'], .unsloth-tick) {
|
||||
width: var(--ui-icon-size);
|
||||
height: var(--ui-icon-size);
|
||||
}
|
||||
/* Menu items default un-classed icons to size-4. */
|
||||
& [data-slot*='item'] svg:not([class*='size-'], [class*='w-'], [class*='h-']) {
|
||||
width: var(--ui-icon-size);
|
||||
height: var(--ui-icon-size);
|
||||
}
|
||||
}
|
||||
|
||||
/* Sonner injects fixed 13px toast text and 12px action labels at runtime;
|
||||
text follows the preference at full rate. Line heights are unitless so
|
||||
they track automatically. */
|
||||
[data-sonner-toast][data-styled='true'] {
|
||||
font-size: calc(13px * var(--ui-font-scale, 1)) !important;
|
||||
}
|
||||
[data-sonner-toast][data-styled='true'] [data-description] {
|
||||
font-size: calc(13px * var(--ui-font-scale, 1)) !important;
|
||||
}
|
||||
[data-sonner-toast][data-styled='true'] [data-button] {
|
||||
font-size: calc(12px * var(--ui-font-scale, 1)) !important;
|
||||
}
|
||||
/* Sonner's icon well is a fixed 16px box; track the glyph. */
|
||||
[data-sonner-toast][data-styled='true'] [data-icon] {
|
||||
width: var(--ui-icon-size) !important;
|
||||
height: var(--ui-icon-size) !important;
|
||||
}
|
||||
/* Defensive: the built-in loader is unused (a custom loading icon is always
|
||||
passed) but keep its fixed --size on the scale in case that changes. */
|
||||
[data-sonner-toast] .sonner-loading-wrapper {
|
||||
--size: var(--ui-icon-size) !important;
|
||||
}
|
||||
|
|
|
|||
51
studio/frontend/src/lib/sparkles-icon.tsx
Normal file
51
studio/frontend/src/lib/sparkles-icon.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// 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 { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
|
||||
|
||||
// Hugeicons "AI Security 03" (stroke-rounded). A four-point sparkle inside a
|
||||
// shield. https://hugeicons.com/icon/ai-security-03
|
||||
export const SparklesIcon: IconSvgElement = [
|
||||
[
|
||||
"path",
|
||||
{
|
||||
d: "M11.6769 8.67348C11.8274 8.43697 12.1726 8.43697 12.3231 8.67348L12.7586 9.35767C13.2401 10.1143 13.8818 10.756 14.6384 11.2375L15.3226 11.6729C15.5591 11.8235 15.5591 12.1687 15.3226 12.3192L14.6384 12.7547C13.8818 13.2362 13.2401 13.8779 12.7586 14.6345L12.3231 15.3187C12.1726 15.5552 11.8274 15.5552 11.6769 15.3187L11.2414 14.6345C10.7599 13.8779 10.1182 13.2362 9.36157 12.7547L8.67738 12.3192C8.44087 12.1687 8.44087 11.8235 8.67738 11.6729L9.36157 11.2375C10.1182 10.756 10.7599 10.1143 11.2414 9.35767L11.6769 8.67348Z",
|
||||
stroke: "currentColor",
|
||||
strokeLinejoin: "round",
|
||||
strokeWidth: "1.5",
|
||||
key: "0",
|
||||
},
|
||||
],
|
||||
[
|
||||
"path",
|
||||
{
|
||||
d: "M3.9068 5.28387C6.87149 5.4984 8.78311 2.49713 12.0262 2.49713C15.2208 2.43341 16.784 5.32395 20.059 5.32395C21.8147 14.2606 18.1622 19.8743 12.053 21.4961C6.38992 20.15 2.13481 14.4788 3.9068 5.28387Z",
|
||||
stroke: "currentColor",
|
||||
strokeLinejoin: "round",
|
||||
strokeWidth: "1.5",
|
||||
key: "1",
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* AI Security shield glyph wrapped as a lucide-compatible component so it can drop
|
||||
* into the permission-mode option list alongside lucide icons (same className /
|
||||
* strokeWidth props). strokeWidth is a number here (lucide style); Hugeicons
|
||||
* accepts it directly.
|
||||
*/
|
||||
export function SparklesGlyph({
|
||||
className,
|
||||
strokeWidth,
|
||||
}: {
|
||||
className?: string;
|
||||
strokeWidth?: number;
|
||||
}) {
|
||||
return (
|
||||
<HugeiconsIcon
|
||||
icon={SparklesIcon}
|
||||
className={className}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -46,10 +46,13 @@ fn save_filter(file_name: &str) -> (&'static str, Vec<&'static str>) {
|
|||
Some("jsonl") | Some("ndjson") => ("JSON Lines", vec!["jsonl", "ndjson"]),
|
||||
Some("csv") => ("CSV", vec!["csv"]),
|
||||
Some("md") | Some("markdown") => ("Markdown", vec!["md", "markdown"]),
|
||||
Some("html") | Some("htm") => ("HTML", vec!["html", "htm"]),
|
||||
Some("zip") => ("ZIP archive", vec!["zip"]),
|
||||
_ => (
|
||||
"Export files",
|
||||
vec!["json", "jsonl", "ndjson", "csv", "md", "markdown", "zip"],
|
||||
vec![
|
||||
"json", "jsonl", "ndjson", "csv", "md", "markdown", "html", "htm", "zip",
|
||||
],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
|
@ -252,6 +255,12 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_canvas_exports_use_an_html_save_filter() {
|
||||
assert_eq!(save_filter("canvas.html"), ("HTML", vec!["html", "htm"]));
|
||||
assert_eq!(save_filter("canvas.HTM"), ("HTML", vec!["html", "htm"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_supported_import_and_rejects_other_extensions() {
|
||||
let jsonl_path = temp_path("allowed").with_extension("JSONL");
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"security": {
|
||||
"csp": "default-src 'self'; connect-src 'self' http://localhost:* ws://localhost:* ws://127.0.0.1:* http://127.0.0.1:* https://huggingface.co https://*.huggingface.co https://datasets-server.huggingface.co; img-src 'self' data: blob: https:; media-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; font-src 'self' data:"
|
||||
"csp": "default-src 'self'; connect-src 'self' http://localhost:* ws://localhost:* ws://127.0.0.1:* http://127.0.0.1:* https://huggingface.co https://*.huggingface.co https://datasets-server.huggingface.co; img-src 'self' data: blob: https:; media-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; frame-src 'self' http://localhost:* http://127.0.0.1:*"
|
||||
},
|
||||
"windows": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -208,6 +208,14 @@ def main():
|
|||
# text-ui-12p5 at scale 0.75; 16px means twMerge dropped the token.
|
||||
if not near(tab_font, 12.5 * 12 / 16):
|
||||
fail(f"hub tab font did not scale (twMerge drop?): {tab_font}")
|
||||
icon_w = page.evaluate(
|
||||
"() => { const el = document.querySelector('.size-icon');"
|
||||
" return el ? parseFloat(getComputedStyle(el).width) : null; }"
|
||||
)
|
||||
# Standard icons render at the UI font size itself below the
|
||||
# default, so setting 12 gives 12px glyphs.
|
||||
if not near(icon_w, 12):
|
||||
fail(f"size-icon did not match the UI font size below 16: {icon_w}")
|
||||
page.goto(BASE, wait_until = "domcontentloaded")
|
||||
page.wait_for_timeout(1500)
|
||||
open_appearance(page)
|
||||
|
|
|
|||
26
tests/studio/test_generation_length_ui_contract.py
Normal file
26
tests/studio/test_generation_length_ui_contract.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CHAT_API = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "studio"
|
||||
/ "frontend"
|
||||
/ "src"
|
||||
/ "features"
|
||||
/ "chat"
|
||||
/ "api"
|
||||
/ "chat-api.ts"
|
||||
)
|
||||
|
||||
|
||||
def test_length_detection_classifies_visible_and_reasoning_content():
|
||||
source = CHAT_API.read_text(encoding = "utf-8")
|
||||
|
||||
assert "return value.trim().length > 0;" in source
|
||||
assert 'record.type === "thinking" || record.type === "reasoning"' in source
|
||||
assert 'record.type === "text" || record.type === "output_text"' in source
|
||||
assert "sawAssistantContent ||= contentState.hasAssistantContent;" in source
|
||||
assert "sawReasoningContent ||= contentState.hasReasoningContent;" in source
|
||||
|
|
@ -217,6 +217,24 @@ def test_local_picker_rows_require_chat_capability():
|
|||
assert "row.capabilities.canChat" in memo.group(0)
|
||||
|
||||
|
||||
def test_model_picker_toolbar_reflows_before_crossing_picker_edge():
|
||||
"""The content-sized section tabs and fixed-width dropdowns must reflow,
|
||||
while an oversized tab group must shrink labels but preserve its icons."""
|
||||
picker = _read("features/model-picker/components/model-selector/pickers.tsx")
|
||||
assert '"flex flex-wrap items-center gap-2"' in picker
|
||||
assert 'hasConnected ? "-mr-4" : "-mr-2"' in picker
|
||||
assert '"flex max-w-full min-w-0 flex-wrap items-center gap-2"' in picker
|
||||
|
||||
tabs = _read("features/model-picker/components/model-selector/pill-tabs.tsx")
|
||||
assert 'fit ? "min-w-0 shrink" : "min-w-0 flex-1"' in tabs
|
||||
assert '<span className="min-w-0 truncate">{tab.label}</span>' in tabs
|
||||
|
||||
selector = _read("features/model-picker/components/model-selector.tsx")
|
||||
assert 'icon={StarIcon} className="size-3.5 shrink-0"' in selector
|
||||
assert 'icon={Download01Icon} className="size-3.5 shrink-0"' in selector
|
||||
assert 'icon={CloudIcon} className="size-3.5 shrink-0"' in selector
|
||||
|
||||
|
||||
def test_native_picked_gguf_template_read_through_lease():
|
||||
"""A native (picked / drag-drop) GGUF's path lives only in its signed lease,
|
||||
and the picker chat-template GET has no lease plumbing, so the default
|
||||
|
|
|
|||
|
|
@ -98,6 +98,39 @@ def test_cn_knows_the_ui_typography_tokens():
|
|||
assert "/^ui-\\d+(p5)?$/.test(value)" in UTILS
|
||||
|
||||
|
||||
def test_icons_follow_the_ui_font_size_itself():
|
||||
"""Standard glyphs render at --ui-icon-size, which follows the UI font
|
||||
size itself: matches it below the 16px default and grows at half the
|
||||
change above it (setting 20 gives 18px icons), so icons track the text
|
||||
when shrinking and read slightly smaller than it when growing. Sub 16px
|
||||
glyphs keep their proportions through the same curve as a factor.
|
||||
Sonner toast text and action labels are text, so they follow at full
|
||||
rate everywhere."""
|
||||
assert (
|
||||
"--ui-icon-size: min(calc(1rem * var(--ui-font-scale, 1)), "
|
||||
"calc(0.5rem + 0.5rem * var(--ui-font-scale, 1)));"
|
||||
) in INDEX_CSS
|
||||
assert "--icon-size: var(--ui-icon-size);" in INDEX_CSS
|
||||
assert "& svg.size-4 { width: var(--ui-icon-size); height: var(--ui-icon-size); }" in INDEX_CSS
|
||||
assert "font-size: calc(13px * var(--ui-font-scale, 1)) !important;" in INDEX_CSS
|
||||
assert "font-size: calc(12px * var(--ui-font-scale, 1)) !important;" in INDEX_CSS
|
||||
# Menu rules that outrank the scoped block must carry the token too,
|
||||
# without flattening the smaller thinking ticks.
|
||||
assert "width: var(--ui-icon-size) !important;" in INDEX_CSS
|
||||
assert "svg:not(.unsloth-tick) {" in INDEX_CSS
|
||||
# Oversized art glyphs stay proportional instead of uniform.
|
||||
assert "& svg.size-6 { width: min(calc(1.5rem" in INDEX_CSS
|
||||
for scope in (
|
||||
"[data-slot='dropdown-menu-content']",
|
||||
"[data-slot='select-content']",
|
||||
"[data-slot='select-trigger']",
|
||||
"[data-slot='combobox-content']",
|
||||
"[data-sonner-toast]",
|
||||
".aui-root",
|
||||
):
|
||||
assert scope in INDEX_CSS
|
||||
|
||||
|
||||
def test_no_raw_pixel_text_utilities():
|
||||
offenders = []
|
||||
for path in _frontend_sources():
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue