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:
vvzvlad
2026-06-17 04:28:29 +03:00
parent 44b340dc1a
commit a4b7919753
44 changed files with 2633 additions and 122 deletions

View File

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

View File

@@ -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);

View File

@@ -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 (

View File

@@ -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>
);

View File

@@ -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>
);
}

View File

@@ -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);

View File

@@ -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>
);

View File

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