* webui: Move static build output from `tools/server/public` to `build/ui` directory * refactor: Move to `tools/ui` * refactor: rename CMake variables and preprocessor defines - Rename LLAMA_BUILD_WEBUI -> LLAMA_BUILD_UI (old kept as deprecated) - Rename LLAMA_USE_PREBUILT_WEBUI -> LLAMA_USE_PREBUILT_UI (old kept as deprecated) - Backward compat: old vars auto-forward to new ones with DEPRECATION warning - Rename internal vars: WEBUI_SOURCE -> UI_SOURCE, WEBUI_SOURCE_DIR -> UI_SOURCE_DIR, etc. - Rename HF bucket: LLAMA_WEBUI_HF_BUCKET -> LLAMA_UI_HF_BUCKET - Emit both LLAMA_BUILD_WEBUI and LLAMA_BUILD_UI preprocessor defines - Emit both LLAMA_WEBUI_DEFAULT_ENABLED and LLAMA_UI_DEFAULT_ENABLED * refactor: rename CLI flags (--webui -> --ui) with backward compat - Add --ui/--no-ui (old --webui/--no-webui kept as deprecated aliases) - Add --ui-config (old --webui-config kept as deprecated alias) - Add --ui-config-file (old --webui-config-file kept as deprecated alias) - Add --ui-mcp-proxy/--no-ui-mcp-proxy (old --webui-mcp-proxy kept as deprecated) - Add new env vars: LLAMA_ARG_UI, LLAMA_ARG_UI_CONFIG, LLAMA_ARG_UI_CONFIG_FILE, LLAMA_ARG_UI_MCP_PROXY - C++ struct fields: params.ui, params.ui_config_json, params.ui_mcp_proxy added alongside old fields - Backward compat: old fields synced to new ones in g_params_to_internals * refactor: update C++ server internals with backward compat - Rename json_webui_settings -> json_ui_settings (both kept in server_context_meta) - Rename params.webui usage -> params.ui (both synced, old still works) - JSON API emits both "ui"/"ui_settings" and "webui"/"webui_settings" keys - Server routes use params.ui_mcp_proxy || params.webui_mcp_proxy - Preprocessor guards use #if defined(LLAMA_BUILD_UI) || defined(LLAMA_BUILD_WEBUI) * refactor: rename CI/CD workflows, artifacts, and build script - Rename webui-build.yml -> ui-build.yml; artifact webui-build -> ui-build - Rename webui-publish.yml -> ui-publish.yml; var HF_BUCKET_WEBUI_STATIC_OUTPUT -> HF_BUCKET_UI_STATIC_OUTPUT - Rename server-webui.yml -> server-ui.yml; job webui-build/checks -> ui-build/checks - Update server.yml: job/artifact refs webui-build -> ui-build - Update release.yml: all webui-build/publish refs -> ui-build/publish; HF_TOKEN_WEBUI_STATIC_OUTPUT -> HF_TOKEN_UI_STATIC_OUTPUT - Update server-self-hosted.yml: webui-build -> ui-build - Update build-self-hosted.yml: HF_WEBUI_VERSION -> HF_UI_VERSION - Rename webui-download.cmake -> ui-download.cmake (internal refs updated) - Update labeler.yml: server/webui -> server/ui path label * docs: update CODEOWNERS and server README docs - Update CODEOWNERS: team ggml-org/llama-webui -> ggml-org/llama-ui, path /tools/server/webui/ -> /tools/ui/ - Update server README.md: CLI tables show --ui flags with deprecated --webui aliases - Update server README-dev.md: "WebUI" -> "UI", paths updated to tools/ui/ * fix: Small fixes for UI build * fix: CMake.txt syntax * chore: Formatting * fix: `.editorconfig` for llama-ui * chore: Formatting * refactor: Use `APP_NAME` in Error route * refactor: Cleanup * refactor: Single migration service * make llama-ui a linkable target * fix: UI Build output * fix: Missing change * fix: separate llama-ui npm build output into build/tools/ui/dist subfolder + use cmake npm build instead of downloading ui-build.yml artifacts in CI * refactor: UI workflows cleanup --------- Co-authored-by: Xuan Son Nguyen <son@huggingface.co>
416 lines
12 KiB
Svelte
416 lines
12 KiB
Svelte
<script lang="ts">
|
|
import { Wrench, Loader2, Brain } from '@lucide/svelte';
|
|
import {
|
|
ChatMessageStatistics,
|
|
CollapsibleContentBlock,
|
|
MarkdownContent,
|
|
SyntaxHighlightedCode,
|
|
ChatMessageActionCardPermissionRequest,
|
|
ChatMessageActionCardContinueRequest
|
|
} from '$lib/components/app';
|
|
|
|
import {
|
|
AgenticSectionType,
|
|
ChatMessageStatsView,
|
|
FileTypeText,
|
|
ToolPermissionDecision
|
|
} from '$lib/enums';
|
|
import type {
|
|
ChatMessageAgenticTimings,
|
|
ChatMessageAgenticTurnStats,
|
|
DatabaseMessage
|
|
} from '$lib/types';
|
|
import {
|
|
deriveAgenticSections,
|
|
formatJsonPretty,
|
|
parseToolResultWithImages,
|
|
type AgenticSection,
|
|
type ToolResultLine
|
|
} from '$lib/utils';
|
|
import {
|
|
agenticPendingPermissionRequest,
|
|
agenticResolvePermission,
|
|
agenticPendingContinueRequest,
|
|
agenticResolveContinue
|
|
} from '$lib/stores/agentic.svelte';
|
|
import { config } from '$lib/stores/settings.svelte';
|
|
|
|
interface Props {
|
|
message: DatabaseMessage;
|
|
toolMessages?: DatabaseMessage[];
|
|
isStreaming?: boolean;
|
|
isLastAssistantMessage?: boolean;
|
|
highlightTurns?: boolean;
|
|
}
|
|
|
|
let {
|
|
message,
|
|
toolMessages = [],
|
|
isStreaming = false,
|
|
isLastAssistantMessage = false,
|
|
highlightTurns = false
|
|
}: Props = $props();
|
|
|
|
let expandedStates: Record<number, boolean> = $state({});
|
|
|
|
const showToolCallInProgress = $derived(config().showToolCallInProgress as boolean);
|
|
const showThoughtInProgress = $derived(config().showThoughtInProgress as boolean);
|
|
|
|
let permissionDismissed = $state(false);
|
|
|
|
const pendingPermission = $derived(
|
|
isStreaming && isLastAssistantMessage ? agenticPendingPermissionRequest(message.convId) : null
|
|
);
|
|
|
|
// Reset dismissed when pendingPermission changes (new request or cleared)
|
|
let prevPendingRef: typeof pendingPermission = null;
|
|
$effect(() => {
|
|
if (pendingPermission !== prevPendingRef) {
|
|
prevPendingRef = pendingPermission;
|
|
if (pendingPermission) {
|
|
permissionDismissed = false;
|
|
}
|
|
}
|
|
});
|
|
|
|
function handlePermission(decision: ToolPermissionDecision) {
|
|
permissionDismissed = true;
|
|
agenticResolvePermission(message.convId, decision);
|
|
}
|
|
|
|
let continueDismissed = $state(false);
|
|
|
|
const pendingContinue = $derived(
|
|
isStreaming && isLastAssistantMessage ? agenticPendingContinueRequest(message.convId) : false
|
|
);
|
|
|
|
let prevContinueRef = false;
|
|
$effect(() => {
|
|
if (pendingContinue !== prevContinueRef) {
|
|
prevContinueRef = pendingContinue;
|
|
if (pendingContinue) {
|
|
continueDismissed = false;
|
|
}
|
|
}
|
|
});
|
|
|
|
function handleContinue(shouldContinue: boolean) {
|
|
continueDismissed = true;
|
|
agenticResolveContinue(message.convId, shouldContinue);
|
|
}
|
|
|
|
const sections = $derived(deriveAgenticSections(message, toolMessages, [], isStreaming));
|
|
|
|
// Parse tool results with images
|
|
const sectionsParsed = $derived(
|
|
sections.map((section) => ({
|
|
...section,
|
|
parsedLines: section.toolResult
|
|
? parseToolResultWithImages(section.toolResult, section.toolResultExtras || message?.extra)
|
|
: ([] as ToolResultLine[])
|
|
}))
|
|
);
|
|
|
|
// Group flat sections into agentic turns
|
|
// A new turn starts when a non-tool section follows a tool section
|
|
const turnGroups = $derived.by(() => {
|
|
const turns: { sections: (typeof sectionsParsed)[number][]; flatIndices: number[] }[] = [];
|
|
let currentTurn: (typeof sectionsParsed)[number][] = [];
|
|
let currentIndices: number[] = [];
|
|
let prevWasTool = false;
|
|
|
|
for (let i = 0; i < sectionsParsed.length; i++) {
|
|
const section = sectionsParsed[i];
|
|
const isTool =
|
|
section.type === AgenticSectionType.TOOL_CALL ||
|
|
section.type === AgenticSectionType.TOOL_CALL_PENDING ||
|
|
section.type === AgenticSectionType.TOOL_CALL_STREAMING;
|
|
|
|
if (!isTool && prevWasTool && currentTurn.length > 0) {
|
|
turns.push({ sections: currentTurn, flatIndices: currentIndices });
|
|
currentTurn = [];
|
|
currentIndices = [];
|
|
}
|
|
|
|
currentTurn.push(section);
|
|
currentIndices.push(i);
|
|
prevWasTool = isTool;
|
|
}
|
|
|
|
if (currentTurn.length > 0) {
|
|
turns.push({ sections: currentTurn, flatIndices: currentIndices });
|
|
}
|
|
|
|
return turns;
|
|
});
|
|
|
|
function getDefaultExpanded(section: AgenticSection): boolean {
|
|
if (
|
|
section.type === AgenticSectionType.TOOL_CALL_PENDING ||
|
|
section.type === AgenticSectionType.TOOL_CALL_STREAMING
|
|
) {
|
|
return showToolCallInProgress;
|
|
}
|
|
|
|
if (section.type === AgenticSectionType.REASONING_PENDING) {
|
|
return showThoughtInProgress;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function isExpanded(index: number, section: AgenticSection): boolean {
|
|
if (expandedStates[index] !== undefined) {
|
|
return expandedStates[index];
|
|
}
|
|
|
|
return getDefaultExpanded(section);
|
|
}
|
|
|
|
function toggleExpanded(index: number, section: AgenticSection) {
|
|
const currentState = isExpanded(index, section);
|
|
|
|
expandedStates[index] = !currentState;
|
|
}
|
|
|
|
function buildTurnAgenticTimings(stats: ChatMessageAgenticTurnStats): ChatMessageAgenticTimings {
|
|
return {
|
|
turns: 1,
|
|
toolCallsCount: stats.toolCalls.length,
|
|
toolsMs: stats.toolsMs,
|
|
toolCalls: stats.toolCalls,
|
|
llm: stats.llm
|
|
};
|
|
}
|
|
</script>
|
|
|
|
{#snippet renderSection(section: (typeof sectionsParsed)[number], index: number)}
|
|
{#if section.type === AgenticSectionType.TEXT}
|
|
<div class="agentic-text">
|
|
<MarkdownContent content={section.content} attachments={message?.extra} />
|
|
</div>
|
|
{:else if section.type === AgenticSectionType.TOOL_CALL_STREAMING}
|
|
{@const streamingIcon = isStreaming ? Loader2 : Loader2}
|
|
{@const streamingIconClass = isStreaming ? 'h-4 w-4 animate-spin' : 'h-4 w-4'}
|
|
|
|
<CollapsibleContentBlock
|
|
open={isExpanded(index, section)}
|
|
class="my-2"
|
|
icon={streamingIcon}
|
|
iconClass={streamingIconClass}
|
|
title={section.toolName || 'Tool call'}
|
|
subtitle={isStreaming ? '' : 'incomplete'}
|
|
{isStreaming}
|
|
onToggle={() => toggleExpanded(index, section)}
|
|
>
|
|
<div class="pt-3">
|
|
<div class="my-3 flex items-center gap-2 text-xs text-muted-foreground">
|
|
<span>Arguments:</span>
|
|
|
|
{#if isStreaming}
|
|
<Loader2 class="h-3 w-3 animate-spin" />
|
|
{/if}
|
|
</div>
|
|
{#if section.toolArgs}
|
|
<SyntaxHighlightedCode
|
|
code={formatJsonPretty(section.toolArgs)}
|
|
language={FileTypeText.JSON}
|
|
maxHeight="20rem"
|
|
class="text-xs"
|
|
/>
|
|
{:else if isStreaming}
|
|
<div class="rounded bg-muted/30 p-2 text-xs text-muted-foreground italic">
|
|
Receiving arguments...
|
|
</div>
|
|
{:else}
|
|
<div
|
|
class="rounded bg-yellow-500/10 p-2 text-xs text-yellow-600 italic dark:text-yellow-400"
|
|
>
|
|
Response was truncated
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</CollapsibleContentBlock>
|
|
{:else if section.type === AgenticSectionType.TOOL_CALL || section.type === AgenticSectionType.TOOL_CALL_PENDING}
|
|
{@const isPending = section.type === AgenticSectionType.TOOL_CALL_PENDING}
|
|
{@const toolIcon = isPending ? Loader2 : Wrench}
|
|
{@const toolIconClass = isPending ? 'h-4 w-4 animate-spin' : 'h-4 w-4'}
|
|
|
|
<CollapsibleContentBlock
|
|
open={isExpanded(index, section)}
|
|
class="my-2"
|
|
icon={toolIcon}
|
|
iconClass={toolIconClass}
|
|
title={section.toolName || ''}
|
|
subtitle={isPending ? 'executing...' : undefined}
|
|
isStreaming={isPending}
|
|
onToggle={() => toggleExpanded(index, section)}
|
|
>
|
|
{#if section.toolArgs && section.toolArgs !== '{}'}
|
|
<div class="pt-3">
|
|
<div class="my-3 text-xs text-muted-foreground">Arguments:</div>
|
|
|
|
<SyntaxHighlightedCode
|
|
code={formatJsonPretty(section.toolArgs)}
|
|
language={FileTypeText.JSON}
|
|
maxHeight="20rem"
|
|
class="text-xs"
|
|
/>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="pt-3">
|
|
<div class="my-3 flex items-center gap-2 text-xs text-muted-foreground">
|
|
<span>Result:</span>
|
|
|
|
{#if isPending}
|
|
<Loader2 class="h-3 w-3 animate-spin" />
|
|
{/if}
|
|
</div>
|
|
{#if isPending}
|
|
<div class="rounded bg-muted/30 p-2 text-xs text-muted-foreground italic">
|
|
Waiting for result...
|
|
</div>
|
|
{:else if section.toolResult}
|
|
<div class="overflow-auto rounded-lg border border-border bg-muted p-4">
|
|
{#each section.parsedLines as line, i (i)}
|
|
<div class="font-mono text-xs leading-relaxed whitespace-pre-wrap">
|
|
{line.text}
|
|
</div>
|
|
{#if line.image}
|
|
<img
|
|
src={line.image.base64Url}
|
|
alt={line.image.name}
|
|
class="mt-2 mb-2 h-auto max-w-full rounded-lg"
|
|
loading="lazy"
|
|
/>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<div class="rounded bg-muted/30 p-2 text-xs text-muted-foreground italic">No output</div>
|
|
{/if}
|
|
</div>
|
|
</CollapsibleContentBlock>
|
|
{:else if section.type === AgenticSectionType.REASONING}
|
|
<CollapsibleContentBlock
|
|
open={isExpanded(index, section)}
|
|
class="my-2"
|
|
icon={Brain}
|
|
title="Reasoning"
|
|
onToggle={() => toggleExpanded(index, section)}
|
|
>
|
|
<div class="pt-3">
|
|
<div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
|
|
{section.content}
|
|
</div>
|
|
</div>
|
|
</CollapsibleContentBlock>
|
|
{:else if section.type === AgenticSectionType.REASONING_PENDING}
|
|
{@const reasoningTitle = isStreaming ? 'Reasoning...' : 'Reasoning'}
|
|
{@const reasoningSubtitle = isStreaming ? '' : 'incomplete'}
|
|
|
|
<CollapsibleContentBlock
|
|
open={isExpanded(index, section)}
|
|
class="my-2"
|
|
icon={Brain}
|
|
title={reasoningTitle}
|
|
subtitle={reasoningSubtitle}
|
|
{isStreaming}
|
|
onToggle={() => toggleExpanded(index, section)}
|
|
>
|
|
<div class="pt-3">
|
|
<div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
|
|
{section.content}
|
|
</div>
|
|
</div>
|
|
</CollapsibleContentBlock>
|
|
{/if}
|
|
{/snippet}
|
|
|
|
<div class="agentic-content">
|
|
{#if highlightTurns && turnGroups.length > 1}
|
|
{#each turnGroups as turn, turnIndex (turnIndex)}
|
|
{@const turnStats = message?.timings?.agentic?.perTurn?.[turnIndex]}
|
|
<div class="agentic-turn my-2 hover:bg-muted/80 dark:hover:bg-muted/30">
|
|
<span class="agentic-turn-label">Turn {turnIndex + 1}</span>
|
|
{#each turn.sections as section, sIdx (turn.flatIndices[sIdx])}
|
|
{@render renderSection(section, turn.flatIndices[sIdx])}
|
|
{/each}
|
|
{#if turnStats}
|
|
<div class="turn-stats">
|
|
<ChatMessageStatistics
|
|
promptTokens={turnStats.llm.prompt_n}
|
|
promptMs={turnStats.llm.prompt_ms}
|
|
predictedTokens={turnStats.llm.predicted_n}
|
|
predictedMs={turnStats.llm.predicted_ms}
|
|
agenticTimings={turnStats.toolCalls.length > 0
|
|
? buildTurnAgenticTimings(turnStats)
|
|
: undefined}
|
|
initialView={ChatMessageStatsView.GENERATION}
|
|
hideSummary
|
|
/>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
{:else}
|
|
{#each sectionsParsed as section, index (index)}
|
|
{@render renderSection(section, index)}
|
|
{/each}
|
|
{/if}
|
|
|
|
{#if pendingPermission && !permissionDismissed}
|
|
<ChatMessageActionCardPermissionRequest
|
|
toolName={pendingPermission.toolName}
|
|
serverLabel={pendingPermission.serverLabel}
|
|
onDecision={handlePermission}
|
|
/>
|
|
{/if}
|
|
|
|
{#if pendingContinue && !continueDismissed}
|
|
<ChatMessageActionCardContinueRequest onDecision={handleContinue} />
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.agentic-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
width: 100%;
|
|
max-width: 48rem;
|
|
}
|
|
|
|
.agentic-text {
|
|
width: 100%;
|
|
}
|
|
|
|
.agentic-turn {
|
|
position: relative;
|
|
border: 1.5px dashed var(--muted-foreground);
|
|
border-radius: 0.75rem;
|
|
padding: 1rem;
|
|
transition: background 0.1s;
|
|
}
|
|
|
|
.agentic-turn-label {
|
|
position: absolute;
|
|
top: -1rem;
|
|
left: 0.75rem;
|
|
padding: 0 0.375rem;
|
|
background: var(--background);
|
|
font-size: 0.7rem;
|
|
font-weight: 500;
|
|
color: var(--muted-foreground);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.turn-stats {
|
|
margin-top: 0.75rem;
|
|
padding-top: 0.5rem;
|
|
border-top: 1px solid hsl(var(--muted) / 0.5);
|
|
}
|
|
</style>
|