feat: expand assistant interaction capabilities
This commit is contained in:
1 parent
9e6a0c7748
commit
33e23021ae
12 files changed
+2046
-81
No files matched your search
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getAssistantTelemetry, getAssistantUsage } from "./assistant-message-metadata";
|
||||
|
||||
describe("getAssistantTelemetry", () => {
|
||||
test("extracts persisted request telemetry", () => {
|
||||
expect(
|
||||
getAssistantTelemetry({
|
||||
custom: {
|
||||
telemetry: {
|
||||
requestId: "request-1",
|
||||
provider: "openrouter",
|
||||
modelId: "example/model",
|
||||
startedAt: "2026-07-30T12:00:00.000Z",
|
||||
completedAt: "2026-07-30T12:00:01.000Z",
|
||||
durationMs: 1000,
|
||||
contextLength: 128_000,
|
||||
contextUsed: 2000,
|
||||
contextUtilization: 0.015625,
|
||||
cost: 0.001,
|
||||
costType: "reported",
|
||||
steps: [{ index: 1, durationMs: 900, modelDurationMs: 800, toolDurationMs: 100 }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
requestId: "request-1",
|
||||
provider: "openrouter",
|
||||
durationMs: 1000,
|
||||
contextLength: 128_000,
|
||||
costType: "reported",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects malformed metadata without throwing", () => {
|
||||
expect(getAssistantTelemetry(null)).toBeNull();
|
||||
expect(getAssistantTelemetry({ custom: { telemetry: { requestId: 42 } } })).toBeNull();
|
||||
expect(
|
||||
getAssistantTelemetry({
|
||||
custom: {
|
||||
telemetry: {
|
||||
requestId: "request-1",
|
||||
provider: "openrouter",
|
||||
modelId: "example/model",
|
||||
startedAt: "invalid-but-display-safe",
|
||||
durationMs: Number.NaN,
|
||||
contextUtilization: 4,
|
||||
steps: [{ index: 1, durationMs: "fast" }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toMatchObject({ contextUtilization: 1, steps: [] });
|
||||
});
|
||||
|
||||
test("only returns finite usage values", () => {
|
||||
expect(
|
||||
getAssistantUsage({
|
||||
usage: {
|
||||
inputTokens: 120,
|
||||
outputTokens: Number.POSITIVE_INFINITY,
|
||||
totalTokens: "120",
|
||||
cachedInputTokens: 24,
|
||||
},
|
||||
}),
|
||||
).toEqual({ inputTokens: 120, cachedInputTokens: 24 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { UIMessage } from "ai";
|
||||
|
||||
export type AssistantUsage = {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
reasoningTokens?: number;
|
||||
cachedInputTokens?: number;
|
||||
cacheWriteTokens?: number;
|
||||
};
|
||||
|
||||
export type AssistantRequestStep = {
|
||||
index: number;
|
||||
durationMs: number;
|
||||
modelDurationMs: number;
|
||||
toolDurationMs: number;
|
||||
timeToFirstOutputMs?: number;
|
||||
outputTokensPerSecond?: number;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
cost?: number;
|
||||
upstreamCost?: number;
|
||||
generationId?: string;
|
||||
routedProvider?: string;
|
||||
};
|
||||
|
||||
export type AssistantRequestTelemetry = {
|
||||
requestId: string;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
startedAt: string;
|
||||
completedAt?: string;
|
||||
durationMs?: number;
|
||||
timeToFirstOutputMs?: number;
|
||||
outputTokensPerSecond?: number;
|
||||
contextLength?: number;
|
||||
contextUsed?: number;
|
||||
contextUtilization?: number;
|
||||
cost?: number;
|
||||
upstreamCost?: number;
|
||||
costType?: "reported" | "estimated";
|
||||
finishReason?: string;
|
||||
steps: AssistantRequestStep[];
|
||||
};
|
||||
|
||||
export type AssistantMessageMetadata = {
|
||||
usage?: AssistantUsage;
|
||||
custom: {
|
||||
telemetry: AssistantRequestTelemetry;
|
||||
};
|
||||
};
|
||||
|
||||
export type AssistantUIMessage = UIMessage<AssistantMessageMetadata>;
|
||||
|
||||
const getFiniteNonNegativeNumber = (value: unknown) =>
|
||||
typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
||||
|
||||
export const getAssistantUsage = (metadata: unknown): AssistantUsage | null => {
|
||||
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata) || !("usage" in metadata)) return null;
|
||||
const usage = metadata.usage;
|
||||
if (!usage || typeof usage !== "object" || Array.isArray(usage)) return null;
|
||||
const value = usage as Record<string, unknown>;
|
||||
return {
|
||||
...(getFiniteNonNegativeNumber(value.inputTokens) !== undefined
|
||||
? { inputTokens: getFiniteNonNegativeNumber(value.inputTokens) }
|
||||
: {}),
|
||||
...(getFiniteNonNegativeNumber(value.outputTokens) !== undefined
|
||||
? { outputTokens: getFiniteNonNegativeNumber(value.outputTokens) }
|
||||
: {}),
|
||||
...(getFiniteNonNegativeNumber(value.totalTokens) !== undefined
|
||||
? { totalTokens: getFiniteNonNegativeNumber(value.totalTokens) }
|
||||
: {}),
|
||||
...(getFiniteNonNegativeNumber(value.reasoningTokens) !== undefined
|
||||
? { reasoningTokens: getFiniteNonNegativeNumber(value.reasoningTokens) }
|
||||
: {}),
|
||||
...(getFiniteNonNegativeNumber(value.cachedInputTokens) !== undefined
|
||||
? { cachedInputTokens: getFiniteNonNegativeNumber(value.cachedInputTokens) }
|
||||
: {}),
|
||||
...(getFiniteNonNegativeNumber(value.cacheWriteTokens) !== undefined
|
||||
? { cacheWriteTokens: getFiniteNonNegativeNumber(value.cacheWriteTokens) }
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
export const getAssistantTelemetry = (metadata: unknown): AssistantRequestTelemetry | null => {
|
||||
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return null;
|
||||
const custom = (metadata as { custom?: unknown }).custom;
|
||||
if (!custom || typeof custom !== "object" || Array.isArray(custom)) return null;
|
||||
const telemetry = (custom as { telemetry?: unknown }).telemetry;
|
||||
if (!telemetry || typeof telemetry !== "object" || Array.isArray(telemetry)) return null;
|
||||
const value = telemetry as Partial<AssistantRequestTelemetry>;
|
||||
if (
|
||||
typeof value.requestId !== "string" ||
|
||||
typeof value.provider !== "string" ||
|
||||
typeof value.modelId !== "string" ||
|
||||
typeof value.startedAt !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const steps = Array.isArray(value.steps)
|
||||
? value.steps.flatMap((step): AssistantRequestStep[] => {
|
||||
if (!step || typeof step !== "object" || Array.isArray(step)) return [];
|
||||
const candidate = step as Record<string, unknown>;
|
||||
const index = getFiniteNonNegativeNumber(candidate.index);
|
||||
const durationMs = getFiniteNonNegativeNumber(candidate.durationMs);
|
||||
const modelDurationMs = getFiniteNonNegativeNumber(candidate.modelDurationMs);
|
||||
const toolDurationMs = getFiniteNonNegativeNumber(candidate.toolDurationMs);
|
||||
if (
|
||||
index === undefined ||
|
||||
durationMs === undefined ||
|
||||
modelDurationMs === undefined ||
|
||||
toolDurationMs === undefined
|
||||
)
|
||||
return [];
|
||||
return [
|
||||
{
|
||||
index,
|
||||
durationMs,
|
||||
modelDurationMs,
|
||||
toolDurationMs,
|
||||
...(getFiniteNonNegativeNumber(candidate.timeToFirstOutputMs) !== undefined
|
||||
? { timeToFirstOutputMs: getFiniteNonNegativeNumber(candidate.timeToFirstOutputMs) }
|
||||
: {}),
|
||||
...(getFiniteNonNegativeNumber(candidate.outputTokensPerSecond) !== undefined
|
||||
? { outputTokensPerSecond: getFiniteNonNegativeNumber(candidate.outputTokensPerSecond) }
|
||||
: {}),
|
||||
...(getFiniteNonNegativeNumber(candidate.inputTokens) !== undefined
|
||||
? { inputTokens: getFiniteNonNegativeNumber(candidate.inputTokens) }
|
||||
: {}),
|
||||
...(getFiniteNonNegativeNumber(candidate.outputTokens) !== undefined
|
||||
? { outputTokens: getFiniteNonNegativeNumber(candidate.outputTokens) }
|
||||
: {}),
|
||||
...(getFiniteNonNegativeNumber(candidate.cost) !== undefined
|
||||
? { cost: getFiniteNonNegativeNumber(candidate.cost) }
|
||||
: {}),
|
||||
...(getFiniteNonNegativeNumber(candidate.upstreamCost) !== undefined
|
||||
? { upstreamCost: getFiniteNonNegativeNumber(candidate.upstreamCost) }
|
||||
: {}),
|
||||
...(typeof candidate.generationId === "string" ? { generationId: candidate.generationId } : {}),
|
||||
...(typeof candidate.routedProvider === "string" ? { routedProvider: candidate.routedProvider } : {}),
|
||||
},
|
||||
];
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
requestId: value.requestId,
|
||||
provider: value.provider,
|
||||
modelId: value.modelId,
|
||||
startedAt: value.startedAt,
|
||||
steps,
|
||||
...(typeof value.completedAt === "string" ? { completedAt: value.completedAt } : {}),
|
||||
...(getFiniteNonNegativeNumber(value.durationMs) !== undefined
|
||||
? { durationMs: getFiniteNonNegativeNumber(value.durationMs) }
|
||||
: {}),
|
||||
...(getFiniteNonNegativeNumber(value.timeToFirstOutputMs) !== undefined
|
||||
? { timeToFirstOutputMs: getFiniteNonNegativeNumber(value.timeToFirstOutputMs) }
|
||||
: {}),
|
||||
...(getFiniteNonNegativeNumber(value.outputTokensPerSecond) !== undefined
|
||||
? { outputTokensPerSecond: getFiniteNonNegativeNumber(value.outputTokensPerSecond) }
|
||||
: {}),
|
||||
...(getFiniteNonNegativeNumber(value.contextLength) !== undefined
|
||||
? { contextLength: getFiniteNonNegativeNumber(value.contextLength) }
|
||||
: {}),
|
||||
...(getFiniteNonNegativeNumber(value.contextUsed) !== undefined
|
||||
? { contextUsed: getFiniteNonNegativeNumber(value.contextUsed) }
|
||||
: {}),
|
||||
...(getFiniteNonNegativeNumber(value.contextUtilization) !== undefined
|
||||
? { contextUtilization: Math.min(getFiniteNonNegativeNumber(value.contextUtilization) ?? 0, 1) }
|
||||
: {}),
|
||||
...(getFiniteNonNegativeNumber(value.cost) !== undefined ? { cost: getFiniteNonNegativeNumber(value.cost) } : {}),
|
||||
...(getFiniteNonNegativeNumber(value.upstreamCost) !== undefined
|
||||
? { upstreamCost: getFiniteNonNegativeNumber(value.upstreamCost) }
|
||||
: {}),
|
||||
...(value.costType === "reported" || value.costType === "estimated" ? { costType: value.costType } : {}),
|
||||
...(typeof value.finishReason === "string" ? { finishReason: value.finishReason } : {}),
|
||||
};
|
||||
};
|
||||
@@ -133,10 +133,13 @@
|
||||
margin-block: 0.9rem;
|
||||
}
|
||||
|
||||
.userMessage {
|
||||
.userMessageWrap {
|
||||
width: fit-content;
|
||||
max-width: 86%;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.userMessage {
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-radius: 0.9rem 0.9rem 0.25rem 0.9rem;
|
||||
background: var(--mantine-color-red-light);
|
||||
@@ -151,6 +154,14 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.messageQuote {
|
||||
margin: 0 0 0.55rem;
|
||||
padding-inline-start: 0.65rem;
|
||||
border-inline-start: 2px solid var(--mantine-color-red-filled);
|
||||
color: var(--mantine-color-dimmed);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.messageError {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
@@ -173,6 +184,170 @@
|
||||
background: var(--mantine-color-default);
|
||||
}
|
||||
|
||||
.toolHeader {
|
||||
width: 100%;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.disclosureIcon {
|
||||
flex: 0 0 auto;
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.disclosureIcon[data-opened] {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.codeBlock {
|
||||
max-height: 12rem;
|
||||
margin: 0.25rem 0 0;
|
||||
overflow: auto;
|
||||
padding: 0.5rem;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
.reasoning {
|
||||
margin-block: 0.45rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-inline-start: 2px solid var(--mantine-color-default-border);
|
||||
}
|
||||
|
||||
.reasoning summary,
|
||||
.telemetry summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.reasoning summary::-webkit-details-marker,
|
||||
.telemetry summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.reasoningText {
|
||||
margin-top: 0.45rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.source {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-inline-end: 0.4rem;
|
||||
}
|
||||
|
||||
.messageFile,
|
||||
.attachment {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background: var(--mantine-color-default);
|
||||
}
|
||||
|
||||
.messageImage {
|
||||
display: block;
|
||||
width: auto;
|
||||
max-width: min(100%, 28rem);
|
||||
max-height: 22rem;
|
||||
margin-block: 0.5rem;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.attachment {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin: 0 0.35rem 0.35rem 0;
|
||||
}
|
||||
|
||||
.attachmentThumb {
|
||||
display: grid;
|
||||
width: 1.7rem;
|
||||
height: 1.7rem;
|
||||
place-items: center;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
color: var(--mantine-color-dimmed);
|
||||
background: var(--mantine-color-body);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.attachmentName {
|
||||
max-width: 12rem;
|
||||
}
|
||||
|
||||
.messageActions {
|
||||
min-height: 1.75rem;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.userActions {
|
||||
min-height: 1.75rem;
|
||||
}
|
||||
|
||||
.branchPicker {
|
||||
display: flex;
|
||||
min-height: 1.75rem;
|
||||
align-items: center;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.editComposer {
|
||||
margin-block: 0.9rem;
|
||||
padding: 0.65rem;
|
||||
border: 1px solid var(--mantine-primary-color-filled);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background: var(--mantine-color-default);
|
||||
}
|
||||
|
||||
.editComposerInput {
|
||||
width: 100%;
|
||||
min-height: 4.5rem;
|
||||
resize: vertical;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: var(--mantine-color-text);
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.telemetry {
|
||||
margin-top: 0.65rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background: color-mix(in srgb, var(--mantine-color-default) 60%, transparent);
|
||||
}
|
||||
|
||||
.telemetrySummary {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.usageGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.stepRow {
|
||||
padding: 0.3rem 0.4rem;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
.generationId {
|
||||
overflow: hidden;
|
||||
margin-top: 0.2rem;
|
||||
font-family: var(--mantine-font-family-monospace);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.composerWrap {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.65rem 0.75rem 0.75rem;
|
||||
@@ -181,11 +356,13 @@
|
||||
}
|
||||
|
||||
.composer {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 44rem;
|
||||
margin-inline: auto;
|
||||
align-items: flex-end;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem;
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
@@ -193,6 +370,45 @@
|
||||
background: var(--mantine-color-default);
|
||||
}
|
||||
|
||||
.composerDropzone[data-dragging] .composer {
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
box-shadow: 0 0 0 2px var(--mantine-primary-color-light);
|
||||
}
|
||||
|
||||
.composerQuote {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-inline-start: 2px solid var(--mantine-color-red-filled);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
.composerQuoteText {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
color: var(--mantine-color-dimmed);
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.selectionToolbar {
|
||||
z-index: 400;
|
||||
padding: 0.2rem;
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background: var(--mantine-color-body);
|
||||
box-shadow: var(--mantine-shadow-md);
|
||||
}
|
||||
|
||||
.composerRow {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.composer:focus-within {
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
}
|
||||
@@ -210,6 +426,53 @@
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.composerHints {
|
||||
padding-inline: 0.45rem;
|
||||
}
|
||||
|
||||
.triggerPopover {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
inset-inline: 0;
|
||||
bottom: calc(100% + 0.45rem);
|
||||
max-height: min(22rem, 45dvh);
|
||||
overflow: auto;
|
||||
padding: 0.35rem;
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background: var(--mantine-color-body);
|
||||
box-shadow: var(--mantine-shadow-lg);
|
||||
}
|
||||
|
||||
.triggerList {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.triggerItem {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 2.75rem;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border: 0;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
color: var(--mantine-color-text);
|
||||
background: transparent;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.triggerItem:hover,
|
||||
.triggerItem[data-highlighted] {
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.triggerItemText {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.composerInput::placeholder {
|
||||
color: var(--mantine-color-dimmed);
|
||||
opacity: 1;
|
||||
@@ -342,8 +605,20 @@
|
||||
}
|
||||
|
||||
.userMessage {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.userMessageWrap {
|
||||
max-width: 92%;
|
||||
}
|
||||
|
||||
.usageGrid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.composerHints {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -1,27 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
|
||||
import { createContext, useContext, useMemo, useState } from "react";
|
||||
import type {
|
||||
FileMessagePartProps,
|
||||
ImageMessagePartProps,
|
||||
ReasoningMessagePartProps,
|
||||
SourceMessagePartProps,
|
||||
ToolCallMessagePartProps,
|
||||
Unstable_TriggerItem,
|
||||
} from "@assistant-ui/react";
|
||||
import type { MessageStatus } from "@assistant-ui/react";
|
||||
import {
|
||||
ActionBarPrimitive,
|
||||
AttachmentPrimitive,
|
||||
BranchPickerPrimitive,
|
||||
ComposerPrimitive,
|
||||
ErrorPrimitive,
|
||||
MessagePartPrimitive,
|
||||
MessagePrimitive,
|
||||
SelectionToolbarPrimitive,
|
||||
ThreadListItemPrimitive,
|
||||
ThreadListPrimitive,
|
||||
ThreadPrimitive,
|
||||
useAui,
|
||||
useAuiState,
|
||||
unstable_useMentionAdapter,
|
||||
unstable_useSlashCommandAdapter,
|
||||
} from "@assistant-ui/react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Collapse,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Popover,
|
||||
Progress,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
@@ -35,23 +52,42 @@ import {
|
||||
IconAlertTriangle,
|
||||
IconApps,
|
||||
IconArrowUp,
|
||||
IconAt,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconChevronUp,
|
||||
IconCopy,
|
||||
IconDownload,
|
||||
IconCommand,
|
||||
IconFile,
|
||||
IconFileExport,
|
||||
IconLink,
|
||||
IconMessage,
|
||||
IconMicrophone,
|
||||
IconPaperclip,
|
||||
IconPencil,
|
||||
IconHistory,
|
||||
IconPlayerStop,
|
||||
IconPlus,
|
||||
IconQuote,
|
||||
IconRefresh,
|
||||
IconRobot,
|
||||
IconSearch,
|
||||
IconSpeakerphone,
|
||||
IconThumbDown,
|
||||
IconThumbUp,
|
||||
IconTool,
|
||||
IconTrash,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import classes from "./assistant-panel.module.css";
|
||||
import { getAssistantTelemetry, getAssistantUsage } from "./assistant-message-metadata";
|
||||
|
||||
interface AssistantPanelProps {
|
||||
opened: boolean;
|
||||
@@ -67,6 +103,90 @@ interface AssistantPanelProps {
|
||||
|
||||
const TextPart = () => <MessagePartPrimitive.Text className={classes.messageText} />;
|
||||
|
||||
const Attachment = ({ removable = false }: { removable?: boolean }) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
return (
|
||||
<AttachmentPrimitive.Root className={classes.attachment}>
|
||||
<AttachmentPrimitive.unstable_Thumb className={classes.attachmentThumb} />
|
||||
<Text size="xs" lineClamp={1} className={classes.attachmentName}>
|
||||
<AttachmentPrimitive.Name />
|
||||
</Text>
|
||||
{removable && (
|
||||
<AttachmentPrimitive.Remove asChild>
|
||||
<ActionIcon variant="subtle" color="gray" size="xs" aria-label={t("removeAttachment")}>
|
||||
<IconX size={12} />
|
||||
</ActionIcon>
|
||||
</AttachmentPrimitive.Remove>
|
||||
)}
|
||||
</AttachmentPrimitive.Root>
|
||||
);
|
||||
};
|
||||
const SentAttachment = () => <Attachment />;
|
||||
|
||||
const ReasoningPart = ({ text, status }: ReasoningMessagePartProps) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
return (
|
||||
<details className={classes.reasoning} open={status.type === "running"}>
|
||||
<summary>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Loader type="bars" size="xs" color="gray" style={{ opacity: status.type === "running" ? 1 : 0 }} />
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t("reasoning")}
|
||||
</Text>
|
||||
</Group>
|
||||
</summary>
|
||||
<Text size="sm" c="dimmed" className={classes.reasoningText}>
|
||||
{text}
|
||||
</Text>
|
||||
</details>
|
||||
);
|
||||
};
|
||||
|
||||
const SourcePart = (source: SourceMessagePartProps) => (
|
||||
<Anchor
|
||||
className={classes.source}
|
||||
href={source.url}
|
||||
target={source.url ? "_blank" : undefined}
|
||||
rel={source.url ? "noreferrer" : undefined}
|
||||
size="xs"
|
||||
>
|
||||
<IconLink size={13} />
|
||||
{source.title}
|
||||
</Anchor>
|
||||
);
|
||||
|
||||
const FilePart = ({ data, filename, mimeType }: FileMessagePartProps) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const downloadable = data.startsWith("data:");
|
||||
const displayName = filename ?? mimeType;
|
||||
return (
|
||||
<Group className={classes.messageFile} gap="xs" wrap="nowrap">
|
||||
<IconFile size={16} />
|
||||
<Text size="xs" lineClamp={1} flex={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
{downloadable && (
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={data}
|
||||
download={filename ?? "assistant-file"}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="xs"
|
||||
aria-label={t("downloadFile", { name: displayName })}
|
||||
>
|
||||
<IconDownload size={13} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
const ImagePart = ({ image, filename }: ImageMessagePartProps) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
return <Box component="img" src={image} alt={filename ?? t("attachedImage")} className={classes.messageImage} />;
|
||||
};
|
||||
|
||||
const ToolPart = ({
|
||||
toolName,
|
||||
args,
|
||||
@@ -75,8 +195,10 @@ const ToolPart = ({
|
||||
status,
|
||||
approval,
|
||||
respondToApproval,
|
||||
timing,
|
||||
}: ToolCallMessagePartProps) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const [opened, setOpened] = useState(false);
|
||||
const completed = status?.type === "complete";
|
||||
const awaitingApproval = approval !== undefined && approval.approved === undefined && !approval.resolution;
|
||||
const denied = approval?.approved === false;
|
||||
@@ -86,40 +208,56 @@ const ToolPart = ({
|
||||
status?.type === "incomplete" ||
|
||||
(typeof result === "object" && result !== null && "error" in result));
|
||||
const successful = completed && !denied && !failed;
|
||||
const duration = timing?.completedAt !== undefined ? Math.max(0, timing.completedAt - timing.startedAt) : undefined;
|
||||
return (
|
||||
<Box className={classes.tool}>
|
||||
<Group justify="space-between" wrap="nowrap" gap="xs">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size="sm"
|
||||
radius="xl"
|
||||
variant="light"
|
||||
color={denied || failed ? "red" : successful ? "green" : "gray"}
|
||||
>
|
||||
{denied || failed ? <IconX size={13} /> : successful ? <IconCheck size={13} /> : <IconRobot size={13} />}
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600}>
|
||||
{toolName.replaceAll("_", " ")}
|
||||
</Text>
|
||||
<UnstyledButton
|
||||
className={classes.toolHeader}
|
||||
onClick={() => setOpened((current) => !current)}
|
||||
aria-expanded={opened || awaitingApproval}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" gap="xs">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size="sm"
|
||||
radius="xl"
|
||||
variant="light"
|
||||
color={denied || failed ? "red" : successful ? "green" : "gray"}
|
||||
>
|
||||
{denied || failed ? <IconX size={13} /> : successful ? <IconCheck size={13} /> : <IconRobot size={13} />}
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600}>
|
||||
{toolName.replaceAll("_", " ")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{duration !== undefined && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{duration < 1000 ? `${duration} ms` : `${(duration / 1000).toFixed(1)} s`}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="xs" c="dimmed">
|
||||
{awaitingApproval
|
||||
? t("approvalRequired")
|
||||
: denied
|
||||
? t("denied")
|
||||
: failed
|
||||
? t("failed")
|
||||
: successful
|
||||
? t("complete")
|
||||
: t("working")}
|
||||
</Text>
|
||||
<IconChevronDown
|
||||
size={14}
|
||||
className={classes.disclosureIcon}
|
||||
data-opened={opened || awaitingApproval || undefined}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{awaitingApproval
|
||||
? t("approvalRequired")
|
||||
: denied
|
||||
? t("denied")
|
||||
: failed
|
||||
? t("failed")
|
||||
: successful
|
||||
? t("complete")
|
||||
: t("working")}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
{awaitingApproval && (
|
||||
<Stack gap="xs" mt="sm">
|
||||
<Text size="sm">{t("approvalDescription")}</Text>
|
||||
<Text size="xs" c="dimmed" ff="monospace">
|
||||
{JSON.stringify(args)}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button size="compact-sm" onClick={() => respondToApproval({ approved: true })}>
|
||||
{t("allowOnce")}
|
||||
@@ -130,44 +268,366 @@ const ToolPart = ({
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
{!awaitingApproval && !denied && result !== undefined && (
|
||||
<Text size="xs" c="dimmed" mt={6} lineClamp={3}>
|
||||
{typeof result === "string" ? result : JSON.stringify(result)}
|
||||
</Text>
|
||||
)}
|
||||
<Collapse expanded={opened || awaitingApproval}>
|
||||
<Stack gap="xs" mt="sm">
|
||||
<Box>
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t("toolInput")}
|
||||
</Text>
|
||||
<Text className={classes.codeBlock} size="xs" component="pre">
|
||||
{JSON.stringify(args, null, 2)}
|
||||
</Text>
|
||||
</Box>
|
||||
{!denied && result !== undefined && (
|
||||
<Box>
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t("toolOutput")}
|
||||
</Text>
|
||||
<Text className={classes.codeBlock} size="xs" component="pre">
|
||||
{typeof result === "string" ? result : JSON.stringify(result, null, 2)}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const MessageActions = () => {
|
||||
const BranchPicker = () => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
return (
|
||||
<ActionBarPrimitive.Root hideWhenRunning>
|
||||
<BranchPickerPrimitive.Root hideWhenSingleBranch className={classes.branchPicker}>
|
||||
<BranchPickerPrimitive.Previous asChild>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("previousResponse")}>
|
||||
<IconChevronLeft size={14} />
|
||||
</ActionIcon>
|
||||
</BranchPickerPrimitive.Previous>
|
||||
<Text size="xs" c="dimmed">
|
||||
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
|
||||
</Text>
|
||||
<BranchPickerPrimitive.Next asChild>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("nextResponse")}>
|
||||
<IconChevronRight size={14} />
|
||||
</ActionIcon>
|
||||
</BranchPickerPrimitive.Next>
|
||||
</BranchPickerPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const AssistantMessageActions = () => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
return (
|
||||
<Group className={classes.messageActions} gap="xs" justify="space-between">
|
||||
<BranchPicker />
|
||||
<ActionBarPrimitive.Root hideWhenRunning>
|
||||
<Group gap={2}>
|
||||
<Tooltip label={t("copy")}>
|
||||
<ActionBarPrimitive.Copy asChild>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("copy")}>
|
||||
<IconCopy size={14} />
|
||||
</ActionIcon>
|
||||
</ActionBarPrimitive.Copy>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("speak")}>
|
||||
<ActionBarPrimitive.Speak asChild>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("speak")}>
|
||||
<IconSpeakerphone size={14} />
|
||||
</ActionIcon>
|
||||
</ActionBarPrimitive.Speak>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("stopSpeaking")}>
|
||||
<ActionBarPrimitive.StopSpeaking asChild>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("stopSpeaking")}>
|
||||
<IconPlayerStop size={14} />
|
||||
</ActionIcon>
|
||||
</ActionBarPrimitive.StopSpeaking>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("helpful")}>
|
||||
<ActionBarPrimitive.FeedbackPositive asChild>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("helpful")}>
|
||||
<IconThumbUp size={14} />
|
||||
</ActionIcon>
|
||||
</ActionBarPrimitive.FeedbackPositive>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("notHelpful")}>
|
||||
<ActionBarPrimitive.FeedbackNegative asChild>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("notHelpful")}>
|
||||
<IconThumbDown size={14} />
|
||||
</ActionIcon>
|
||||
</ActionBarPrimitive.FeedbackNegative>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("exportMarkdown")}>
|
||||
<ActionBarPrimitive.ExportMarkdown asChild>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("exportMarkdown")}>
|
||||
<IconFileExport size={14} />
|
||||
</ActionIcon>
|
||||
</ActionBarPrimitive.ExportMarkdown>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("regenerate")}>
|
||||
<ActionBarPrimitive.Reload asChild>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("regenerate")}>
|
||||
<IconRefresh size={14} />
|
||||
</ActionIcon>
|
||||
</ActionBarPrimitive.Reload>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</ActionBarPrimitive.Root>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
const UserMessageActions = () => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
return (
|
||||
<ActionBarPrimitive.Root hideWhenRunning className={classes.userActions}>
|
||||
<Group gap={2}>
|
||||
<Tooltip label={t("copy")}>
|
||||
<Tooltip label={t("copyMessage")}>
|
||||
<ActionBarPrimitive.Copy asChild>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm">
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("copyMessage")}>
|
||||
<IconCopy size={14} />
|
||||
</ActionIcon>
|
||||
</ActionBarPrimitive.Copy>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("regenerate")}>
|
||||
<ActionBarPrimitive.Reload asChild>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm">
|
||||
<IconRefresh size={14} />
|
||||
<Tooltip label={t("editMessage")}>
|
||||
<ActionBarPrimitive.Edit asChild>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("editMessage")}>
|
||||
<IconPencil size={14} />
|
||||
</ActionIcon>
|
||||
</ActionBarPrimitive.Reload>
|
||||
</ActionBarPrimitive.Edit>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</ActionBarPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const UserMessage = () => (
|
||||
<MessagePrimitive.Root className={`${classes.message} ${classes.userMessage}`}>
|
||||
<MessagePrimitive.Parts components={{ Text: TextPart }} />
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
const EditComposer = () => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
return (
|
||||
<ComposerPrimitive.Root className={classes.editComposer}>
|
||||
<ComposerPrimitive.Input className={classes.editComposerInput} rows={2} />
|
||||
<Group gap="xs" justify="flex-end">
|
||||
<ComposerPrimitive.Cancel asChild>
|
||||
<Button size="compact-sm" variant="default">
|
||||
{t("cancelEdit")}
|
||||
</Button>
|
||||
</ComposerPrimitive.Cancel>
|
||||
<ComposerPrimitive.Send asChild>
|
||||
<Button size="compact-sm">{t("updateMessage")}</Button>
|
||||
</ComposerPrimitive.Send>
|
||||
</Group>
|
||||
</ComposerPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const UserMessage = () => {
|
||||
const isEditing = useAuiState((state) => state.composer.isEditing);
|
||||
if (isEditing) return <EditComposer />;
|
||||
return (
|
||||
<MessagePrimitive.Root className={`${classes.message} ${classes.userMessageWrap}`}>
|
||||
<Box className={classes.userMessage}>
|
||||
<MessagePrimitive.Quote>
|
||||
{(quote) => (
|
||||
<Text component="blockquote" className={classes.messageQuote} size="sm">
|
||||
{quote.text}
|
||||
</Text>
|
||||
)}
|
||||
</MessagePrimitive.Quote>
|
||||
<MessagePrimitive.Attachments components={{ Attachment: SentAttachment }} />
|
||||
<MessagePrimitive.Parts components={{ Text: TextPart, File: FilePart, Image: ImagePart }} />
|
||||
</Box>
|
||||
<Group justify="flex-end" gap="xs">
|
||||
<BranchPicker />
|
||||
<UserMessageActions />
|
||||
</Group>
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const formatDuration = (milliseconds: number) =>
|
||||
milliseconds < 1000 ? `${Math.round(milliseconds)} ms` : `${(milliseconds / 1000).toFixed(1)} s`;
|
||||
|
||||
const formatCost = (cost: number) => {
|
||||
if (cost === 0) return "$0";
|
||||
if (cost < 0.0001) return `<$0.0001`;
|
||||
return `$${cost.toFixed(cost < 0.01 ? 4 : 3)}`;
|
||||
};
|
||||
|
||||
const RequestTelemetry = () => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const metadata = useAuiState((state) => state.message.metadata);
|
||||
const telemetry = getAssistantTelemetry(metadata);
|
||||
const usage = getAssistantUsage(metadata);
|
||||
if (!telemetry) return null;
|
||||
|
||||
return (
|
||||
<details className={classes.telemetry}>
|
||||
<summary className={classes.telemetrySummary}>
|
||||
<Group gap="xs" wrap="wrap">
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{telemetry.modelId}
|
||||
</Badge>
|
||||
<Badge size="xs" variant="outline" color="gray">
|
||||
{telemetry.provider}
|
||||
</Badge>
|
||||
{telemetry.durationMs !== undefined && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatDuration(telemetry.durationMs)}
|
||||
</Text>
|
||||
)}
|
||||
{telemetry.outputTokensPerSecond !== undefined && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{telemetry.outputTokensPerSecond.toFixed(1)} tok/s
|
||||
</Text>
|
||||
)}
|
||||
{telemetry.cost !== undefined && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatCost(telemetry.cost)}
|
||||
</Text>
|
||||
)}
|
||||
{usage?.totalTokens !== undefined && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{usage.totalTokens.toLocaleString()} {t("usage.tokens")}
|
||||
</Text>
|
||||
)}
|
||||
<IconChevronDown size={13} />
|
||||
</Group>
|
||||
</summary>
|
||||
<Stack gap="sm" mt="xs">
|
||||
{telemetry.contextLength !== undefined && telemetry.contextUsed !== undefined && (
|
||||
<Box>
|
||||
<Group justify="space-between" gap="xs">
|
||||
<Text size="xs" fw={600}>
|
||||
{t("usage.contextWindow")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{telemetry.contextUsed.toLocaleString()} / {telemetry.contextLength.toLocaleString()}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress value={(telemetry.contextUtilization ?? 0) * 100} size="sm" mt={5} />
|
||||
</Box>
|
||||
)}
|
||||
<Box className={classes.usageGrid}>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("usage.input")}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{usage?.inputTokens?.toLocaleString() ?? t("usage.notReported")}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("usage.output")}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{usage?.outputTokens?.toLocaleString() ?? t("usage.notReported")}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("usage.cached")}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{usage?.cachedInputTokens?.toLocaleString() ?? t("usage.notReported")}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("usage.reasoning")}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{usage?.reasoningTokens?.toLocaleString() ?? t("usage.notReported")}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("usage.cacheWrite")}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{usage?.cacheWriteTokens?.toLocaleString() ?? t("usage.notReported")}
|
||||
</Text>
|
||||
</div>
|
||||
</Box>
|
||||
<Divider />
|
||||
<Group gap="lg" align="flex-start">
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("usage.firstOutput")}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{telemetry.timeToFirstOutputMs !== undefined
|
||||
? formatDuration(telemetry.timeToFirstOutputMs)
|
||||
: t("usage.notReported")}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("usage.cost")}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{telemetry.cost !== undefined ? formatCost(telemetry.cost) : t("usage.notReported")}
|
||||
</Text>
|
||||
{telemetry.costType && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(`usage.${telemetry.costType}`)}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
{telemetry.upstreamCost !== undefined && (
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("usage.upstreamCost")}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{formatCost(telemetry.upstreamCost)}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{telemetry.finishReason && (
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("usage.finishReason")}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{telemetry.finishReason}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Group>
|
||||
{telemetry.steps.length > 0 && (
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" fw={600}>
|
||||
{t("usage.agentSteps")}
|
||||
</Text>
|
||||
{telemetry.steps.map((step) => (
|
||||
<Box key={step.index} className={classes.stepRow}>
|
||||
<Group justify="space-between" gap="xs">
|
||||
<Text size="xs">
|
||||
{t("usage.step", { number: step.index })}
|
||||
{step.routedProvider ? ` · ${step.routedProvider}` : ""}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatDuration(step.durationMs)}
|
||||
{step.outputTokensPerSecond !== undefined
|
||||
? ` · ${step.outputTokensPerSecond.toFixed(1)} tok/s`
|
||||
: ""}
|
||||
{step.cost !== undefined ? ` · ${formatCost(step.cost)}` : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
{step.generationId && (
|
||||
<Text size="xs" c="dimmed" className={classes.generationId} title={step.generationId}>
|
||||
{t("usage.generation")}: {step.generationId}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</details>
|
||||
);
|
||||
};
|
||||
|
||||
const RuntimeError = () => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
@@ -206,11 +666,16 @@ const AssistantMessage = () => (
|
||||
<MessagePrimitive.Parts
|
||||
components={{
|
||||
Text: TextPart,
|
||||
Reasoning: ReasoningPart,
|
||||
Source: SourcePart,
|
||||
File: FilePart,
|
||||
Image: ImagePart,
|
||||
tools: { Fallback: ToolPart },
|
||||
}}
|
||||
/>
|
||||
<RuntimeError />
|
||||
<MessageActions />
|
||||
<RequestTelemetry />
|
||||
<AssistantMessageActions />
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
|
||||
@@ -355,27 +820,219 @@ const EmptyThread = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const contextIcons = {
|
||||
app: IconApps,
|
||||
integration: IconLink,
|
||||
board: IconMessage,
|
||||
widget: IconTool,
|
||||
tools: IconTool,
|
||||
};
|
||||
|
||||
const TriggerItem = ({ item, index }: { item: Unstable_TriggerItem; index: number }) => {
|
||||
const Icon =
|
||||
typeof item.metadata?.icon === "string" && item.metadata.icon in contextIcons
|
||||
? contextIcons[item.metadata.icon as keyof typeof contextIcons]
|
||||
: IconAt;
|
||||
return (
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverItem className={classes.triggerItem} item={item} index={index}>
|
||||
<ThemeIcon size="sm" variant="light" color="gray">
|
||||
<Icon size={13} />
|
||||
</ThemeIcon>
|
||||
<div className={classes.triggerItemText}>
|
||||
<Text size="sm" fw={600} lineClamp={1}>
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.description && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{item.description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverItem>
|
||||
);
|
||||
};
|
||||
|
||||
const ComposerTriggers = () => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const aui = useAui();
|
||||
const { data: entities = [], isLoading } = clientApi.assistant.getContextEntities.useQuery(undefined, {
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const categories = useMemo(
|
||||
() =>
|
||||
(["app", "integration", "board", "widget"] as const).map((type) => ({
|
||||
id: type,
|
||||
label: t(`mentions.${type}`),
|
||||
items: entities
|
||||
.filter((entity) => entity.type === type)
|
||||
.map((entity) => ({
|
||||
id: entity.id,
|
||||
type: entity.type,
|
||||
label: entity.label,
|
||||
description: entity.description,
|
||||
icon: entity.type,
|
||||
})),
|
||||
})),
|
||||
[entities, t],
|
||||
);
|
||||
const mention = unstable_useMentionAdapter({
|
||||
categories,
|
||||
includeModelContextTools: {
|
||||
category: { id: "tools", label: t("mentions.tools") },
|
||||
formatLabel: (name) => name.replaceAll("_", " "),
|
||||
icon: "tools",
|
||||
},
|
||||
iconMap: contextIcons,
|
||||
fallbackIcon: IconAt,
|
||||
});
|
||||
const slash = unstable_useSlashCommandAdapter({
|
||||
removeOnExecute: true,
|
||||
commands: [
|
||||
{
|
||||
id: "health",
|
||||
label: "/health",
|
||||
description: t("commands.health"),
|
||||
execute: () => aui.composer().setText(t("suggestions.health.prompt")),
|
||||
},
|
||||
{
|
||||
id: "explore",
|
||||
label: "/explore",
|
||||
description: t("commands.explore"),
|
||||
execute: () => aui.composer().setText(t("suggestions.explore.prompt")),
|
||||
},
|
||||
{
|
||||
id: "media",
|
||||
label: "/media",
|
||||
description: t("commands.media"),
|
||||
execute: () => aui.composer().setText(t("suggestions.media.prompt")),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<ComposerPrimitive.Unstable_TriggerPopover
|
||||
className={classes.triggerPopover}
|
||||
char="@"
|
||||
adapter={mention.adapter}
|
||||
isLoading={isLoading}
|
||||
aria-label={t("mentions.menu")}
|
||||
>
|
||||
<ComposerPrimitive.Unstable_TriggerPopover.Directive {...mention.directive} />
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverCategories className={classes.triggerList}>
|
||||
{(items) =>
|
||||
items.map((category) => {
|
||||
const Icon = contextIcons[category.id as keyof typeof contextIcons] ?? IconAt;
|
||||
return (
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverCategoryItem
|
||||
key={category.id}
|
||||
categoryId={category.id}
|
||||
className={classes.triggerItem}
|
||||
>
|
||||
<ThemeIcon size="sm" variant="light" color="gray">
|
||||
<Icon size={13} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600} flex={1}>
|
||||
{category.label}
|
||||
</Text>
|
||||
<IconChevronRight size={14} />
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverCategoryItem>
|
||||
);
|
||||
})
|
||||
}
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverCategories>
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverItems className={classes.triggerList}>
|
||||
{(items) =>
|
||||
items.map((item, index) => <TriggerItem key={`${item.type}:${item.id}`} item={item} index={index} />)
|
||||
}
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverItems>
|
||||
</ComposerPrimitive.Unstable_TriggerPopover>
|
||||
<ComposerPrimitive.Unstable_TriggerPopover
|
||||
className={classes.triggerPopover}
|
||||
char="/"
|
||||
adapter={slash.adapter}
|
||||
aria-label={t("commands.menu")}
|
||||
>
|
||||
<ComposerPrimitive.Unstable_TriggerPopover.Action {...slash.action} />
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverItems className={classes.triggerList}>
|
||||
{(items) => items.map((item, index) => <TriggerItem key={item.id} item={item} index={index} />)}
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverItems>
|
||||
</ComposerPrimitive.Unstable_TriggerPopover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Composer = () => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const running = useAuiState((state) => state.thread.isRunning);
|
||||
return (
|
||||
<Box className={classes.composerWrap}>
|
||||
<ComposerPrimitive.Root className={classes.composer}>
|
||||
<ComposerPrimitive.Input className={classes.composerInput} placeholder={t("composerPlaceholder")} rows={1} />
|
||||
{running ? (
|
||||
<ComposerPrimitive.Cancel asChild>
|
||||
<ActionIcon color="red" variant="light" size="lg" aria-label={t("stop")}>
|
||||
<IconPlayerStop size={18} />
|
||||
</ActionIcon>
|
||||
</ComposerPrimitive.Cancel>
|
||||
) : (
|
||||
<ComposerPrimitive.Send asChild>
|
||||
<ActionIcon color="red" variant="filled" size="lg" aria-label={t("send")}>
|
||||
<IconArrowUp size={18} />
|
||||
</ActionIcon>
|
||||
</ComposerPrimitive.Send>
|
||||
)}
|
||||
</ComposerPrimitive.Root>
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverRoot>
|
||||
<ComposerPrimitive.AttachmentDropzone className={classes.composerDropzone}>
|
||||
<ComposerPrimitive.Root className={classes.composer}>
|
||||
<ComposerTriggers />
|
||||
<ComposerPrimitive.Quote className={classes.composerQuote}>
|
||||
<IconQuote size={15} />
|
||||
<ComposerPrimitive.QuoteText className={classes.composerQuoteText} />
|
||||
<ComposerPrimitive.QuoteDismiss asChild>
|
||||
<ActionIcon variant="subtle" color="gray" size="xs" aria-label={t("dismissQuote")}>
|
||||
<IconX size={12} />
|
||||
</ActionIcon>
|
||||
</ComposerPrimitive.QuoteDismiss>
|
||||
</ComposerPrimitive.Quote>
|
||||
<ComposerPrimitive.Attachments>{() => <Attachment removable />}</ComposerPrimitive.Attachments>
|
||||
<Group className={classes.composerRow} gap="xs" wrap="nowrap" align="flex-end">
|
||||
<Group gap={2} wrap="nowrap">
|
||||
<Tooltip label={t("attachments.add")}>
|
||||
<ComposerPrimitive.AddAttachment asChild>
|
||||
<ActionIcon variant="subtle" color="gray" size="lg" aria-label={t("attachments.add")}>
|
||||
<IconPaperclip size={17} />
|
||||
</ActionIcon>
|
||||
</ComposerPrimitive.AddAttachment>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("dictate")}>
|
||||
<ComposerPrimitive.Dictate asChild>
|
||||
<ActionIcon variant="subtle" color="gray" size="lg" aria-label={t("dictate")}>
|
||||
<IconMicrophone size={17} />
|
||||
</ActionIcon>
|
||||
</ComposerPrimitive.Dictate>
|
||||
</Tooltip>
|
||||
<ComposerPrimitive.StopDictation asChild>
|
||||
<ActionIcon variant="light" color="red" size="lg" aria-label={t("stopDictation")}>
|
||||
<IconPlayerStop size={17} />
|
||||
</ActionIcon>
|
||||
</ComposerPrimitive.StopDictation>
|
||||
</Group>
|
||||
<ComposerPrimitive.Input
|
||||
className={classes.composerInput}
|
||||
placeholder={t("composerPlaceholder")}
|
||||
rows={1}
|
||||
/>
|
||||
{running ? (
|
||||
<ComposerPrimitive.Cancel asChild>
|
||||
<ActionIcon color="red" variant="light" size="lg" aria-label={t("stop")}>
|
||||
<IconPlayerStop size={18} />
|
||||
</ActionIcon>
|
||||
</ComposerPrimitive.Cancel>
|
||||
) : (
|
||||
<ComposerPrimitive.Send asChild>
|
||||
<ActionIcon color="red" variant="filled" size="lg" aria-label={t("send")}>
|
||||
<IconArrowUp size={18} />
|
||||
</ActionIcon>
|
||||
</ComposerPrimitive.Send>
|
||||
)}
|
||||
</Group>
|
||||
<Group className={classes.composerHints} gap="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("mentions.hint")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("commands.hint")}
|
||||
</Text>
|
||||
</Group>
|
||||
</ComposerPrimitive.Root>
|
||||
</ComposerPrimitive.AttachmentDropzone>
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverRoot>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -536,6 +1193,13 @@ export const AssistantPanel = ({
|
||||
<EmptyThread />
|
||||
<ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} />
|
||||
</Box>
|
||||
<SelectionToolbarPrimitive.Root className={classes.selectionToolbar}>
|
||||
<SelectionToolbarPrimitive.Quote asChild>
|
||||
<Button variant="filled" color="dark" size="compact-sm" leftSection={<IconQuote size={14} />}>
|
||||
{t("quoteSelection")}
|
||||
</Button>
|
||||
</SelectionToolbarPrimitive.Quote>
|
||||
</SelectionToolbarPrimitive.Root>
|
||||
<ThreadPrimitive.ScrollToBottom asChild>
|
||||
<ActionIcon
|
||||
className={classes.scrollToBottom}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { PropsWithChildren } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type {
|
||||
AttachmentAdapter,
|
||||
MessageFormatAdapter,
|
||||
RemoteThreadListAdapter,
|
||||
ThreadHistoryAdapter,
|
||||
@@ -12,16 +13,18 @@ import type {
|
||||
} from "@assistant-ui/react";
|
||||
import {
|
||||
AssistantRuntimeProvider,
|
||||
WebSpeechDictationAdapter,
|
||||
WebSpeechSynthesisAdapter,
|
||||
defineToolkit,
|
||||
Tools,
|
||||
useAui,
|
||||
useAuiEvent,
|
||||
useAuiState,
|
||||
useRemoteThreadListRuntime,
|
||||
} from "@assistant-ui/react";
|
||||
import { AssistantChatTransport, useChatRuntime } from "@assistant-ui/react-ai-sdk";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
import { createAssistantStream } from "assistant-stream";
|
||||
import type { UIMessage } from "ai";
|
||||
import { lastAssistantMessageIsCompleteWithApprovalResponses } from "ai";
|
||||
|
||||
import { clientApi, fetchApi } from "@homarr/api/client";
|
||||
@@ -39,6 +42,7 @@ import {
|
||||
import { AssistantPanel } from "./assistant-panel";
|
||||
import { createAssistantPromptInteraction } from "./assistant-spotlight";
|
||||
import { browserToolContracts } from "./assistant-tool-contracts";
|
||||
import type { AssistantUIMessage } from "./assistant-message-metadata";
|
||||
|
||||
interface AssistantContextValue {
|
||||
enabled: boolean;
|
||||
@@ -53,6 +57,82 @@ interface AssistantContextValue {
|
||||
|
||||
const AssistantContext = createContext<AssistantContextValue | null>(null);
|
||||
const ignoreUnsupportedArchiveAction = () => Promise.resolve();
|
||||
const assistantImageAttachmentTypes = ["image/gif", "image/jpeg", "image/png", "image/webp"];
|
||||
const assistantDocumentAttachmentTypes = [
|
||||
"application/json",
|
||||
"text/csv",
|
||||
"text/html",
|
||||
"text/markdown",
|
||||
"text/plain",
|
||||
"text/xml",
|
||||
];
|
||||
|
||||
const fileToDataUrlAsync = (file: File) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", () => resolve(String(reader.result)), { once: true });
|
||||
reader.addEventListener("error", () => reject(new Error("The attachment could not be read.")), { once: true });
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
const createAssistantAttachmentAdapter = (allowImages: boolean): AttachmentAdapter => {
|
||||
const pendingAttachmentIds = new Set<string>();
|
||||
|
||||
return {
|
||||
accept: [...(allowImages ? assistantImageAttachmentTypes : []), ...assistantDocumentAttachmentTypes].join(","),
|
||||
async add({ file }) {
|
||||
if (pendingAttachmentIds.size >= 5) {
|
||||
throw new Error("A message can include up to 5 attachments.");
|
||||
}
|
||||
const isImage = file.type.startsWith("image/");
|
||||
if (isImage && !allowImages) {
|
||||
throw new Error("The selected model does not support image input.");
|
||||
}
|
||||
if (
|
||||
(!isImage && !assistantDocumentAttachmentTypes.includes(file.type)) ||
|
||||
(isImage && !assistantImageAttachmentTypes.includes(file.type))
|
||||
) {
|
||||
throw new Error("This file type is not supported.");
|
||||
}
|
||||
const sizeLimit = isImage ? 1_000_000 : 350_000;
|
||||
if (file.size === 0) throw new Error("Empty files cannot be attached.");
|
||||
if (file.size > sizeLimit) {
|
||||
throw new Error(isImage ? "Images must be smaller than 1 MB." : "Documents must be smaller than 350 KB.");
|
||||
}
|
||||
const id = crypto.randomUUID();
|
||||
pendingAttachmentIds.add(id);
|
||||
return {
|
||||
id,
|
||||
type: isImage ? "image" : "document",
|
||||
name: file.name,
|
||||
file,
|
||||
contentType: file.type,
|
||||
status: { type: "requires-action", reason: "composer-send" },
|
||||
};
|
||||
},
|
||||
async send(attachment) {
|
||||
try {
|
||||
return {
|
||||
...attachment,
|
||||
status: { type: "complete" },
|
||||
content: [
|
||||
{
|
||||
type: "file",
|
||||
filename: attachment.name,
|
||||
mimeType: attachment.contentType ?? "application/octet-stream",
|
||||
data: await fileToDataUrlAsync(attachment.file),
|
||||
},
|
||||
],
|
||||
};
|
||||
} finally {
|
||||
pendingAttachmentIds.delete(attachment.id);
|
||||
}
|
||||
},
|
||||
async remove(attachment) {
|
||||
pendingAttachmentIds.delete(attachment.id);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const useHomarrAssistant = () => {
|
||||
const value = useContext(AssistantContext);
|
||||
@@ -190,6 +270,27 @@ const AssistantThreadRuntime = () => {
|
||||
const threadId = useAuiState((state) => state.threadListItem.remoteId);
|
||||
const transport = useMemo(() => new AssistantChatTransport({ api: "/api/assistant/chat" }), []);
|
||||
const history = useMemo(() => createHistoryAdapter(threadId), [threadId]);
|
||||
const { data: modelCapabilities } = clientApi.assistant.getModelCapabilities.useQuery(undefined, {
|
||||
staleTime: 10 * 60_000,
|
||||
});
|
||||
const attachments = useMemo(
|
||||
() => createAssistantAttachmentAdapter(modelCapabilities?.imageInput !== "unsupported"),
|
||||
[modelCapabilities?.imageInput],
|
||||
);
|
||||
const speech = useMemo(() => new WebSpeechSynthesisAdapter(), []);
|
||||
const dictation = useMemo(
|
||||
() => (WebSpeechDictationAdapter.isSupported() ? new WebSpeechDictationAdapter() : undefined),
|
||||
[],
|
||||
);
|
||||
const feedback = useMemo(
|
||||
() => ({
|
||||
submit: ({ message, type }: { message: ThreadMessage; type: "positive" | "negative" }) => {
|
||||
if (!threadId) return;
|
||||
void fetchApi.assistant.submitFeedback.mutate({ threadId, messageId: message.id, type });
|
||||
},
|
||||
}),
|
||||
[threadId],
|
||||
);
|
||||
const onError = useCallback(
|
||||
() =>
|
||||
showErrorNotification({
|
||||
@@ -200,11 +301,17 @@ const AssistantThreadRuntime = () => {
|
||||
[t],
|
||||
);
|
||||
|
||||
return useChatRuntime<UIMessage>({
|
||||
return useChatRuntime<AssistantUIMessage>({
|
||||
transport,
|
||||
onError,
|
||||
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
|
||||
adapters: { history },
|
||||
adapters: {
|
||||
history,
|
||||
attachments,
|
||||
speech,
|
||||
...(dictation ? { dictation } : {}),
|
||||
feedback,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -265,6 +372,7 @@ const AssistantRuntime = ({ children }: PropsWithChildren) => {
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<AssistantTools toolkit={toolkit} />
|
||||
<AssistantRuntimeEvents />
|
||||
{children}
|
||||
</AssistantRuntimeProvider>
|
||||
);
|
||||
@@ -275,6 +383,17 @@ const AssistantTools = ({ toolkit }: { toolkit: Toolkit }) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const AssistantRuntimeEvents = () => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
useAuiEvent("composer.attachmentAddError", ({ message }) => {
|
||||
showErrorNotification({
|
||||
title: t("attachments.errorTitle"),
|
||||
message,
|
||||
});
|
||||
});
|
||||
return null;
|
||||
};
|
||||
|
||||
const getMessageText = (message: ThreadMessage | undefined) =>
|
||||
message?.content
|
||||
.filter((part) => part.type === "text")
|
||||
|
||||
Reference in new issue
Block a user