Compare commits
5 Commits
feat/189-c
...
batch/issu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b392219659 | ||
|
|
ba5cd02439 | ||
|
|
9b61024b95 | ||
|
|
2644fe6a83 | ||
|
|
993f884e64 |
157
.github/workflows/develop.yml
vendored
157
.github/workflows/develop.yml
vendored
@@ -56,3 +56,160 @@ jobs:
|
|||||||
tags: ${{ env.IMAGE }}:develop
|
tags: ${{ env.IMAGE }}:develop
|
||||||
cache-from: type=gha,scope=develop-amd64
|
cache-from: type=gha,scope=develop-amd64
|
||||||
cache-to: type=gha,scope=develop-amd64,mode=max,ignore-error=true
|
cache-to: type=gha,scope=develop-amd64,mode=max,ignore-error=true
|
||||||
|
|
||||||
|
# e2e jobs run on every develop push but DO NOT gate the build/publish above:
|
||||||
|
# `build` stays `needs: test` only, so the :develop image still ships even if
|
||||||
|
# e2e fails. A failing e2e job turns the run red and triggers GitHub's email
|
||||||
|
# to the pusher — that red run + email is the intended notification, not a
|
||||||
|
# deploy block.
|
||||||
|
e2e-server:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://docmost:docmost@localhost:5432/docmost
|
||||||
|
REDIS_URL: redis://localhost:6379
|
||||||
|
APP_SECRET: ci-e2e-secret-change-me-min-32-characters
|
||||||
|
APP_URL: http://localhost:3000
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: pgvector/pgvector:pg18
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: docmost
|
||||||
|
POSTGRES_USER: docmost
|
||||||
|
POSTGRES_PASSWORD: docmost
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U docmost"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 20
|
||||||
|
redis:
|
||||||
|
image: redis:7
|
||||||
|
ports:
|
||||||
|
- 6379:6379
|
||||||
|
options: >-
|
||||||
|
--health-cmd "redis-cli ping"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 20
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build editor-ext
|
||||||
|
run: pnpm --filter @docmost/editor-ext build
|
||||||
|
|
||||||
|
- name: Run migrations
|
||||||
|
run: pnpm --filter ./apps/server migration:latest
|
||||||
|
|
||||||
|
- name: Run server e2e
|
||||||
|
run: pnpm --filter ./apps/server test:e2e
|
||||||
|
|
||||||
|
# Same rationale as e2e-server: this job is intentionally NOT in
|
||||||
|
# `build.needs`. Deploy of the :develop image must not be blocked by e2e;
|
||||||
|
# a red run plus GitHub's email to the pusher is the notification mechanism.
|
||||||
|
e2e-mcp:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
DATABASE_URL: postgresql://docmost:docmost@localhost:5432/docmost
|
||||||
|
REDIS_URL: redis://localhost:6379
|
||||||
|
APP_SECRET: ci-e2e-secret-change-me-min-32-characters
|
||||||
|
APP_URL: http://localhost:3000
|
||||||
|
NODE_ENV: production
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: pgvector/pgvector:pg18
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: docmost
|
||||||
|
POSTGRES_USER: docmost
|
||||||
|
POSTGRES_PASSWORD: docmost
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U docmost"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 20
|
||||||
|
redis:
|
||||||
|
image: redis:7
|
||||||
|
ports:
|
||||||
|
- 6379:6379
|
||||||
|
options: >-
|
||||||
|
--health-cmd "redis-cli ping"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 20
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build editor-ext
|
||||||
|
run: pnpm --filter @docmost/editor-ext build
|
||||||
|
|
||||||
|
- name: Build server
|
||||||
|
run: pnpm server:build
|
||||||
|
|
||||||
|
- name: Build mcp
|
||||||
|
run: pnpm --filter @docmost/mcp build
|
||||||
|
|
||||||
|
- name: Run migrations
|
||||||
|
run: pnpm --filter ./apps/server migration:latest
|
||||||
|
|
||||||
|
- name: Start server (prod)
|
||||||
|
# Capture stdout/stderr so a start-up crash (bind error, stack trace,
|
||||||
|
# migration mismatch) is diagnosable; without this the only signal is
|
||||||
|
# the generic health-loop timeout below, ~120s later.
|
||||||
|
run: pnpm --filter ./apps/server start:prod > /tmp/server.log 2>&1 &
|
||||||
|
|
||||||
|
- name: Wait for server health
|
||||||
|
run: |
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if curl -fsS http://localhost:3000/api/health > /dev/null; then
|
||||||
|
echo "Server is healthy"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "Server did not become healthy in time"
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Dump server log on failure
|
||||||
|
if: failure()
|
||||||
|
run: cat /tmp/server.log || true
|
||||||
|
|
||||||
|
- name: Seed admin
|
||||||
|
run: |
|
||||||
|
curl -fsS -X POST http://localhost:3000/api/auth/setup \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"E2E","email":"e2e@example.com","password":"E2ePassword123","workspaceName":"E2E"}'
|
||||||
|
|
||||||
|
- name: Run mcp e2e
|
||||||
|
env:
|
||||||
|
DOCMOST_API_URL: http://localhost:3000/api
|
||||||
|
DOCMOST_EMAIL: e2e@example.com
|
||||||
|
DOCMOST_PASSWORD: E2ePassword123
|
||||||
|
run: pnpm --filter @docmost/mcp test:e2e
|
||||||
|
|||||||
13
CHANGELOG.md
13
CHANGELOG.md
@@ -43,13 +43,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
OpenRouter, etc.; `openai` uses the official provider (real-OpenAI
|
OpenRouter, etc.; `openai` uses the official provider (real-OpenAI
|
||||||
reasoning-model request shaping). Chosen explicitly rather than inferred from
|
reasoning-model request shaping). Chosen explicitly rather than inferred from
|
||||||
the base URL, since a custom URL can front real OpenAI too. (#175, #177)
|
the base URL, since a custom URL can front real OpenAI too. (#175, #177)
|
||||||
- **AI chat "Context window (tokens)" setting (`chatContextWindow`).** A new
|
|
||||||
admin field in AI settings that records the chat model's context-window size.
|
|
||||||
When set (> 0) it becomes the denominator of the header context-badge, which
|
|
||||||
now reads "used / max"; `0`/empty clears the limit and the badge shows only
|
|
||||||
the current context as before. There is no provider-independent way to read a
|
|
||||||
model's window automatically, so it is an explicit workspace-level value.
|
|
||||||
(#189)
|
|
||||||
- **Per-MCP-server instructions in the agent prompt.** Each external MCP server
|
- **Per-MCP-server instructions in the agent prompt.** Each external MCP server
|
||||||
now has an admin-authored `instructions` field ("how/when to use this server's
|
now has an admin-authored `instructions` field ("how/when to use this server's
|
||||||
tools") that is injected into the agent's system prompt next to that server's
|
tools") that is injected into the agent's system prompt next to that server's
|
||||||
@@ -68,12 +61,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
model's reasoning out of the box. An endpoint that is real OpenAI behind a
|
model's reasoning out of the box. An endpoint that is real OpenAI behind a
|
||||||
custom base URL should set the new `chatApiStyle` "Protocol" to `openai`. (#177)
|
custom base URL should set the new `chatApiStyle` "Protocol" to `openai`. (#177)
|
||||||
|
|
||||||
- **AI chat header context-badge now shows "used / max".** When an admin sets
|
|
||||||
the new `chatContextWindow`, the badge displays the current context size over
|
|
||||||
the configured window (e.g. `120k / 200k`) instead of switching to a live
|
|
||||||
per-turn token counter during streaming. With no window configured the badge
|
|
||||||
keeps showing just the current context. (#189)
|
|
||||||
|
|
||||||
- **Footnotes now reuse (Pandoc semantics).** Multiple `[^a]` references to the
|
- **Footnotes now reuse (Pandoc semantics).** Multiple `[^a]` references to the
|
||||||
same id are ONE footnote — one number, one definition, several back-references
|
same id are ONE footnote — one number, one definition, several back-references
|
||||||
— instead of being renamed to `a__2`, `a__3`. Duplicate `[^a]:` definitions are
|
— instead of being renamed to `a__2`, `a__3`. Duplicate `[^a]:` definitions are
|
||||||
|
|||||||
@@ -715,6 +715,8 @@
|
|||||||
"Test": "Test",
|
"Test": "Test",
|
||||||
"Available tools": "Available tools",
|
"Available tools": "Available tools",
|
||||||
"No tools available": "No tools available",
|
"No tools available": "No tools available",
|
||||||
|
"Failed": "Failed",
|
||||||
|
"OK · {{n}}": "OK · {{n}}",
|
||||||
"Created successfully": "Created successfully",
|
"Created successfully": "Created successfully",
|
||||||
"Deleted successfully": "Deleted successfully",
|
"Deleted successfully": "Deleted successfully",
|
||||||
"Clear": "Clear",
|
"Clear": "Clear",
|
||||||
@@ -1167,11 +1169,9 @@
|
|||||||
"Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.": "Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.",
|
"Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.": "Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.",
|
||||||
"Built-in assistant persona": "Built-in assistant persona",
|
"Built-in assistant persona": "Built-in assistant persona",
|
||||||
"Minimize": "Minimize",
|
"Minimize": "Minimize",
|
||||||
"Current context size": "Current context size",
|
|
||||||
"Context size / model limit": "Context size / model limit",
|
"Context size / model limit": "Context size / model limit",
|
||||||
"Context window (tokens)": "Context window (tokens)",
|
"Context window (tokens)": "Context window (tokens)",
|
||||||
"Shows used / total in the chat header badge; empty hides the total.": "Shows used / total in the chat header badge; empty hides the total.",
|
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Shown as used / total in the chat header. Leave empty to hide the limit.",
|
||||||
"e.g. 200000": "e.g. 200000",
|
|
||||||
"AI agent": "AI agent",
|
"AI agent": "AI agent",
|
||||||
"Take a look at the current document": "Take a look at the current document",
|
"Take a look at the current document": "Take a look at the current document",
|
||||||
"AI agent is typing…": "AI agent is typing…",
|
"AI agent is typing…": "AI agent is typing…",
|
||||||
|
|||||||
@@ -704,16 +704,19 @@
|
|||||||
"Ask the AI agent…": "Спросите AI-агента…",
|
"Ask the AI agent…": "Спросите AI-агента…",
|
||||||
"Copy chat": "Копировать чат",
|
"Copy chat": "Копировать чат",
|
||||||
"Created successfully": "Успешно создано",
|
"Created successfully": "Успешно создано",
|
||||||
"Current context size": "Текущий размер контекста",
|
|
||||||
"Context size / model limit": "Размер контекста / лимит модели",
|
"Context size / model limit": "Размер контекста / лимит модели",
|
||||||
"Context window (tokens)": "Размер окна контекста (токены)",
|
"Context window (tokens)": "Окно контекста (токены)",
|
||||||
"Shows used / total in the chat header badge; empty hides the total.": "Показывает использовано/всего в шапке чата; пусто — скрыть лимит.",
|
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Показывается в шапке чата как использовано / всего. Пусто — лимит скрыт.",
|
||||||
"e.g. 200000": "напр. 200000",
|
|
||||||
"Delete this chat?": "Удалить этот чат?",
|
"Delete this chat?": "Удалить этот чат?",
|
||||||
"Deleted successfully": "Успешно удалено",
|
"Deleted successfully": "Успешно удалено",
|
||||||
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
|
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
|
||||||
"Failed to delete chat": "Не удалось удалить чат",
|
"Failed to delete chat": "Не удалось удалить чат",
|
||||||
"Failed to rename chat": "Не удалось переименовать чат",
|
"Failed to rename chat": "Не удалось переименовать чат",
|
||||||
|
"Failed": "Ошибка",
|
||||||
|
"OK · {{n}}": "OK · {{n}}",
|
||||||
|
"Test": "Тест",
|
||||||
|
"No tools available": "Инструменты недоступны",
|
||||||
|
"Available tools": "Доступные инструменты",
|
||||||
"Minimize": "Свернуть",
|
"Minimize": "Свернуть",
|
||||||
"No chats yet.": "Чатов пока нет.",
|
"No chats yet.": "Чатов пока нет.",
|
||||||
"Send": "Отправить",
|
"Send": "Отправить",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Group, Loader } from "@mantine/core";
|
import { Group, Loader, Tooltip } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
IconArrowsDiagonal,
|
IconArrowsDiagonal,
|
||||||
IconCheck,
|
IconCheck,
|
||||||
@@ -39,13 +39,13 @@ import {
|
|||||||
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||||
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
|
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
|
||||||
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
||||||
import { ContextBadge } from "@/features/ai-chat/components/context-badge.tsx";
|
|
||||||
import { exportAiChat } from "@/features/ai-chat/services/ai-chat-service.ts";
|
import { exportAiChat } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||||
import { useChatSession } from "@/features/ai-chat/hooks/use-chat-session.ts";
|
import { useChatSession } from "@/features/ai-chat/hooks/use-chat-session.ts";
|
||||||
import {
|
import {
|
||||||
shouldCollapseOnOutsidePointer,
|
shouldCollapseOnOutsidePointer,
|
||||||
isHeaderClick,
|
isHeaderClick,
|
||||||
} from "@/features/ai-chat/utils/collapse-helpers.ts";
|
} from "@/features/ai-chat/utils/collapse-helpers.ts";
|
||||||
|
import { selectContextBadge } from "@/features/ai-chat/utils/context-badge.ts";
|
||||||
import { useClipboard } from "@/hooks/use-clipboard";
|
import { useClipboard } from "@/hooks/use-clipboard";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import classes from "@/features/ai-chat/components/ai-chat-window.module.css";
|
import classes from "@/features/ai-chat/components/ai-chat-window.module.css";
|
||||||
@@ -61,6 +61,13 @@ const MIN_HEIGHT = 400;
|
|||||||
// Margin kept between the window and the viewport edges while dragging.
|
// Margin kept between the window and the viewport edges while dragging.
|
||||||
const EDGE_MARGIN = 8;
|
const EDGE_MARGIN = 8;
|
||||||
|
|
||||||
|
/** Compact token formatter: 1.2M / 3.4k / 950. */
|
||||||
|
function formatTokens(n: number): string {
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
// Compute the initial top-right placement at the default size, fitted to the
|
// Compute the initial top-right placement at the default size, fitted to the
|
||||||
// current viewport. Reads `window` only when called (inside an effect).
|
// current viewport. Reads `window` only when called (inside an effect).
|
||||||
function computeInitialGeom() {
|
function computeInitialGeom() {
|
||||||
@@ -275,39 +282,19 @@ export default function AiChatWindow() {
|
|||||||
// shipped; older rows fall back to that turn's `usage` total. NOTE: reflects
|
// shipped; older rows fall back to that turn's `usage` total. NOTE: reflects
|
||||||
// PERSISTED rows (updates on chat open/switch); it does not tick live
|
// PERSISTED rows (updates on chat open/switch); it does not tick live
|
||||||
// mid-stream — acceptable for v1.
|
// mid-stream — acceptable for v1.
|
||||||
const contextTokens = useMemo(() => {
|
//
|
||||||
if (!activeChatId || !messageRows) return 0;
|
// The denominator `maxContextTokens` (the model's configured max window) is
|
||||||
for (let i = messageRows.length - 1; i >= 0; i--) {
|
// derived in the SAME backward scan: it is stamped alongside `contextTokens`
|
||||||
const meta = messageRows[i].metadata;
|
// on a completed turn, but the numerator and denominator are taken from the
|
||||||
if (!meta) continue;
|
// most recent row carrying EACH value independently — they may land on
|
||||||
if (typeof meta.contextTokens === "number" && meta.contextTokens > 0) {
|
// different rows (e.g. a fresh error row can carry contextTokens but not
|
||||||
return meta.contextTokens;
|
// maxContextTokens), so we keep scanning for whichever is still unset. 0 when
|
||||||
}
|
// no row has it (older rows, or no admin-configured limit) — the badge then
|
||||||
const usage = meta.usage;
|
// shows just the current size with no denominator.
|
||||||
if (usage) {
|
const { contextTokens, maxContextTokens } = useMemo(
|
||||||
const fallback =
|
() => selectContextBadge(activeChatId ? messageRows : undefined),
|
||||||
usage.totalTokens ??
|
[activeChatId, messageRows],
|
||||||
(usage.inputTokens ?? 0) + (usage.outputTokens ?? 0);
|
);
|
||||||
if (fallback > 0) return fallback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}, [activeChatId, messageRows]);
|
|
||||||
|
|
||||||
// The model's context-window size (badge denominator), read from the most
|
|
||||||
// recent assistant row that carries it. Admin-configured in AI settings and
|
|
||||||
// stamped onto the turn server-side, so it travels with the message metadata —
|
|
||||||
// no client-side model resolution, and it survives public shares / per-role
|
|
||||||
// models automatically. 0 (no limit configured, or older rows) → the badge
|
|
||||||
// hides the denominator and shows only the current context size.
|
|
||||||
const maxContextTokens = useMemo(() => {
|
|
||||||
if (!activeChatId || !messageRows) return 0;
|
|
||||||
for (let i = messageRows.length - 1; i >= 0; i--) {
|
|
||||||
const max = messageRows[i].metadata?.maxContextTokens;
|
|
||||||
if (typeof max === "number" && max > 0) return max;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}, [activeChatId, messageRows]);
|
|
||||||
|
|
||||||
// On (re)open, settle the geometry before paint (useLayoutEffect → no
|
// On (re)open, settle the geometry before paint (useLayoutEffect → no
|
||||||
// first-frame jump): compute an initial top-right placement the first time,
|
// first-frame jump): compute an initial top-right placement the first time,
|
||||||
@@ -498,14 +485,20 @@ export default function AiChatWindow() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
|
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
|
||||||
{/* Context badge: always "current / max" context size (or just current
|
{/* Always show the persisted "current / max" context. The denominator
|
||||||
when no model limit is configured). It no longer flips to a live
|
(the admin-configured model limit) is appended only when known;
|
||||||
per-turn generation counter mid-stream — that live feedback lives in
|
not clamped when current > max (shown as-is, e.g. "210k / 200k").
|
||||||
the chat body's "Thinking · N tokens" block. */}
|
Hidden entirely until a turn has recorded a context figure. */}
|
||||||
<ContextBadge
|
{contextTokens > 0 ? (
|
||||||
contextTokens={contextTokens}
|
<Tooltip label={t("Context size / model limit")} withArrow>
|
||||||
maxContextTokens={maxContextTokens}
|
<span className={classes.badge}>
|
||||||
/>
|
{formatTokens(contextTokens)}
|
||||||
|
{maxContextTokens > 0
|
||||||
|
? ` / ${formatTokens(maxContextTokens)}`
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 1 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
|
||||||
import { MantineProvider } from "@mantine/core";
|
|
||||||
import { ContextBadge, formatTokens } from "./context-badge";
|
|
||||||
|
|
||||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
|
||||||
// Without an I18nextProvider, `t(key)` returns the key verbatim, so tooltip
|
|
||||||
// labels assert against their English source strings.
|
|
||||||
|
|
||||||
function renderBadge(props: {
|
|
||||||
contextTokens: number;
|
|
||||||
maxContextTokens?: number;
|
|
||||||
}) {
|
|
||||||
return render(
|
|
||||||
<MantineProvider>
|
|
||||||
<ContextBadge {...props} />
|
|
||||||
</MantineProvider>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("formatTokens", () => {
|
|
||||||
it("formats with k / M suffixes", () => {
|
|
||||||
expect(formatTokens(572)).toBe("572");
|
|
||||||
expect(formatTokens(200_000)).toBe("200.0k");
|
|
||||||
expect(formatTokens(1_500_000)).toBe("1.5M");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ContextBadge", () => {
|
|
||||||
it("shows `current / max` when a limit is configured", () => {
|
|
||||||
renderBadge({ contextTokens: 572, maxContextTokens: 200_000 });
|
|
||||||
expect(screen.getByText("572 / 200.0k")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows only the current size when no limit is configured", () => {
|
|
||||||
renderBadge({ contextTokens: 572, maxContextTokens: 0 });
|
|
||||||
expect(screen.getByText("572")).toBeDefined();
|
|
||||||
// No denominator rendered.
|
|
||||||
expect(screen.queryByText(/\//)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats an undefined limit as no limit", () => {
|
|
||||||
renderBadge({ contextTokens: 1234 });
|
|
||||||
expect(screen.getByText("1.2k")).toBeDefined();
|
|
||||||
expect(screen.queryByText(/\//)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders nothing until there is a current context size", () => {
|
|
||||||
const { container } = renderBadge({
|
|
||||||
contextTokens: 0,
|
|
||||||
maxContextTokens: 200_000,
|
|
||||||
});
|
|
||||||
expect(container.querySelector("span")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("never flips to a live per-turn counter (no live mode); shows context as-is even above max", () => {
|
|
||||||
// `current > max` (estimate drift / smaller-model role) is shown unclamped.
|
|
||||||
renderBadge({ contextTokens: 210_000, maxContextTokens: 200_000 });
|
|
||||||
expect(screen.getByText("210.0k / 200.0k")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("exposes the limit tooltip label on hover", async () => {
|
|
||||||
renderBadge({ contextTokens: 572, maxContextTokens: 200_000 });
|
|
||||||
fireEvent.mouseEnter(screen.getByText("572 / 200.0k"));
|
|
||||||
expect(
|
|
||||||
await screen.findByText("Context size / model limit"),
|
|
||||||
).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { Tooltip } from "@mantine/core";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import classes from "@/features/ai-chat/components/ai-chat-window.module.css";
|
|
||||||
|
|
||||||
/** Compact token formatter: 1.2M / 3.4k / 950. */
|
|
||||||
export function formatTokens(n: number): string {
|
|
||||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
||||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
|
||||||
return String(n);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ContextBadgeProps {
|
|
||||||
// Current context size for the active chat (tokens occupied in the model's
|
|
||||||
// window). 0 = unknown → nothing is rendered.
|
|
||||||
contextTokens: number;
|
|
||||||
// The model's context-window size (tokens), from AI settings. 0/undefined =
|
|
||||||
// no limit known → only the current size is shown (no denominator).
|
|
||||||
maxContextTokens?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Header badge that ALWAYS shows the current context size, and — when the model's
|
|
||||||
* context-window size is configured — appends "/ max" so the badge reads
|
|
||||||
* "current / max" (e.g. `572 / 200k`). This is a single, stable meaning: unlike
|
|
||||||
* the previous design it never flips to a live per-turn generation counter while
|
|
||||||
* streaming (that live feedback lives in the chat body's "Thinking · N tokens").
|
|
||||||
*
|
|
||||||
* No limit configured (or older history rows without it) → the denominator is
|
|
||||||
* hidden and the badge shows the current size only, matching the prior at-rest
|
|
||||||
* behaviour. `context > max` (estimate drift, or a role on a smaller model) is
|
|
||||||
* shown as-is, without clamping.
|
|
||||||
*/
|
|
||||||
export function ContextBadge({
|
|
||||||
contextTokens,
|
|
||||||
maxContextTokens,
|
|
||||||
}: ContextBadgeProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
// Nothing to show until the first persisted context figure exists.
|
|
||||||
if (!(contextTokens > 0)) return null;
|
|
||||||
|
|
||||||
const hasMax = typeof maxContextTokens === "number" && maxContextTokens > 0;
|
|
||||||
const label = hasMax
|
|
||||||
? `${formatTokens(contextTokens)} / ${formatTokens(maxContextTokens)}`
|
|
||||||
: formatTokens(contextTokens);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip
|
|
||||||
label={
|
|
||||||
hasMax
|
|
||||||
? t("Context size / model limit")
|
|
||||||
: t("Current context size")
|
|
||||||
}
|
|
||||||
withArrow
|
|
||||||
>
|
|
||||||
<span className={classes.badge}>{label}</span>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ContextBadge;
|
|
||||||
@@ -113,13 +113,11 @@ export interface IAiChatMessageRow {
|
|||||||
};
|
};
|
||||||
// Current context size for the turn = final-step (input+output) tokens, i.e.
|
// 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
|
// how much the conversation occupies in the model's context window after this
|
||||||
// turn. Distinct from `usage` (legacy cumulative totalUsage). Shown as the
|
// turn. Distinct from `usage` (legacy cumulative totalUsage). Shown in the
|
||||||
// numerator of the floating window's "current / max" header badge.
|
// floating window's header badge.
|
||||||
contextTokens?: number;
|
contextTokens?: number;
|
||||||
// The model's context-window size (tokens), admin-configured in AI settings
|
// The model's max context window (denominator for the header badge); set
|
||||||
// and stamped onto the turn server-side. The denominator of the header badge.
|
// alongside contextTokens on a completed turn; absent on older rows.
|
||||||
// Absent/0 (older rows, or no limit configured) → the badge hides the
|
|
||||||
// denominator and shows only the current context size (`contextTokens`).
|
|
||||||
maxContextTokens?: number;
|
maxContextTokens?: number;
|
||||||
// Set on an assistant row whose turn ended in a provider/stream error; the
|
// Set on an assistant row whose turn ended in a provider/stream error; the
|
||||||
// raw provider error text (e.g. "402: ...") for inline display in the thread.
|
// raw provider error text (e.g. "402: ...") for inline display in the thread.
|
||||||
|
|||||||
90
apps/client/src/features/ai-chat/utils/context-badge.test.ts
Normal file
90
apps/client/src/features/ai-chat/utils/context-badge.test.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
import { selectContextBadge } from "@/features/ai-chat/utils/context-badge.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure-helper tests for the header context badge selection. Covers the two
|
||||||
|
* non-obvious rules: numerator and denominator are each taken from the most
|
||||||
|
* recent row carrying THAT value (they may live on different rows), and a fresh
|
||||||
|
* row with a zero/absent value must NOT shadow an older positive one.
|
||||||
|
*/
|
||||||
|
const row = (metadata: IAiChatMessageRow["metadata"]): IAiChatMessageRow => ({
|
||||||
|
id: Math.random().toString(),
|
||||||
|
role: "assistant",
|
||||||
|
content: null,
|
||||||
|
metadata,
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("selectContextBadge", () => {
|
||||||
|
it("returns zeros for empty / nullish input", () => {
|
||||||
|
expect(selectContextBadge(undefined)).toEqual({
|
||||||
|
contextTokens: 0,
|
||||||
|
maxContextTokens: 0,
|
||||||
|
});
|
||||||
|
expect(selectContextBadge(null)).toEqual({
|
||||||
|
contextTokens: 0,
|
||||||
|
maxContextTokens: 0,
|
||||||
|
});
|
||||||
|
expect(selectContextBadge([])).toEqual({
|
||||||
|
contextTokens: 0,
|
||||||
|
maxContextTokens: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads both figures from the most recent row that carries them", () => {
|
||||||
|
expect(
|
||||||
|
selectContextBadge([
|
||||||
|
row({ contextTokens: 100, maxContextTokens: 200000 }),
|
||||||
|
row({ contextTokens: 1500, maxContextTokens: 200000 }),
|
||||||
|
]),
|
||||||
|
).toEqual({ contextTokens: 1500, maxContextTokens: 200000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to legacy usage total for older rows without contextTokens", () => {
|
||||||
|
expect(
|
||||||
|
selectContextBadge([
|
||||||
|
row({ usage: { inputTokens: 30, outputTokens: 70 } }),
|
||||||
|
]),
|
||||||
|
).toEqual({ contextTokens: 100, maxContextTokens: 0 });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
selectContextBadge([row({ usage: { totalTokens: 250 } })]),
|
||||||
|
).toEqual({ contextTokens: 250, maxContextTokens: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes numerator and denominator from different rows", () => {
|
||||||
|
// Freshest row (an error turn) carries contextTokens but no max; the older
|
||||||
|
// completed turn carries the max. Each is picked from its own latest row.
|
||||||
|
expect(
|
||||||
|
selectContextBadge([
|
||||||
|
row({ contextTokens: 800, maxContextTokens: 200000 }),
|
||||||
|
row({ contextTokens: 1200, error: "402: nope" }),
|
||||||
|
]),
|
||||||
|
).toEqual({ contextTokens: 1200, maxContextTokens: 200000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a fresh zero/absent max shadow an older positive max", () => {
|
||||||
|
expect(
|
||||||
|
selectContextBadge([
|
||||||
|
row({ contextTokens: 100, maxContextTokens: 200000 }),
|
||||||
|
row({ contextTokens: 1200, maxContextTokens: 0 }),
|
||||||
|
]),
|
||||||
|
).toEqual({ contextTokens: 1200, maxContextTokens: 200000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips rows with null metadata", () => {
|
||||||
|
expect(
|
||||||
|
selectContextBadge([
|
||||||
|
row({ contextTokens: 500, maxContextTokens: 200000 }),
|
||||||
|
row(null),
|
||||||
|
]),
|
||||||
|
).toEqual({ contextTokens: 500, maxContextTokens: 200000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports current > max as-is (no clamp)", () => {
|
||||||
|
expect(
|
||||||
|
selectContextBadge([row({ contextTokens: 250000, maxContextTokens: 200000 })]),
|
||||||
|
).toEqual({ contextTokens: 250000, maxContextTokens: 200000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
49
apps/client/src/features/ai-chat/utils/context-badge.ts
Normal file
49
apps/client/src/features/ai-chat/utils/context-badge.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the header context badge figures from the persisted message rows.
|
||||||
|
*
|
||||||
|
* - `contextTokens` (numerator): how much the conversation now occupies in the
|
||||||
|
* model's context window. Read from the most recent row carrying a context
|
||||||
|
* figure — `contextTokens` (final-step input+output) on rows recorded after
|
||||||
|
* this shipped, else that turn's legacy `usage` total for older rows.
|
||||||
|
* - `maxContextTokens` (denominator): the model's configured max window, stamped
|
||||||
|
* alongside `contextTokens` on a completed turn.
|
||||||
|
*
|
||||||
|
* Each value is taken from the most recent row carrying THAT value
|
||||||
|
* independently — they may land on different rows (e.g. a fresh error row can
|
||||||
|
* carry `contextTokens` but not `maxContextTokens`), so the scan continues for
|
||||||
|
* whichever is still unset. `0` means "no row has it" (older rows, or no
|
||||||
|
* admin-configured limit); the badge then omits the value.
|
||||||
|
*/
|
||||||
|
export function selectContextBadge(
|
||||||
|
messageRows: readonly IAiChatMessageRow[] | undefined | null,
|
||||||
|
): { contextTokens: number; maxContextTokens: number } {
|
||||||
|
let contextTokens = 0;
|
||||||
|
let maxContextTokens = 0;
|
||||||
|
if (!messageRows) return { contextTokens, maxContextTokens };
|
||||||
|
for (let i = messageRows.length - 1; i >= 0; i--) {
|
||||||
|
const meta = messageRows[i].metadata;
|
||||||
|
if (!meta) continue;
|
||||||
|
if (contextTokens === 0) {
|
||||||
|
if (typeof meta.contextTokens === "number" && meta.contextTokens > 0) {
|
||||||
|
contextTokens = meta.contextTokens;
|
||||||
|
} else if (meta.usage) {
|
||||||
|
const usage = meta.usage;
|
||||||
|
const fallback =
|
||||||
|
usage.totalTokens ??
|
||||||
|
(usage.inputTokens ?? 0) + (usage.outputTokens ?? 0);
|
||||||
|
if (fallback > 0) contextTokens = fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
maxContextTokens === 0 &&
|
||||||
|
typeof meta.maxContextTokens === "number" &&
|
||||||
|
meta.maxContextTokens > 0
|
||||||
|
) {
|
||||||
|
maxContextTokens = meta.maxContextTokens;
|
||||||
|
}
|
||||||
|
if (contextTokens !== 0 && maxContextTokens !== 0) break;
|
||||||
|
}
|
||||||
|
return { contextTokens, maxContextTokens };
|
||||||
|
}
|
||||||
@@ -1,16 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Live token ESTIMATION for a streaming AI-chat turn.
|
* Rough client-side token estimation for AI-chat UI affordances.
|
||||||
*
|
*
|
||||||
* No provider streams exact per-token usage mid-stream, so the live number is a
|
* No provider streams exact per-token usage mid-stream, so any in-flight figure
|
||||||
* CLIENT ESTIMATE (chars/≈4 heuristic). It powers the chat body's
|
* is a CLIENT ESTIMATE (chars/≈4 heuristic). Pure + unit-testable: it never runs
|
||||||
* `Thinking… · N tokens` indicator (see `ReasoningBlock`), which reconciles to
|
* a real BPE tokenizer (that would be O(n²) on the hot path, bloat the bundle,
|
||||||
* the authoritative server usage once it lands. Pure + unit-testable: it never
|
* and be wrong for Gemini/Ollama anyway). Used by the in-body reasoning counter
|
||||||
* runs a real BPE tokenizer (that would be O(n²) on the hot path, bloat the
|
* ("Thinking · N tokens").
|
||||||
* bundle, and be wrong for Gemini/Ollama anyway).
|
|
||||||
*
|
|
||||||
* The former header-badge `liveTurnTokens()` split was removed with #189 (the
|
|
||||||
* header badge now shows the stable "current / max" context size, not a live
|
|
||||||
* per-turn counter); the live feedback remains in `ReasoningBlock`.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { mcpTestButtonView } from "./ai-mcp-server-test-view";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure-helper tests for the inline "Test" button presentation. Covers the four
|
||||||
|
* states (idle / loading is handled by the component's `isPending`, so here:
|
||||||
|
* idle / ok-with-tools / ok-without-tools / failed) and the tooltip text
|
||||||
|
* branches that are easiest to break silently.
|
||||||
|
*/
|
||||||
|
// Identity-ish translator that echoes the key and interpolates {{n}} so the
|
||||||
|
// label/tooltip branches are observable without the real i18n bundle.
|
||||||
|
const t = (key: string, options?: Record<string, unknown>): string =>
|
||||||
|
options && "n" in options
|
||||||
|
? key.replace("{{n}}", String((options as { n: unknown }).n))
|
||||||
|
: key;
|
||||||
|
|
||||||
|
describe("mcpTestButtonView", () => {
|
||||||
|
it("idle when there is no result", () => {
|
||||||
|
expect(mcpTestButtonView(undefined, t)).toEqual({
|
||||||
|
state: "idle",
|
||||||
|
color: undefined,
|
||||||
|
variant: "default",
|
||||||
|
label: "Test",
|
||||||
|
tooltip: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ok with tools lists them in the tooltip", () => {
|
||||||
|
expect(mcpTestButtonView({ ok: true, tools: ["a", "b"] }, t)).toEqual({
|
||||||
|
state: "ok",
|
||||||
|
color: "green",
|
||||||
|
variant: "light",
|
||||||
|
label: "OK · 2",
|
||||||
|
tooltip: "a, b",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ok with zero tools shows "No tools available"', () => {
|
||||||
|
expect(mcpTestButtonView({ ok: true, tools: [] }, t)).toEqual({
|
||||||
|
state: "ok",
|
||||||
|
color: "green",
|
||||||
|
variant: "light",
|
||||||
|
label: "OK · 0",
|
||||||
|
tooltip: "No tools available",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("failed surfaces the error text in the tooltip", () => {
|
||||||
|
expect(
|
||||||
|
mcpTestButtonView({ ok: false, error: "402: nope" }, t),
|
||||||
|
).toEqual({
|
||||||
|
state: "failed",
|
||||||
|
color: "red",
|
||||||
|
variant: "light",
|
||||||
|
label: "Failed",
|
||||||
|
tooltip: "402: nope",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("failed when the request itself rejects (no result payload)", () => {
|
||||||
|
// 401/403/500/network: there is no { ok } body, only a thrown error. The
|
||||||
|
// row must still show a red "Failed" rather than reverting to idle "Test".
|
||||||
|
expect(
|
||||||
|
mcpTestButtonView(undefined, t, {
|
||||||
|
response: { data: { message: "Unauthorized" } },
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
state: "failed",
|
||||||
|
color: "red",
|
||||||
|
variant: "light",
|
||||||
|
label: "Failed",
|
||||||
|
tooltip: "Unauthorized",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reject without a server message falls back to the generic label", () => {
|
||||||
|
// A bare network error (no response body) still surfaces as failed, using
|
||||||
|
// the i18n fallback for the tooltip.
|
||||||
|
expect(mcpTestButtonView(undefined, t, new Error("network down"))).toEqual({
|
||||||
|
state: "failed",
|
||||||
|
color: "red",
|
||||||
|
variant: "light",
|
||||||
|
label: "Failed",
|
||||||
|
tooltip: "Failed to update data",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import type { IAiMcpServerTestResult } from "@/features/workspace/services/ai-mcp-server-service.ts";
|
||||||
|
|
||||||
|
/** Minimal translator shape (i18next `t`): key + optional interpolation. */
|
||||||
|
type Translate = (key: string, options?: Record<string, unknown>) => string;
|
||||||
|
|
||||||
|
/** Subset of an axios-style rejection we read for the reject tooltip. */
|
||||||
|
type McpTestRequestError = {
|
||||||
|
response?: { data?: { message?: string } };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort extraction of a server-sent message from a rejected test request
|
||||||
|
* (axios stores it at `error.response.data.message`). Returns undefined for a
|
||||||
|
* bare/network error so the caller can fall back to a generic label.
|
||||||
|
*/
|
||||||
|
function readRequestErrorMessage(error: unknown): string | undefined {
|
||||||
|
if (error && typeof error === "object" && "response" in error) {
|
||||||
|
return (error as McpTestRequestError).response?.data?.message;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Presentation for the inline "Test" button, derived from the current test
|
||||||
|
* result tristate (no result yet / ok / failed). Color is never the only signal
|
||||||
|
* — the label and icon change too (a11y / colorblind-friendly). Kept as a single
|
||||||
|
* pure derivation (rather than two parallel if/else chains) so the button and
|
||||||
|
* tooltip can never drift apart, and so the text branches are unit-testable
|
||||||
|
* without rendering the row.
|
||||||
|
*/
|
||||||
|
export interface McpTestButtonView {
|
||||||
|
/** Tristate; the component maps this to the leftSection icon. */
|
||||||
|
state: "idle" | "ok" | "failed";
|
||||||
|
/** Mantine Button color; undefined = theme default (idle). */
|
||||||
|
color?: string;
|
||||||
|
/** Mantine Button variant. */
|
||||||
|
variant: string;
|
||||||
|
/** Translated button label. */
|
||||||
|
label: string;
|
||||||
|
/** Translated tooltip text; "" while there is no result (tooltip disabled). */
|
||||||
|
tooltip: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mcpTestButtonView(
|
||||||
|
result: IAiMcpServerTestResult | undefined,
|
||||||
|
t: Translate,
|
||||||
|
error?: unknown,
|
||||||
|
): McpTestButtonView {
|
||||||
|
if (result?.ok) {
|
||||||
|
return {
|
||||||
|
state: "ok",
|
||||||
|
color: "green",
|
||||||
|
variant: "light",
|
||||||
|
label: t("OK · {{n}}", { n: result.tools.length }),
|
||||||
|
tooltip:
|
||||||
|
result.tools.length > 0
|
||||||
|
? result.tools.join(", ")
|
||||||
|
: t("No tools available"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (result && result.ok === false) {
|
||||||
|
return {
|
||||||
|
state: "failed",
|
||||||
|
color: "red",
|
||||||
|
variant: "light",
|
||||||
|
label: t("Failed"),
|
||||||
|
tooltip: result.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
// The test request itself rejected (401/403/500/network) — there is no
|
||||||
|
// `{ ok }` payload, so without this branch the row would silently revert to
|
||||||
|
// the idle "Test" instead of reporting the failure. Tooltip prefers the
|
||||||
|
// server-sent message, else the generic i18n fallback.
|
||||||
|
return {
|
||||||
|
state: "failed",
|
||||||
|
color: "red",
|
||||||
|
variant: "light",
|
||||||
|
label: t("Failed"),
|
||||||
|
tooltip: readRequestErrorMessage(error) ?? t("Failed to update data"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
state: "idle",
|
||||||
|
color: undefined,
|
||||||
|
variant: "default",
|
||||||
|
label: t("Test"),
|
||||||
|
tooltip: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Badge,
|
Badge,
|
||||||
@@ -10,18 +10,28 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
Switch,
|
Switch,
|
||||||
Text,
|
Text,
|
||||||
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { modals } from "@mantine/modals";
|
import { modals } from "@mantine/modals";
|
||||||
import { IconPencil, IconPlus, IconTrash } from "@tabler/icons-react";
|
import {
|
||||||
|
IconCheck,
|
||||||
|
IconPencil,
|
||||||
|
IconPlugConnected,
|
||||||
|
IconPlus,
|
||||||
|
IconTrash,
|
||||||
|
IconX,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import useUserRole from "@/hooks/use-user-role.tsx";
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
import {
|
import {
|
||||||
useAiMcpServersQuery,
|
useAiMcpServersQuery,
|
||||||
useDeleteAiMcpServerMutation,
|
useDeleteAiMcpServerMutation,
|
||||||
|
useTestAiMcpServerMutation,
|
||||||
useUpdateAiMcpServerMutation,
|
useUpdateAiMcpServerMutation,
|
||||||
} from "@/features/workspace/queries/ai-mcp-server-query.ts";
|
} from "@/features/workspace/queries/ai-mcp-server-query.ts";
|
||||||
import { IAiMcpServer } from "@/features/workspace/services/ai-mcp-server-service.ts";
|
import { IAiMcpServer } from "@/features/workspace/services/ai-mcp-server-service.ts";
|
||||||
|
import { mcpTestButtonView } from "@/features/workspace/components/settings/components/ai-mcp-server-test-view.ts";
|
||||||
import AiMcpServerForm from "./ai-mcp-server-form.tsx";
|
import AiMcpServerForm from "./ai-mcp-server-form.tsx";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -112,55 +122,15 @@ export default function AiMcpServers() {
|
|||||||
|
|
||||||
<Stack gap="xs" mt="sm">
|
<Stack gap="xs" mt="sm">
|
||||||
{servers?.map((server) => (
|
{servers?.map((server) => (
|
||||||
<Group key={server.id} justify="space-between" wrap="nowrap">
|
<AiMcpServerRow
|
||||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
key={server.id}
|
||||||
<Group gap="xs">
|
server={server}
|
||||||
<Text fw={500} truncate>
|
onEdit={openEdit}
|
||||||
{server.name}
|
onDelete={confirmDelete}
|
||||||
</Text>
|
onToggleEnabled={(enabled) =>
|
||||||
<Badge size="xs" variant="light">
|
updateMutation.mutate({ id: server.id, enabled })
|
||||||
{server.transport.toUpperCase()}
|
}
|
||||||
</Badge>
|
/>
|
||||||
</Group>
|
|
||||||
<Text
|
|
||||||
size="xs"
|
|
||||||
c="dimmed"
|
|
||||||
truncate
|
|
||||||
style={{ fontFamily: "ui-monospace, Menlo, monospace" }}
|
|
||||||
>
|
|
||||||
{server.url}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Group gap="xs" wrap="nowrap">
|
|
||||||
<Switch
|
|
||||||
size="sm"
|
|
||||||
checked={server.enabled}
|
|
||||||
aria-label={t("Enabled")}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateMutation.mutate({
|
|
||||||
id: server.id,
|
|
||||||
enabled: event.currentTarget.checked,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
aria-label={t("Edit")}
|
|
||||||
onClick={() => openEdit(server)}
|
|
||||||
>
|
|
||||||
<IconPencil size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color="red"
|
|
||||||
aria-label={t("Delete")}
|
|
||||||
onClick={() => confirmDelete(server)}
|
|
||||||
>
|
|
||||||
<IconTrash size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
@@ -180,3 +150,127 @@ export default function AiMcpServers() {
|
|||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AiMcpServerRowProps {
|
||||||
|
server: IAiMcpServer;
|
||||||
|
onEdit: (server: IAiMcpServer) => void;
|
||||||
|
onDelete: (server: IAiMcpServer) => void;
|
||||||
|
onToggleEnabled: (enabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single external MCP server row: name/badge/url on the left and the
|
||||||
|
* Test / Switch / Edit / Delete controls on the right. Each row owns its own
|
||||||
|
* `useTestAiMcpServerMutation()` so the inline Test result and loading state are
|
||||||
|
* independent per row (a shared mutation would make `isPending` global and make
|
||||||
|
* every row flicker).
|
||||||
|
*/
|
||||||
|
function AiMcpServerRow({
|
||||||
|
server,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onToggleEnabled,
|
||||||
|
}: AiMcpServerRowProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const testMutation = useTestAiMcpServerMutation();
|
||||||
|
const result = testMutation.data;
|
||||||
|
|
||||||
|
// The row is keyed by `server.id`, so editing the connection-relevant fields
|
||||||
|
// (url/transport/headers) does NOT remount it — an old success/failure result
|
||||||
|
// would otherwise stick. Clear the result when those fields change.
|
||||||
|
useEffect(() => {
|
||||||
|
testMutation.reset();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [server.url, server.transport, server.hasHeaders]);
|
||||||
|
|
||||||
|
// Single derivation of the button/tooltip presentation from the test tristate
|
||||||
|
// (idle / ok / failed), so the two can never drift apart. Tooltip is "" while
|
||||||
|
// there is no result; the icon is mapped from `view.state` below. When the
|
||||||
|
// request itself rejects (401/403/500/network) there is no `data` payload, so
|
||||||
|
// we feed the mutation error in too — otherwise the row would silently revert
|
||||||
|
// to "Test" instead of showing a red "Failed".
|
||||||
|
const view = mcpTestButtonView(
|
||||||
|
result,
|
||||||
|
t,
|
||||||
|
testMutation.isError ? testMutation.error : undefined,
|
||||||
|
);
|
||||||
|
const tooltipLabel = view.tooltip;
|
||||||
|
const buttonColor = view.color;
|
||||||
|
const buttonVariant = view.variant;
|
||||||
|
const buttonLabel = view.label;
|
||||||
|
const buttonIcon =
|
||||||
|
view.state === "ok" ? (
|
||||||
|
<IconCheck size={16} />
|
||||||
|
) : view.state === "failed" ? (
|
||||||
|
<IconX size={16} />
|
||||||
|
) : (
|
||||||
|
<IconPlugConnected size={16} />
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Text fw={500} truncate>
|
||||||
|
{server.name}
|
||||||
|
</Text>
|
||||||
|
<Badge size="xs" variant="light">
|
||||||
|
{server.transport.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
c="dimmed"
|
||||||
|
truncate
|
||||||
|
style={{ fontFamily: "ui-monospace, Menlo, monospace" }}
|
||||||
|
>
|
||||||
|
{server.url}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
{/* Always clickable: testing a disabled server before enabling it is useful. */}
|
||||||
|
<Tooltip
|
||||||
|
label={tooltipLabel}
|
||||||
|
disabled={view.state === "idle"}
|
||||||
|
multiline
|
||||||
|
maw={320}
|
||||||
|
withinPortal
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
miw={88}
|
||||||
|
color={buttonColor}
|
||||||
|
variant={buttonVariant}
|
||||||
|
leftSection={testMutation.isPending ? undefined : buttonIcon}
|
||||||
|
loading={testMutation.isPending}
|
||||||
|
onClick={() => testMutation.mutate(server.id)}
|
||||||
|
>
|
||||||
|
{buttonLabel}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Switch
|
||||||
|
size="sm"
|
||||||
|
checked={server.enabled}
|
||||||
|
aria-label={t("Enabled")}
|
||||||
|
onChange={(event) => onToggleEnabled(event.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
aria-label={t("Edit")}
|
||||||
|
onClick={() => onEdit(server)}
|
||||||
|
>
|
||||||
|
<IconPencil size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
aria-label={t("Delete")}
|
||||||
|
onClick={() => onDelete(server)}
|
||||||
|
>
|
||||||
|
<IconTrash size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -84,11 +84,11 @@ const STT_LANGUAGE_OPTIONS: { value: string; label: string }[] = [
|
|||||||
// (empty means "leave unchanged" unless explicitly cleared).
|
// (empty means "leave unchanged" unless explicitly cleared).
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
chatModel: z.string(),
|
chatModel: z.string(),
|
||||||
|
// Max context window in tokens shown in the chat header badge. A number, or ""
|
||||||
|
// when the NumberInput is empty (no limit).
|
||||||
|
chatContextWindow: z.union([z.number(), z.literal("")]),
|
||||||
// Chat provider implementation (reasoning surfacing). Default openai-compatible.
|
// Chat provider implementation (reasoning surfacing). Default openai-compatible.
|
||||||
chatApiStyle: z.enum(["openai-compatible", "openai"]),
|
chatApiStyle: z.enum(["openai-compatible", "openai"]),
|
||||||
// Model context-window size (tokens) shown as the chat header badge's "max".
|
|
||||||
// Empty string = no limit (NumberInput emits "" when cleared).
|
|
||||||
chatContextWindow: z.union([z.number(), z.literal("")]),
|
|
||||||
// Cheap model id for the anonymous public-share assistant; empty = use chatModel.
|
// Cheap model id for the anonymous public-share assistant; empty = use chatModel.
|
||||||
publicShareChatModel: z.string(),
|
publicShareChatModel: z.string(),
|
||||||
// Agent-role id whose persona the public-share assistant adopts; empty =
|
// Agent-role id whose persona the public-share assistant adopts; empty =
|
||||||
@@ -315,8 +315,8 @@ export default function AiProviderSettings() {
|
|||||||
validate: zod4Resolver(formSchema),
|
validate: zod4Resolver(formSchema),
|
||||||
initialValues: {
|
initialValues: {
|
||||||
chatModel: "",
|
chatModel: "",
|
||||||
|
chatContextWindow: "",
|
||||||
chatApiStyle: "openai-compatible" as ChatApiStyle,
|
chatApiStyle: "openai-compatible" as ChatApiStyle,
|
||||||
chatContextWindow: "" as number | "",
|
|
||||||
publicShareChatModel: "",
|
publicShareChatModel: "",
|
||||||
publicShareAssistantRoleId: "",
|
publicShareAssistantRoleId: "",
|
||||||
embeddingModel: "",
|
embeddingModel: "",
|
||||||
@@ -339,11 +339,8 @@ export default function AiProviderSettings() {
|
|||||||
if (!settings) return;
|
if (!settings) return;
|
||||||
form.setValues({
|
form.setValues({
|
||||||
chatModel: settings.chatModel ?? "",
|
chatModel: settings.chatModel ?? "",
|
||||||
|
chatContextWindow: settings.chatContextWindow ?? "",
|
||||||
chatApiStyle: settings.chatApiStyle ?? "openai-compatible",
|
chatApiStyle: settings.chatApiStyle ?? "openai-compatible",
|
||||||
// 0/unset = no limit → show an empty field (not a literal "0").
|
|
||||||
chatContextWindow: settings.chatContextWindow
|
|
||||||
? settings.chatContextWindow
|
|
||||||
: "",
|
|
||||||
publicShareChatModel: settings.publicShareChatModel ?? "",
|
publicShareChatModel: settings.publicShareChatModel ?? "",
|
||||||
publicShareAssistantRoleId: settings.publicShareAssistantRoleId ?? "",
|
publicShareAssistantRoleId: settings.publicShareAssistantRoleId ?? "",
|
||||||
embeddingModel: settings.embeddingModel ?? "",
|
embeddingModel: settings.embeddingModel ?? "",
|
||||||
@@ -373,12 +370,13 @@ export default function AiProviderSettings() {
|
|||||||
// Everything is OpenAI-compatible.
|
// Everything is OpenAI-compatible.
|
||||||
driver: "openai",
|
driver: "openai",
|
||||||
chatModel: values.chatModel,
|
chatModel: values.chatModel,
|
||||||
chatApiStyle: values.chatApiStyle,
|
// Max context window for the chat header badge; empty NumberInput ("") →
|
||||||
// Empty → 0, which clears the limit server-side (badge shows current only).
|
// 0, which clears the limit server-side (no denominator shown).
|
||||||
chatContextWindow:
|
chatContextWindow:
|
||||||
typeof values.chatContextWindow === "number"
|
typeof values.chatContextWindow === "number"
|
||||||
? values.chatContextWindow
|
? values.chatContextWindow
|
||||||
: 0,
|
: 0,
|
||||||
|
chatApiStyle: values.chatApiStyle,
|
||||||
// Cheap model id for the anonymous public-share assistant; empty falls
|
// Cheap model id for the anonymous public-share assistant; empty falls
|
||||||
// back to chatModel server-side.
|
// back to chatModel server-side.
|
||||||
publicShareChatModel: values.publicShareChatModel,
|
publicShareChatModel: values.publicShareChatModel,
|
||||||
@@ -781,6 +779,18 @@ export default function AiProviderSettings() {
|
|||||||
{t("Resolves to {{url}}", { url: chatResolved })}
|
{t("Resolves to {{url}}", { url: chatResolved })}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
mt="sm"
|
||||||
|
label={t("Context window (tokens)")}
|
||||||
|
description={t(
|
||||||
|
"Shown as used / total in the chat header. Leave empty to hide the limit.",
|
||||||
|
)}
|
||||||
|
min={0}
|
||||||
|
allowDecimal={false}
|
||||||
|
disabled={isLoading}
|
||||||
|
{...form.getInputProps("chatContextWindow")}
|
||||||
|
/>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
mt="sm"
|
mt="sm"
|
||||||
label={t("Protocol")}
|
label={t("Protocol")}
|
||||||
@@ -799,22 +809,6 @@ export default function AiProviderSettings() {
|
|||||||
{...form.getInputProps("chatApiStyle")}
|
{...form.getInputProps("chatApiStyle")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NumberInput
|
|
||||||
mt="sm"
|
|
||||||
label={t("Context window (tokens)")}
|
|
||||||
description={t(
|
|
||||||
"Shows used / total in the chat header badge; empty hides the total.",
|
|
||||||
)}
|
|
||||||
placeholder={t("e.g. 200000")}
|
|
||||||
min={0}
|
|
||||||
step={1000}
|
|
||||||
allowDecimal={false}
|
|
||||||
allowNegative={false}
|
|
||||||
thousandSeparator=" "
|
|
||||||
disabled={isLoading}
|
|
||||||
{...form.getInputProps("chatContextWindow")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Anonymous public-share assistant: a single master toggle + an
|
{/* Anonymous public-share assistant: a single master toggle + an
|
||||||
optional cheaper model id. Reuses this card's driver/URL/key. */}
|
optional cheaper model id. Reuses this card's driver/URL/key. */}
|
||||||
<Group justify="space-between" align="center" wrap="nowrap" mt="md">
|
<Group justify="space-between" align="center" wrap="nowrap" mt="md">
|
||||||
|
|||||||
@@ -22,10 +22,9 @@ export type ChatApiStyle = "openai-compatible" | "openai";
|
|||||||
export interface IAiSettings {
|
export interface IAiSettings {
|
||||||
driver?: AiDriver;
|
driver?: AiDriver;
|
||||||
chatModel?: string;
|
chatModel?: string;
|
||||||
chatApiStyle?: ChatApiStyle;
|
// Max context window in tokens shown in the chat header badge; 0/unset = no limit.
|
||||||
// Chat model context-window size (tokens); shown as the "max" in the chat
|
|
||||||
// header context badge. 0/unset = no limit (badge shows the current size only).
|
|
||||||
chatContextWindow?: number;
|
chatContextWindow?: number;
|
||||||
|
chatApiStyle?: ChatApiStyle;
|
||||||
// Cheap model id for the anonymous public-share assistant; empty = chatModel.
|
// Cheap model id for the anonymous public-share assistant; empty = chatModel.
|
||||||
publicShareChatModel?: string;
|
publicShareChatModel?: string;
|
||||||
// Agent-role id whose persona the public-share assistant adopts; empty =
|
// Agent-role id whose persona the public-share assistant adopts; empty =
|
||||||
@@ -59,9 +58,9 @@ export interface IAiSettings {
|
|||||||
export interface IAiSettingsUpdate {
|
export interface IAiSettingsUpdate {
|
||||||
driver?: AiDriver;
|
driver?: AiDriver;
|
||||||
chatModel?: string;
|
chatModel?: string;
|
||||||
chatApiStyle?: ChatApiStyle;
|
// Max context window in tokens for the chat header badge; 0 = clear the limit.
|
||||||
// Chat model context-window size (tokens); 0 clears the limit.
|
|
||||||
chatContextWindow?: number;
|
chatContextWindow?: number;
|
||||||
|
chatApiStyle?: ChatApiStyle;
|
||||||
publicShareChatModel?: string;
|
publicShareChatModel?: string;
|
||||||
// Agent-role id whose persona the public-share assistant adopts; empty =
|
// Agent-role id whose persona the public-share assistant adopts; empty =
|
||||||
// built-in locked persona.
|
// built-in locked persona.
|
||||||
|
|||||||
@@ -275,11 +275,12 @@ describe('flushAssistant', () => {
|
|||||||
expect(f.toolCalls).not.toBeNull();
|
expect(f.toolCalls).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('completed: attaches finishReason + normalized usage + contextTokens', () => {
|
it('completed: attaches finishReason + normalized usage + contextTokens + maxContextTokens', () => {
|
||||||
const f = flushAssistant([toolStep], '', 'completed', {
|
const f = flushAssistant([toolStep], '', 'completed', {
|
||||||
finishReason: 'stop',
|
finishReason: 'stop',
|
||||||
usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
|
usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
|
||||||
contextTokens: 15,
|
contextTokens: 15,
|
||||||
|
maxContextTokens: 200000,
|
||||||
});
|
});
|
||||||
expect(f.status).toBe('completed');
|
expect(f.status).toBe('completed');
|
||||||
expect(f.metadata.finishReason).toBe('stop');
|
expect(f.metadata.finishReason).toBe('stop');
|
||||||
@@ -290,26 +291,23 @@ describe('flushAssistant', () => {
|
|||||||
reasoningTokens: undefined,
|
reasoningTokens: undefined,
|
||||||
});
|
});
|
||||||
expect(f.metadata.contextTokens).toBe(15);
|
expect(f.metadata.contextTokens).toBe(15);
|
||||||
|
expect(f.metadata.maxContextTokens).toBe(200000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('completed: writes maxContextTokens when the model limit is > 0', () => {
|
it('completed: omits maxContextTokens when unset or 0', () => {
|
||||||
|
// No maxContextTokens in the extra (admin set no context window).
|
||||||
const f = flushAssistant([toolStep], '', 'completed', {
|
const f = flushAssistant([toolStep], '', 'completed', {
|
||||||
contextTokens: 15,
|
finishReason: 'stop',
|
||||||
maxContextTokens: 200_000,
|
|
||||||
});
|
|
||||||
expect(f.metadata.maxContextTokens).toBe(200_000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('omits maxContextTokens when the limit is unset or 0', () => {
|
|
||||||
const unset = flushAssistant([toolStep], '', 'completed', {
|
|
||||||
contextTokens: 15,
|
contextTokens: 15,
|
||||||
});
|
});
|
||||||
expect('maxContextTokens' in unset.metadata).toBe(false);
|
expect('maxContextTokens' in f.metadata).toBe(false);
|
||||||
const zero = flushAssistant([toolStep], '', 'completed', {
|
// Explicit 0 is treated the same as unset (no limit -> key omitted).
|
||||||
|
const f0 = flushAssistant([toolStep], '', 'completed', {
|
||||||
|
finishReason: 'stop',
|
||||||
contextTokens: 15,
|
contextTokens: 15,
|
||||||
maxContextTokens: 0,
|
maxContextTokens: 0,
|
||||||
});
|
});
|
||||||
expect('maxContextTokens' in zero.metadata).toBe(false);
|
expect('maxContextTokens' in f0.metadata).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('error: records the error and a derived finishReason', () => {
|
it('error: records the error and a derived finishReason', () => {
|
||||||
|
|||||||
@@ -616,8 +616,9 @@ export class AiChatService implements OnModuleInit {
|
|||||||
contextTokens:
|
contextTokens:
|
||||||
(usage?.inputTokens ?? 0) + (usage?.outputTokens ?? 0) ||
|
(usage?.inputTokens ?? 0) + (usage?.outputTokens ?? 0) ||
|
||||||
undefined,
|
undefined,
|
||||||
// Admin-configured context-window size for this model (badge max).
|
// Max context window for the chat header badge denominator;
|
||||||
// Resolved once per turn above; written to metadata only when > 0.
|
// resolved from the admin-configured provider settings (in
|
||||||
|
// closure scope here). Omitted/0 = no limit.
|
||||||
maxContextTokens: resolved?.chatContextWindow,
|
maxContextTokens: resolved?.chatContextWindow,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -1215,8 +1216,9 @@ export async function applyFinalize(
|
|||||||
* `metadata.parts` is built by assistantParts over the finished steps, then the
|
* `metadata.parts` is built by assistantParts over the finished steps, then the
|
||||||
* in-progress text appended as a trailing text part, so rowToUiMessage /
|
* in-progress text appended as a trailing text part, so rowToUiMessage /
|
||||||
* findRecent keep replaying the turn unchanged. `metadata.finishReason`,
|
* findRecent keep replaying the turn unchanged. `metadata.finishReason`,
|
||||||
* `metadata.error`, `metadata.usage` and `metadata.contextTokens` are attached
|
* `metadata.error`, `metadata.usage`, `metadata.contextTokens` and
|
||||||
* only when provided/relevant, matching the pre-#183 onFinish/onError records.
|
* `metadata.maxContextTokens` are attached only when provided/relevant, matching
|
||||||
|
* the pre-#183 onFinish/onError records.
|
||||||
*/
|
*/
|
||||||
export function flushAssistant(
|
export function flushAssistant(
|
||||||
capturedSteps: ReadonlyArray<StepLike> | undefined,
|
capturedSteps: ReadonlyArray<StepLike> | undefined,
|
||||||
@@ -1226,9 +1228,6 @@ export function flushAssistant(
|
|||||||
finishReason?: string;
|
finishReason?: string;
|
||||||
usage?: ChatStreamUsage | StreamUsage | undefined;
|
usage?: ChatStreamUsage | StreamUsage | undefined;
|
||||||
contextTokens?: number;
|
contextTokens?: number;
|
||||||
// Admin-configured context-window size (tokens) for this turn's model; the
|
|
||||||
// denominator of the client's "current / max" header badge. Written only
|
|
||||||
// when > 0 (0/unset = no limit known → the badge shows current only).
|
|
||||||
maxContextTokens?: number;
|
maxContextTokens?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
},
|
},
|
||||||
@@ -1260,9 +1259,8 @@ export function flushAssistant(
|
|||||||
normalizeStreamUsage(extra.usage as StreamUsage) ?? extra.usage;
|
normalizeStreamUsage(extra.usage as StreamUsage) ?? extra.usage;
|
||||||
}
|
}
|
||||||
if (extra?.contextTokens) metadata.contextTokens = extra.contextTokens;
|
if (extra?.contextTokens) metadata.contextTokens = extra.contextTokens;
|
||||||
if (extra?.maxContextTokens && extra.maxContextTokens > 0) {
|
if (extra?.maxContextTokens)
|
||||||
metadata.maxContextTokens = extra.maxContextTokens;
|
metadata.maxContextTokens = extra.maxContextTokens;
|
||||||
}
|
|
||||||
if (extra?.error) metadata.error = extra.error;
|
if (extra?.error) metadata.error = extra.error;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ import { DB, Workspaces } from '@docmost/db/types/db';
|
|||||||
export const AI_PROVIDER_SETTINGS_ALLOWED: readonly string[] = [
|
export const AI_PROVIDER_SETTINGS_ALLOWED: readonly string[] = [
|
||||||
'driver',
|
'driver',
|
||||||
'chatModel',
|
'chatModel',
|
||||||
'chatApiStyle',
|
|
||||||
'chatContextWindow',
|
'chatContextWindow',
|
||||||
|
'chatApiStyle',
|
||||||
'embeddingModel',
|
'embeddingModel',
|
||||||
'baseUrl',
|
'baseUrl',
|
||||||
'embeddingBaseUrl',
|
'embeddingBaseUrl',
|
||||||
@@ -256,17 +256,11 @@ export class WorkspaceRepo {
|
|||||||
): Promise<Workspace> {
|
): Promise<Workspace> {
|
||||||
const db = dbOrTx(this.db, trx);
|
const db = dbOrTx(this.db, trx);
|
||||||
// Assemble the provider object IN SQL. Keys are fixed provider field names
|
// Assemble the provider object IN SQL. Keys are fixed provider field names
|
||||||
// (sql.lit -> inlined literals, no injection); values are bound params with
|
// (sql.lit -> inlined literals, no injection); values are bound params cast
|
||||||
// an explicit cast — postgres.js sends bound params untyped, and
|
// to ::text — postgres.js sends bound params untyped, and jsonb_build_object's
|
||||||
// jsonb_build_object's value args are polymorphic ("any"), so without the
|
// value args are polymorphic ("any"), so without the explicit ::text cast
|
||||||
// cast Postgres throws "could not determine data type of parameter $1". The
|
// Postgres throws "could not determine data type of parameter $1". The result
|
||||||
// cast is branched by the JS runtime type so the value lands in jsonb with
|
// is a real jsonb object, never a double-encoded string. The CASE self-heals
|
||||||
// the matching JSON type: a number stays a JSON number (e.g.
|
|
||||||
// chatContextWindow → `{"chatContextWindow":200000}`, jsonb_typeof 'number'),
|
|
||||||
// a boolean a JSON boolean, everything else a JSON string. A plain `::text`
|
|
||||||
// for all would store a numeric field as the JSON STRING `"200000"`, which
|
|
||||||
// the client's `typeof === "number"` guards reject. The result is a real
|
|
||||||
// jsonb object, never a double-encoded string. The CASE self-heals
|
|
||||||
// workspaces whose settings.ai.provider was previously corrupted into an
|
// workspaces whose settings.ai.provider was previously corrupted into an
|
||||||
// array/string.
|
// array/string.
|
||||||
const entries = Object.entries(provider).filter(
|
const entries = Object.entries(provider).filter(
|
||||||
@@ -274,14 +268,7 @@ export class WorkspaceRepo {
|
|||||||
);
|
);
|
||||||
const patch = entries.length
|
const patch = entries.length
|
||||||
? sql`jsonb_build_object(${sql.join(
|
? sql`jsonb_build_object(${sql.join(
|
||||||
entries.flatMap(([k, v]) => [
|
entries.flatMap(([k, v]) => [sql.lit(k), sql`${v}::text`]),
|
||||||
sql.lit(k),
|
|
||||||
typeof v === 'number'
|
|
||||||
? sql`${v}::numeric`
|
|
||||||
: typeof v === 'boolean'
|
|
||||||
? sql`${v}::boolean`
|
|
||||||
: sql`${v}::text`,
|
|
||||||
]),
|
|
||||||
)})`
|
)})`
|
||||||
: sql`'{}'::jsonb`;
|
: sql`'{}'::jsonb`;
|
||||||
return db
|
return db
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ describe('UpdateAiSettingsDto.chatApiStyle', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/** DTO validation for chatContextWindow (@IsOptional @IsInt @Min(0)). */
|
/** DTO validation for the new chatContextWindow field (@IsInt @Min(0)). */
|
||||||
describe('UpdateAiSettingsDto.chatContextWindow', () => {
|
describe('UpdateAiSettingsDto.chatContextWindow', () => {
|
||||||
const errorsFor = async (chatContextWindow: unknown) =>
|
const errorsFor = async (chatContextWindow: unknown) =>
|
||||||
validate(plainToInstance(UpdateAiSettingsDto, { chatContextWindow }));
|
validate(plainToInstance(UpdateAiSettingsDto, { chatContextWindow }));
|
||||||
|
|||||||
43
apps/server/src/integrations/ai/ai-settings.service.spec.ts
Normal file
43
apps/server/src/integrations/ai/ai-settings.service.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { parsePositiveInt } from './ai-settings.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Round-trip coercion for numeric `::text` provider settings (e.g.
|
||||||
|
* chatContextWindow). Values are stored as text and read back as strings, so
|
||||||
|
* this guards the read path the DTO write-validation does not cover: a silent
|
||||||
|
* loss of `Math.floor` or a `> 0` → `>= 0` drift would otherwise go unnoticed.
|
||||||
|
*/
|
||||||
|
describe('parsePositiveInt', () => {
|
||||||
|
it('keeps a valid positive integer string', () => {
|
||||||
|
expect(parsePositiveInt('200000')).toBe(200000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('floors a fractional string', () => {
|
||||||
|
expect(parsePositiveInt('1.9')).toBe(1);
|
||||||
|
expect(parsePositiveInt('1.0')).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for zero', () => {
|
||||||
|
expect(parsePositiveInt('0')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for a negative value', () => {
|
||||||
|
expect(parsePositiveInt('-5')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for an empty string', () => {
|
||||||
|
expect(parsePositiveInt('')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for a non-numeric string', () => {
|
||||||
|
expect(parsePositiveInt('abc')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for undefined / null', () => {
|
||||||
|
expect(parsePositiveInt(undefined)).toBeUndefined();
|
||||||
|
expect(parsePositiveInt(null)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a real number too (not only ::text strings)', () => {
|
||||||
|
expect(parsePositiveInt(42)).toBe(42);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,18 @@ import {
|
|||||||
PROVIDER_SETTINGS_KEYS,
|
PROVIDER_SETTINGS_KEYS,
|
||||||
} from './ai.types';
|
} from './ai.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coerce a raw provider value (stored as `::text`, so it arrives as a string —
|
||||||
|
* see workspace.repo.ts) into a positive integer, or `undefined` when it is not
|
||||||
|
* a finite number greater than zero. Used for numeric `::text` settings such as
|
||||||
|
* `chatContextWindow`. Fractions are floored: `"1.9" → 1`, `"0"`/`"-5"`/`""`/
|
||||||
|
* `"abc"`/`undefined` → `undefined`.
|
||||||
|
*/
|
||||||
|
export function parsePositiveInt(raw: unknown): number | undefined {
|
||||||
|
const n = Number(raw);
|
||||||
|
return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shape of the partial update accepted by `update`. Mirrors the validated
|
* Shape of the partial update accepted by `update`. Mirrors the validated
|
||||||
* controller DTO. `apiKey` / `embeddingApiKey` are write-only: undefined =
|
* controller DTO. `apiKey` / `embeddingApiKey` are write-only: undefined =
|
||||||
@@ -26,9 +38,9 @@ import {
|
|||||||
export interface UpdateAiSettingsInput {
|
export interface UpdateAiSettingsInput {
|
||||||
driver?: AiDriver;
|
driver?: AiDriver;
|
||||||
chatModel?: string;
|
chatModel?: string;
|
||||||
chatApiStyle?: ChatApiStyle;
|
// Max context window in tokens for the chat header badge. 0/empty = no limit.
|
||||||
// Chat context-window size (tokens); 0/empty clears the limit.
|
|
||||||
chatContextWindow?: number;
|
chatContextWindow?: number;
|
||||||
|
chatApiStyle?: ChatApiStyle;
|
||||||
embeddingModel?: string;
|
embeddingModel?: string;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
embeddingBaseUrl?: string;
|
embeddingBaseUrl?: string;
|
||||||
@@ -162,10 +174,11 @@ export class AiSettingsService {
|
|||||||
const config: ResolvedAiConfig = {
|
const config: ResolvedAiConfig = {
|
||||||
driver: provider.driver,
|
driver: provider.driver,
|
||||||
chatModel: provider.chatModel,
|
chatModel: provider.chatModel,
|
||||||
|
// Max context window for the chat header badge denominator. Stored as
|
||||||
|
// ::text; 0/unset/invalid = no limit (undefined).
|
||||||
|
chatContextWindow: parsePositiveInt(provider.chatContextWindow),
|
||||||
// Plain passthrough; getChatModel defaults unset to 'openai-compatible'.
|
// Plain passthrough; getChatModel defaults unset to 'openai-compatible'.
|
||||||
chatApiStyle: provider.chatApiStyle,
|
chatApiStyle: provider.chatApiStyle,
|
||||||
// Admin-configured context-window size; 0/unset = no limit (badge denominator).
|
|
||||||
chatContextWindow: provider.chatContextWindow,
|
|
||||||
// Cheap model id for the anonymous public-share assistant; reuses the chat
|
// Cheap model id for the anonymous public-share assistant; reuses the chat
|
||||||
// driver/baseUrl/apiKey. Empty/unset → callers fall back to chatModel.
|
// driver/baseUrl/apiKey. Empty/unset → callers fall back to chatModel.
|
||||||
publicShareChatModel: provider.publicShareChatModel,
|
publicShareChatModel: provider.publicShareChatModel,
|
||||||
@@ -223,6 +236,10 @@ export class AiSettingsService {
|
|||||||
async getMasked(workspaceId: string): Promise<MaskedAiSettings> {
|
async getMasked(workspaceId: string): Promise<MaskedAiSettings> {
|
||||||
const provider = await this.readProvider(workspaceId);
|
const provider = await this.readProvider(workspaceId);
|
||||||
|
|
||||||
|
// Stored as ::text; coerce to a positive integer (or undefined) so the
|
||||||
|
// client receives a real number.
|
||||||
|
const chatContextWindow = parsePositiveInt(provider.chatContextWindow);
|
||||||
|
|
||||||
let hasApiKey = false;
|
let hasApiKey = false;
|
||||||
let hasEmbeddingApiKey = false;
|
let hasEmbeddingApiKey = false;
|
||||||
let hasSttApiKey = false;
|
let hasSttApiKey = false;
|
||||||
@@ -247,8 +264,8 @@ export class AiSettingsService {
|
|||||||
return {
|
return {
|
||||||
driver: provider.driver,
|
driver: provider.driver,
|
||||||
chatModel: provider.chatModel,
|
chatModel: provider.chatModel,
|
||||||
|
chatContextWindow,
|
||||||
chatApiStyle: provider.chatApiStyle,
|
chatApiStyle: provider.chatApiStyle,
|
||||||
chatContextWindow: provider.chatContextWindow,
|
|
||||||
embeddingModel: provider.embeddingModel,
|
embeddingModel: provider.embeddingModel,
|
||||||
baseUrl: provider.baseUrl,
|
baseUrl: provider.baseUrl,
|
||||||
embeddingBaseUrl: provider.embeddingBaseUrl,
|
embeddingBaseUrl: provider.embeddingBaseUrl,
|
||||||
|
|||||||
@@ -32,16 +32,12 @@ export const CHAT_API_STYLES: ChatApiStyle[] = ['openai-compatible', 'openai'];
|
|||||||
export interface AiProviderSettings {
|
export interface AiProviderSettings {
|
||||||
driver: AiDriver;
|
driver: AiDriver;
|
||||||
chatModel: string;
|
chatModel: string;
|
||||||
|
// Max context window in tokens; surfaced to the chat header badge as the
|
||||||
|
// denominator ("current / max"). 0/unset = no limit (badge shows no denominator).
|
||||||
|
chatContextWindow?: number;
|
||||||
// Chat provider implementation for the `openai` driver. Unset → defaults to
|
// Chat provider implementation for the `openai` driver. Unset → defaults to
|
||||||
// 'openai-compatible' (so reasoning is surfaced by default). See ChatApiStyle.
|
// 'openai-compatible' (so reasoning is surfaced by default). See ChatApiStyle.
|
||||||
chatApiStyle?: ChatApiStyle;
|
chatApiStyle?: ChatApiStyle;
|
||||||
// Admin-configured chat model context-window size, in tokens. There is no
|
|
||||||
// provider-independent way to discover this (OpenAI's /v1/models usually omits
|
|
||||||
// it, Gemini/Ollama/OpenRouter each expose it differently), so it is entered
|
|
||||||
// manually. Surfaced to the chat client (via assistant message metadata) as the
|
|
||||||
// denominator of the header "current / max" context badge. Empty/0 = no limit
|
|
||||||
// known → the badge shows only the current context size.
|
|
||||||
chatContextWindow?: number;
|
|
||||||
embeddingModel?: string;
|
embeddingModel?: string;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
// Embedding-specific base URL. Falls back to `baseUrl` when empty/unset.
|
// Embedding-specific base URL. Falls back to `baseUrl` when empty/unset.
|
||||||
@@ -79,8 +75,8 @@ export interface AiProviderSettings {
|
|||||||
export const PROVIDER_SETTINGS_KEYS = [
|
export const PROVIDER_SETTINGS_KEYS = [
|
||||||
'driver',
|
'driver',
|
||||||
'chatModel',
|
'chatModel',
|
||||||
'chatApiStyle',
|
|
||||||
'chatContextWindow',
|
'chatContextWindow',
|
||||||
|
'chatApiStyle',
|
||||||
'embeddingModel',
|
'embeddingModel',
|
||||||
'baseUrl',
|
'baseUrl',
|
||||||
'embeddingBaseUrl',
|
'embeddingBaseUrl',
|
||||||
@@ -106,9 +102,8 @@ export const PROVIDER_SETTINGS_KEYS = [
|
|||||||
export interface ResolvedAiConfig extends Partial<AiProviderSettings> {
|
export interface ResolvedAiConfig extends Partial<AiProviderSettings> {
|
||||||
driver?: AiDriver;
|
driver?: AiDriver;
|
||||||
chatModel?: string;
|
chatModel?: string;
|
||||||
// Admin-configured chat context-window size (tokens); 0/unset = no limit. Used
|
// Max context window in tokens; surfaced to the chat header badge as the
|
||||||
// as the header context-badge denominator. Re-declared for parity with the
|
// "current / max" denominator. 0/unset = no limit.
|
||||||
// explicit fields above.
|
|
||||||
chatContextWindow?: number;
|
chatContextWindow?: number;
|
||||||
// Cheap model id for the public-share assistant; reuses the chat creds.
|
// Cheap model id for the public-share assistant; reuses the chat creds.
|
||||||
publicShareChatModel?: string;
|
publicShareChatModel?: string;
|
||||||
@@ -128,9 +123,10 @@ export interface ResolvedAiConfig extends Partial<AiProviderSettings> {
|
|||||||
export interface MaskedAiSettings {
|
export interface MaskedAiSettings {
|
||||||
driver?: AiDriver;
|
driver?: AiDriver;
|
||||||
chatModel?: string;
|
chatModel?: string;
|
||||||
chatApiStyle?: ChatApiStyle;
|
// Max context window in tokens; the chat header badge denominator. 0/unset =
|
||||||
// Admin-configured chat context-window size (tokens); 0/unset = no limit.
|
// no limit.
|
||||||
chatContextWindow?: number;
|
chatContextWindow?: number;
|
||||||
|
chatApiStyle?: ChatApiStyle;
|
||||||
embeddingModel?: string;
|
embeddingModel?: string;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
embeddingBaseUrl?: string;
|
embeddingBaseUrl?: string;
|
||||||
|
|||||||
@@ -25,17 +25,17 @@ export class UpdateAiSettingsDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
chatModel?: string;
|
chatModel?: string;
|
||||||
|
|
||||||
@IsOptional()
|
// Max context window in tokens shown in the chat header badge. 0/empty =
|
||||||
@IsIn(CHAT_API_STYLES)
|
// clear the limit (no denominator shown).
|
||||||
chatApiStyle?: ChatApiStyle;
|
|
||||||
|
|
||||||
// Chat model context-window size in tokens (header context-badge denominator).
|
|
||||||
// 0 (or empty) clears the limit so the badge shows only the current context.
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
chatContextWindow?: number;
|
chatContextWindow?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(CHAT_API_STYLES)
|
||||||
|
chatApiStyle?: ChatApiStyle;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
embeddingModel?: string;
|
embeddingModel?: string;
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
import { Kysely, sql } from 'kysely';
|
|
||||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
|
||||||
import { getTestDb, destroyTestDb, createWorkspace } from './db';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WorkspaceRepo.updateAiProviderSettings numeric round-trip (#189, #213).
|
|
||||||
*
|
|
||||||
* `chatContextWindow` is the first NUMERIC provider field routed through this
|
|
||||||
* generic SQL layer. The patch builder must cast a JS number so it lands in
|
|
||||||
* jsonb as a JSON NUMBER, not the JSON STRING `"200000"` — the client guards
|
|
||||||
* (`typeof === "number"`) reject a string, silently killing the `/ max` badge
|
|
||||||
* denominator. A plain `::text` cast (the prior code) regressed exactly this.
|
|
||||||
* These specs are real SQL and assert both the JS value type and the on-disk
|
|
||||||
* `jsonb_typeof`.
|
|
||||||
*/
|
|
||||||
describe('WorkspaceRepo.updateAiProviderSettings (numeric round-trip) [integration]', () => {
|
|
||||||
let db: Kysely<any>;
|
|
||||||
let repo: WorkspaceRepo;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
db = getTestDb();
|
|
||||||
repo = new WorkspaceRepo(db as any);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await destroyTestDb();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stores chatContextWindow as a JSON number (not a "200000" string)', async () => {
|
|
||||||
const ws = await createWorkspace(db, { settings: undefined });
|
|
||||||
|
|
||||||
const updated = await repo.updateAiProviderSettings(ws.id, {
|
|
||||||
driver: 'openai',
|
|
||||||
chatModel: 'gpt-4o',
|
|
||||||
chatContextWindow: 200000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Returned row: the number survives as a real JS number, alongside the
|
|
||||||
// string fields which stay strings.
|
|
||||||
const provider = (updated.settings as any)?.ai?.provider;
|
|
||||||
expect(provider.chatContextWindow).toBe(200000);
|
|
||||||
expect(typeof provider.chatContextWindow).toBe('number');
|
|
||||||
expect(provider.driver).toBe('openai');
|
|
||||||
expect(provider.chatModel).toBe('gpt-4o');
|
|
||||||
|
|
||||||
// On disk: the jsonb value is typed 'number' (the must-fix assertion), and
|
|
||||||
// sibling string fields are typed 'string'.
|
|
||||||
const typed = await db
|
|
||||||
.selectFrom('workspaces')
|
|
||||||
.select([
|
|
||||||
sql<string>`jsonb_typeof(settings->'ai'->'provider'->'chatContextWindow')`.as(
|
|
||||||
'windowType',
|
|
||||||
),
|
|
||||||
sql<string>`jsonb_typeof(settings->'ai'->'provider'->'chatModel')`.as(
|
|
||||||
'modelType',
|
|
||||||
),
|
|
||||||
])
|
|
||||||
.where('id', '=', ws.id)
|
|
||||||
.executeTakeFirstOrThrow();
|
|
||||||
|
|
||||||
expect(typed.windowType).toBe('number');
|
|
||||||
expect(typed.modelType).toBe('string');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('re-reads chatContextWindow as a number after a partial-merge update', async () => {
|
|
||||||
const ws = await createWorkspace(db, {
|
|
||||||
settings: { ai: { provider: { driver: 'openai', chatModel: 'x' } } },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Merge in only the numeric field; siblings must be preserved and the value
|
|
||||||
// must still be a JSON number, not a string.
|
|
||||||
await repo.updateAiProviderSettings(ws.id, { chatContextWindow: 128000 });
|
|
||||||
|
|
||||||
const row = await db
|
|
||||||
.selectFrom('workspaces')
|
|
||||||
.select([
|
|
||||||
'settings',
|
|
||||||
sql<string>`jsonb_typeof(settings->'ai'->'provider'->'chatContextWindow')`.as(
|
|
||||||
'windowType',
|
|
||||||
),
|
|
||||||
])
|
|
||||||
.where('id', '=', ws.id)
|
|
||||||
.executeTakeFirstOrThrow();
|
|
||||||
|
|
||||||
expect(row.windowType).toBe('number');
|
|
||||||
const provider = (row.settings as any)?.ai?.provider;
|
|
||||||
expect(provider.chatContextWindow).toBe(128000);
|
|
||||||
expect(provider.driver).toBe('openai');
|
|
||||||
expect(provider.chatModel).toBe('x');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user