Compare commits
2 Commits
623c89554a
...
96a1bda8d0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96a1bda8d0 | ||
|
|
2a07255ffb |
13
AGENTS.md
13
AGENTS.md
@@ -157,19 +157,6 @@ 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,12 +1147,6 @@
|
||||
"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.",
|
||||
@@ -1164,7 +1158,6 @@
|
||||
"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…",
|
||||
@@ -1273,10 +1266,6 @@
|
||||
"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",
|
||||
@@ -1299,8 +1288,6 @@
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -677,21 +677,9 @@
|
||||
"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-чат отключён для этого рабочего пространства.",
|
||||
@@ -702,7 +690,6 @@
|
||||
"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}}",
|
||||
@@ -1151,8 +1138,6 @@
|
||||
"Dictation language": "Язык диктовки",
|
||||
"Auto-detect": "Автоопределение",
|
||||
"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": "Переключить режим отображения подстраниц",
|
||||
|
||||
@@ -156,12 +156,6 @@ 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"
|
||||
@@ -491,19 +485,11 @@ export default function AiChatWindow() {
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
|
||||
{/* 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 ? (
|
||||
{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 }}>
|
||||
@@ -622,7 +608,6 @@ export default function AiChatWindow() {
|
||||
assistantName={currentRole?.name}
|
||||
onTurnFinished={onTurnFinished}
|
||||
liveStateRef={liveThreadRef}
|
||||
onLiveTurnTokens={setLiveTurnTokens}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -111,24 +111,6 @@
|
||||
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,13 +21,8 @@ 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,
|
||||
@@ -74,12 +69,6 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,7 +113,6 @@ export default function ChatThread({
|
||||
assistantName,
|
||||
onTurnFinished,
|
||||
liveStateRef,
|
||||
onLiveTurnTokens,
|
||||
}: ChatThreadProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -322,98 +310,21 @@ 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;
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
const handleRolePick = (role: IAiRole): void => {
|
||||
roleIdRef.current = role.id;
|
||||
onRolePicked?.(role);
|
||||
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);
|
||||
}
|
||||
sendMessage({ text: t("Take a look at the current document") });
|
||||
};
|
||||
// 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 showRoleCards = chatId === null && (roles?.length ?? 0) > 0;
|
||||
const roleCardsEmptyState = showRoleCards ? (
|
||||
<RoleCards roles={roles ?? []} onPick={handleRolePick} />
|
||||
) : undefined;
|
||||
|
||||
@@ -2,14 +2,12 @@ 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";
|
||||
|
||||
@@ -79,31 +77,12 @@ 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,7 +6,6 @@ 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 {
|
||||
@@ -95,19 +94,6 @@ 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
|
||||
@@ -204,13 +190,7 @@ export default function MessageList({
|
||||
assistantName={assistantName}
|
||||
/>
|
||||
))}
|
||||
{typing && (
|
||||
<TypingIndicator
|
||||
assistantName={assistantName}
|
||||
showName={typingIndicatorShowsName(messages)}
|
||||
thinkingTokens={tailThinkingTokens(messages)}
|
||||
/>
|
||||
)}
|
||||
{typing && <TypingIndicator assistantName={assistantName} showName={typingIndicatorShowsName(messages)} />}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
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,8 +13,6 @@ const roles: IAiRole[] = [
|
||||
emoji: "🏴☠️",
|
||||
description: "Talks like a pirate",
|
||||
enabled: true,
|
||||
autoStart: true,
|
||||
launchMessage: null,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
@@ -22,8 +20,6 @@ const roles: IAiRole[] = [
|
||||
emoji: null,
|
||||
description: null,
|
||||
enabled: true,
|
||||
autoStart: true,
|
||||
launchMessage: null,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
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,12 +16,6 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,14 +30,9 @@ interface TypingIndicatorProps {
|
||||
* typing line is always the generic "Thinking…" (it never includes the
|
||||
* role/identity name).
|
||||
*/
|
||||
export default function TypingIndicator({ assistantName, showName = true, thinkingTokens }: TypingIndicatorProps) {
|
||||
export default function TypingIndicator({ assistantName, showName = true }: 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}>
|
||||
@@ -59,7 +48,7 @@ export default function TypingIndicator({ assistantName, showName = true, thinki
|
||||
<span />
|
||||
</span>
|
||||
<Text size="sm" c="dimmed">
|
||||
{thinkingLine}
|
||||
{t("Thinking…")}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
@@ -53,10 +53,6 @@ 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;
|
||||
}
|
||||
@@ -69,8 +65,6 @@ export interface IAiRoleCreate {
|
||||
instructions: string;
|
||||
modelConfig?: IAiRoleModelConfig | null;
|
||||
enabled?: boolean;
|
||||
autoStart?: boolean;
|
||||
launchMessage?: string;
|
||||
}
|
||||
|
||||
/** Admin update payload for a role (partial). */
|
||||
@@ -82,8 +76,6 @@ export interface IAiRoleUpdate {
|
||||
instructions?: string;
|
||||
modelConfig?: IAiRoleModelConfig | null;
|
||||
enabled?: boolean;
|
||||
autoStart?: boolean;
|
||||
launchMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,10 +98,6 @@ 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 `chatStreamMetadata`) reference here
|
||||
* (use-chat-session.ts, the server's `chatStreamStartMetadata`) 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 `chatStreamMetadata` server-side);
|
||||
* metadata's `start` part (see `chatStreamStartMetadata` 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
|
||||
* `chatStreamMetadata`). Returns it only when it is a string; undefined for
|
||||
* `chatStreamStartMetadata`). Returns it only when it is a string; undefined for
|
||||
* a missing message, missing metadata, or a non-string `chatId`.
|
||||
*/
|
||||
export function extractServerChatId(
|
||||
|
||||
@@ -314,57 +314,6 @@ 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,7 +77,6 @@ function rowTokens(usage: {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
reasoningTokens?: number;
|
||||
}): number {
|
||||
return (
|
||||
usage.totalTokens ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0)
|
||||
@@ -176,14 +175,8 @@ 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 ?? "?"}${reasoning}, total: ${total}_`,
|
||||
`_Tokens — in: ${usage.inputTokens ?? "?"}, out: ${usage.outputTokens ?? "?"}, total: ${total}_`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
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,18 +73,3 @@
|
||||
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,8 +13,6 @@ import {
|
||||
IconLayoutAlignCenter,
|
||||
IconLayoutAlignLeft,
|
||||
IconLayoutAlignRight,
|
||||
IconFloatLeft,
|
||||
IconFloatRight,
|
||||
IconDownload,
|
||||
IconRefresh,
|
||||
IconTrash,
|
||||
@@ -43,8 +41,6 @@ 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 || "",
|
||||
};
|
||||
@@ -108,22 +104,6 @@ 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);
|
||||
@@ -221,30 +201,6 @@ 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}
|
||||
|
||||
@@ -53,8 +53,6 @@ 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>;
|
||||
@@ -85,8 +83,6 @@ 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 ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -100,8 +96,6 @@ 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
|
||||
@@ -128,8 +122,6 @@ export default function AiAgentRoleForm({
|
||||
instructions: values.instructions,
|
||||
modelConfig,
|
||||
enabled: values.enabled,
|
||||
autoStart: values.autoStart,
|
||||
launchMessage: values.launchMessage,
|
||||
};
|
||||
await updateMutation.mutateAsync(payload);
|
||||
} else {
|
||||
@@ -140,10 +132,6 @@ 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);
|
||||
}
|
||||
@@ -207,28 +195,6 @@ 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,8 +5,7 @@ import {
|
||||
rowToUiMessage,
|
||||
prepareAgentStep,
|
||||
buildPartialAssistantRecord,
|
||||
chatStreamMetadata,
|
||||
accumulateStepUsage,
|
||||
chatStreamStartMetadata,
|
||||
MAX_AGENT_STEPS,
|
||||
FINAL_STEP_INSTRUCTION,
|
||||
} from './ai-chat.service';
|
||||
@@ -299,135 +298,18 @@ describe('buildPartialAssistantRecord', () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
describe('chatStreamMetadata', () => {
|
||||
describe('chatStreamStartMetadata', () => {
|
||||
it('returns { chatId } for the start part', () => {
|
||||
expect(chatStreamMetadata({ type: 'start' }, 'chat-1')).toEqual({
|
||||
expect(chatStreamStartMetadata({ type: 'start' }, 'chat-1')).toEqual({
|
||||
chatId: 'chat-1',
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
it('returns undefined for a finish part (any non-start part)', () => {
|
||||
expect(chatStreamStartMetadata({ type: 'finish' }, 'chat-1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -420,11 +420,7 @@ export class AiChatService {
|
||||
toolCalls: serializeSteps(steps),
|
||||
metadata: {
|
||||
finishReason,
|
||||
// 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,
|
||||
usage: 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,
|
||||
@@ -516,42 +512,17 @@ 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`, `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,
|
||||
// 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),
|
||||
onError: (error: unknown) => {
|
||||
// Reuse the shared formatter so provider error formatting stays
|
||||
// unified between the log line and the streamed error message.
|
||||
@@ -602,97 +573,16 @@ 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),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export function chatStreamMetadata(
|
||||
part: StreamMetadataPart,
|
||||
export function chatStreamStartMetadata(
|
||||
part: { type: string },
|
||||
chatId: string,
|
||||
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;
|
||||
): { chatId: string } | undefined {
|
||||
return part.type === 'start' ? { chatId } : undefined;
|
||||
}
|
||||
|
||||
/** The last message with role 'user' from a useChat payload, if any. */
|
||||
|
||||
@@ -25,8 +25,6 @@ describe('AiAgentRolesService guards', () => {
|
||||
instructions: 'be a researcher',
|
||||
modelConfig: null,
|
||||
enabled: true,
|
||||
autoStart: true,
|
||||
launchMessage: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...over,
|
||||
@@ -161,8 +159,6 @@ describe('AiAgentRolesService guards', () => {
|
||||
instructions: 'updated instructions',
|
||||
modelConfig: { driver: 'gemini', chatModel: 'gemini-2.0-flash' },
|
||||
enabled: false,
|
||||
autoStart: true,
|
||||
launchMessage: null,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
});
|
||||
@@ -190,35 +186,6 @@ 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', () => {
|
||||
@@ -352,40 +319,6 @@ 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)', () => {
|
||||
@@ -416,25 +349,19 @@ 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 — INCLUDING the auto-start fields, which
|
||||
// the client needs to decide whether/what to auto-send on role pick.
|
||||
// The picker fields ARE present...
|
||||
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,8 +22,6 @@ export interface AgentRoleView {
|
||||
instructions: string;
|
||||
modelConfig: RoleModelConfig | null;
|
||||
enabled: boolean;
|
||||
autoStart: boolean;
|
||||
launchMessage: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -33,11 +31,6 @@ 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;
|
||||
@@ -45,8 +38,6 @@ export interface AgentRolePickerView {
|
||||
emoji: string | null;
|
||||
description: string | null;
|
||||
enabled: boolean;
|
||||
autoStart: boolean;
|
||||
launchMessage: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,9 +87,6 @@ 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) {
|
||||
@@ -140,12 +128,6 @@ 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);
|
||||
@@ -174,18 +156,12 @@ 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 plus the auto-start
|
||||
* fields the client needs to decide whether/what to send on role pick. Still
|
||||
* WITHOUT instructions/modelConfig (admin-only).
|
||||
*/
|
||||
/** Non-admin picker view: id/name/emoji/description/enabled only. */
|
||||
private toPickerView(row: AiAgentRole): AgentRolePickerView {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -193,8 +169,6 @@ export class AiAgentRolesService {
|
||||
emoji: row.emoji ?? null,
|
||||
description: row.description ?? null,
|
||||
enabled: row.enabled,
|
||||
autoStart: row.autoStart,
|
||||
launchMessage: row.launchMessage ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,32 +78,4 @@ 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,22 +65,6 @@ 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). */
|
||||
@@ -114,19 +98,4 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
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,81 +49,3 @@ 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,9 +76,6 @@ 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> {
|
||||
@@ -94,9 +91,6 @@ 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();
|
||||
@@ -114,9 +108,6 @@ 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> {
|
||||
@@ -130,11 +121,6 @@ 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,11 +601,6 @@ 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;
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
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,9 +51,7 @@ declare module "@tiptap/core" {
|
||||
setImageAt: (
|
||||
attributes: ImageAttributes & { pos: number | Range },
|
||||
) => ReturnType;
|
||||
setImageAlign: (
|
||||
align: "left" | "center" | "right" | "floatLeft" | "floatRight",
|
||||
) => ReturnType;
|
||||
setImageAlign: (align: "left" | "center" | "right") => ReturnType;
|
||||
setImageWidth: (width: number) => ReturnType;
|
||||
setImageSize: (width: number, height: number) => ReturnType;
|
||||
};
|
||||
@@ -376,27 +374,8 @@ export const TiptapImage = Image.extend<ImageOptions>({
|
||||
},
|
||||
});
|
||||
|
||||
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") {
|
||||
function applyAlignment(container: HTMLElement, align: string) {
|
||||
if (align === "left") {
|
||||
container.style.justifyContent = "flex-start";
|
||||
} else if (align === "right") {
|
||||
container.style.justifyContent = "flex-end";
|
||||
|
||||
Reference in New Issue
Block a user