Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 186 additions & 0 deletions frontend/src/components/assistant/AssistantMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, Info, OctagonX } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { AnswerChecks } from "@/types/api";
import { AssistantMarkdown } from "./AssistantMarkdown";
import { AssistantSources } from "./AssistantSources";
import { cleanChecks, cleanSources, cleanToolsUsed, sourceAnchorId } from "./message-shape";
import type { AssistantMessage as AssistantMessageData, AssistantMessageError } from "./useAssistantChat";

const KNOWN_TOOLS = new Set([
"search_docs",
"list_my_projects",
"get_project",
"get_project_result",
"get_project_opinions",
]);

interface FailureBlockProps {
readonly cut: boolean;
/** Whether some of the answer is on screen above the block. */
readonly hasText: boolean;
readonly error?: AssistantMessageError;
readonly onRetry?: () => void;
}

/**
* The red block of a failed or interrupted turn. The text is chosen by the status code
* from fixed copy: `error.detail` is whatever the server or the network said, and is
* never shown.
*/
function FailureBlock({ cut, hasText, error, onRetry }: FailureBlockProps) {
const { t } = useTranslation("assistant");
let title = t("message.errors.generic");
let hint: string | null = null;

if (cut) {
title = t("message.errors.cut");
hint = hasText ? t("message.errors.cutHint") : null;
} else if (error?.code === 429) {
const seconds = error.retryAfter;
title =
seconds !== undefined && Number.isFinite(seconds) && seconds > 0
? t("message.errors.rateLimited", { minutes: Math.ceil(seconds / 60) })
: t("message.errors.rateLimitedGeneric");
} else if (error?.code === 503) {
title = t("message.errors.unavailable");
hint = t("message.errors.unavailableHint");
} else if (error?.code === 404) {
title = t("message.errors.notFound");
hint = t("message.errors.notFoundHint");
}

return (
<div className="mt-2 flex items-start gap-2.5 rounded-lg border border-destructive/50 bg-destructive/5 px-3 py-2.5 text-[13px]">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" aria-hidden="true" />
<div>
<p className="font-medium">{title}</p>
{hint && <p className="mt-0.5 text-[12.5px] text-muted-foreground">{hint}</p>}
{onRetry && (
<Button type="button" variant="outline" size="sm" className="mt-2" onClick={onRetry}>
{t("message.retry")}
</Button>
)}
</div>
</div>
);
}

function ChecksLines({ checks }: { readonly checks: AnswerChecks }) {
const { t } = useTranslation("assistant");
const lines: string[] = [];
if (!checks.numbers_grounded) {
lines.push(
checks.ungrounded_numbers.length > 0
? t("message.numbersNotConfirmed", { numbers: checks.ungrounded_numbers.join(", ") })
: t("message.numbersNotConfirmedGeneric"),
);
}
if (!checks.citations_valid) {
lines.push(t("message.citationInvalid"));
}

return lines.map((line) => (
<p key={line} className="mt-2 flex items-start gap-2 text-[12.5px] text-warning">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span>{line}</span>
</p>
));
}

function ToolsLine({ tools }: { readonly tools: readonly string[] }) {
const { t } = useTranslation("assistant");
const labels = tools.map((tool) => (KNOWN_TOOLS.has(tool) ? t(`message.tools.${tool}`) : tool));

return (
<p className="mt-2 flex items-center gap-1.5 text-[11.5px] text-muted-foreground">
<Info className="h-3 w-3 shrink-0" aria-hidden="true" />
<span>{t("message.read", { tools: labels.join(", ") })}</span>
</p>
);
}

export interface AssistantMessageProps {
readonly message: AssistantMessageData;
readonly onRetry?: () => void;
}

