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}>