Merge remote-tracking branch 'gitea/develop' into fix/ai-chat-current-page

This commit is contained in:
claude_code
2026-06-21 01:29:37 +03:00
107 changed files with 10815 additions and 696 deletions

View File

@@ -10,6 +10,18 @@ import classes from "@/features/ai-chat/components/ai-chat.module.css";
interface MessageItemProps {
message: UIMessage;
/**
* Forwarded to ToolCallCard: whether tool cards render page citation links.
* Defaults to true (internal chat). The public share passes false.
*/
showCitations?: boolean;
/**
* Neutralize internal/relative markdown links in the rendered answer (drop
* their href so they become inert text). Defaults to false (internal chat,
* links stay clickable). The anonymous public share passes true so internal
* UUIDs/routes in the assistant's markdown don't leak as clickable links.
*/
neutralizeInternalLinks?: boolean;
}
/**
@@ -24,7 +36,11 @@ interface MessageItemProps {
* `message` prop identity (and its `parts`) changes each tick. Re-rendering the
* text parts on each delta is what makes the answer stream in progressively.
*/
export default function MessageItem({ message }: MessageItemProps) {
export default function MessageItem({
message,
showCitations = true,
neutralizeInternalLinks = false,
}: MessageItemProps) {
const { t } = useTranslation();
const isUser = message.role === "user";
@@ -53,7 +69,9 @@ export default function MessageItem({ message }: MessageItemProps) {
// starts with an empty text part before the first token arrives); the
// typing indicator covers that gap until real content streams in.
if (!part.text.trim()) return null;
const html = renderChatMarkdown(part.text);
const html = renderChatMarkdown(part.text, {
neutralizeInternalLinks,
});
if (html) {
return (
<div
@@ -73,7 +91,13 @@ export default function MessageItem({ message }: MessageItemProps) {
}
if (isToolPart(part.type)) {
return <ToolCallCard key={index} part={part as unknown as ToolUiPart} />;
return (
<ToolCallCard
key={index}
part={part as unknown as ToolUiPart}
showCitations={showCitations}
/>
);
}
return null;

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { ReactNode, useEffect, useRef } from "react";
import { Center, ScrollArea, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { UIMessage } from "@ai-sdk/react";
@@ -10,6 +10,26 @@ import classes from "@/features/ai-chat/components/ai-chat.module.css";
interface MessageListProps {
messages: UIMessage[];
isStreaming: boolean;
/**
* Content shown when the transcript is empty and no turn is in flight.
* Defaults to the internal chat's prompt. The public share passes its own
* documentation-focused copy. This is purely the empty-state text; the
* streaming/typing/markdown/tool-card paths below are shared verbatim.
*/
emptyState?: ReactNode;
/**
* Forwarded to MessageItem -> ToolCallCard: whether tool cards render page
* citation links. Defaults to true (internal chat). The public share passes
* false because an anonymous reader cannot open the linked internal pages.
*/
showCitations?: boolean;
/**
* Forwarded to MessageItem: neutralize internal/relative markdown links in
* the rendered answers (drop their href so they render as inert text).
* Defaults to false (internal chat). The public share passes true so internal
* UUIDs/routes don't leak as clickable links to anonymous readers.
*/
neutralizeInternalLinks?: boolean;
}
// Distance (px) from the bottom within which the viewport still counts as
@@ -24,7 +44,7 @@ const BOTTOM_THRESHOLD = 40;
* - the last (assistant) message has no non-empty text and no tool part.
* Once any text/tool part arrives, MessageItem renders it and this hides.
*/
function showTypingIndicator(messages: UIMessage[], isStreaming: boolean): boolean {
export function showTypingIndicator(messages: UIMessage[], isStreaming: boolean): boolean {
if (!isStreaming) return false;
const last = messages[messages.length - 1];
if (!last) return true; // submitted with nothing rendered yet.
@@ -41,7 +61,13 @@ function showTypingIndicator(messages: UIMessage[], isStreaming: boolean): boole
* but only while the user is pinned to the bottom — if they scrolled up to read
* earlier messages, streamed deltas no longer yank them back down.
*/
export default function MessageList({ messages, isStreaming }: MessageListProps) {
export default function MessageList({
messages,
isStreaming,
emptyState,
showCitations = true,
neutralizeInternalLinks = false,
}: MessageListProps) {
const { t } = useTranslation();
const viewportRef = useRef<HTMLDivElement>(null);
// Whether the viewport is currently pinned to the bottom. Starts true so the
@@ -104,9 +130,11 @@ export default function MessageList({ messages, isStreaming }: MessageListProps)
if (messages.length === 0 && !typing) {
return (
<Center className={classes.messages}>
<Text size="sm" c="dimmed" ta="center">
{t("Ask the AI agent anything about your workspace.")}
</Text>
{emptyState ?? (
<Text size="sm" c="dimmed" ta="center">
{t("Ask the AI agent anything about your workspace.")}
</Text>
)}
</Center>
);
}
@@ -115,7 +143,12 @@ export default function MessageList({ messages, isStreaming }: MessageListProps)
<ScrollArea className={classes.messages} viewportRef={viewportRef} scrollbarSize={6} type="scroll">
<Stack gap={0} pr="xs">
{messages.map((message) => (
<MessageItem key={message.id} message={message} />
<MessageItem
key={message.id}
message={message}
showCitations={showCitations}
neutralizeInternalLinks={neutralizeInternalLinks}
/>
))}
{typing && <TypingIndicator />}
</Stack>

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import type { UIMessage } from "@ai-sdk/react";
import { showTypingIndicator } from "@/features/ai-chat/components/message-list.tsx";
/**
* Pure-helper tests for the typing-indicator bridging logic that the internal
* chat and the public share widget now share. This is the behavior that decides
* whether the animated "AI agent is typing…" placeholder shows in the gap
* between sending and the first streamed token.
*/
const msg = (
role: "user" | "assistant",
parts: UIMessage["parts"],
): UIMessage => ({ id: Math.random().toString(), role, parts }) as UIMessage;
describe("showTypingIndicator", () => {
it("is hidden when not streaming", () => {
expect(showTypingIndicator([], false)).toBe(false);
expect(
showTypingIndicator([msg("assistant", [{ type: "text", text: "hi" }])], false),
).toBe(false);
});
it("shows while streaming with no messages yet (just submitted)", () => {
expect(showTypingIndicator([], true)).toBe(true);
});
it("shows while streaming when the last message is still the user's", () => {
expect(
showTypingIndicator([msg("user", [{ type: "text", text: "q" }])], true),
).toBe(true);
});
it("shows while streaming when the assistant row has no visible content", () => {
expect(
showTypingIndicator([msg("assistant", [{ type: "text", text: "" }])], true),
).toBe(true);
expect(
showTypingIndicator([msg("assistant", [{ type: "text", text: " " }])], true),
).toBe(true);
});
it("hides once the assistant streams non-empty text", () => {
expect(
showTypingIndicator([msg("assistant", [{ type: "text", text: "answer" }])], true),
).toBe(false);
});
it("hides once a tool part appears (even before any text)", () => {
const toolPart = { type: "tool-searchPages" } as unknown as UIMessage["parts"][number];
expect(
showTypingIndicator([msg("assistant", [toolPart])], true),
).toBe(false);
});
});

View File

@@ -13,6 +13,14 @@ import classes from "@/features/ai-chat/components/ai-chat.module.css";
interface ToolCallCardProps {
part: ToolUiPart;
/**
* Whether to render page citation links. Defaults to true (the internal chat,
* where the reader is authenticated and the `/p/{id}` links resolve). The
* public share passes false: an anonymous reader cannot open internal pages,
* so the links would 404/redirect to login. Suppressing them keeps the card
* (the action log itself) while dropping the unusable links.
*/
showCitations?: boolean;
}
/**
@@ -20,12 +28,15 @@ interface ToolCallCardProps {
* agent DID (the agent writes without confirmation — D2), its run state
* (running / done / error), and citation link(s) to any referenced page(s).
*/
export default function ToolCallCard({ part }: ToolCallCardProps) {
export default function ToolCallCard({
part,
showCitations = true,
}: ToolCallCardProps) {
const { t } = useTranslation();
const toolName = getToolName(part);
const state = toolRunState(part.state);
const { key, values } = toolLabelKey(toolName);
const citations = toolCitations(part);
const citations = showCitations ? toolCitations(part) : [];
return (
<div className={classes.toolCard}>

View File

@@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
import { describeChatError } from "./error-message";
// Identity translator: assert on the raw English key so the tests do not depend
// on the i18n catalog.
const t = (key: string) => key;
describe("describeChatError", () => {
it('surfaces a provider "402: ..." stream error verbatim', () => {
expect(describeChatError("402: Insufficient credits", t)).toBe(
"402: Insufficient credits",
);
});
it('does NOT misclassify a body that merely contains "403" (no "statusCode":403)', () => {
// A provider message mentioning the number 403 must be surfaced verbatim,
// never folded into the "AI chat is disabled" gating message.
const msg = "429: rate limited after 403 attempts";
expect(describeChatError(msg, t)).toBe(msg);
});
it('maps a {"statusCode":403} body to the disabled message', () => {
const body = '{"statusCode":403,"message":"Forbidden"}';
expect(describeChatError(body, t)).toBe(
"AI chat is disabled for this workspace.",
);
});
it('maps a {"statusCode":503} body to the not-configured message', () => {
const body = '{"statusCode":503,"message":"Service Unavailable"}';
expect(describeChatError(body, t)).toBe(
"The AI provider is not configured. Ask an administrator to set it up.",
);
});
it('falls back to the generic message for "An error occurred."', () => {
expect(describeChatError("An error occurred.", t)).toBe(
"The AI agent could not respond. Please try again.",
);
});
it('falls back to the generic message for "Internal server error"', () => {
expect(describeChatError("Internal server error", t)).toBe(
"The AI agent could not respond. Please try again.",
);
});
it("falls back to the generic message for empty input", () => {
expect(describeChatError("", t)).toBe(
"The AI agent could not respond. Please try again.",
);
});
});

View File

@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
/**
* Tests for the internal-link neutralization used by the anonymous public
* share. Now that the share renders the assistant's MARKDOWN (not plain text),
* internal app links (e.g. `[page](/p/{uuid})`) would otherwise become clickable
* `<a href="/p/...">`, leaking internal UUIDs/structure and linking to auth-gated
* routes. With the flag ON those links are made inert (href removed) while the
* visible text and the rest of the markdown formatting are preserved; genuinely
* EXTERNAL http(s) links (a DIFFERENT host than the app's own origin) are kept
* with a safe rel/target, while absolute links back to our OWN origin are
* neutralized too. With the flag OFF (internal default) links keep their href so
* the authenticated chat is unchanged.
*/
/** Parse the rendered HTML and return the first <a> element (or null). */
function firstAnchor(html: string): HTMLAnchorElement | null {
const doc = new DOMParser().parseFromString(html, "text/html");
return doc.querySelector("a");
}
describe("renderChatMarkdown — internal link neutralization", () => {
it("makes an internal link inert when the flag is ON (no href, text kept)", () => {
const html = renderChatMarkdown("[x](/p/abc)", {
neutralizeInternalLinks: true,
});
const a = firstAnchor(html);
expect(a).not.toBeNull();
expect(a!.hasAttribute("href")).toBe(false);
expect(a!.hasAttribute("target")).toBe(false);
// Visible link text is preserved.
expect(a!.textContent).toBe("x");
});
it("neutralizes bare-fragment links when the flag is ON", () => {
const html = renderChatMarkdown("[here](#section)", {
neutralizeInternalLinks: true,
});
const a = firstAnchor(html);
expect(a).not.toBeNull();
expect(a!.hasAttribute("href")).toBe(false);
});
it("keeps an external http(s) link with a safe rel/target when the flag is ON", () => {
const html = renderChatMarkdown("[y](https://example.com/x)", {
neutralizeInternalLinks: true,
});
const a = firstAnchor(html);
expect(a).not.toBeNull();
expect(a!.getAttribute("href")).toBe("https://example.com/x");
expect(a!.getAttribute("rel")).toBe("noopener noreferrer nofollow");
expect(a!.getAttribute("target")).toBe("_blank");
});
it("neutralizes an absolute link to our OWN origin when the flag is ON", () => {
// An LLM can emit an absolute URL back at our own host (e.g.
// `http://self/p/{uuid}`); it is internal and must be made inert just like a
// relative `/p/...` link, not kept clickable as if it were external.
const ownOrigin = `${window.location.origin}/p/abc`;
const html = renderChatMarkdown(`[x](${ownOrigin})`, {
neutralizeInternalLinks: true,
});
const a = firstAnchor(html);
expect(a).not.toBeNull();
expect(a!.hasAttribute("href")).toBe(false);
expect(a!.hasAttribute("target")).toBe(false);
expect(a!.textContent).toBe("x");
});
it("neutralizes dangerous/unsafe schemes when the flag is ON", () => {
// javascript:, data:, and protocol-relative `//...` must never stay
// clickable on the anonymous share — they are not genuinely external
// http(s) links to a different host, so the href is dropped (or sanitized
// away entirely by DOMPurify).
for (const markdown of [
"[a](javascript:alert(1))",
"[b](data:text/html,<script>alert(1)</script>)",
"[c](//evil.com/x)",
]) {
const html = renderChatMarkdown(markdown, {
neutralizeInternalLinks: true,
});
const a = firstAnchor(html);
// Either the anchor was stripped of its href, or DOMPurify removed the
// unsafe href outright; in both cases nothing dangerous remains.
if (a !== null) {
expect(a.hasAttribute("href")).toBe(false);
expect(a.hasAttribute("target")).toBe(false);
}
}
});
it("keeps internal links clickable when the flag is OFF (internal default)", () => {
const html = renderChatMarkdown("[x](/p/abc)");
const a = firstAnchor(html);
expect(a).not.toBeNull();
expect(a!.getAttribute("href")).toBe("/p/abc");
});
it("keeps an absolute own-origin link clickable when the flag is OFF (internal default)", () => {
const ownOrigin = `${window.location.origin}/p/abc`;
const html = renderChatMarkdown(`[x](${ownOrigin})`);
const a = firstAnchor(html);
expect(a).not.toBeNull();
expect(a!.getAttribute("href")).toBe(ownOrigin);
});
it("does not leave a global DOMPurify hook that affects a later internal render", () => {
// A neutralizing render first, then an internal render: the internal link
// must survive (the hook is removed after the share render).
renderChatMarkdown("[x](/p/abc)", { neutralizeInternalLinks: true });
const html = renderChatMarkdown("[x](/p/abc)");
const a = firstAnchor(html);
expect(a!.getAttribute("href")).toBe("/p/abc");
});
});

View File

@@ -1,6 +1,66 @@
import { markdownToHtml } from "@docmost/editor-ext";
import DOMPurify from "dompurify";
export interface RenderChatMarkdownOptions {
/**
* Neutralize INTERNAL links so they render as inert text (no `href`/`target`).
* Used by the anonymous public share: the assistant's answer can contain
* relative app links (e.g. `[page](/p/{uuid})`, `[settings](/settings/members)`)
* that would otherwise become clickable `<a href="/p/...">`, leaking internal
* UUIDs/structure and pointing at auth-gated routes. An anonymous reader can
* still follow genuinely EXTERNAL `http(s)` links (a DIFFERENT host than the
* app's own origin), so those are kept (with a safe `rel`/`target`); absolute
* links back to our OWN origin (e.g. `https://self/p/{uuid}`) are internal and
* neutralized too. Defaults to false — the internal chat keeps internal links
* clickable for authenticated users.
*/
neutralizeInternalLinks?: boolean;
}
/**
* Whether `href` points at an EXTERNAL absolute URL we are happy for an
* anonymous reader to follow. A link qualifies only if it is absolute
* `http(s)://` AND its host differs from the app's own origin
* (`window.location.host`): absolute links back to our OWN host (e.g.
* `https://self/p/{uuid}`) are internal and must be neutralized, exactly like
* relative `/p/...` links. Everything else (relative `/...`, bare fragments
* `#...`, protocol-relative `//...`, other schemes, or anything that does not
* parse) is treated as internal/unsafe and neutralized — fail closed.
*/
function isExternalHttpUrl(href: string): boolean {
const value = href.trim();
if (!/^https?:\/\//i.test(value)) return false;
try {
// External only if it points at a DIFFERENT host than the app's own origin.
// Absolute links back to our own host (e.g. https://self/p/{uuid}) are
// internal and must be neutralized, same as relative `/p/...` links.
return new URL(value).host !== window.location.host;
} catch {
return false; // unparseable -> treat as internal/unsafe, neutralize
}
}
/**
* DOMPurify `afterSanitizeAttributes` hook that neutralizes internal links.
* Hooks are GLOBAL on the DOMPurify instance, so this is only ever registered
* for the duration of a single sanitize call (added then removed in
* `renderChatMarkdown`) — it must never leak into the internal chat's renders.
*/
function neutralizeInternalLinksHook(node: Element): void {
if (node.nodeName !== "A") return;
const href = node.getAttribute("href");
if (href !== null && isExternalHttpUrl(href)) {
// Genuinely external link: keep it, but force a safe rel/target.
node.setAttribute("rel", "noopener noreferrer nofollow");
node.setAttribute("target", "_blank");
return;
}
// Internal/relative/fragment link (or no href): make it inert text. Drop the
// href and any target so it is no longer clickable; the visible text stays.
node.removeAttribute("href");
node.removeAttribute("target");
}
/**
* Render AI markdown to sanitized HTML for read-only display. We reuse the
* app's `markdownToHtml` (the same `marked` pipeline used for paste/import) so
@@ -12,9 +72,31 @@ import DOMPurify from "dompurify";
* synchronously, but we guard the Promise case by returning a safe empty string
* for that branch (the caller renders the raw text fallback instead).
*/
export function renderChatMarkdown(markdown: string): string {
export function renderChatMarkdown(
markdown: string,
options: RenderChatMarkdownOptions = {},
): string {
if (!markdown) return "";
const html = markdownToHtml(markdown);
if (typeof html !== "string") return "";
return DOMPurify.sanitize(html);
if (!options.neutralizeInternalLinks) {
// Internal chat: unchanged behavior, no hook registered.
return DOMPurify.sanitize(html);
}
// Public share: register the neutralization hook only for THIS sanitize call,
// then remove it immediately so it can never affect the internal chat (hooks
// are global on the shared DOMPurify instance).
DOMPurify.addHook("afterSanitizeAttributes", neutralizeInternalLinksHook);
try {
return DOMPurify.sanitize(html);
} finally {
// Remove by reference (not a bare pop) so we only ever remove OUR hook,
// robust to any other afterSanitizeAttributes hook registered in future.
DOMPurify.removeHook(
"afterSanitizeAttributes",
neutralizeInternalLinksHook,
);
}
}

View File

@@ -0,0 +1,100 @@
import { describe, it, expect } from "vitest";
import {
toolCitations,
toolRunState,
type ToolUiPart,
} from "./tool-parts";
describe("toolCitations", () => {
it("emits one citation per searchPages item with a /p/{id} href", () => {
const part: ToolUiPart = {
type: "tool-searchPages",
state: "output-available",
output: [
{ id: "p1", title: "First" },
{ id: "p2", title: "Second" },
],
};
expect(toolCitations(part)).toEqual([
{ pageId: "p1", title: "First", href: "/p/p1" },
{ pageId: "p2", title: "Second", href: "/p/p2" },
]);
});
it("drops searchPages items missing an id", () => {
const part: ToolUiPart = {
type: "tool-searchPages",
state: "output-available",
output: [{ title: "No id here" }, { id: "p2", title: "Kept" }],
};
expect(toolCitations(part)).toEqual([
{ pageId: "p2", title: "Kept", href: "/p/p2" },
]);
});
it("falls back to input.pageId / input.title for a page-op with only pageId", () => {
// The mutating tools echo `pageId` (no `id`); title is taken from the input.
const part: ToolUiPart = {
type: "tool-updatePageContent",
state: "output-available",
input: { pageId: "host-1", title: "From input" },
output: { pageId: "host-1" },
};
expect(toolCitations(part)).toEqual([
{ pageId: "host-1", title: "From input", href: "/p/host-1" },
]);
});
it("prefers output.id over input.pageId when both exist", () => {
const part: ToolUiPart = {
type: "tool-getPage",
state: "output-available",
input: { pageId: "input-id", title: "Input title" },
output: { id: "output-id", title: "Output title" },
};
expect(toolCitations(part)).toEqual([
{ pageId: "output-id", title: "Output title", href: "/p/output-id" },
]);
});
it("returns [] when the state is not output-available", () => {
const part: ToolUiPart = {
type: "tool-getPage",
state: "input-available",
output: { id: "p1", title: "Pending" },
};
expect(toolCitations(part)).toEqual([]);
});
it("returns [] for a page-op output with no resolvable id", () => {
const part: ToolUiPart = {
type: "tool-getPage",
state: "output-available",
input: {},
output: { title: "Only a title" },
};
expect(toolCitations(part)).toEqual([]);
});
});
describe("toolRunState", () => {
it('maps "output-error" to error', () => {
expect(toolRunState("output-error")).toBe("error");
});
it('maps "output-denied" to error', () => {
expect(toolRunState("output-denied")).toBe("error");
});
it('maps "output-available" to done', () => {
expect(toolRunState("output-available")).toBe("done");
});
it('maps "input-available" to running', () => {
expect(toolRunState("input-available")).toBe("running");
});
it("maps undefined to running", () => {
expect(toolRunState(undefined)).toBe("running");
});
});