function AssistantAnswer({ message, onRetry }: AssistantMessageProps) {
const { t } = useTranslation("assistant");
const [openN, setOpenN] = useState<number | null>(null);
// A fresh object per citation click, so citing the row that is already open scrolls again.
const [scrollRequest, setScrollRequest] = useState<{ readonly n: number } | null>(null);
const sources = useMemo(() => cleanSources(message.sources), [message.sources]);
const sourceNumbers = useMemo(() => sources.map((source) => source.n), [sources]);
const tools = cleanToolsUsed(message.toolsUsed);
const checks = cleanChecks(message.checks);

const { status } = message;
const partial = status === "error" || status === "cut" || status === "cancelled";
const hasText = message.content.trim() !== "";

const messageId = message.id;
const handleCite = useCallback((n: number) => {
setOpenN(n);
setScrollRequest({ n });
}, []);

// After the render that opened the row, so its detail exists when the scroll measures it.
useEffect(() => {
if (scrollRequest === null) return;
document.getElementById(sourceAnchorId(messageId, scrollRequest.n))?.scrollIntoView?.({ block: "nearest" });
}, [scrollRequest, messageId]);

function handleToggle(n: number) {
setOpenN((current) => (current === n ? null : n));
}

return (
<div className="text-sm">
{hasText && (
<div
className={cn(
"[&_code]:whitespace-pre-wrap [&_li]:my-0.5 [&_ol]:mb-2 [&_ol]:list-decimal [&_ol]:pl-[18px] [&_p]:mb-2 [&_ul]:mb-2 [&_ul]:list-disc [&_ul]:pl-[18px]",
partial && "border-l-2 border-dashed pl-2.5 opacity-75",
)}
>
<AssistantMarkdown
content={message.content}
messageId={message.id}
sourceNumbers={status === "done" ? sourceNumbers : undefined}
onCite={handleCite}
/>
</div>
)}
{status === "cancelled" && (
<p className="mt-1.5 flex items-center gap-1.5 text-xs text-muted-foreground">
<OctagonX className="h-3 w-3 shrink-0" aria-hidden="true" />
<span>{t("message.stopped")}</span>
{hasText && <span>· {t("message.stoppedHint")}</span>}
</p>
)}
{(status === "error" || status === "cut") && (
<FailureBlock cut={status === "cut"} hasText={hasText} error={message.error} onRetry={onRetry} />
)}
{checks && <ChecksLines checks={checks} />}
{sources.length > 0 && (
<AssistantSources messageId={message.id} sources={sources} openN={openN} onToggle={handleToggle} />
)}
{tools.length > 0 && <ToolsLine tools={tools} />}
</div>
);
}

/** One turn of the conversation. The user's text is never read as markdown. */
export function AssistantMessage({ message, onRetry }: AssistantMessageProps) {
if (message.role === "user") {
return (
<div className="ml-auto max-w-[80%] whitespace-pre-wrap rounded-xl rounded-br-sm bg-primary px-3 py-2 text-sm text-primary-foreground">
{message.content}
</div>
);
}
return <AssistantAnswer message={message} onRetry={onRetry} />;
}
95 changes: 95 additions & 0 deletions frontend/src/components/assistant/AssistantSources.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { useTranslation } from "react-i18next";
import type { SourceRef } from "@/types/api";
import { sourceAnchorId } from "./message-shape";
import { isAllowedAssistantUrl } from "./url-allowlist";

interface SourceRowProps {
readonly messageId: string;
readonly source: SourceRef;
readonly open: boolean;
readonly onToggle: (n: number) => void;
}

function SourceRow({ messageId, source, open, onToggle }: SourceRowProps) {
const { t } = useTranslation("assistant");
const rowId = sourceAnchorId(messageId, source.n);
const detailId = `${rowId}-detail`;
const isLocal = source.layer === "local";
// A local source never links, whatever its url says.
const href = !isLocal && isAllowedAssistantUrl(source.url) ? source.url : null;

return (
<div id={rowId} className="py-0.5">
<div className="flex items-baseline gap-2">
<button
type="button"
aria-expanded={open}
aria-controls={detailId}
onClick={() => onToggle(source.n)}
className="flex min-w-0 flex-1 items-baseline gap-2 rounded text-left"
>
<span className="min-w-[18px] text-[11px] font-semibold">[{source.n}]</span>
<span className="min-w-0">
<span className="underline decoration-muted-foreground underline-offset-2">{source.title}</span>
{source.section !== "" && <span> · {source.section}</span>}
</span>
</button>
{isLocal && (
<span
title={t("message.localHint")}
className="whitespace-nowrap rounded border px-1.5 text-[10.5px]"
>
{t("message.localCopy")}
</span>
)}
</div>
{open && (
<div id={detailId} className="mt-1 space-y-1 rounded-md border bg-card p-2 text-foreground">
{source.snippet !== "" && (
<p className="italic text-muted-foreground">
<q>{source.snippet}</q>
</p>
)}
{isLocal ? <p>{t("message.localHint")}</p> : <p>{t("message.publicLayer")}</p>}
{href && (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2"
>
{t("message.openSource")}
</a>
)}
</div>
)}
</div>
);
}

export interface AssistantSourcesProps {
readonly messageId: string;
readonly sources: readonly SourceRef[];
readonly openN: number | null;
readonly onToggle: (n: number) => void;
}

