Compare commits
14 Commits
96a1bda8d0
...
623c89554a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
623c89554a | ||
|
|
f7b99f9fb3 | ||
| e97024343a | |||
|
|
43cf1913e0 | ||
| 9225eeeeed | |||
|
|
044e3f7e6a | ||
|
|
99359fa0fa | ||
|
|
7325eeac19 | ||
| b321bbafc4 | |||
|
|
5519f4b23b | ||
|
|
0ebb1adce8 | ||
|
|
8ef66ba712 | ||
|
|
0ec0af405a | ||
|
|
3662d21c99 |
13
AGENTS.md
13
AGENTS.md
@@ -157,6 +157,19 @@ below.
|
||||
| `origin` | GitHub mirror `vvzvlad/gitmost` — **do not push**, updated by the owner's CI |
|
||||
| `upstream` | The original Docmost — **never push** |
|
||||
|
||||
## Creating issues (Gitea `tea` CLI)
|
||||
|
||||
Issues are filed with the official Gitea CLI `tea`, already logged in as
|
||||
`claude_code` (`tea logins list` shows the `gitea` login as default):
|
||||
|
||||
```bash
|
||||
tea issues create --repo vvzvlad/gitmost --labels feature \
|
||||
--title '<title>' --description "$(cat body.md)"
|
||||
```
|
||||
|
||||
> Gotcha (tea 0.14.1): the issue body flag is `--description`/`-d`, **not**
|
||||
> `--body` — passing `--body` fails with `flag provided but not defined: -body`.
|
||||
|
||||
---
|
||||
|
||||
# Architecture and codebase
|
||||
|
||||
@@ -1147,6 +1147,12 @@
|
||||
"Ask a question about this documentation.": "Ask a question about this documentation.",
|
||||
"Ask a question…": "Ask a question…",
|
||||
"Thinking…": "Thinking…",
|
||||
"Thinking… · {{count}} tokens": "Thinking… · {{count}} tokens",
|
||||
"Thinking… · {{count}} tokens_one": "Thinking… · {{count}} token",
|
||||
"Thinking… · {{count}} tokens_other": "Thinking… · {{count}} tokens",
|
||||
"Thinking · {{count}} tokens": "Thinking · {{count}} tokens",
|
||||
"Thinking · {{count}} tokens_one": "Thinking · {{count}} token",
|
||||
"Thinking · {{count}} tokens_other": "Thinking · {{count}} tokens",
|
||||
"The assistant is unavailable right now. Please try again.": "The assistant is unavailable right now. Please try again.",
|
||||
"Public share assistant": "Public share assistant",
|
||||
"Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.": "Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.",
|
||||
@@ -1158,6 +1164,7 @@
|
||||
"Built-in assistant persona": "Built-in assistant persona",
|
||||
"Minimize": "Minimize",
|
||||
"Current context size": "Current context size",
|
||||
"Tokens generated this turn": "Tokens generated this turn",
|
||||
"AI agent": "AI agent",
|
||||
"Take a look at the current document": "Take a look at the current document",
|
||||
"AI agent is typing…": "AI agent is typing…",
|
||||
@@ -1266,6 +1273,10 @@
|
||||
"Optional. Defaults to the workspace model.": "Optional. Defaults to the workspace model.",
|
||||
"e.g. gpt-4o-mini": "e.g. gpt-4o-mini",
|
||||
"If you choose a different provider, it must already be configured in AI settings.": "If you choose a different provider, it must already be configured in AI settings.",
|
||||
"Start automatically": "Start automatically",
|
||||
"When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.": "When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.",
|
||||
"Launch message": "Launch message",
|
||||
"Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.": "Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.",
|
||||
"Agent roles": "Agent roles",
|
||||
"Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.": "Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.",
|
||||
"No roles configured": "No roles configured",
|
||||
@@ -1287,5 +1298,14 @@
|
||||
"Analytics / tracker": "Analytics / tracker",
|
||||
"Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.": "Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.",
|
||||
"Go to login page": "Go to login page",
|
||||
"Move to space": "Move to space"
|
||||
"Move to space": "Move to space",
|
||||
"Float left (wrap text)": "Float left (wrap text)",
|
||||
"Float right (wrap text)": "Float right (wrap text)",
|
||||
"Switch to tree": "Switch to tree",
|
||||
"Switch to flat list": "Switch to flat list",
|
||||
"Toggle subpages display mode": "Toggle subpages display mode",
|
||||
"Page tree (child pages, recursive)": "Page tree (child pages, recursive)",
|
||||
"Render the full nested tree of all descendant pages": "Render the full nested tree of all descendant pages",
|
||||
"Showing {{count}} subpages_one": "Showing {{count}} subpage",
|
||||
"Showing {{count}} subpages_other": "Showing {{count}} subpages"
|
||||
}
|
||||
|
||||
@@ -677,9 +677,21 @@
|
||||
"Ask AI": "Спросить ИИ",
|
||||
"AI agent": "AI-агент",
|
||||
"Take a look at the current document": "Посмотри текущий документ",
|
||||
"Start automatically": "Запускать автоматически",
|
||||
"When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.": "Когда включено, выбор этой роли отправляет стартовое сообщение и начинает чат. Когда выключено, роль выбирается, а первое сообщение вы вводите сами.",
|
||||
"Launch message": "Стартовое сообщение",
|
||||
"Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.": "Отправляется автоматически при выборе этой роли. Оставьте пустым, чтобы использовать текст по умолчанию. Игнорируется, когда «Запускать автоматически» выключено.",
|
||||
"AI agent is typing…": "AI-агент печатает…",
|
||||
"{{name}} is typing…": "{{name}} печатает…",
|
||||
"Thinking…": "Думаю…",
|
||||
"Thinking… · {{count}} tokens": "Думаю… · {{count}} токенов",
|
||||
"Thinking… · {{count}} tokens_one": "Думаю… · {{count}} токен",
|
||||
"Thinking… · {{count}} tokens_few": "Думаю… · {{count}} токена",
|
||||
"Thinking… · {{count}} tokens_many": "Думаю… · {{count}} токенов",
|
||||
"Thinking · {{count}} tokens": "Размышления · {{count}} токенов",
|
||||
"Thinking · {{count}} tokens_one": "Размышления · {{count}} токен",
|
||||
"Thinking · {{count}} tokens_few": "Размышления · {{count}} токена",
|
||||
"Thinking · {{count}} tokens_many": "Размышления · {{count}} токенов",
|
||||
"Agent role": "Роль агента",
|
||||
"AI chat": "AI-чат",
|
||||
"AI chat is disabled for this workspace.": "AI-чат отключён для этого рабочего пространства.",
|
||||
@@ -690,6 +702,7 @@
|
||||
"Copy chat": "Копировать чат",
|
||||
"Created successfully": "Успешно создано",
|
||||
"Current context size": "Текущий размер контекста",
|
||||
"Tokens generated this turn": "Токенов сгенерировано за ход",
|
||||
"Delete this chat?": "Удалить этот чат?",
|
||||
"Deleted successfully": "Успешно удалено",
|
||||
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
|
||||
@@ -1137,5 +1150,15 @@
|
||||
"Create subpage of {{name}}": "Создать подстраницу для {{name}}",
|
||||
"Dictation language": "Язык диктовки",
|
||||
"Auto-detect": "Автоопределение",
|
||||
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью."
|
||||
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью.",
|
||||
"Float left (wrap text)": "Обтекание слева",
|
||||
"Float right (wrap text)": "Обтекание справа",
|
||||
"Switch to tree": "Переключить на дерево",
|
||||
"Switch to flat list": "Переключить на плоский список",
|
||||
"Toggle subpages display mode": "Переключить режим отображения подстраниц",
|
||||
"Page tree (child pages, recursive)": "Дерево страниц (дочерние, рекурсивно)",
|
||||
"Render the full nested tree of all descendant pages": "Показать полное вложенное дерево всех дочерних страниц",
|
||||
"Showing {{count}} subpages_one": "Показано {{count}} подстраница",
|
||||
"Showing {{count}} subpages_few": "Показано {{count}} подстраницы",
|
||||
"Showing {{count}} subpages_many": "Показано {{count}} подстраниц"
|
||||
}
|
||||
|
||||
@@ -156,6 +156,12 @@ export default function AiChatWindow() {
|
||||
isStreaming: false,
|
||||
});
|
||||
|
||||
// Live turn-token total (reasoning + output) for the in-flight turn, pushed up
|
||||
// (THROTTLED to ~8 Hz inside ChatThread) so the header badge ticks mid-stream.
|
||||
// `null` means no turn is in flight -> the badge falls back to the persisted
|
||||
// context size below.
|
||||
const [liveTurnTokens, setLiveTurnTokens] = useState<number | null>(null);
|
||||
|
||||
// The page the user is currently viewing. AiChatWindow lives in a pathless
|
||||
// parent layout route, so useParams() can't see :pageSlug. Match the full
|
||||
// pathname against the authenticated page route instead so "the current page"
|
||||
@@ -485,11 +491,19 @@ export default function AiChatWindow() {
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
|
||||
{contextTokens > 0 && (
|
||||
{/* While a turn streams, show the LIVE turn-token count (ticks ~8 Hz);
|
||||
once it finishes, fall back to the persisted context size. Require
|
||||
> 0 so the very first emit (an empty tail message, count 0) does not
|
||||
flash a "0" badge before any token streams in (#151 review). */}
|
||||
{liveTurnTokens !== null && liveTurnTokens > 0 ? (
|
||||
<Tooltip label={t("Tokens generated this turn")} withArrow>
|
||||
<span className={classes.badge}>{formatTokens(liveTurnTokens)}</span>
|
||||
</Tooltip>
|
||||
) : contextTokens > 0 ? (
|
||||
<Tooltip label={t("Current context size")} withArrow>
|
||||
<span className={classes.badge}>{formatTokens(contextTokens)}</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
@@ -608,6 +622,7 @@ export default function AiChatWindow() {
|
||||
assistantName={currentRole?.name}
|
||||
onTurnFinished={onTurnFinished}
|
||||
liveStateRef={liveThreadRef}
|
||||
onLiveTurnTokens={setLiveTurnTokens}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -111,6 +111,24 @@
|
||||
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
/* Collapsible "Thinking" (reasoning) block: a subtle left rule, dimmer than the
|
||||
answer so it reads as secondary thinking context above the real answer. */
|
||||
.reasoningBlock {
|
||||
border-left: 2px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.reasoningText {
|
||||
margin-top: 4px;
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.reasoningText p {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.inputWrapper {
|
||||
flex: 0 0 auto;
|
||||
padding-top: var(--mantine-spacing-xs);
|
||||
|
||||
@@ -21,8 +21,13 @@ import {
|
||||
IAiChatMessageRow,
|
||||
IAiRole,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import {
|
||||
roleLaunchMessage,
|
||||
shouldResetRolePicked,
|
||||
} from "@/features/ai-chat/utils/role-launch.ts";
|
||||
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
||||
import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts";
|
||||
import { liveTurnTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
||||
import {
|
||||
dequeue,
|
||||
enqueueMessage,
|
||||
@@ -69,6 +74,12 @@ interface ChatThreadProps {
|
||||
* assistant message. A ref (not state) avoids re-rendering the parent on
|
||||
* every streamed delta. */
|
||||
liveStateRef?: MutableRefObject<{ messages: UIMessage[]; isStreaming: boolean }>;
|
||||
/** Reports the live turn-token total (reasoning + output) for the in-flight
|
||||
* turn so the parent can show a header badge that ticks mid-stream. THROTTLED
|
||||
* here (~8 Hz) so the parent re-renders a handful of times a second, not on
|
||||
* every streamed delta. Called with `null` when no turn is in flight (the
|
||||
* parent then reverts the badge to the persisted context size). */
|
||||
onLiveTurnTokens?: (tokens: number | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,6 +124,7 @@ export default function ChatThread({
|
||||
assistantName,
|
||||
onTurnFinished,
|
||||
liveStateRef,
|
||||
onLiveTurnTokens,
|
||||
}: ChatThreadProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -310,21 +322,98 @@ export default function ChatThread({
|
||||
};
|
||||
}, [liveStateRef, messages, isStreaming]);
|
||||
|
||||
// Report the live turn-token total to the parent header badge, THROTTLED to
|
||||
// ~8 Hz so the parent re-renders a few times a second instead of on every
|
||||
// streamed delta. The tail assistant message's reasoning+output (estimate while
|
||||
// streaming, authoritative once a step reports usage) is the live figure. When
|
||||
// the turn ends we emit a final exact value, then `null` so the parent reverts
|
||||
// the badge to the persisted context size.
|
||||
const lastEmitRef = useRef(0);
|
||||
const emitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
if (!onLiveTurnTokens) return;
|
||||
if (!isStreaming) {
|
||||
// Turn ended (or never started): clear any pending throttle and revert.
|
||||
if (emitTimerRef.current) {
|
||||
clearTimeout(emitTimerRef.current);
|
||||
emitTimerRef.current = null;
|
||||
}
|
||||
lastEmitRef.current = 0;
|
||||
onLiveTurnTokens(null);
|
||||
return;
|
||||
}
|
||||
const tail = messages[messages.length - 1];
|
||||
const live =
|
||||
tail?.role === "assistant" ? liveTurnTokens(tail) : null;
|
||||
const total = live ? live.reasoning + live.output : 0;
|
||||
const now = Date.now();
|
||||
const MIN_INTERVAL = 120; // ms (~8 Hz)
|
||||
const elapsed = now - lastEmitRef.current;
|
||||
if (elapsed >= MIN_INTERVAL) {
|
||||
lastEmitRef.current = now;
|
||||
onLiveTurnTokens(total);
|
||||
} else if (!emitTimerRef.current) {
|
||||
// Schedule a trailing emit so the FINAL value of a burst is not dropped.
|
||||
emitTimerRef.current = setTimeout(() => {
|
||||
emitTimerRef.current = null;
|
||||
lastEmitRef.current = Date.now();
|
||||
onLiveTurnTokens(total);
|
||||
}, MIN_INTERVAL - elapsed);
|
||||
}
|
||||
}, [messages, isStreaming, onLiveTurnTokens]);
|
||||
|
||||
// Clear any pending throttle timer on unmount (chat switch via `key`) so a
|
||||
// trailing emit can't fire into a torn-down thread's parent.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (emitTimerRef.current) clearTimeout(emitTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Classify the turn error into a heading + detail so the banner names the cause
|
||||
// (connection reset, timeout, rate limit, context overflow, quota, ...) instead
|
||||
// of a generic "Something went wrong".
|
||||
const errorView = error ? describeChatError(error.message ?? "", t) : null;
|
||||
|
||||
// Clicking a role card both binds the role to THIS new chat and immediately
|
||||
// starts the conversation. roleIdRef is set synchronously here because the
|
||||
// parent's selectedRoleId state update would only reach roleIdRef on the next
|
||||
// render — after this synchronous sendMessage has already read it.
|
||||
// A role was picked with autoStart=false: the role is bound but NOTHING was
|
||||
// sent, so chatId stays null and the empty state would keep showing the cards.
|
||||
// This flag hides the cards and reveals the composer (with the role indicated)
|
||||
// so the user can type the first message themselves. roleIdRef is already set,
|
||||
// so that first manual message carries the roleId.
|
||||
const [rolePickedNoSend, setRolePickedNoSend] = useState(false);
|
||||
|
||||
// Clicking a role card always binds the role to THIS new chat. Whether it also
|
||||
// auto-starts the conversation is per-role (autoStart). roleIdRef is set
|
||||
// synchronously here because the parent's selectedRoleId state update would
|
||||
// only reach roleIdRef on the next render — after this synchronous sendMessage
|
||||
// has already read it.
|
||||
const handleRolePick = (role: IAiRole): void => {
|
||||
roleIdRef.current = role.id;
|
||||
onRolePicked?.(role);
|
||||
sendMessage({ text: t("Take a look at the current document") });
|
||||
const launch = roleLaunchMessage(
|
||||
role,
|
||||
t("Take a look at the current document"),
|
||||
);
|
||||
if (launch !== null) {
|
||||
sendMessage({ text: launch });
|
||||
} else {
|
||||
// autoStart=false -> bind only: hide the cards, show the composer.
|
||||
setRolePickedNoSend(true);
|
||||
}
|
||||
};
|
||||
const showRoleCards = chatId === null && (roles?.length ?? 0) > 0;
|
||||
// Reset the "picked, not sent" flag when the thread returns to a truly empty,
|
||||
// role-less state — e.g. the user hit "New chat" after picking an autoStart=false
|
||||
// role. That path clears the parent's selectedRoleId (roleId -> null) but leaves
|
||||
// chatId null, so the thread never remounts and the flag would stay set, hiding
|
||||
// the cards forever. A picked-and-bound role keeps roleId non-null, so the cards
|
||||
// correctly stay hidden then. Render-phase reset (React "adjust state on prop
|
||||
// change"): one-shot — it re-renders with the flag false and the guard no longer
|
||||
// matches, so it cannot loop. (Review of #149.)
|
||||
if (shouldResetRolePicked(chatId, roleId, rolePickedNoSend)) {
|
||||
setRolePickedNoSend(false);
|
||||
}
|
||||
const showRoleCards =
|
||||
chatId === null && (roles?.length ?? 0) > 0 && !rolePickedNoSend;
|
||||
const roleCardsEmptyState = showRoleCards ? (
|
||||
<RoleCards roles={roles ?? []} onPick={handleRolePick} />
|
||||
) : undefined;
|
||||
|
||||
@@ -2,12 +2,14 @@ import { Box, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import ToolCallCard from "@/features/ai-chat/components/tool-call-card.tsx";
|
||||
import ReasoningBlock from "@/features/ai-chat/components/reasoning-block.tsx";
|
||||
import ChatErrorAlert from "@/features/ai-chat/components/chat-error-alert.tsx";
|
||||
import ChatStoppedNotice from "@/features/ai-chat/components/chat-stopped-notice.tsx";
|
||||
import { ToolUiPart, isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx";
|
||||
import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts";
|
||||
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||
import { resolveAssistantName } from "@/features/ai-chat/utils/assistant-name.ts";
|
||||
import { reasoningTokensForPart } from "@/features/ai-chat/utils/reasoning-tokens.ts";
|
||||
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
@@ -77,12 +79,31 @@ export default function MessageItem({
|
||||
// return won't fire for them.
|
||||
if (!assistantMessageHasVisibleContent(message)) return null;
|
||||
|
||||
// Authoritative reasoning token count to attribute to a reasoning block, or
|
||||
// undefined when the block must estimate on its own. See reasoningTokensForPart
|
||||
// for the #151 anti-double-count rule (only a single reasoning part may carry
|
||||
// the turn total). The authoritative turn total is still surfaced live in the
|
||||
// header badge regardless.
|
||||
const reasoningTokens = reasoningTokensForPart(message);
|
||||
|
||||
return (
|
||||
<Box className={classes.messageRow}>
|
||||
<Text size="xs" c="dimmed" mb={4}>
|
||||
{resolveAssistantName(assistantName) ?? t("AI agent")}
|
||||
</Text>
|
||||
{message.parts.map((part, index) => {
|
||||
if (part.type === "reasoning") {
|
||||
// Reasoning ("thinking") -> a collapsible block with its own token
|
||||
// count. Empty/whitespace reasoning with no authoritative count carries
|
||||
// nothing to show, so skip it (avoids an empty 0-token block).
|
||||
const text = (part as { text?: string }).text ?? "";
|
||||
if (!text.trim() && !(reasoningTokens && reasoningTokens > 0))
|
||||
return null;
|
||||
return (
|
||||
<ReasoningBlock key={index} text={text} tokens={reasoningTokens} />
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -6,6 +6,7 @@ import MessageItem from "@/features/ai-chat/components/message-item.tsx";
|
||||
import TypingIndicator from "@/features/ai-chat/components/typing-indicator.tsx";
|
||||
import { isToolPart, toolRunState, ToolUiPart } from "@/features/ai-chat/utils/tool-parts.tsx";
|
||||
import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts";
|
||||
import { liveTurnTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
interface MessageListProps {
|
||||
@@ -94,6 +95,19 @@ export function typingIndicatorShowsName(messages: UIMessage[]): boolean {
|
||||
return !assistantMessageHasVisibleContent(last);
|
||||
}
|
||||
|
||||
/**
|
||||
* The live thinking-token count to show on the standalone typing indicator. It
|
||||
* is the reasoning split of the tail assistant message (estimate while streaming,
|
||||
* authoritative once the server attaches usage at a step/turn boundary). Returns
|
||||
* 0 when the turn has produced no reasoning yet — the indicator then shows the
|
||||
* plain "Thinking…" line.
|
||||
*/
|
||||
export function tailThinkingTokens(messages: UIMessage[]): number {
|
||||
const last = messages[messages.length - 1];
|
||||
if (!last || last.role !== "assistant") return 0;
|
||||
return liveTurnTokens(last).reasoning;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrollable transcript. Auto-scrolls to the newest message as it streams in,
|
||||
* but only while the user is pinned to the bottom — if they scrolled up to read
|
||||
@@ -190,7 +204,13 @@ export default function MessageList({
|
||||
assistantName={assistantName}
|
||||
/>
|
||||
))}
|
||||
{typing && <TypingIndicator assistantName={assistantName} showName={typingIndicatorShowsName(messages)} />}
|
||||
{typing && (
|
||||
<TypingIndicator
|
||||
assistantName={assistantName}
|
||||
showName={typingIndicatorShowsName(messages)}
|
||||
thinkingTokens={tailThinkingTokens(messages)}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
// Stub react-i18next so `t` returns the key with `{{count}}` interpolated. This
|
||||
// keeps the assertions on the component's OWN count logic (authoritative vs
|
||||
// estimate) rather than on translation, and mirrors the t-mock pattern used by
|
||||
// other component tests in the repo.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: { count?: number }) =>
|
||||
opts && typeof opts.count === "number"
|
||||
? key.replace("{{count}}", String(opts.count))
|
||||
: key,
|
||||
}),
|
||||
}));
|
||||
|
||||
import ReasoningBlock from "./reasoning-block";
|
||||
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
||||
|
||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||
|
||||
function renderBlock(props: { text: string; tokens?: number }) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<ReasoningBlock {...props} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ReasoningBlock", () => {
|
||||
it("shows the authoritative count in the header when tokens > 0", () => {
|
||||
// Text "thinking…" estimates to ceil(9/4) = 3, but the authoritative 42
|
||||
// must win, so the header shows 42 (and NOT the 3-token estimate).
|
||||
renderBlock({ text: "thinking…", tokens: 42 });
|
||||
expect(screen.getByText("Thinking · 42 tokens")).toBeDefined();
|
||||
expect(screen.queryByText("Thinking · 3 tokens")).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the text-length estimate when no authoritative tokens", () => {
|
||||
const text = "some reasoning prose that streams in";
|
||||
const estimate = estimateTokens(text);
|
||||
renderBlock({ text });
|
||||
expect(estimate).toBeGreaterThan(0);
|
||||
expect(screen.getByText(new RegExp(`${estimate} tokens`))).toBeDefined();
|
||||
});
|
||||
|
||||
it("header-only when text is empty but an authoritative count is present", () => {
|
||||
renderBlock({ text: "", tokens: 17 });
|
||||
expect(screen.getByText(/17 tokens/)).toBeDefined();
|
||||
// No disclosure body to expand: the toggle button is disabled.
|
||||
const button = screen.getByRole("button");
|
||||
expect((button as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("renders the reasoning body (markdown or raw-text fallback)", () => {
|
||||
renderBlock({ text: "**bold** reasoning", tokens: 5 });
|
||||
// The toggle is enabled because there IS body text to expand.
|
||||
const button = screen.getByRole("button");
|
||||
expect((button as HTMLButtonElement).disabled).toBe(false);
|
||||
// The body prose renders (markdown -> sanitized html, or raw-text fallback);
|
||||
// either way the text is present in the document.
|
||||
expect(screen.getByText(/reasoning/)).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useState } from "react";
|
||||
import { Box, Collapse, Group, Text, UnstyledButton } from "@mantine/core";
|
||||
import { IconChevronDown } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
||||
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
interface ReasoningBlockProps {
|
||||
/** The streamed/persisted reasoning (thinking) text. May be empty when the
|
||||
* provider reports only a reasoning token COUNT without the text. */
|
||||
text: string;
|
||||
/** Authoritative reasoning token count from `usage.reasoningTokens`, when the
|
||||
* step/turn has finished. When absent (or 0) the count is estimated from the
|
||||
* text length so it ticks live as the reasoning streams in. */
|
||||
tokens?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsible "Thinking" block for an assistant `reasoning` part. Mirrors Claude
|
||||
* Code's surfacing of the model's thinking: a header that shows the thinking
|
||||
* token count (authoritative when the step has reported usage, else a live
|
||||
* estimate from the streamed text) and an expandable body with the reasoning
|
||||
* prose. Collapsed by default so it never crowds out the answer.
|
||||
*
|
||||
* Providers that don't stream reasoning TEXT still render this block from the
|
||||
* authoritative count alone (header only, empty body) so the cost is visible.
|
||||
*/
|
||||
export default function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Authoritative count wins; otherwise estimate live from the streamed text.
|
||||
const count = tokens && tokens > 0 ? tokens : estimateTokens(text);
|
||||
const trimmed = text.trim();
|
||||
const html = trimmed ? renderChatMarkdown(trimmed, {}) : "";
|
||||
|
||||
return (
|
||||
<Box className={classes.reasoningBlock} mb={6}>
|
||||
<UnstyledButton
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
// No body to expand when the provider reported only a token count.
|
||||
disabled={!trimmed}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<Group gap={6} wrap="nowrap" align="center">
|
||||
<IconChevronDown
|
||||
size={12}
|
||||
style={{
|
||||
transform: open ? "none" : "rotate(-90deg)",
|
||||
transition: "transform 150ms ease",
|
||||
opacity: trimmed ? 1 : 0.4,
|
||||
}}
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
{count > 0
|
||||
? t("Thinking · {{count}} tokens", { count })
|
||||
: t("Thinking")}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
{trimmed && (
|
||||
<Collapse in={open}>
|
||||
{html ? (
|
||||
<div
|
||||
className={classes.reasoningText}
|
||||
// Sanitized by renderChatMarkdown (DOMPurify) before insertion.
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
className={classes.reasoningText}
|
||||
style={{ whiteSpace: "pre-wrap" }}
|
||||
>
|
||||
{trimmed}
|
||||
</Text>
|
||||
)}
|
||||
</Collapse>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,8 @@ const roles: IAiRole[] = [
|
||||
emoji: "🏴☠️",
|
||||
description: "Talks like a pirate",
|
||||
enabled: true,
|
||||
autoStart: true,
|
||||
launchMessage: null,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
@@ -20,6 +22,8 @@ const roles: IAiRole[] = [
|
||||
emoji: null,
|
||||
description: null,
|
||||
enabled: true,
|
||||
autoStart: true,
|
||||
launchMessage: null,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import { tailThinkingTokens } from "@/features/ai-chat/components/message-list.tsx";
|
||||
|
||||
/**
|
||||
* Pure-helper tests for `tailThinkingTokens`: the live thinking-token count the
|
||||
* standalone typing indicator shows. It is the reasoning split of the tail
|
||||
* assistant message (estimate while streaming, authoritative once usage arrives).
|
||||
*/
|
||||
const msg = (
|
||||
role: "user" | "assistant",
|
||||
parts: unknown[],
|
||||
metadata?: unknown,
|
||||
): UIMessage =>
|
||||
({ id: Math.random().toString(), role, parts, metadata }) as UIMessage;
|
||||
|
||||
describe("tailThinkingTokens", () => {
|
||||
it("is 0 when there are no messages", () => {
|
||||
expect(tailThinkingTokens([])).toBe(0);
|
||||
});
|
||||
|
||||
it("is 0 when the tail message is the user's", () => {
|
||||
expect(tailThinkingTokens([msg("user", [{ type: "text", text: "q" }])])).toBe(0);
|
||||
});
|
||||
|
||||
it("is 0 when the assistant has produced no reasoning yet", () => {
|
||||
expect(
|
||||
tailThinkingTokens([msg("assistant", [{ type: "text", text: "answer" }])]),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("estimates reasoning tokens from streamed reasoning text", () => {
|
||||
// 8 chars -> 2 tokens.
|
||||
expect(
|
||||
tailThinkingTokens([
|
||||
msg("assistant", [{ type: "reasoning", text: "12345678" }]),
|
||||
]),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it("uses authoritative usage.reasoningTokens once the server attaches it", () => {
|
||||
expect(
|
||||
tailThinkingTokens([
|
||||
msg("assistant", [{ type: "reasoning", text: "x" }], {
|
||||
usage: { outputTokens: 100, reasoningTokens: 42 },
|
||||
}),
|
||||
]),
|
||||
).toBe(42);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,12 @@ interface TypingIndicatorProps {
|
||||
* assistant row above already shows the same name, to avoid a duplicate label.
|
||||
*/
|
||||
showName?: boolean;
|
||||
/**
|
||||
* Live thinking/reasoning token count for the in-flight turn. When > 0 the
|
||||
* typing line becomes `Thinking… · {count} tokens` (like Claude Code). Omitted
|
||||
* / 0 keeps the plain `Thinking…` line.
|
||||
*/
|
||||
thinkingTokens?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,9 +36,14 @@ interface TypingIndicatorProps {
|
||||
* typing line is always the generic "Thinking…" (it never includes the
|
||||
* role/identity name).
|
||||
*/
|
||||
export default function TypingIndicator({ assistantName, showName = true }: TypingIndicatorProps) {
|
||||
export default function TypingIndicator({ assistantName, showName = true, thinkingTokens }: TypingIndicatorProps) {
|
||||
const { t } = useTranslation();
|
||||
const name = resolveAssistantName(assistantName);
|
||||
// Show the running thinking-token count only once there is something to count.
|
||||
const thinkingLine =
|
||||
thinkingTokens && thinkingTokens > 0
|
||||
? t("Thinking… · {{count}} tokens", { count: thinkingTokens })
|
||||
: t("Thinking…");
|
||||
|
||||
return (
|
||||
<Box className={classes.messageRow}>
|
||||
@@ -48,7 +59,7 @@ export default function TypingIndicator({ assistantName, showName = true }: Typi
|
||||
<span />
|
||||
</span>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Thinking…")}
|
||||
{thinkingLine}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
@@ -53,6 +53,10 @@ export interface IAiRole {
|
||||
instructions?: string;
|
||||
modelConfig?: IAiRoleModelConfig | null;
|
||||
enabled: boolean;
|
||||
// Whether picking the role auto-sends a launch message and starts the chat.
|
||||
autoStart: boolean;
|
||||
// Custom auto-start text; null/empty => the default launch message is sent.
|
||||
launchMessage: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
@@ -65,6 +69,8 @@ export interface IAiRoleCreate {
|
||||
instructions: string;
|
||||
modelConfig?: IAiRoleModelConfig | null;
|
||||
enabled?: boolean;
|
||||
autoStart?: boolean;
|
||||
launchMessage?: string;
|
||||
}
|
||||
|
||||
/** Admin update payload for a role (partial). */
|
||||
@@ -76,6 +82,8 @@ export interface IAiRoleUpdate {
|
||||
instructions?: string;
|
||||
modelConfig?: IAiRoleModelConfig | null;
|
||||
enabled?: boolean;
|
||||
autoStart?: boolean;
|
||||
launchMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,6 +106,10 @@ export interface IAiChatMessageRow {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
// Reasoning (thinking) tokens, when the provider reports them. Optional so
|
||||
// old history rows (recorded before this shipped) stay valid. Included in
|
||||
// `outputTokens` per the AI SDK usage shape.
|
||||
reasoningTokens?: number;
|
||||
};
|
||||
// Current context size for the turn = final-step (input+output) tokens, i.e.
|
||||
// how much the conversation occupies in the model's context window after this
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* ============================ CANONICAL #137 NOTE ============================
|
||||
* This docblock is the single authoritative explanation of the new-chat id
|
||||
* adoption design and the #137 two-tab race it fixes. Other call sites
|
||||
* (use-chat-session.ts, the server's `chatStreamStartMetadata`) reference here
|
||||
* (use-chat-session.ts, the server's `chatStreamMetadata`) reference here
|
||||
* rather than restating it.
|
||||
*
|
||||
* When a user sends the first turn of a BRAND-NEW chat, the client has no chat
|
||||
@@ -17,7 +17,7 @@
|
||||
* leak its later turns into it (#137). We adopt by IDENTITY instead, two ways:
|
||||
*
|
||||
* PRIMARY path: the server streams the real chat id on the assistant message
|
||||
* metadata's `start` part (see `chatStreamStartMetadata` server-side);
|
||||
* metadata's `start` part (see `chatStreamMetadata` server-side);
|
||||
* `extractServerChatId` reads it off the finished message and
|
||||
* `resolveAdoptedChatId` turns it into the id to adopt for a new chat. This is
|
||||
* authoritative and immune to the race.
|
||||
@@ -46,7 +46,7 @@ export function resolveAdoptedChatId(
|
||||
/**
|
||||
* Read the authoritative server chat id off a finished assistant message. The
|
||||
* server attaches it as `message.metadata.chatId` on the `start` part (see
|
||||
* `chatStreamStartMetadata`). Returns it only when it is a string; undefined for
|
||||
* `chatStreamMetadata`). Returns it only when it is a string; undefined for
|
||||
* a missing message, missing metadata, or a non-string `chatId`.
|
||||
*/
|
||||
export function extractServerChatId(
|
||||
|
||||
@@ -314,6 +314,57 @@ describe("buildChatMarkdown — token totals", () => {
|
||||
});
|
||||
expect(md).toContain("- Total tokens: 99");
|
||||
});
|
||||
|
||||
it("appends the reasoning figure to the row footer when reasoningTokens > 0", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "x",
|
||||
metadata: {
|
||||
usage: { inputTokens: 10, outputTokens: 8, reasoningTokens: 3 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
t,
|
||||
});
|
||||
expect(md).toContain("_Tokens — in: 10, out: 8, reasoning: 3, total: 18_");
|
||||
});
|
||||
|
||||
it("omits the reasoning figure when reasoningTokens is 0 / absent", () => {
|
||||
const zero = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "x",
|
||||
metadata: {
|
||||
usage: { inputTokens: 10, outputTokens: 5, reasoningTokens: 0 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
t,
|
||||
});
|
||||
expect(zero).toContain("_Tokens — in: 10, out: 5, total: 15_");
|
||||
expect(zero).not.toContain("reasoning:");
|
||||
|
||||
const absent = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "x",
|
||||
metadata: { usage: { inputTokens: 10, outputTokens: 5 } },
|
||||
}),
|
||||
],
|
||||
t,
|
||||
});
|
||||
expect(absent).not.toContain("reasoning:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildChatMarkdown — pending / in-progress messages", () => {
|
||||
|
||||
@@ -77,6 +77,7 @@ function rowTokens(usage: {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
reasoningTokens?: number;
|
||||
}): number {
|
||||
return (
|
||||
usage.totalTokens ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0)
|
||||
@@ -175,8 +176,14 @@ export function buildChatMarkdown(args: BuildChatMarkdownArgs): string {
|
||||
const usage = row.metadata?.usage;
|
||||
if (usage) {
|
||||
const total = usage.totalTokens ?? rowTokens(usage);
|
||||
// Reasoning (thinking) tokens are shown only when the provider reported a
|
||||
// positive count; old rows / non-reasoning providers omit it.
|
||||
const reasoning =
|
||||
usage.reasoningTokens && usage.reasoningTokens > 0
|
||||
? `, reasoning: ${usage.reasoningTokens}`
|
||||
: "";
|
||||
blocks.push(
|
||||
`_Tokens — in: ${usage.inputTokens ?? "?"}, out: ${usage.outputTokens ?? "?"}, total: ${total}_`,
|
||||
`_Tokens — in: ${usage.inputTokens ?? "?"}, out: ${usage.outputTokens ?? "?"}${reasoning}, total: ${total}_`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import {
|
||||
estimateTokens,
|
||||
liveTurnTokens,
|
||||
} from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
||||
|
||||
const msg = (parts: unknown[], metadata?: unknown): UIMessage =>
|
||||
({
|
||||
id: Math.random().toString(),
|
||||
role: "assistant",
|
||||
parts,
|
||||
metadata,
|
||||
}) as UIMessage;
|
||||
|
||||
describe("estimateTokens", () => {
|
||||
it("returns 0 for the empty string", () => {
|
||||
expect(estimateTokens("")).toBe(0);
|
||||
});
|
||||
|
||||
it("ceils chars/4 so any non-empty text is at least 1 token", () => {
|
||||
expect(estimateTokens("a")).toBe(1);
|
||||
expect(estimateTokens("abcd")).toBe(1);
|
||||
expect(estimateTokens("abcde")).toBe(2);
|
||||
expect(estimateTokens("12345678")).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("liveTurnTokens — estimate path", () => {
|
||||
it("is all zeros for an undefined message", () => {
|
||||
expect(liveTurnTokens(undefined)).toEqual({
|
||||
reasoning: 0,
|
||||
output: 0,
|
||||
authoritative: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("is all zeros for a parts-less message", () => {
|
||||
expect(liveTurnTokens({ id: "x", role: "assistant" } as UIMessage)).toEqual({
|
||||
reasoning: 0,
|
||||
output: 0,
|
||||
authoritative: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("estimates output from text parts", () => {
|
||||
// 8 chars -> 2 tokens.
|
||||
const r = liveTurnTokens(msg([{ type: "text", text: "12345678" }]));
|
||||
expect(r).toEqual({ reasoning: 0, output: 2, authoritative: false });
|
||||
});
|
||||
|
||||
it("estimates reasoning from reasoning parts (kept separate from output)", () => {
|
||||
const r = liveTurnTokens(
|
||||
msg([
|
||||
{ type: "reasoning", text: "12345678" },
|
||||
{ type: "text", text: "abcd" },
|
||||
]),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 2, output: 1, authoritative: false });
|
||||
});
|
||||
|
||||
it("accumulates across multiple text + reasoning parts (multi-step)", () => {
|
||||
const r = liveTurnTokens(
|
||||
msg([
|
||||
{ type: "reasoning", text: "abcd" }, // 1
|
||||
{ type: "text", text: "abcd" }, // 1
|
||||
{ type: "tool-getPage", state: "output-available" }, // ignored
|
||||
{ type: "reasoning", text: "abcd" }, // 1
|
||||
{ type: "text", text: "abcdefgh" }, // 2
|
||||
]),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 2, output: 3, authoritative: false });
|
||||
});
|
||||
|
||||
it("ignores non text/reasoning parts (tools, step-start)", () => {
|
||||
const r = liveTurnTokens(
|
||||
msg([
|
||||
{ type: "step-start" },
|
||||
{ type: "tool-getPage", state: "input-available" },
|
||||
]),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 0, output: 0, authoritative: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("liveTurnTokens — authoritative path", () => {
|
||||
it("returns authoritative usage verbatim, splitting reasoning out of output", () => {
|
||||
// outputTokens INCLUDES reasoning in the AI SDK shape -> answer = 100 - 30.
|
||||
const r = liveTurnTokens(
|
||||
msg([{ type: "text", text: "estimate would be tiny" }], {
|
||||
usage: { inputTokens: 500, outputTokens: 100, reasoningTokens: 30 },
|
||||
}),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 30, output: 70, authoritative: true });
|
||||
});
|
||||
|
||||
it("treats missing reasoningTokens as 0 and keeps full output", () => {
|
||||
const r = liveTurnTokens(
|
||||
msg([{ type: "text", text: "x" }], {
|
||||
usage: { inputTokens: 10, outputTokens: 42 },
|
||||
}),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 0, output: 42, authoritative: true });
|
||||
});
|
||||
|
||||
it("never returns a negative output when reasoning exceeds reported output", () => {
|
||||
const r = liveTurnTokens(
|
||||
msg([], { usage: { outputTokens: 10, reasoningTokens: 40 } }),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 40, output: 0, authoritative: true });
|
||||
});
|
||||
|
||||
it("falls back to the estimate when metadata has no usage object", () => {
|
||||
const r = liveTurnTokens(
|
||||
msg([{ type: "text", text: "abcd" }], { chatId: "c1" }),
|
||||
);
|
||||
expect(r).toEqual({ reasoning: 0, output: 1, authoritative: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
/**
|
||||
* Live token counting for a streaming AI-chat turn — split into REASONING
|
||||
* (thinking) and OUTPUT (answer) tokens, mirroring how Claude Code shows
|
||||
* `Thinking… · 60 tokens` next to its thinking indicator.
|
||||
*
|
||||
* No provider streams exact per-token usage mid-stream, so the live number is a
|
||||
* CLIENT ESTIMATE (chars/≈4 heuristic) that is reconciled to AUTHORITATIVE usage
|
||||
* once the server attaches it on a step/turn boundary (see the server's
|
||||
* `chatStreamMetadata` + the client's read of `message.metadata.usage`). When
|
||||
* authoritative usage is present we return it verbatim (the number "jumps to
|
||||
* exact"); otherwise we return the running estimate. Pure + unit-testable: it
|
||||
* never runs a real BPE tokenizer (that would be O(n²) on the hot path, bloat the
|
||||
* bundle, and be wrong for Gemini/Ollama anyway).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Rough token estimate for a piece of text using the standard chars/≈4 heuristic.
|
||||
* Returns 0 for empty/whitespace-free-of-content input, and ceils so any
|
||||
* non-empty text counts as at least one token.
|
||||
*/
|
||||
export function estimateTokens(text: string): number {
|
||||
if (!text) return 0;
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
|
||||
/** Authoritative per-step/turn usage the server attaches to message metadata. */
|
||||
export interface AuthoritativeUsage {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
reasoningTokens?: number;
|
||||
}
|
||||
|
||||
/** Live token split for a turn's tail (streaming) assistant message. */
|
||||
export interface LiveTurnTokens {
|
||||
/** Thinking/reasoning tokens (estimate, or authoritative when available). */
|
||||
reasoning: number;
|
||||
/** Answer/output tokens (estimate, or authoritative when available). */
|
||||
output: number;
|
||||
/** True when the numbers come from authoritative server usage, not estimate. */
|
||||
authoritative: boolean;
|
||||
}
|
||||
|
||||
/** Read the authoritative usage off a UIMessage's metadata, if the server set it. */
|
||||
function metadataUsage(message: UIMessage): AuthoritativeUsage | undefined {
|
||||
const meta = message?.metadata as
|
||||
| { usage?: AuthoritativeUsage }
|
||||
| undefined;
|
||||
const usage = meta?.usage;
|
||||
if (!usage || typeof usage !== "object") return undefined;
|
||||
return usage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token split for the given (streaming) assistant message.
|
||||
*
|
||||
* Prefers AUTHORITATIVE `metadata.usage` when the server has attached it (at a
|
||||
* step/turn boundary, incl. `reasoningTokens`) — so the live counter snaps to the
|
||||
* provider's exact figures. Until then it returns a running ESTIMATE summed over
|
||||
* the message parts: `reasoning` parts feed the reasoning estimate, `text` parts
|
||||
* feed the output estimate. Multi-part / multi-step turns accumulate naturally
|
||||
* because every part of the turn is summed.
|
||||
*
|
||||
* Providers that don't stream reasoning text still surface a reasoning count once
|
||||
* the authoritative usage arrives (`usage.reasoningTokens`); on the pure estimate
|
||||
* path such a turn simply shows `reasoning: 0` until then.
|
||||
*/
|
||||
export function liveTurnTokens(message: UIMessage | undefined): LiveTurnTokens {
|
||||
if (!message) return { reasoning: 0, output: 0, authoritative: false };
|
||||
|
||||
const usage = metadataUsage(message);
|
||||
if (usage) {
|
||||
// Authoritative branch: outputTokens already INCLUDES reasoning tokens in the
|
||||
// AI SDK usage shape, so subtract reasoning out for the "answer" figure (never
|
||||
// go negative if a provider reports them inconsistently).
|
||||
const reasoning = usage.reasoningTokens ?? 0;
|
||||
const totalOutput = usage.outputTokens ?? 0;
|
||||
const output = Math.max(0, totalOutput - reasoning);
|
||||
return { reasoning, output, authoritative: true };
|
||||
}
|
||||
|
||||
let reasoning = 0;
|
||||
let output = 0;
|
||||
for (const part of message.parts ?? []) {
|
||||
if (part.type === "reasoning") {
|
||||
reasoning += estimateTokens((part as { text?: string }).text ?? "");
|
||||
} else if (part.type === "text") {
|
||||
output += estimateTokens((part as { text?: string }).text ?? "");
|
||||
}
|
||||
}
|
||||
return { reasoning, output, authoritative: false };
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import { reasoningTokensForPart } from "@/features/ai-chat/utils/reasoning-tokens.ts";
|
||||
|
||||
/**
|
||||
* Pure-helper tests for `reasoningTokensForPart`, the #151 anti-double-count
|
||||
* rule: the authoritative `usage.reasoningTokens` is the TURN TOTAL, so it may
|
||||
* only be attributed when the turn has exactly one reasoning part. With multiple
|
||||
* reasoning parts (or no authoritative usage) every part falls back to its own
|
||||
* per-part estimate, signalled here by `undefined`.
|
||||
*/
|
||||
const msg = (
|
||||
parts: UIMessage["parts"],
|
||||
metadata?: unknown,
|
||||
): UIMessage =>
|
||||
({
|
||||
id: Math.random().toString(),
|
||||
role: "assistant",
|
||||
parts,
|
||||
metadata,
|
||||
}) as UIMessage;
|
||||
|
||||
describe("reasoningTokensForPart", () => {
|
||||
it("single reasoning part -> the authoritative turn total", () => {
|
||||
const m = msg(
|
||||
[
|
||||
{ type: "reasoning", text: "thinking…" } as never,
|
||||
{ type: "text", text: "answer" },
|
||||
],
|
||||
{ usage: { reasoningTokens: 42 } },
|
||||
);
|
||||
expect(reasoningTokensForPart(m)).toBe(42);
|
||||
});
|
||||
|
||||
it("multiple reasoning parts -> undefined (each estimates on its own)", () => {
|
||||
const m = msg(
|
||||
[
|
||||
{ type: "reasoning", text: "step one" } as never,
|
||||
{ type: "reasoning", text: "step two" } as never,
|
||||
{ type: "text", text: "answer" },
|
||||
],
|
||||
{ usage: { reasoningTokens: 99 } },
|
||||
);
|
||||
// Even with an authoritative total, two reasoning parts must each estimate
|
||||
// (attributing the total to one would double-count against the other).
|
||||
expect(reasoningTokensForPart(m)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("no authoritative usage -> undefined even for a single reasoning part", () => {
|
||||
const m = msg([
|
||||
{ type: "reasoning", text: "thinking…" } as never,
|
||||
{ type: "text", text: "answer" },
|
||||
]);
|
||||
expect(reasoningTokensForPart(m)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
34
apps/client/src/features/ai-chat/utils/reasoning-tokens.ts
Normal file
34
apps/client/src/features/ai-chat/utils/reasoning-tokens.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
/**
|
||||
* Decide the authoritative reasoning token count to attribute to a single
|
||||
* `reasoning` part of an assistant message — or `undefined` when the part should
|
||||
* fall back to its own per-part estimate.
|
||||
*
|
||||
* `usage.reasoningTokens` is the TURN TOTAL, so it may only be attributed to a
|
||||
* block when the turn has exactly ONE reasoning part (the common one-step turn):
|
||||
* then that block can show the exact figure. With MULTIPLE reasoning parts (a
|
||||
* multi-step agent turn) every block must fall back to its own estimate —
|
||||
* attributing the turn total to one of them would double-count against the
|
||||
* others' estimates (#151 review anti-double-count rule). When there is no
|
||||
* authoritative usage at all, every part estimates.
|
||||
*
|
||||
* Returns the authoritative `reasoningTokens` only for the single-reasoning-part
|
||||
* case; `undefined` otherwise (the caller estimates from the part text).
|
||||
*/
|
||||
export function reasoningTokensForPart(
|
||||
message: UIMessage,
|
||||
): number | undefined {
|
||||
const reasoningTokens = (
|
||||
message.metadata as { usage?: { reasoningTokens?: number } } | undefined
|
||||
)?.usage?.reasoningTokens;
|
||||
|
||||
const reasoningPartCount = (message.parts ?? []).reduce(
|
||||
(acc, p) => (p.type === "reasoning" ? acc + 1 : acc),
|
||||
0,
|
||||
);
|
||||
|
||||
// Exactly one reasoning part -> attribute the authoritative turn total to it.
|
||||
// Otherwise (zero or multiple) each part estimates on its own.
|
||||
return reasoningPartCount === 1 ? reasoningTokens : undefined;
|
||||
}
|
||||
72
apps/client/src/features/ai-chat/utils/role-launch.test.ts
Normal file
72
apps/client/src/features/ai-chat/utils/role-launch.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { roleLaunchMessage, shouldResetRolePicked } from "./role-launch.ts";
|
||||
|
||||
const DEFAULT = "Take a look at the current document";
|
||||
|
||||
// Covers the three-way handleRolePick behavior (issue #149) without mounting the
|
||||
// chat-thread component — the logic lives in these pure helpers.
|
||||
describe("roleLaunchMessage", () => {
|
||||
it("autoStart=true + custom launchMessage -> the trimmed custom text", () => {
|
||||
expect(
|
||||
roleLaunchMessage(
|
||||
{ autoStart: true, launchMessage: " Draft a plan " },
|
||||
DEFAULT,
|
||||
),
|
||||
).toBe("Draft a plan");
|
||||
});
|
||||
|
||||
it("autoStart=true + empty launchMessage -> the default fallback", () => {
|
||||
expect(
|
||||
roleLaunchMessage({ autoStart: true, launchMessage: "" }, DEFAULT),
|
||||
).toBe(DEFAULT);
|
||||
});
|
||||
|
||||
it("autoStart=true + whitespace-only launchMessage -> the default fallback", () => {
|
||||
expect(
|
||||
roleLaunchMessage({ autoStart: true, launchMessage: " " }, DEFAULT),
|
||||
).toBe(DEFAULT);
|
||||
});
|
||||
|
||||
it("autoStart=true + null launchMessage -> the default fallback", () => {
|
||||
expect(
|
||||
roleLaunchMessage({ autoStart: true, launchMessage: null }, DEFAULT),
|
||||
).toBe(DEFAULT);
|
||||
});
|
||||
|
||||
it("autoStart=false -> null (bind only, send nothing) regardless of message", () => {
|
||||
expect(
|
||||
roleLaunchMessage(
|
||||
{ autoStart: false, launchMessage: "ignored" },
|
||||
DEFAULT,
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
roleLaunchMessage({ autoStart: false, launchMessage: null }, DEFAULT),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// Regression guard for #149: the "picked, not sent" flag must reset when the
|
||||
// user starts a fresh chat after an autoStart=false pick. On pre-fix code there
|
||||
// was no reset, so the flag stayed stuck and the role cards never returned —
|
||||
// this is exactly the `true` case below (which the old code never acted on).
|
||||
describe("shouldResetRolePicked", () => {
|
||||
it("resets when the thread is empty and the bound role was cleared (New chat)", () => {
|
||||
// chatId still null, roleId cleared by the parent, flag stuck -> reset.
|
||||
expect(shouldResetRolePicked(null, null, true)).toBe(true);
|
||||
expect(shouldResetRolePicked(null, undefined, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT reset while a role is still bound (cards stay hidden, composer shown)", () => {
|
||||
// Right after the autoStart=false pick, roleId is the picked role -> keep hidden.
|
||||
expect(shouldResetRolePicked(null, "role-1", true)).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT reset once the chat exists (a message was sent / chat created)", () => {
|
||||
expect(shouldResetRolePicked("chat-1", null, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("is a no-op when the flag is already false", () => {
|
||||
expect(shouldResetRolePicked(null, null, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
34
apps/client/src/features/ai-chat/utils/role-launch.ts
Normal file
34
apps/client/src/features/ai-chat/utils/role-launch.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { IAiRole } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
/**
|
||||
* Decide what (if anything) to auto-send when an agent role card is picked
|
||||
* (issue #149). Extracted as a pure function so the three-way behavior is
|
||||
* unit-testable without mounting the chat-thread component:
|
||||
* - autoStart=false -> null (bind the role only, send nothing)
|
||||
* - autoStart=true + message -> the trimmed custom launchMessage
|
||||
* - autoStart=true + empty/null -> the default fallback text
|
||||
*/
|
||||
export function roleLaunchMessage(
|
||||
role: Pick<IAiRole, "autoStart" | "launchMessage">,
|
||||
defaultText: string,
|
||||
): string | null {
|
||||
if (!role.autoStart) return null;
|
||||
return role.launchMessage?.trim() || defaultText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the "role picked but nothing sent yet" flag (`rolePickedNoSend`)
|
||||
* should reset to false. After an autoStart=false pick the thread shows the
|
||||
* composer with chatId still null; when the user then starts a fresh chat the
|
||||
* parent clears the bound role (roleId -> null) but chatId stays null, so the
|
||||
* thread never remounts and the flag would otherwise stay set — hiding the role
|
||||
* cards forever. Reset exactly in that state; a still-bound role (roleId set)
|
||||
* keeps the cards hidden. (Regression guard for #149.)
|
||||
*/
|
||||
export function shouldResetRolePicked(
|
||||
chatId: string | null,
|
||||
roleId: string | null | undefined,
|
||||
rolePickedNoSend: boolean,
|
||||
): boolean {
|
||||
return chatId === null && roleId == null && rolePickedNoSend;
|
||||
}
|
||||
@@ -73,3 +73,18 @@
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Float image (#145): on narrow screens a floated image would crowd the text to
|
||||
an unreadable column, so collapse it to full width and drop the float.
|
||||
`!important` is required because applyAlignment sets `float`/`padding` inline,
|
||||
which a normal rule cannot override. Keys off the `data-image-align` attribute
|
||||
the image node view mirrors onto its container. This module is the one actually
|
||||
imported by the resize node views (node-resize-handles.ts), so the rule loads. */
|
||||
@media (max-width: 600px) {
|
||||
.container:global([data-image-align="floatLeft"]),
|
||||
.container:global([data-image-align="floatRight"]) {
|
||||
float: none !important;
|
||||
width: 100% !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
IconLayoutAlignCenter,
|
||||
IconLayoutAlignLeft,
|
||||
IconLayoutAlignRight,
|
||||
IconFloatLeft,
|
||||
IconFloatRight,
|
||||
IconDownload,
|
||||
IconRefresh,
|
||||
IconTrash,
|
||||
@@ -41,6 +43,8 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
isAlignLeft: ctx.editor.isActive("image", { align: "left" }),
|
||||
isAlignCenter: ctx.editor.isActive("image", { align: "center" }),
|
||||
isAlignRight: ctx.editor.isActive("image", { align: "right" }),
|
||||
isFloatLeft: ctx.editor.isActive("image", { align: "floatLeft" }),
|
||||
isFloatRight: ctx.editor.isActive("image", { align: "floatRight" }),
|
||||
src: imageAttrs?.src || null,
|
||||
alt: imageAttrs?.alt || "",
|
||||
};
|
||||
@@ -104,6 +108,22 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
.run();
|
||||
}, [editor]);
|
||||
|
||||
const alignImageFloatLeft = useCallback(() => {
|
||||
editor
|
||||
.chain()
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
.setImageAlign("floatLeft")
|
||||
.run();
|
||||
}, [editor]);
|
||||
|
||||
const alignImageFloatRight = useCallback(() => {
|
||||
editor
|
||||
.chain()
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
.setImageAlign("floatRight")
|
||||
.run();
|
||||
}, [editor]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!editorState?.src) return;
|
||||
const url = getFileUrl(editorState.src);
|
||||
@@ -201,6 +221,30 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Float left (wrap text)")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={alignImageFloatLeft}
|
||||
size="lg"
|
||||
aria-label={t("Float left (wrap text)")}
|
||||
variant="subtle"
|
||||
className={clsx({ [classes.active]: editorState?.isFloatLeft })}
|
||||
>
|
||||
<IconFloatLeft size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Float right (wrap text)")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={alignImageFloatRight}
|
||||
size="lg"
|
||||
aria-label={t("Float right (wrap text)")}
|
||||
variant="subtle"
|
||||
className={clsx({ [classes.active]: editorState?.isFloatRight })}
|
||||
>
|
||||
<IconFloatRight size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
{altTextButton}
|
||||
|
||||
@@ -524,6 +524,29 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
editor.chain().focus().deleteRange(range).insertSubpages().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Page tree (child pages, recursive)",
|
||||
description: "Render the full nested tree of all descendant pages",
|
||||
searchTerms: [
|
||||
"subpages",
|
||||
"child",
|
||||
"children",
|
||||
"nested",
|
||||
"hierarchy",
|
||||
"tree",
|
||||
"recursive",
|
||||
"toc",
|
||||
],
|
||||
icon: IconSitemap,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.insertSubpages({ recursive: true })
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Synced block",
|
||||
description: "Create a block that stays in sync across pages.",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
import { posToDOMRect, findParentNode } from "@tiptap/react";
|
||||
import { posToDOMRect, findParentNode, useEditorState } from "@tiptap/react";
|
||||
import { Node as PMNode } from "@tiptap/pm/model";
|
||||
import React, { useCallback } from "react";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { IconTrash } from "@tabler/icons-react";
|
||||
import { ActionIcon, Group, Tooltip } from "@mantine/core";
|
||||
import { IconTrash, IconList, IconSitemap } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { isEditorReady } from "@docmost/editor-ext";
|
||||
@@ -47,6 +47,13 @@ export const SubpagesMenu = React.memo(
|
||||
return posToDOMRect(editor.view, selection.from, selection.to);
|
||||
}, [editor]);
|
||||
|
||||
const toggleRecursive = useCallback(() => {
|
||||
const current = editor.getAttributes("subpages")?.recursive ?? false;
|
||||
editor.commands.updateAttributes("subpages", {
|
||||
recursive: !current,
|
||||
});
|
||||
}, [editor]);
|
||||
|
||||
const deleteNode = useCallback(() => {
|
||||
const { selection } = editor.state;
|
||||
editor
|
||||
@@ -57,6 +64,15 @@ export const SubpagesMenu = React.memo(
|
||||
.run();
|
||||
}, [editor]);
|
||||
|
||||
// Subscribe to the live `recursive` attribute the standard way (as the
|
||||
// sibling bubble menus do): useEditorState re-renders only when the selected
|
||||
// value actually changes, so the mode icon/tooltip stay current after a
|
||||
// toggle without re-rendering on every keystroke.
|
||||
const isRecursive = useEditorState({
|
||||
editor,
|
||||
selector: (ctx) => ctx.editor?.getAttributes("subpages")?.recursive ?? false,
|
||||
});
|
||||
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
@@ -64,17 +80,41 @@ export const SubpagesMenu = React.memo(
|
||||
updateDelay={0}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<Tooltip position="top" label={t("Delete")}>
|
||||
<ActionIcon
|
||||
onClick={deleteNode}
|
||||
variant="default"
|
||||
size="lg"
|
||||
color="red"
|
||||
aria-label={t("Delete")}
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Tooltip
|
||||
position="top"
|
||||
label={
|
||||
isRecursive
|
||||
? t("Switch to flat list")
|
||||
: t("Switch to tree")
|
||||
}
|
||||
>
|
||||
<IconTrash size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<ActionIcon
|
||||
onClick={toggleRecursive}
|
||||
variant="default"
|
||||
size="lg"
|
||||
aria-label={t("Toggle subpages display mode")}
|
||||
>
|
||||
{isRecursive ? (
|
||||
<IconList size={18} />
|
||||
) : (
|
||||
<IconSitemap size={18} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Delete")}>
|
||||
<ActionIcon
|
||||
onClick={deleteNode}
|
||||
variant="default"
|
||||
size="lg"
|
||||
color="red"
|
||||
aria-label={t("Delete")}
|
||||
>
|
||||
<IconTrash size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</BaseBubbleMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { Stack, Text, Anchor, ActionIcon } from "@mantine/core";
|
||||
import { IconFileDescription } from "@tabler/icons-react";
|
||||
import { useGetSidebarPagesQuery } from "@/features/page/queries/page-query";
|
||||
import {
|
||||
useGetSidebarPagesQuery,
|
||||
useGetPageTreeQuery,
|
||||
} from "@/features/page/queries/page-query";
|
||||
import { useMemo } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import classes from "./subpages.module.css";
|
||||
@@ -12,16 +15,130 @@ import {
|
||||
} from "@/features/page/page.utils.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { sortPositionKeys } from "@/features/page/tree/utils/utils";
|
||||
import { useSharedPageSubpages } from "@/features/share/hooks/use-shared-page-subpages";
|
||||
import {
|
||||
useSharedPageSubpages,
|
||||
useSharedPageSubtree,
|
||||
} from "@/features/share/hooks/use-shared-page-subpages";
|
||||
import {
|
||||
SubpageNode,
|
||||
buildSubtree,
|
||||
mapSharedNodes,
|
||||
countNodes,
|
||||
} from "./subpages-view.utils";
|
||||
|
||||
// Threshold above which the recursive tree shows a small count note. We never
|
||||
// cap the data — this is only an informational hint for very large trees.
|
||||
const LARGE_TREE_THRESHOLD = 300;
|
||||
|
||||
interface TreeNodeProps {
|
||||
node: SubpageNode;
|
||||
depth: number;
|
||||
shareId?: string;
|
||||
spaceSlug?: string;
|
||||
// Threaded down from the variant component so a large tree does not create one
|
||||
// i18n subscription (useTranslation) per rendered node.
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
// Recursive renderer for a single node and its descendants. Indents each level
|
||||
// by depth * 16px and reuses the same link/icon markup as the flat list.
|
||||
function TreeNode({ node, depth, shareId, spaceSlug, t }: TreeNodeProps) {
|
||||
return (
|
||||
<>
|
||||
<Anchor
|
||||
component={Link}
|
||||
fw={500}
|
||||
to={
|
||||
shareId
|
||||
? buildSharedPageUrl({
|
||||
shareId,
|
||||
pageSlugId: node.slugId,
|
||||
pageTitle: node.title,
|
||||
})
|
||||
: buildPageUrl(spaceSlug, node.slugId, node.title)
|
||||
}
|
||||
underline="never"
|
||||
className={styles.pageMentionLink}
|
||||
draggable={false}
|
||||
style={{ paddingLeft: depth * 16 }}
|
||||
>
|
||||
{node?.icon ? (
|
||||
<span style={{ marginRight: "4px" }}>{node.icon}</span>
|
||||
) : (
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
component="span"
|
||||
size={18}
|
||||
style={{ verticalAlign: "text-bottom" }}
|
||||
>
|
||||
<IconFileDescription size={18} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
|
||||
<span className={styles.pageMentionText}>
|
||||
{node?.title || t("untitled")}
|
||||
</span>
|
||||
</Anchor>
|
||||
|
||||
{node.children.map((child) => (
|
||||
<TreeNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
shareId={shareId}
|
||||
spaceSlug={spaceSlug}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SubpagesView(props: NodeViewProps) {
|
||||
const { editor } = props;
|
||||
const { spaceSlug, shareId } = useParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const recursive: boolean = props.node.attrs.recursive ?? false;
|
||||
|
||||
//@ts-ignore
|
||||
const currentPageId = editor.storage.pageId;
|
||||
|
||||
if (recursive) {
|
||||
return (
|
||||
<RecursiveSubpages
|
||||
currentPageId={currentPageId}
|
||||
shareId={shareId}
|
||||
spaceSlug={spaceSlug}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatSubpages
|
||||
currentPageId={currentPageId}
|
||||
shareId={shareId}
|
||||
spaceSlug={spaceSlug}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface SubpagesVariantProps {
|
||||
currentPageId: string;
|
||||
shareId?: string;
|
||||
spaceSlug?: string;
|
||||
t: (key: string, options?: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
function FlatSubpages({
|
||||
currentPageId,
|
||||
shareId,
|
||||
spaceSlug,
|
||||
t,
|
||||
}: SubpagesVariantProps) {
|
||||
// Get subpages from shared tree if we're in a shared context
|
||||
const sharedSubpages = useSharedPageSubpages(currentPageId);
|
||||
|
||||
@@ -119,3 +236,78 @@ export default function SubpagesView(props: NodeViewProps) {
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function RecursiveSubpages({
|
||||
currentPageId,
|
||||
shareId,
|
||||
spaceSlug,
|
||||
t,
|
||||
}: SubpagesVariantProps) {
|
||||
// In a shared/public context reuse the already-loaded nested shared tree
|
||||
// instead of issuing a /pages/tree request.
|
||||
const sharedSubtree = useSharedPageSubtree(currentPageId);
|
||||
|
||||
const { data, isLoading, error } = useGetPageTreeQuery(
|
||||
shareId ? "" : currentPageId,
|
||||
);
|
||||
|
||||
const tree = useMemo<SubpageNode[]>(() => {
|
||||
if (shareId) {
|
||||
return mapSharedNodes(sharedSubtree);
|
||||
}
|
||||
if (!data) return [];
|
||||
return buildSubtree(data, currentPageId);
|
||||
}, [data, shareId, sharedSubtree, currentPageId]);
|
||||
|
||||
const total = useMemo(() => countNodes(tree), [tree]);
|
||||
|
||||
if (isLoading && !shareId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (error && !shareId) {
|
||||
return (
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<Text c="dimmed" size="md" py="md">
|
||||
{t("Failed to load subpages")}
|
||||
</Text>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (tree.length === 0) {
|
||||
return (
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<div className={classes.container}>
|
||||
<Text c="dimmed" size="md" py="md">
|
||||
{t("No subpages")}
|
||||
</Text>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<div className={classes.container}>
|
||||
<Stack gap={5}>
|
||||
{tree.map((node) => (
|
||||
<TreeNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
depth={0}
|
||||
shareId={shareId}
|
||||
spaceSlug={spaceSlug}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
{total > LARGE_TREE_THRESHOLD && (
|
||||
<Text c="dimmed" size="xs" pt="xs">
|
||||
{t("Showing {{count}} subpages", { count: total })}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildSubtree,
|
||||
countNodes,
|
||||
mapSharedNodes,
|
||||
SubpageNode,
|
||||
} from "./subpages-view.utils";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
|
||||
// Minimal IPage fixture — buildSubtree only reads id/slugId/title/icon/position/
|
||||
// parentPageId. `position` keys are fractional-indexing strings (lexicographic).
|
||||
const page = (p: Partial<IPage> & { id: string }): IPage =>
|
||||
({
|
||||
slugId: `slug-${p.id}`,
|
||||
title: `Title ${p.id}`,
|
||||
icon: undefined,
|
||||
position: "a0",
|
||||
parentPageId: null,
|
||||
...p,
|
||||
}) as IPage;
|
||||
|
||||
const ids = (nodes: SubpageNode[]): string[] => nodes.map((n) => n.id);
|
||||
|
||||
describe("buildSubtree", () => {
|
||||
it("nests children under the root and excludes the root itself", () => {
|
||||
const pages = [
|
||||
page({ id: "root" }),
|
||||
page({ id: "a", parentPageId: "root", position: "a0" }),
|
||||
page({ id: "b", parentPageId: "root", position: "a1" }),
|
||||
page({ id: "a1", parentPageId: "a", position: "a0" }),
|
||||
];
|
||||
const tree = buildSubtree(pages, "root");
|
||||
// Root is not rendered; only its descendants.
|
||||
expect(ids(tree)).toEqual(["a", "b"]);
|
||||
expect(ids(tree[0].children)).toEqual(["a1"]);
|
||||
expect(tree[1].children).toEqual([]);
|
||||
});
|
||||
|
||||
it("sorts each level by position", () => {
|
||||
const pages = [
|
||||
page({ id: "root" }),
|
||||
page({ id: "z", parentPageId: "root", position: "a2" }),
|
||||
page({ id: "x", parentPageId: "root", position: "a0" }),
|
||||
page({ id: "y", parentPageId: "root", position: "a1" }),
|
||||
];
|
||||
expect(ids(buildSubtree(pages, "root"))).toEqual(["x", "y", "z"]);
|
||||
});
|
||||
|
||||
it("returns [] when the root is absent from the page set", () => {
|
||||
const pages = [page({ id: "a", parentPageId: "missing-root" })];
|
||||
expect(buildSubtree(pages, "missing-root")).toEqual([]);
|
||||
});
|
||||
|
||||
it("silently drops a node whose parent is absent (unreachable parent)", () => {
|
||||
const pages = [
|
||||
page({ id: "root" }),
|
||||
page({ id: "ok", parentPageId: "root" }),
|
||||
page({ id: "orphan", parentPageId: "ghost" }), // parent not in the set
|
||||
];
|
||||
expect(ids(buildSubtree(pages, "root"))).toEqual(["ok"]);
|
||||
});
|
||||
|
||||
it("guards against self-parenting / attaching the root", () => {
|
||||
const pages = [
|
||||
// A (defensive) self-parented root must not attach to itself.
|
||||
page({ id: "root", parentPageId: "root" }),
|
||||
page({ id: "a", parentPageId: "root" }),
|
||||
];
|
||||
const tree = buildSubtree(pages, "root");
|
||||
expect(ids(tree)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("returns [] for empty input", () => {
|
||||
expect(buildSubtree([], "root")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("countNodes", () => {
|
||||
it("counts every descendant across all levels", () => {
|
||||
const tree: SubpageNode[] = [
|
||||
{
|
||||
id: "a",
|
||||
slugId: "s",
|
||||
title: "A",
|
||||
children: [
|
||||
{ id: "a1", slugId: "s", title: "A1", children: [] },
|
||||
{ id: "a2", slugId: "s", title: "A2", children: [] },
|
||||
],
|
||||
},
|
||||
{ id: "b", slugId: "s", title: "B", children: [] },
|
||||
];
|
||||
expect(countNodes(tree)).toBe(4);
|
||||
expect(countNodes([])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapSharedNodes", () => {
|
||||
it("remaps value->id / name->title and keeps nested children", () => {
|
||||
const shared = [
|
||||
{
|
||||
value: "p1",
|
||||
slugId: "s1",
|
||||
name: "Parent",
|
||||
icon: "📁",
|
||||
children: [
|
||||
{ value: "c1", slugId: "sc1", name: "Child", children: [] },
|
||||
],
|
||||
},
|
||||
] as any;
|
||||
const mapped = mapSharedNodes(shared);
|
||||
expect(mapped[0]).toMatchObject({ id: "p1", slugId: "s1", title: "Parent", icon: "📁" });
|
||||
expect(mapped[0].children[0]).toMatchObject({ id: "c1", title: "Child" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { sortPositionKeys } from "@/features/page/tree/utils/utils";
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
import { SharedPageTreeNode } from "@/features/share/utils";
|
||||
|
||||
// Normalized node shared by the flat and recursive subpages renderers so the
|
||||
// same link/icon markup works for both API pages and shared-tree nodes.
|
||||
export interface SubpageNode {
|
||||
id: string;
|
||||
slugId: string;
|
||||
title: string;
|
||||
icon?: string;
|
||||
children: SubpageNode[];
|
||||
}
|
||||
|
||||
// Subpage node carrying `position` so each level can be sorted in place.
|
||||
export type SubpageNodeWithPos = SubpageNode & {
|
||||
position: string;
|
||||
children: SubpageNodeWithPos[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a nested subtree (the current page's descendants) from the flat `IPage[]`
|
||||
* the `/pages/tree` endpoint returns. Attaches each node to its parent by
|
||||
* `parentPageId`, drops the root itself, and sorts every level by `position`.
|
||||
*
|
||||
* Guards only against SELF-PARENTING and attaching the root (`p.id !== rootId`) —
|
||||
* NOT against multi-node `parentPageId` cycles. Those cannot occur here: the
|
||||
* server rejects cyclic moves, and the recursive `getPageAndDescendants` CTE that
|
||||
* produces this list would itself loop before reaching the client, so the flat
|
||||
* input is acyclic by construction. A node whose `parentPageId` points outside
|
||||
* the result set (an unreachable parent) is silently dropped — it is, by
|
||||
* definition, not a descendant of the root being rendered.
|
||||
*/
|
||||
export function buildSubtree(pages: IPage[], rootId: string): SubpageNode[] {
|
||||
const byId = new Map<string, SubpageNodeWithPos>(
|
||||
pages.map((p) => [
|
||||
p.id,
|
||||
{
|
||||
id: p.id,
|
||||
slugId: p.slugId,
|
||||
title: p.title,
|
||||
icon: p.icon,
|
||||
position: p.position,
|
||||
children: [],
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
for (const p of pages) {
|
||||
const node = byId.get(p.id);
|
||||
const parent = p.parentPageId ? byId.get(p.parentPageId) : undefined;
|
||||
if (node && parent && p.id !== rootId) {
|
||||
parent.children.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
const sortRecursive = (
|
||||
nodes: SubpageNodeWithPos[],
|
||||
): SubpageNodeWithPos[] => {
|
||||
const sorted = sortPositionKeys(nodes) as SubpageNodeWithPos[];
|
||||
sorted.forEach((n) => sortRecursive(n.children));
|
||||
return sorted;
|
||||
};
|
||||
|
||||
const root = byId.get(rootId);
|
||||
return root ? sortRecursive(root.children) : [];
|
||||
}
|
||||
|
||||
// Map shared-tree nodes (already nested) onto the normalized SubpageNode shape.
|
||||
export function mapSharedNodes(nodes: SharedPageTreeNode[]): SubpageNode[] {
|
||||
return nodes.map((node) => ({
|
||||
id: node.value,
|
||||
slugId: node.slugId,
|
||||
title: node.name,
|
||||
icon: node.icon,
|
||||
children: node.children ? mapSharedNodes(node.children) : [],
|
||||
}));
|
||||
}
|
||||
|
||||
// Count every descendant in a normalized subtree.
|
||||
export function countNodes(nodes: SubpageNode[]): number {
|
||||
return nodes.reduce((acc, n) => acc + 1 + countNodes(n.children), 0);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
getAllSidebarPages,
|
||||
getDeletedPages,
|
||||
restorePage,
|
||||
getSpaceTree,
|
||||
} from "@/features/page/services/page-service";
|
||||
import {
|
||||
IMovePage,
|
||||
@@ -303,6 +304,15 @@ export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useGetPageTreeQuery(pageId: string) {
|
||||
return useQuery({
|
||||
queryKey: ["page-tree", pageId],
|
||||
queryFn: () => getSpaceTree({ pageId }),
|
||||
enabled: !!pageId,
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePageBreadcrumbsQuery(
|
||||
pageId: string,
|
||||
): UseQueryResult<Partial<IPage[]>, Error> {
|
||||
@@ -363,7 +373,18 @@ export function useDeletedPagesQuery(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate every cached page-subtree (the recursive `subpages` node, issue
|
||||
* #150). Called from each tree-structure cache helper below so a create / move /
|
||||
* rename / delete (local OR websocket-echoed) refreshes any open recursive tree.
|
||||
* Keyed loosely (`["page-tree"]` prefix) so all subtrees are caught.
|
||||
*/
|
||||
function invalidatePageTree() {
|
||||
queryClient.invalidateQueries({ queryKey: ["page-tree"] });
|
||||
}
|
||||
|
||||
export function invalidateOnCreatePage(data: Partial<IPage>) {
|
||||
invalidatePageTree();
|
||||
const newPage: Partial<IPage> = {
|
||||
creatorId: data.creatorId,
|
||||
hasChildren: data.hasChildren,
|
||||
@@ -478,6 +499,7 @@ export function invalidateOnUpdatePage(
|
||||
title: string,
|
||||
icon: string,
|
||||
) {
|
||||
invalidatePageTree();
|
||||
let queryKey: QueryKey = null;
|
||||
if (parentPageId === null) {
|
||||
queryKey = ["root-sidebar-pages", spaceId];
|
||||
@@ -516,6 +538,7 @@ export function updateCacheOnMovePage(
|
||||
newParentId: string | null,
|
||||
pageData: Partial<IPage>,
|
||||
) {
|
||||
invalidatePageTree();
|
||||
// Remove page from old parent's cache
|
||||
const oldQueryKey =
|
||||
oldParentId === null
|
||||
@@ -633,6 +656,7 @@ export function updateCacheOnMovePage(
|
||||
}
|
||||
|
||||
export function invalidateOnDeletePage(pageId: string) {
|
||||
invalidatePageTree();
|
||||
//update all sidebar pages
|
||||
const allSideBarMatches = queryClient.getQueriesData({
|
||||
predicate: (query) =>
|
||||
|
||||
@@ -93,7 +93,7 @@ export async function getAllSidebarPages(
|
||||
}
|
||||
|
||||
export async function getSpaceTree(params: {
|
||||
spaceId: string;
|
||||
spaceId?: string;
|
||||
pageId?: string;
|
||||
}): Promise<IPage[]> {
|
||||
const req = await api.post<{ items: IPage[] }>("/pages/tree", params);
|
||||
|
||||
@@ -27,3 +27,11 @@ export function useSharedPageSubpages(pageId: string | undefined) {
|
||||
return findSubpages(treeData);
|
||||
}, [treeData, pageId]);
|
||||
}
|
||||
|
||||
// Recursive variant for the subpages node in a shared/public context. The shared
|
||||
// tree (`sharedTreeDataAtom`) is ALREADY fully nested, so a page's `children`
|
||||
// each carry their own nested `children` — exactly what the recursive renderer
|
||||
// needs. The data is therefore identical to the flat hook; only the rendering
|
||||
// differs (the recursive view walks `children` instead of showing one level).
|
||||
// Thin alias to avoid duplicating the lookup. No `/pages/tree` request here.
|
||||
export const useSharedPageSubtree = useSharedPageSubpages;
|
||||
|
||||
@@ -53,6 +53,8 @@ const formSchema = z.object({
|
||||
driver: z.enum(["", ...AI_DRIVER_VALUES]),
|
||||
chatModel: z.string(),
|
||||
enabled: z.boolean(),
|
||||
autoStart: z.boolean(),
|
||||
launchMessage: z.string(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
@@ -83,6 +85,8 @@ export default function AiAgentRoleForm({
|
||||
driver: (role?.modelConfig?.driver ?? "") as FormValues["driver"],
|
||||
chatModel: role?.modelConfig?.chatModel ?? "",
|
||||
enabled: role?.enabled ?? true,
|
||||
autoStart: role?.autoStart ?? true,
|
||||
launchMessage: role?.launchMessage ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -96,6 +100,8 @@ export default function AiAgentRoleForm({
|
||||
driver: (role?.modelConfig?.driver ?? "") as FormValues["driver"],
|
||||
chatModel: role?.modelConfig?.chatModel ?? "",
|
||||
enabled: role?.enabled ?? true,
|
||||
autoStart: role?.autoStart ?? true,
|
||||
launchMessage: role?.launchMessage ?? "",
|
||||
});
|
||||
form.resetDirty();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -122,6 +128,8 @@ export default function AiAgentRoleForm({
|
||||
instructions: values.instructions,
|
||||
modelConfig,
|
||||
enabled: values.enabled,
|
||||
autoStart: values.autoStart,
|
||||
launchMessage: values.launchMessage,
|
||||
};
|
||||
await updateMutation.mutateAsync(payload);
|
||||
} else {
|
||||
@@ -132,6 +140,10 @@ export default function AiAgentRoleForm({
|
||||
instructions: values.instructions,
|
||||
modelConfig,
|
||||
enabled: values.enabled,
|
||||
autoStart: values.autoStart,
|
||||
// Send the raw (trimmed) value like the update path; the server
|
||||
// normalizes an empty string to null (emptyToNull). Symmetric.
|
||||
launchMessage: values.launchMessage,
|
||||
};
|
||||
await createMutation.mutateAsync(payload);
|
||||
}
|
||||
@@ -195,6 +207,28 @@ export default function AiAgentRoleForm({
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Switch
|
||||
label={t("Start automatically")}
|
||||
description={t(
|
||||
"When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.",
|
||||
)}
|
||||
checked={form.values.autoStart}
|
||||
onChange={(event) =>
|
||||
form.setFieldValue("autoStart", event.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label={t("Launch message")}
|
||||
description={t(
|
||||
"Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.",
|
||||
)}
|
||||
autosize
|
||||
minRows={2}
|
||||
maxRows={6}
|
||||
{...form.getInputProps("launchMessage")}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label={t("Enabled")}
|
||||
checked={form.values.enabled}
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
rowToUiMessage,
|
||||
prepareAgentStep,
|
||||
buildPartialAssistantRecord,
|
||||
chatStreamStartMetadata,
|
||||
chatStreamMetadata,
|
||||
accumulateStepUsage,
|
||||
MAX_AGENT_STEPS,
|
||||
FINAL_STEP_INSTRUCTION,
|
||||
} from './ai-chat.service';
|
||||
@@ -298,18 +299,135 @@ describe('buildPartialAssistantRecord', () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* chatStreamStartMetadata: attach the authoritative chatId to the streamed
|
||||
* assistant UI message ONLY on the `start` part (so the client adopts the real
|
||||
* created chat id at the first chunk — see #137). Any non-start part adds none.
|
||||
* chatStreamMetadata: attach metadata to the streamed assistant UI message per
|
||||
* part type — `chatId` on `start` (so the client adopts the real created chat id
|
||||
* at the first chunk — see #137), and AUTHORITATIVE usage (incl. reasoning
|
||||
* tokens) on `finish-step` and `finish` so the client's live token counter snaps
|
||||
* to exact at each step/turn boundary.
|
||||
*/
|
||||
describe('chatStreamStartMetadata', () => {
|
||||
describe('chatStreamMetadata', () => {
|
||||
it('returns { chatId } for the start part', () => {
|
||||
expect(chatStreamStartMetadata({ type: 'start' }, 'chat-1')).toEqual({
|
||||
expect(chatStreamMetadata({ type: 'start' }, 'chat-1')).toEqual({
|
||||
chatId: 'chat-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined for a finish part (any non-start part)', () => {
|
||||
expect(chatStreamStartMetadata({ type: 'finish' }, 'chat-1')).toBeUndefined();
|
||||
it('returns the CUMULATIVE step usage passed in for the finish-step part', () => {
|
||||
// finish-step usage is per-step in v6; the caller accumulates and passes the
|
||||
// running sum, which this just wraps.
|
||||
expect(
|
||||
chatStreamMetadata(
|
||||
{ type: 'finish-step', usage: { outputTokens: 100 } },
|
||||
'chat-1',
|
||||
{ inputTokens: 500, outputTokens: 220, totalTokens: 720, reasoningTokens: 30 },
|
||||
),
|
||||
).toEqual({
|
||||
usage: { inputTokens: 500, outputTokens: 220, totalTokens: 720, reasoningTokens: 30 },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns turn usage for the finish part (reasoning from deprecated top-level field)', () => {
|
||||
expect(
|
||||
chatStreamMetadata(
|
||||
{
|
||||
type: 'finish',
|
||||
totalUsage: {
|
||||
inputTokens: 1000,
|
||||
outputTokens: 250,
|
||||
totalTokens: 1250,
|
||||
reasoningTokens: 50,
|
||||
},
|
||||
},
|
||||
'chat-1',
|
||||
),
|
||||
).toEqual({
|
||||
usage: {
|
||||
inputTokens: 1000,
|
||||
outputTokens: 250,
|
||||
totalTokens: 1250,
|
||||
reasoningTokens: 50,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers outputTokenDetails.reasoningTokens over the deprecated field (finish)', () => {
|
||||
expect(
|
||||
chatStreamMetadata(
|
||||
{
|
||||
type: 'finish',
|
||||
totalUsage: {
|
||||
outputTokens: 100,
|
||||
reasoningTokens: 5,
|
||||
outputTokenDetails: { reasoningTokens: 30 },
|
||||
},
|
||||
},
|
||||
'chat-1',
|
||||
),
|
||||
).toEqual({
|
||||
usage: {
|
||||
inputTokens: undefined,
|
||||
outputTokens: 100,
|
||||
totalTokens: undefined,
|
||||
reasoningTokens: 30,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined for a finish-step with no accumulated usage', () => {
|
||||
expect(
|
||||
chatStreamMetadata({ type: 'finish-step' }, 'chat-1'),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for an unrelated part (e.g. text-delta)', () => {
|
||||
expect(
|
||||
chatStreamMetadata({ type: 'text-delta' }, 'chat-1'),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* accumulateStepUsage: sums per-step usage into a running cumulative total so the
|
||||
* client never sees the live counter jump DOWN on a multi-step agent turn (#151).
|
||||
*/
|
||||
describe('accumulateStepUsage', () => {
|
||||
it('sums every field across two steps', () => {
|
||||
expect(
|
||||
accumulateStepUsage(
|
||||
{ inputTokens: 500, outputTokens: 100, totalTokens: 600, reasoningTokens: 30 },
|
||||
{ inputTokens: 520, outputTokens: 80, totalTokens: 600, reasoningTokens: 10 },
|
||||
),
|
||||
).toEqual({
|
||||
inputTokens: 1020,
|
||||
outputTokens: 180,
|
||||
totalTokens: 1200,
|
||||
reasoningTokens: 40,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the step as-is when there is no accumulator yet', () => {
|
||||
expect(accumulateStepUsage(undefined, { outputTokens: 10 })).toEqual({
|
||||
outputTokens: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the accumulator unchanged when the step usage is absent', () => {
|
||||
const acc = { outputTokens: 10 };
|
||||
expect(accumulateStepUsage(acc, undefined)).toBe(acc);
|
||||
});
|
||||
|
||||
it('returns undefined when both sides are absent', () => {
|
||||
expect(accumulateStepUsage(undefined, undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps a field undefined only when neither side has it', () => {
|
||||
expect(
|
||||
accumulateStepUsage({ outputTokens: 5 }, { outputTokens: 7 }),
|
||||
).toEqual({
|
||||
inputTokens: undefined,
|
||||
outputTokens: 12,
|
||||
totalTokens: undefined,
|
||||
reasoningTokens: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -420,7 +420,11 @@ export class AiChatService {
|
||||
toolCalls: serializeSteps(steps),
|
||||
metadata: {
|
||||
finishReason,
|
||||
usage: totalUsage,
|
||||
// Persist the turn's cumulative usage WITH reasoning tokens resolved
|
||||
// from either the new `outputTokenDetails` or the deprecated top-level
|
||||
// field, so reopened history / the Markdown export show the thinking
|
||||
// token cost too.
|
||||
usage: normalizeStreamUsage(totalUsage as StreamUsage) ?? totalUsage,
|
||||
// Final-step usage = the context actually fed to the model on the last LLM
|
||||
// call (full history + tool results) plus the answer it just generated.
|
||||
// input+output of the FINAL step ≈ the conversation's CURRENT context size,
|
||||
@@ -512,17 +516,42 @@ export class AiChatService {
|
||||
// does not buffer responses by default.
|
||||
// Scrub the SDK's hop-by-hop Connection header before it writes the head (Safari/HTTP2).
|
||||
stripStreamingHopByHopHeaders(res.raw);
|
||||
// Running sum of per-step usage (v6 `finish-step.usage` is per-step). Sent
|
||||
// as the cumulative authoritative usage so the client never jumps DOWN.
|
||||
let cumulativeStepUsage: ChatStreamUsage | undefined;
|
||||
result.pipeUIMessageStreamToResponse(res.raw, {
|
||||
headers: { 'X-Accel-Buffering': 'no' },
|
||||
// Surface the authoritative chatId on the streamed assistant UI message so
|
||||
// the client adopts the REAL id of the row we created, instead of guessing
|
||||
// the newest chat in its list. `messageMetadata` is invoked by the AI SDK
|
||||
// on the `start` and `finish` stream parts (ai@6); we attach `chatId` on the
|
||||
// `start` part so it reaches the client (as message.metadata.chatId) at the
|
||||
// very first chunk — before any second tab can race a newer chat into the
|
||||
// list. This fixes the two-tab "adoption race" (#137) where a new chat in
|
||||
// tab A could adopt tab B's id and leak its turns into the wrong row.
|
||||
messageMetadata: ({ part }) => chatStreamStartMetadata(part, chatId),
|
||||
// on the `start`, `finish-step` and `finish` stream parts (ai@6 — note the
|
||||
// `finish-step` trigger relies on it being delivered as its own
|
||||
// message-metadata chunk); we attach `chatId` on the `start` part so it
|
||||
// reaches the client (as message.metadata.chatId) at the very first chunk —
|
||||
// before any second tab can race a newer chat into the list. This fixes the
|
||||
// two-tab "adoption race" (#137).
|
||||
//
|
||||
// `finish-step.usage` is PER-STEP (not cumulative) in v6, and the client
|
||||
// merges each metadata.usage by replacement — so on a multi-step agent turn
|
||||
// (up to MAX_AGENT_STEPS) the naive per-step value would make the live
|
||||
// counter jump DOWN at each boundary. We keep a running sum here and send
|
||||
// the CUMULATIVE usage, which converges to `finish.totalUsage` (#151).
|
||||
messageMetadata: ({ part }) => {
|
||||
const p = part as StreamMetadataPart;
|
||||
if (p.type === 'finish-step') {
|
||||
cumulativeStepUsage = accumulateStepUsage(
|
||||
cumulativeStepUsage,
|
||||
normalizeStreamUsage(p.usage),
|
||||
);
|
||||
}
|
||||
return chatStreamMetadata(p, chatId, cumulativeStepUsage);
|
||||
},
|
||||
// Stream reasoning (thinking) parts to the client so the live counter can
|
||||
// estimate reasoning tokens from streamed text. v6 default is already
|
||||
// true; set explicitly so the intent survives any future SDK default
|
||||
// change. Providers that don't emit reasoning text still surface the
|
||||
// count via the authoritative `usage.reasoningTokens` on finish-step.
|
||||
sendReasoning: true,
|
||||
onError: (error: unknown) => {
|
||||
// Reuse the shared formatter so provider error formatting stays
|
||||
// unified between the log line and the streamed error message.
|
||||
@@ -573,16 +602,97 @@ export class AiChatService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Shape of the AI SDK v6 LanguageModelUsage we forward to the client. The SDK
|
||||
* exposes `reasoningTokens` both as a (deprecated) top-level field and under
|
||||
* `outputTokenDetails.reasoningTokens`; we normalize to a single field so the
|
||||
* client gets one stable usage shape regardless of provider/SDK version. */
|
||||
interface StreamUsage {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
reasoningTokens?: number;
|
||||
outputTokenDetails?: { reasoningTokens?: number };
|
||||
}
|
||||
|
||||
/** A streamed part the messageMetadata callback can receive (only the fields we read). */
|
||||
interface StreamMetadataPart {
|
||||
type: string;
|
||||
usage?: StreamUsage;
|
||||
totalUsage?: StreamUsage;
|
||||
}
|
||||
|
||||
/** Authoritative usage we attach to a streamed assistant message's metadata. */
|
||||
export interface ChatStreamUsage {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
reasoningTokens?: number;
|
||||
}
|
||||
|
||||
/** Normalize an AI SDK usage object to our flat client-facing shape, resolving
|
||||
* reasoning tokens from either the new `outputTokenDetails` or the deprecated
|
||||
* top-level field. Returns undefined for a missing usage object. */
|
||||
function normalizeStreamUsage(
|
||||
usage: StreamUsage | undefined,
|
||||
): ChatStreamUsage | undefined {
|
||||
if (!usage) return undefined;
|
||||
const reasoningTokens =
|
||||
usage.outputTokenDetails?.reasoningTokens ?? usage.reasoningTokens;
|
||||
return {
|
||||
inputTokens: usage.inputTokens,
|
||||
outputTokens: usage.outputTokens,
|
||||
totalTokens: usage.totalTokens,
|
||||
reasoningTokens,
|
||||
};
|
||||
}
|
||||
|
||||
/** Sum a (normalized) per-step usage into a running cumulative usage. v6's
|
||||
* `finish-step.usage` is PER-STEP, so the caller accumulates across steps; the
|
||||
* cumulative sum converges to the turn's `totalUsage` (no down-jump on the
|
||||
* client). Returns undefined only when both sides are absent. Pure. */
|
||||
export function accumulateStepUsage(
|
||||
acc: ChatStreamUsage | undefined,
|
||||
step: ChatStreamUsage | undefined,
|
||||
): ChatStreamUsage | undefined {
|
||||
if (!acc) return step;
|
||||
if (!step) return acc;
|
||||
const add = (a?: number, b?: number): number | undefined =>
|
||||
a == null && b == null ? undefined : (a ?? 0) + (b ?? 0);
|
||||
return {
|
||||
inputTokens: add(acc.inputTokens, step.inputTokens),
|
||||
outputTokens: add(acc.outputTokens, step.outputTokens),
|
||||
totalTokens: add(acc.totalTokens, step.totalTokens),
|
||||
reasoningTokens: add(acc.reasoningTokens, step.reasoningTokens),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the authoritative `chatId` to the streamed assistant message's `start`
|
||||
* part (as `message.metadata.chatId`) so the client can adopt the real id for a
|
||||
* new chat. See the client's adopt-chat-id.ts for the full #137 design.
|
||||
* Pure metadata builder for the streamed assistant UI message. The AI SDK calls
|
||||
* `messageMetadata` on the `start`, `finish-step` and `finish` stream parts; we
|
||||
* attach (as `message.metadata`):
|
||||
* - `start` -> `{ chatId }` so the client adopts the real created chat id
|
||||
* at the first chunk (see adopt-chat-id.ts / #137).
|
||||
* - `finish-step` -> `{ usage }` the CUMULATIVE authoritative usage so far
|
||||
* (incl. reasoning tokens) — the caller passes the running
|
||||
* sum (`cumulativeStepUsage`), since v6 per-step usage is not
|
||||
* cumulative; the client snaps to exact without jumping down.
|
||||
* - `finish` -> `{ usage }` from the turn's `totalUsage` (final reconcile).
|
||||
* Any other part type contributes no metadata. Pure + unit-testable.
|
||||
*/
|
||||
export function chatStreamStartMetadata(
|
||||
part: { type: string },
|
||||
export function chatStreamMetadata(
|
||||
part: StreamMetadataPart,
|
||||
chatId: string,
|
||||
): { chatId: string } | undefined {
|
||||
return part.type === 'start' ? { chatId } : undefined;
|
||||
cumulativeStepUsage?: ChatStreamUsage,
|
||||
): { chatId: string } | { usage: ChatStreamUsage } | undefined {
|
||||
if (part.type === 'start') return { chatId };
|
||||
if (part.type === 'finish-step') {
|
||||
return cumulativeStepUsage ? { usage: cumulativeStepUsage } : undefined;
|
||||
}
|
||||
if (part.type === 'finish') {
|
||||
const usage = normalizeStreamUsage(part.totalUsage);
|
||||
return usage ? { usage } : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The last message with role 'user' from a useChat payload, if any. */
|
||||
|
||||
@@ -25,6 +25,8 @@ describe('AiAgentRolesService guards', () => {
|
||||
instructions: 'be a researcher',
|
||||
modelConfig: null,
|
||||
enabled: true,
|
||||
autoStart: true,
|
||||
launchMessage: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...over,
|
||||
@@ -159,6 +161,8 @@ describe('AiAgentRolesService guards', () => {
|
||||
instructions: 'updated instructions',
|
||||
modelConfig: { driver: 'gemini', chatModel: 'gemini-2.0-flash' },
|
||||
enabled: false,
|
||||
autoStart: true,
|
||||
launchMessage: null,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
});
|
||||
@@ -186,6 +190,35 @@ describe('AiAgentRolesService guards', () => {
|
||||
expect(patch2.emoji).toBeUndefined();
|
||||
expect(patch2.description).toBeUndefined();
|
||||
});
|
||||
|
||||
it('autoStart/launchMessage thread through; launchMessage:"" clears to null', async () => {
|
||||
const { service, repo } = makeService({ existing: makeRow() });
|
||||
await service.update('ws-1', 'r1', {
|
||||
autoStart: false,
|
||||
launchMessage: ' custom ',
|
||||
} as UpdateAgentRoleDto);
|
||||
const patch = repo.update.mock.calls[0][2];
|
||||
expect(patch.autoStart).toBe(false);
|
||||
expect(patch.launchMessage).toBe('custom');
|
||||
|
||||
repo.update.mockClear();
|
||||
|
||||
// Explicit empty => clear to null.
|
||||
await service.update('ws-1', 'r1', {
|
||||
launchMessage: ' ',
|
||||
} as UpdateAgentRoleDto);
|
||||
expect(repo.update.mock.calls[0][2].launchMessage).toBeNull();
|
||||
});
|
||||
|
||||
it('autoStart/launchMessage omitted => undefined (unchanged) in the patch', async () => {
|
||||
const { service, repo } = makeService({ existing: makeRow() });
|
||||
await service.update('ws-1', 'r1', {
|
||||
name: 'Renamed',
|
||||
} as UpdateAgentRoleDto);
|
||||
const patch = repo.update.mock.calls[0][2];
|
||||
expect(patch.autoStart).toBeUndefined();
|
||||
expect(patch.launchMessage).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
@@ -319,6 +352,40 @@ describe('AiAgentRolesService guards', () => {
|
||||
} as CreateAgentRoleDto),
|
||||
).rejects.toBe(other);
|
||||
});
|
||||
|
||||
it('autoStart omitted => defaults to true; launchMessage omitted => null', async () => {
|
||||
const { service, repo } = makeService();
|
||||
await service.create('ws-1', 'u1', {
|
||||
name: 'R',
|
||||
instructions: 'do',
|
||||
} as CreateAgentRoleDto);
|
||||
const values = repo.insert.mock.calls[0][0];
|
||||
expect(values.autoStart).toBe(true);
|
||||
expect(values.launchMessage).toBeNull();
|
||||
});
|
||||
|
||||
it('autoStart:false + launchMessage round-trip (trimmed) to the repo', async () => {
|
||||
const { service, repo } = makeService();
|
||||
await service.create('ws-1', 'u1', {
|
||||
name: 'R',
|
||||
instructions: 'do',
|
||||
autoStart: false,
|
||||
launchMessage: ' do the thing ',
|
||||
} as CreateAgentRoleDto);
|
||||
const values = repo.insert.mock.calls[0][0];
|
||||
expect(values.autoStart).toBe(false);
|
||||
expect(values.launchMessage).toBe('do the thing');
|
||||
});
|
||||
|
||||
it('empty/whitespace launchMessage normalizes to null', async () => {
|
||||
const { service, repo } = makeService();
|
||||
await service.create('ws-1', 'u1', {
|
||||
name: 'R',
|
||||
instructions: 'do',
|
||||
launchMessage: ' ',
|
||||
} as CreateAgentRoleDto);
|
||||
expect(repo.insert.mock.calls[0][0].launchMessage).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('list view (security: non-admin must not see instructions/modelConfig)', () => {
|
||||
@@ -349,19 +416,25 @@ describe('AiAgentRolesService guards', () => {
|
||||
const list = await service.list('ws-1', false);
|
||||
expect(list).toHaveLength(1);
|
||||
const item = list[0] as unknown as Record<string, unknown>;
|
||||
// The picker fields ARE present...
|
||||
// The picker fields ARE present — INCLUDING the auto-start fields, which
|
||||
// the client needs to decide whether/what to auto-send on role pick.
|
||||
expect(item).toEqual({
|
||||
id: 'r1',
|
||||
name: 'Researcher',
|
||||
emoji: '🔬',
|
||||
description: 'finds things',
|
||||
enabled: true,
|
||||
autoStart: true,
|
||||
launchMessage: null,
|
||||
});
|
||||
// ...and the admin-only fields are absent (not just undefined).
|
||||
expect('instructions' in item).toBe(false);
|
||||
expect('modelConfig' in item).toBe(false);
|
||||
expect('createdAt' in item).toBe(false);
|
||||
expect('updatedAt' in item).toBe(false);
|
||||
// autoStart/launchMessage are deliberately NOT admin-only — present here.
|
||||
expect('autoStart' in item).toBe(true);
|
||||
expect('launchMessage' in item).toBe(true);
|
||||
});
|
||||
|
||||
it('admin (isAdmin=true) gets the full view WITH instructions/modelConfig', async () => {
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface AgentRoleView {
|
||||
instructions: string;
|
||||
modelConfig: RoleModelConfig | null;
|
||||
enabled: boolean;
|
||||
autoStart: boolean;
|
||||
launchMessage: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -31,6 +33,11 @@ export interface AgentRoleView {
|
||||
* role picker needs — deliberately WITHOUT `instructions`, `modelConfig`,
|
||||
* creator or timestamps, so non-admins never receive the admin-authored prompt
|
||||
* or the model override.
|
||||
*
|
||||
* `autoStart` / `launchMessage` ARE included (unlike instructions/modelConfig):
|
||||
* the client needs them to decide whether and what to auto-send when a role card
|
||||
* is picked. `launchMessage` is sent verbatim as a normal user message — it is
|
||||
* not a secret, so exposing it to members is intentional.
|
||||
*/
|
||||
export interface AgentRolePickerView {
|
||||
id: string;
|
||||
@@ -38,6 +45,8 @@ export interface AgentRolePickerView {
|
||||
emoji: string | null;
|
||||
description: string | null;
|
||||
enabled: boolean;
|
||||
autoStart: boolean;
|
||||
launchMessage: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,6 +96,9 @@ export class AiAgentRolesService {
|
||||
instructions,
|
||||
modelConfig: modelConfig as Record<string, unknown> | null,
|
||||
enabled: dto.enabled ?? true,
|
||||
autoStart: dto.autoStart ?? true,
|
||||
// Empty/whitespace-only => null (client default launch message).
|
||||
launchMessage: emptyToNull(dto.launchMessage),
|
||||
});
|
||||
return this.toView(row);
|
||||
} catch (err) {
|
||||
@@ -128,6 +140,12 @@ export class AiAgentRolesService {
|
||||
| Record<string, unknown>
|
||||
| null),
|
||||
enabled: dto.enabled,
|
||||
autoStart: dto.autoStart,
|
||||
// undefined => unchanged; '' => clear to null.
|
||||
launchMessage:
|
||||
dto.launchMessage === undefined
|
||||
? undefined
|
||||
: emptyToNull(dto.launchMessage),
|
||||
});
|
||||
} catch (err) {
|
||||
throw rethrowDuplicateName(err, dto.name?.trim() || existing.name);
|
||||
@@ -156,12 +174,18 @@ export class AiAgentRolesService {
|
||||
instructions: row.instructions,
|
||||
modelConfig: (row.modelConfig ?? null) as RoleModelConfig | null,
|
||||
enabled: row.enabled,
|
||||
autoStart: row.autoStart,
|
||||
launchMessage: row.launchMessage ?? null,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Non-admin picker view: id/name/emoji/description/enabled only. */
|
||||
/**
|
||||
* Non-admin picker view: id/name/emoji/description/enabled plus the auto-start
|
||||
* fields the client needs to decide whether/what to send on role pick. Still
|
||||
* WITHOUT instructions/modelConfig (admin-only).
|
||||
*/
|
||||
private toPickerView(row: AiAgentRole): AgentRolePickerView {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -169,6 +193,8 @@ export class AiAgentRolesService {
|
||||
emoji: row.emoji ?? null,
|
||||
description: row.description ?? null,
|
||||
enabled: row.enabled,
|
||||
autoStart: row.autoStart,
|
||||
launchMessage: row.launchMessage ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,4 +78,32 @@ describe('CreateAgentRoleDto with nested modelConfig', () => {
|
||||
});
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('accepts autoStart:false + a launchMessage', () => {
|
||||
expect(
|
||||
validateCreate({ ...base, autoStart: false, launchMessage: 'Go' }),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects a non-boolean autoStart', () => {
|
||||
const errors = validateCreate({ ...base, autoStart: 'yes' });
|
||||
expect(errors.some((e) => e.property === 'autoStart')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a launchMessage longer than 2000 chars', () => {
|
||||
const errors = validateCreate({
|
||||
...base,
|
||||
launchMessage: 'a'.repeat(2001),
|
||||
});
|
||||
expect(errors.some((e) => e.property === 'launchMessage')).toBe(true);
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace from launchMessage', () => {
|
||||
const dto = plainToInstance(CreateAgentRoleDto, {
|
||||
...base,
|
||||
launchMessage: ' Look here ',
|
||||
});
|
||||
expect(validateSync(dto as object)).toHaveLength(0);
|
||||
expect(dto.launchMessage).toBe('Look here');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,6 +65,22 @@ export class CreateAgentRoleDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
|
||||
// Whether picking this role auto-sends a launch message and starts the chat.
|
||||
// Omitted => default true (preserves the previous always-auto-start behavior).
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
autoStart?: boolean;
|
||||
|
||||
// Optional custom auto-start text. Trimmed at the boundary (like chatModel);
|
||||
// empty/whitespace-only => the client falls back to its default launch message.
|
||||
@IsOptional()
|
||||
@Transform(({ value }: TransformFnParams) =>
|
||||
typeof value === 'string' ? value.trim() : value,
|
||||
)
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
launchMessage?: string;
|
||||
}
|
||||
|
||||
/** Admin update payload for an agent role (all fields optional). */
|
||||
@@ -98,4 +114,19 @@ export class UpdateAgentRoleDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
|
||||
// Whether picking this role auto-sends a launch message and starts the chat.
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
autoStart?: boolean;
|
||||
|
||||
// Optional custom auto-start text. Trimmed at the boundary (like chatModel);
|
||||
// empty/whitespace-only => the client falls back to its default launch message.
|
||||
@IsOptional()
|
||||
@Transform(({ value }: TransformFnParams) =>
|
||||
typeof value === 'string' ? value.trim() : value,
|
||||
)
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
launchMessage?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { type Kysely } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
// Per-role control over the new-chat auto-start behavior. Previously picking a
|
||||
// role card ALWAYS sent a hardcoded launch message and started the dialog.
|
||||
// These two columns make that configurable per role.
|
||||
await db.schema
|
||||
.alterTable('ai_agent_roles')
|
||||
// When true (default), picking the role auto-sends a launch message and
|
||||
// starts the conversation; when false the client only binds the role and
|
||||
// reveals the composer (nothing is sent). Default true => existing roles
|
||||
// keep their previous behavior.
|
||||
.addColumn('auto_start', 'boolean', (col) => col.notNull().defaultTo(true))
|
||||
// Optional custom text sent on auto-start instead of the built-in default.
|
||||
// NULL/empty => the client falls back to its default launch message.
|
||||
.addColumn('launch_message', 'text', (col) => col)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.alterTable('ai_agent_roles')
|
||||
.dropColumn('launch_message')
|
||||
.execute();
|
||||
await db.schema
|
||||
.alterTable('ai_agent_roles')
|
||||
.dropColumn('auto_start')
|
||||
.execute();
|
||||
}
|
||||
@@ -49,3 +49,81 @@ describe('AiAgentRoleRepo.findLiveEnabled', () => {
|
||||
expect(await repo.findLiveEnabled('r-1', 'ws-1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Column-threading tests for the auto-start feature: insert defaults autoStart to
|
||||
* true and stores an empty launchMessage as null; update only sets a column when
|
||||
* the patch field is present, and clears launchMessage to null on empty string.
|
||||
*/
|
||||
describe('AiAgentRoleRepo insert/update auto-start columns', () => {
|
||||
function makeInsertRepo() {
|
||||
const values = jest.fn();
|
||||
const builder = {
|
||||
values: jest.fn((v: unknown) => {
|
||||
values(v);
|
||||
return builder;
|
||||
}),
|
||||
returningAll: jest.fn(() => builder),
|
||||
executeTakeFirst: jest.fn().mockResolvedValue({}),
|
||||
};
|
||||
const db = {
|
||||
insertInto: jest.fn(() => builder),
|
||||
} as unknown as KyselyDB;
|
||||
return { repo: new AiAgentRoleRepo(db), values };
|
||||
}
|
||||
|
||||
function makeUpdateRepo() {
|
||||
const set = jest.fn();
|
||||
const builder = {
|
||||
set: jest.fn((s: unknown) => {
|
||||
set(s);
|
||||
return builder;
|
||||
}),
|
||||
where: jest.fn(() => builder),
|
||||
execute: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const db = {
|
||||
updateTable: jest.fn(() => builder),
|
||||
} as unknown as KyselyDB;
|
||||
return { repo: new AiAgentRoleRepo(db), set };
|
||||
}
|
||||
|
||||
it('insert defaults autoStart to true and stores empty launchMessage as null', async () => {
|
||||
const { repo, values } = makeInsertRepo();
|
||||
await repo.insert({
|
||||
workspaceId: 'ws-1',
|
||||
name: 'R',
|
||||
instructions: 'do',
|
||||
launchMessage: '',
|
||||
});
|
||||
const v = values.mock.calls[0][0];
|
||||
expect(v.autoStart).toBe(true);
|
||||
expect(v.launchMessage).toBeNull();
|
||||
});
|
||||
|
||||
it('insert threads autoStart:false and a launchMessage', async () => {
|
||||
const { repo, values } = makeInsertRepo();
|
||||
await repo.insert({
|
||||
workspaceId: 'ws-1',
|
||||
name: 'R',
|
||||
instructions: 'do',
|
||||
autoStart: false,
|
||||
launchMessage: 'Go',
|
||||
});
|
||||
const v = values.mock.calls[0][0];
|
||||
expect(v.autoStart).toBe(false);
|
||||
expect(v.launchMessage).toBe('Go');
|
||||
});
|
||||
|
||||
it('update omits unchanged columns; clears launchMessage to null on empty', async () => {
|
||||
const { repo, set } = makeUpdateRepo();
|
||||
await repo.update('r-1', 'ws-1', { autoStart: false });
|
||||
expect(set.mock.calls[0][0].autoStart).toBe(false);
|
||||
expect('launchMessage' in set.mock.calls[0][0]).toBe(false);
|
||||
|
||||
const { repo: repo2, set: set2 } = makeUpdateRepo();
|
||||
await repo2.update('r-1', 'ws-1', { launchMessage: '' });
|
||||
expect(set2.mock.calls[0][0].launchMessage).toBeNull();
|
||||
expect('autoStart' in set2.mock.calls[0][0]).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,6 +76,9 @@ export class AiAgentRoleRepo {
|
||||
instructions: string;
|
||||
modelConfig?: ModelConfigValue;
|
||||
enabled?: boolean;
|
||||
autoStart?: boolean;
|
||||
// null/'' => stored as null (client default launch message).
|
||||
launchMessage?: string | null;
|
||||
},
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<AiAgentRole> {
|
||||
@@ -91,6 +94,9 @@ export class AiAgentRoleRepo {
|
||||
instructions: values.instructions,
|
||||
modelConfig: jsonbObject(values.modelConfig),
|
||||
enabled: values.enabled ?? true,
|
||||
autoStart: values.autoStart ?? true,
|
||||
// Empty string is treated as "no custom text" => null.
|
||||
launchMessage: values.launchMessage || null,
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
@@ -108,6 +114,9 @@ export class AiAgentRoleRepo {
|
||||
// undefined => unchanged; null => clear; object => set.
|
||||
modelConfig?: ModelConfigValue;
|
||||
enabled?: boolean;
|
||||
autoStart?: boolean;
|
||||
// undefined => unchanged; null/'' => clear to null; string => set.
|
||||
launchMessage?: string | null;
|
||||
},
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
@@ -121,6 +130,11 @@ export class AiAgentRoleRepo {
|
||||
set.modelConfig = jsonbObject(patch.modelConfig);
|
||||
}
|
||||
if (patch.enabled !== undefined) set.enabled = patch.enabled;
|
||||
if (patch.autoStart !== undefined) set.autoStart = patch.autoStart;
|
||||
if (patch.launchMessage !== undefined) {
|
||||
// Empty string clears to null (client default launch message).
|
||||
set.launchMessage = patch.launchMessage || null;
|
||||
}
|
||||
await db
|
||||
.updateTable('aiAgentRoles')
|
||||
.set(set)
|
||||
|
||||
5
apps/server/src/database/types/db.d.ts
vendored
5
apps/server/src/database/types/db.d.ts
vendored
@@ -601,6 +601,11 @@ export interface AiAgentRoles {
|
||||
// { chatModel } | { driver, chatModel } | null. null => workspace default.
|
||||
modelConfig: Json | null;
|
||||
enabled: Generated<boolean>;
|
||||
// When true (default), picking the role auto-sends a launch message and starts
|
||||
// the new chat; when false the client only binds the role and shows the composer.
|
||||
autoStart: Generated<boolean>;
|
||||
// Optional custom auto-start text. null/empty => client default launch message.
|
||||
launchMessage: string | null;
|
||||
createdAt: Generated<Timestamp>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
deletedAt: Timestamp | null;
|
||||
|
||||
67
packages/editor-ext/src/lib/image/image.spec.ts
Normal file
67
packages/editor-ext/src/lib/image/image.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { applyAlignment } from "./image";
|
||||
|
||||
// applyAlignment is a pure DOM mutation: it sets the float / padding /
|
||||
// justify-content / data-image-align on an image node-view container per the
|
||||
// resolved `align`. Tested directly (issue #145 review) since the five-way
|
||||
// branch, the reset-then-apply guard, and the data-image-align mirror (which the
|
||||
// responsive @media rule keys off) are otherwise uncovered.
|
||||
|
||||
describe("applyAlignment", () => {
|
||||
let el: HTMLElement;
|
||||
beforeEach(() => {
|
||||
el = document.createElement("div");
|
||||
});
|
||||
|
||||
it("floatLeft -> float:left + right padding, mirrored on data-image-align", () => {
|
||||
applyAlignment(el, "floatLeft");
|
||||
expect(el.style.cssFloat).toBe("left");
|
||||
expect(el.style.padding).toBe("0px 10px 0px 0px");
|
||||
expect(el.dataset.imageAlign).toBe("floatLeft");
|
||||
expect(el.style.justifyContent).toBe("flex-start");
|
||||
});
|
||||
|
||||
it("floatRight -> float:right + left padding", () => {
|
||||
applyAlignment(el, "floatRight");
|
||||
expect(el.style.cssFloat).toBe("right");
|
||||
expect(el.style.padding).toBe("0px 0px 0px 10px");
|
||||
expect(el.dataset.imageAlign).toBe("floatRight");
|
||||
expect(el.style.justifyContent).toBe("flex-end");
|
||||
});
|
||||
|
||||
it("left -> justify flex-start, no float", () => {
|
||||
applyAlignment(el, "left");
|
||||
expect(el.style.justifyContent).toBe("flex-start");
|
||||
expect(el.style.cssFloat).toBe("");
|
||||
expect(el.style.padding).toBe("");
|
||||
expect(el.dataset.imageAlign).toBe("left");
|
||||
});
|
||||
|
||||
it("right -> justify flex-end, no float", () => {
|
||||
applyAlignment(el, "right");
|
||||
expect(el.style.justifyContent).toBe("flex-end");
|
||||
expect(el.style.cssFloat).toBe("");
|
||||
expect(el.dataset.imageAlign).toBe("right");
|
||||
});
|
||||
|
||||
it("center (default) -> justify center, no float", () => {
|
||||
applyAlignment(el, "center");
|
||||
expect(el.style.justifyContent).toBe("center");
|
||||
expect(el.style.cssFloat).toBe("");
|
||||
expect(el.style.padding).toBe("");
|
||||
expect(el.dataset.imageAlign).toBe("center");
|
||||
});
|
||||
|
||||
it("clears a previous float when switching floatLeft -> left (reset-then-apply)", () => {
|
||||
applyAlignment(el, "floatLeft");
|
||||
expect(el.style.cssFloat).toBe("left");
|
||||
expect(el.style.padding).toBe("0px 10px 0px 0px");
|
||||
// Switching to a block alignment must drop the float and its padding, not
|
||||
// leak them (the bug the reset guard prevents).
|
||||
applyAlignment(el, "left");
|
||||
expect(el.style.cssFloat).toBe("");
|
||||
expect(el.style.padding).toBe("");
|
||||
expect(el.dataset.imageAlign).toBe("left");
|
||||
expect(el.style.justifyContent).toBe("flex-start");
|
||||
});
|
||||
});
|
||||
@@ -51,7 +51,9 @@ declare module "@tiptap/core" {
|
||||
setImageAt: (
|
||||
attributes: ImageAttributes & { pos: number | Range },
|
||||
) => ReturnType;
|
||||
setImageAlign: (align: "left" | "center" | "right") => ReturnType;
|
||||
setImageAlign: (
|
||||
align: "left" | "center" | "right" | "floatLeft" | "floatRight",
|
||||
) => ReturnType;
|
||||
setImageWidth: (width: number) => ReturnType;
|
||||
setImageSize: (width: number, height: number) => ReturnType;
|
||||
};
|
||||
@@ -374,8 +376,27 @@ export const TiptapImage = Image.extend<ImageOptions>({
|
||||
},
|
||||
});
|
||||
|
||||
function applyAlignment(container: HTMLElement, align: string) {
|
||||
if (align === "left") {
|
||||
export function applyAlignment(container: HTMLElement, align: string) {
|
||||
// Reset the float-mode styles first so toggling between any two modes is clean
|
||||
// (a previous float must not leak into a later left/center/right).
|
||||
container.style.cssFloat = "";
|
||||
container.style.padding = "";
|
||||
// Mirror the resolved alignment onto the CONTAINER as a data attribute so the
|
||||
// responsive stylesheet can neutralize the float on small screens (an inline
|
||||
// `float` can only be overridden by `!important`, which keys off this attr).
|
||||
container.dataset.imageAlign = align;
|
||||
|
||||
if (align === "floatLeft") {
|
||||
// Real text wrap: the (shrink-to-fit) container floats left, text flows on
|
||||
// its right. The inner <img> already carries max-width:100%.
|
||||
container.style.cssFloat = "left";
|
||||
container.style.padding = "0 10px 0 0";
|
||||
container.style.justifyContent = "flex-start";
|
||||
} else if (align === "floatRight") {
|
||||
container.style.cssFloat = "right";
|
||||
container.style.padding = "0 0 0 10px";
|
||||
container.style.justifyContent = "flex-end";
|
||||
} else if (align === "left") {
|
||||
container.style.justifyContent = "flex-start";
|
||||
} else if (align === "right") {
|
||||
container.style.justifyContent = "flex-end";
|
||||
|
||||
@@ -6,7 +6,9 @@ export interface SubpagesOptions {
|
||||
view: any;
|
||||
}
|
||||
|
||||
export interface SubpagesAttributes {}
|
||||
export interface SubpagesAttributes {
|
||||
recursive?: boolean;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
@@ -31,6 +33,18 @@ export const Subpages = Node.create<SubpagesOptions>({
|
||||
draggable: true,
|
||||
isolating: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
recursive: {
|
||||
// Existing nodes stay flat -> backward compatible.
|
||||
default: false,
|
||||
parseHTML: (el) => el.getAttribute("data-recursive") === "true",
|
||||
renderHTML: (attrs) =>
|
||||
attrs.recursive ? { "data-recursive": "true" } : {},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user