fix(ai-chat): OpenAI Chat Completions for multi-turn + provider settings, stream UX & errors" -m "Live-stand fixes (OpenRouter / OpenAI-compatible):
- openai provider: use .chat() (Chat Completions) instead of the default callable (Responses API), which gateways reject on multi-turn -> 400. - updateAiProviderSettings: assemble settings.ai.provider via jsonb_build_object with ::text-cast bound params + jsonb_typeof self-heal (postgres.js was double-encoding it into an array; the ::text cast avoids 'could not determine data type of parameter'). - chat agent: drop the hard maxOutputTokens cap (truncated complex tool calls); keep a tiny cap only on the test-connection ping. - testConnection + chat stream: surface the real provider error (statusCode+message) to logs and the UI instead of generic masks; never log the API key. - chat UI: typing indicator, incremental streaming render, tool 'running' status, Stop. Also bundled (prior uncommitted ai-chat work): - history 'AI agent' provenance badge; vector RAG (pgvector image + page_embeddings + AI_QUEUE indexer + space-scoped semanticSearch); external MCP servers backend (@ai-sdk/mcp client, SSRF IP-pinning, encrypted headers, admin CRUD/Test); yjs duplicate-instance fix via pnpm patch (single CJS instance server-side).
This commit is contained in:
@@ -1101,6 +1101,7 @@
|
||||
"Create subpage of {{name}}": "Create subpage of {{name}}",
|
||||
"AI chat": "AI chat",
|
||||
"AI agent": "AI agent",
|
||||
"AI agent is typing…": "AI agent is typing…",
|
||||
"Send": "Send",
|
||||
"Stop": "Stop",
|
||||
"Chat menu": "Chat menu",
|
||||
@@ -1123,5 +1124,7 @@
|
||||
"Deleted page (to trash)": "Deleted page (to trash)",
|
||||
"Commented": "Commented",
|
||||
"Resolved comment": "Resolved comment",
|
||||
"Ran tool {{name}}": "Ran tool {{name}}"
|
||||
"Ran tool {{name}}": "Ran tool {{name}}",
|
||||
"AI-agent": "AI-agent",
|
||||
"Edited by AI agent on behalf of {{name}}": "Edited by AI agent on behalf of {{name}}"
|
||||
}
|
||||
|
||||
@@ -52,6 +52,52 @@
|
||||
padding-inline-start: 1.4em;
|
||||
}
|
||||
|
||||
/* Animated three-dot "typing" indicator shown while the agent is thinking but
|
||||
has not yet produced any visible text/tool parts. */
|
||||
.typingDots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.typingDots span {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--mantine-color-dimmed, var(--mantine-color-gray-5));
|
||||
opacity: 0.4;
|
||||
animation: aiTypingBounce 1.2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.typingDots span:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.typingDots span:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes aiTypingBounce {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.4;
|
||||
}
|
||||
40% {
|
||||
transform: translateY(-3px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Respect reduced-motion preferences: fall back to a static dimmed state. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.typingDots span {
|
||||
animation: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.toolCard {
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
|
||||
@@ -21,6 +21,11 @@ function isToolPart(type: string): boolean {
|
||||
* - `tool-*` / `dynamic-tool` parts -> an action-log card (with citations).
|
||||
* Other part kinds (reasoning, sources, files, step-start) are ignored for v1.
|
||||
* User messages render their text as a right-aligned plain bubble.
|
||||
*
|
||||
* This component is intentionally NOT memoized: `useChat` replaces the streaming
|
||||
* assistant message with a freshly cloned object on every streamed delta, so the
|
||||
* `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) {
|
||||
const { t } = useTranslation();
|
||||
@@ -47,6 +52,10 @@ export default function MessageItem({ message }: MessageItemProps) {
|
||||
</Text>
|
||||
{message.parts.map((part, index) => {
|
||||
if (part.type === "text") {
|
||||
// Skip empty/whitespace-only text parts (a streaming message often
|
||||
// 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);
|
||||
if (html) {
|
||||
return (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Center, ScrollArea, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import MessageItem from "@/features/ai-chat/components/message-item.tsx";
|
||||
import TypingIndicator from "@/features/ai-chat/components/typing-indicator.tsx";
|
||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
interface MessageListProps {
|
||||
@@ -10,20 +11,47 @@ interface MessageListProps {
|
||||
isStreaming: boolean;
|
||||
}
|
||||
|
||||
/** True for AI SDK tool parts (static `tool-*` or `dynamic-tool`). */
|
||||
function isToolPart(type: string): boolean {
|
||||
return type.startsWith("tool-") || type === "dynamic-tool";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to show the standalone "AI agent is typing…" indicator. It bridges the
|
||||
* gap between sending and the first streamed content, so it shows only while a
|
||||
* turn is in flight AND the latest assistant message has nothing visible yet:
|
||||
* - the last message is still the user's (assistant hasn't started a row), or
|
||||
* - 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 {
|
||||
if (!isStreaming) return false;
|
||||
const last = messages[messages.length - 1];
|
||||
if (!last) return true; // submitted with nothing rendered yet.
|
||||
if (last.role !== "assistant") return true; // assistant row not started.
|
||||
const hasVisible = last.parts.some(
|
||||
(p) =>
|
||||
(p.type === "text" && p.text.trim().length > 0) || isToolPart(p.type),
|
||||
);
|
||||
return !hasVisible;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrollable transcript. Auto-scrolls to the newest message as it streams in
|
||||
* (re-runs whenever the message count or the streaming flag changes).
|
||||
* (re-runs whenever the message count, the streaming flag, or the messages array
|
||||
* identity changes — the latter updates on every streamed delta).
|
||||
*/
|
||||
export default function MessageList({ messages, isStreaming }: MessageListProps) {
|
||||
const { t } = useTranslation();
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const typing = showTypingIndicator(messages, isStreaming);
|
||||
|
||||
useEffect(() => {
|
||||
const el = viewportRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [messages.length, isStreaming, messages]);
|
||||
}, [messages.length, isStreaming, messages, typing]);
|
||||
|
||||
if (messages.length === 0) {
|
||||
if (messages.length === 0 && !typing) {
|
||||
return (
|
||||
<Center className={classes.messages}>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
@@ -39,6 +67,7 @@ export default function MessageList({ messages, isStreaming }: MessageListProps)
|
||||
{messages.map((message) => (
|
||||
<MessageItem key={message.id} message={message} />
|
||||
))}
|
||||
{typing && <TypingIndicator />}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Box, Group, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
/**
|
||||
* Live "AI agent is typing…" placeholder shown while a turn is in flight but the
|
||||
* latest assistant message has no visible content yet (no rendered text/tool
|
||||
* parts). It covers the gap between sending and the first streamed token, and is
|
||||
* replaced by the real assistant message once content starts arriving.
|
||||
*
|
||||
* Mirrors the assistant row layout in MessageItem (the dimmed "AI agent" label),
|
||||
* so it reads as the assistant's bubble taking shape.
|
||||
*/
|
||||
export default function TypingIndicator() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box className={classes.messageRow}>
|
||||
<Text size="xs" c="dimmed" mb={4}>
|
||||
{t("AI agent")}
|
||||
</Text>
|
||||
<Group gap={8} align="center">
|
||||
<span className={classes.typingDots} aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("AI agent is typing…")}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
|
||||
/**
|
||||
* Presentation helpers for AI SDK tool UI parts. The agent writes WITHOUT
|
||||
* confirmation (D2), so a tool part is a LOG of what already happened — never a
|
||||
@@ -30,7 +28,13 @@ export type ToolRunState = "running" | "done" | "error";
|
||||
export interface ToolCitation {
|
||||
pageId: string;
|
||||
title?: string;
|
||||
/** Internal route; `/p/{slug}-{id}` resolves via PageRedirect by slugId. */
|
||||
/**
|
||||
* Internal route. The server tools return the page UUID (no slugId), so we
|
||||
* link to `/p/{uuid}` directly — `extractPageSlugId` treats a bare UUID as
|
||||
* valid and returns it whole, which `PageRedirect` then resolves. The title
|
||||
* is the visible label only and must NOT be folded into the slug (that would
|
||||
* mangle the UUID via the trailing-segment split and 404 the link).
|
||||
*/
|
||||
href: string;
|
||||
}
|
||||
|
||||
@@ -89,9 +93,10 @@ function asString(value: unknown): string | undefined {
|
||||
/**
|
||||
* Resolve the page citation(s) a tool part references, from its input/output.
|
||||
* Only output-available parts (the tool returned) yield citations. Search
|
||||
* returns an array of pages; the page-ops return a single page id. We build the
|
||||
* link from the page id alone — the `/p/{slug}-{id}` route resolves the page by
|
||||
* its slugId (PageRedirect), so the space slug is not needed here.
|
||||
* returns an array of pages; the page-ops return a single page id. We link to
|
||||
* `/p/{id}` with the raw page UUID — `PageRedirect` resolves it via
|
||||
* `extractPageSlugId` (which returns a bare UUID unchanged), so the space slug
|
||||
* and a title slug are not needed here.
|
||||
*/
|
||||
export function toolCitations(part: ToolUiPart): ToolCitation[] {
|
||||
if (part.state !== "output-available") return [];
|
||||
@@ -101,7 +106,7 @@ export function toolCitations(part: ToolUiPart): ToolCitation[] {
|
||||
|
||||
const push = (id: string | undefined, title?: string): void => {
|
||||
if (!id) return;
|
||||
citations.push({ pageId: id, title, href: buildPageUrl(undefined, id, title) });
|
||||
citations.push({ pageId: id, title, href: `/p/${id}` });
|
||||
};
|
||||
|
||||
const toolName = getToolName(part);
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
|
||||
import { Text, Group, UnstyledButton, Avatar, Tooltip, Badge } from "@mantine/core";
|
||||
import { IconSparkles } from "@tabler/icons-react";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { formattedDate } from "@/lib/time";
|
||||
import classes from "./css/history.module.css";
|
||||
import clsx from "clsx";
|
||||
import { IPageHistory } from "@/features/page-history/types/page.types";
|
||||
import { memo, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import { activeAiChatIdAtom } from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||
|
||||
const MAX_VISIBLE_AVATARS = 5;
|
||||
|
||||
@@ -17,6 +23,77 @@ interface HistoryItemProps {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Badge marking a version written by the AI agent (provenance C3 / §7.4). It is
|
||||
* ADDITIVE — shown next to the human author, never replacing them. When the
|
||||
* version carries an `aiChatId`, clicking the badge deep-links into that chat:
|
||||
* it sets the active-chat atom, opens the AI-chat aside tab, and closes the
|
||||
* history modal. The click is contained (stopPropagation) so it does not also
|
||||
* trigger the row's version-select.
|
||||
*/
|
||||
function AiAgentBadge({
|
||||
authorName,
|
||||
aiChatId,
|
||||
}: {
|
||||
authorName?: string;
|
||||
aiChatId?: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const setAsideState = useSetAtom(asideStateAtom);
|
||||
const setActiveChatId = useSetAtom(activeAiChatIdAtom);
|
||||
const setHistoryModalOpen = useSetAtom(historyAtoms);
|
||||
|
||||
const tooltip = t("Edited by AI agent on behalf of {{name}}", {
|
||||
name: authorName ?? "",
|
||||
});
|
||||
|
||||
const openChat = useCallback(
|
||||
(event: React.SyntheticEvent) => {
|
||||
event.stopPropagation();
|
||||
if (!aiChatId) return;
|
||||
setActiveChatId(aiChatId);
|
||||
setAsideState({ tab: "ai-chat", isAsideOpen: true });
|
||||
setHistoryModalOpen(false);
|
||||
},
|
||||
[aiChatId, setActiveChatId, setAsideState, setHistoryModalOpen],
|
||||
);
|
||||
|
||||
const badge = (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="violet"
|
||||
radius="sm"
|
||||
leftSection={<IconSparkles size={12} stroke={2} />}
|
||||
style={aiChatId ? { cursor: "pointer" } : undefined}
|
||||
{...(aiChatId
|
||||
? {
|
||||
// Keep the default Badge root element (not a <button>) to avoid an
|
||||
// invalid <button>-in-<button> nesting inside the history row's
|
||||
// UnstyledButton; expose it as an accessible button via role/keyboard.
|
||||
role: "button",
|
||||
tabIndex: 0,
|
||||
onClick: openChat,
|
||||
onKeyDown: (event: React.KeyboardEvent) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
openChat(event);
|
||||
}
|
||||
},
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
{t("AI-agent")}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip label={tooltip} withArrow>
|
||||
{badge}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const HistoryItem = memo(function HistoryItem({
|
||||
historyItem,
|
||||
index,
|
||||
@@ -35,6 +112,7 @@ const HistoryItem = memo(function HistoryItem({
|
||||
|
||||
const contributors = historyItem.contributors;
|
||||
const hasContributors = contributors && contributors.length > 0;
|
||||
const isAgentEdit = historyItem.lastUpdatedSource === "agent";
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
@@ -92,6 +170,13 @@ const HistoryItem = memo(function HistoryItem({
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isAgentEdit && (
|
||||
<AiAgentBadge
|
||||
authorName={historyItem.lastUpdatedBy?.name}
|
||||
aiChatId={historyItem.lastUpdatedAiChatId}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
|
||||
@@ -19,4 +19,9 @@ export interface IPageHistory {
|
||||
updatedAt: string;
|
||||
lastUpdatedBy: IPageHistoryUser;
|
||||
contributors?: IPageHistoryUser[];
|
||||
// Provenance markers copied off the page row when the snapshot was saved.
|
||||
// `'agent'` marks a version written by the AI agent; `lastUpdatedAiChatId`
|
||||
// (when present) deep-links to the chat that produced the edit.
|
||||
lastUpdatedSource?: string;
|
||||
lastUpdatedAiChatId?: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user