/** Compact source rows; one row at a time shows its quote and, for a public page, a link. */
export function AssistantSources({ messageId, sources, openN, onToggle }: AssistantSourcesProps) {
const { t } = useTranslation("assistant");

return (
<div className="mt-2.5 border-t pt-2 text-[12.5px] text-muted-foreground">
<p className="mb-1.5 text-[11px] uppercase tracking-wide">{t("message.sources")}</p>
{sources.map((source) => (
<SourceRow
key={source.n}
messageId={messageId}
source={source}
open={openN === source.n}
onToggle={onToggle}
/>
))}
</div>
);
}
52 changes: 52 additions & 0 deletions frontend/src/components/assistant/message-shape.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,56 @@
import type { AnswerChecks, SourceRef } from "@/types/api";

/** The id of a source row, which a `[n]` citation in the same message points at. */
export function sourceAnchorId(messageId: string, n: number): string {
return `assistant-src-${messageId}-${n}`;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function toSource(value: unknown): SourceRef | null {
if (!isRecord(value)) return null;
const { n, title, section, snippet, url, layer } = value;
if (typeof n !== "number" || !Number.isSafeInteger(n) || n < 1) return null;
if (typeof title !== "string" || typeof section !== "string") return null;
if (layer !== "public" && layer !== "local") return null;
if (url !== null && typeof url !== "string") return null;
// A stored entry from before snippets existed has none; it shows an empty quote.
return { n, title, section, snippet: typeof snippet === "string" ? snippet : "", url, layer };
}

/**
* Storage only checks that `sources` is an array and a stored message can be edited
* by hand, so each entry is checked here: malformed ones and repeated numbers drop out.
*/
export function cleanSources(value: unknown): SourceRef[] {
if (!Array.isArray(value)) return [];
const seen = new Set<number>();
const sources: SourceRef[] = [];
for (const entry of value) {
const source = toSource(entry);
if (source && !seen.has(source.n)) {
seen.add(source.n);
sources.push(source);
}
}
return sources;
}

export function cleanToolsUsed(value: unknown): string[] {
return Array.isArray(value) ? value.filter((tool): tool is string => typeof tool === "string") : [];
}

/** Only a complete set of checks counts; anything else is treated as "no checks". */
export function cleanChecks(value: unknown): AnswerChecks | null {
if (!isRecord(value)) return null;
const { citations_valid, numbers_grounded, ungrounded_numbers } = value;
if (typeof citations_valid !== "boolean" || typeof numbers_grounded !== "boolean") return null;
if (!Array.isArray(ungrounded_numbers)) return null;
return {
citations_valid,
numbers_grounded,
ungrounded_numbers: ungrounded_numbers.filter((n): n is string => typeof n === "string"),
};
}
32 changes: 31 additions & 1 deletion frontend/src/i18n/locales/cs/assistant.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,36 @@
"hint": "Enter odešle · Shift+Enter nový řádek"
},
"message": {
"noSuchSource": "Zdroj neexistuje"
"sources": "Zdroje",
"localCopy": "Místní kopie",
"localHint": "Z neveřejného korpusu (diplomová práce, články). Veřejná stránka není.",
"publicLayer": "Dokumentace",
"openSource": "Otevřít stránku",
"noSuchSource": "Zdroj neexistuje",
"numbersNotConfirmed": "Čísla, která zdroje nepotvrzují: {{numbers}}",
"numbersNotConfirmedGeneric": "Některá čísla zdroje nepotvrzují.",
"citationInvalid": "Citace odkazuje na neexistující zdroj.",
"read": "Načteno: {{tools}}",
"tools": {
"search_docs": "dokumentace",
"list_my_projects": "projekty",
"get_project": "projekt",
"get_project_result": "výsledek",
"get_project_opinions": "názory"
},
"stopped": "Zastaveno",
"stoppedHint": "Částečná odpověď. S další otázkou se neodesílá.",
"retry": "Zkusit znovu",
"errors": {
"rateLimited": "Limit zpráv vyčerpán. Zkuste to znovu za {{minutes}} min.",
"rateLimitedGeneric": "Limit zpráv vyčerpán. Zkuste to znovu později.",
"unavailable": "Asistent je dočasně nedostupný.",
"unavailableHint": "Model neodpovídá.",
"notFound": "Tento projekt není dostupný.",
"notFoundHint": "Mohl být smazán nebo už nejste jeho členem.",
"generic": "Něco se pokazilo. Zkuste to prosím znovu.",
"cut": "Spojení se přerušilo před dokončením odpovědi.",
"cutHint": "Co dorazilo, je výše. S další otázkou se neodesílá."
}
}
}
Loading