Merge remote-tracking branch 'gitea/develop' into fix/ai-chat-current-page
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user