9
.github/workflows/develop.yml
vendored
9
.github/workflows/develop.yml
vendored
@@ -180,7 +180,10 @@ jobs:
|
|||||||
run: pnpm --filter ./apps/server migration:latest
|
run: pnpm --filter ./apps/server migration:latest
|
||||||
|
|
||||||
- name: Start server (prod)
|
- name: Start server (prod)
|
||||||
run: pnpm --filter ./apps/server start: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
|
- name: Wait for server health
|
||||||
run: |
|
run: |
|
||||||
@@ -194,6 +197,10 @@ jobs:
|
|||||||
echo "Server did not become healthy in time"
|
echo "Server did not become healthy in time"
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
|
- name: Dump server log on failure
|
||||||
|
if: failure()
|
||||||
|
run: cat /tmp/server.log || true
|
||||||
|
|
||||||
- name: Seed admin
|
- name: Seed admin
|
||||||
run: |
|
run: |
|
||||||
curl -fsS -X POST http://localhost:3000/api/auth/setup \
|
curl -fsS -X POST http://localhost:3000/api/auth/setup \
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ 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";
|
||||||
@@ -281,43 +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 max context window (badge denominator). Read the most recent row
|
|
||||||
// carrying `maxContextTokens` (set alongside contextTokens on a completed
|
|
||||||
// turn); 0 when no row has it (older rows, or no admin-configured limit) — the
|
|
||||||
// badge then shows just the current size with no denominator.
|
|
||||||
const maxContextTokens = useMemo(() => {
|
|
||||||
if (!activeChatId || !messageRows) return 0;
|
|
||||||
for (let i = messageRows.length - 1; i >= 0; i--) {
|
|
||||||
const meta = messageRows[i].metadata;
|
|
||||||
if (!meta) continue;
|
|
||||||
if (
|
|
||||||
typeof meta.maxContextTokens === "number" &&
|
|
||||||
meta.maxContextTokens > 0
|
|
||||||
) {
|
|
||||||
return meta.maxContextTokens;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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,
|
||||||
|
|||||||
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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
): 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
state: "idle",
|
||||||
|
color: undefined,
|
||||||
|
variant: "default",
|
||||||
|
label: t("Test"),
|
||||||
|
tooltip: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -182,34 +183,22 @@ function AiMcpServerRow({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [server.url, server.transport, server.hasHeaders]);
|
}, [server.url, server.transport, server.hasHeaders]);
|
||||||
|
|
||||||
// Tooltip text describes the cause/details; disabled while there is no result.
|
// Single derivation of the button/tooltip presentation from the test tristate
|
||||||
let tooltipLabel = "";
|
// (idle / ok / failed), so the two can never drift apart. Tooltip is "" while
|
||||||
if (result?.ok) {
|
// there is no result; the icon is mapped from `view.state` below.
|
||||||
tooltipLabel =
|
const view = mcpTestButtonView(result, t);
|
||||||
result.tools.length > 0
|
const tooltipLabel = view.tooltip;
|
||||||
? result.tools.join(", ")
|
const buttonColor = view.color;
|
||||||
: t("No tools available");
|
const buttonVariant = view.variant;
|
||||||
} else if (result && result.ok === false) {
|
const buttonLabel = view.label;
|
||||||
tooltipLabel = result.error;
|
const buttonIcon =
|
||||||
}
|
view.state === "ok" ? (
|
||||||
|
<IconCheck size={16} />
|
||||||
// Pick the button presentation from the current test state. Color is never the
|
) : view.state === "failed" ? (
|
||||||
// only signal — the label changes too (a11y / colorblind-friendly).
|
<IconX size={16} />
|
||||||
let buttonColor: string | undefined;
|
) : (
|
||||||
let buttonVariant = "default";
|
<IconPlugConnected size={16} />
|
||||||
let buttonIcon = <IconPlugConnected size={16} />;
|
);
|
||||||
let buttonLabel = t("Test");
|
|
||||||
if (result?.ok) {
|
|
||||||
buttonColor = "green";
|
|
||||||
buttonVariant = "light";
|
|
||||||
buttonIcon = <IconCheck size={16} />;
|
|
||||||
buttonLabel = t("OK · {{n}}", { n: result.tools.length });
|
|
||||||
} else if (result && result.ok === false) {
|
|
||||||
buttonColor = "red";
|
|
||||||
buttonVariant = "light";
|
|
||||||
buttonIcon = <IconX size={16} />;
|
|
||||||
buttonLabel = t("Failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
|||||||
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 =
|
||||||
@@ -159,20 +171,12 @@ export class AiSettingsService {
|
|||||||
const provider = await this.readProvider(workspaceId);
|
const provider = await this.readProvider(workspaceId);
|
||||||
if (!provider.driver) return null;
|
if (!provider.driver) return null;
|
||||||
|
|
||||||
// Provider values are stored as ::text (see workspace.repo.ts), so
|
|
||||||
// chatContextWindow arrives as a string here; parse it back to a positive
|
|
||||||
// integer or undefined.
|
|
||||||
const ctxWindow = Number(provider.chatContextWindow);
|
|
||||||
|
|
||||||
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. 0/unset = no
|
// Max context window for the chat header badge denominator. Stored as
|
||||||
// limit.
|
// ::text; 0/unset/invalid = no limit (undefined).
|
||||||
chatContextWindow:
|
chatContextWindow: parsePositiveInt(provider.chatContextWindow),
|
||||||
Number.isFinite(ctxWindow) && ctxWindow > 0
|
|
||||||
? Math.floor(ctxWindow)
|
|
||||||
: undefined,
|
|
||||||
// Plain passthrough; getChatModel defaults unset to 'openai-compatible'.
|
// Plain passthrough; getChatModel defaults unset to 'openai-compatible'.
|
||||||
chatApiStyle: provider.chatApiStyle,
|
chatApiStyle: provider.chatApiStyle,
|
||||||
// Cheap model id for the anonymous public-share assistant; reuses the chat
|
// Cheap model id for the anonymous public-share assistant; reuses the chat
|
||||||
@@ -232,14 +236,9 @@ 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);
|
||||||
|
|
||||||
// Provider values are stored as ::text (see workspace.repo.ts), so
|
// Stored as ::text; coerce to a positive integer (or undefined) so the
|
||||||
// chatContextWindow arrives as a string; coerce it to a positive integer or
|
// client receives a real number.
|
||||||
// undefined so the client receives a real number.
|
const chatContextWindow = parsePositiveInt(provider.chatContextWindow);
|
||||||
const ctxWindow = Number(provider.chatContextWindow);
|
|
||||||
const chatContextWindow =
|
|
||||||
Number.isFinite(ctxWindow) && ctxWindow > 0
|
|
||||||
? Math.floor(ctxWindow)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
let hasApiKey = false;
|
let hasApiKey = false;
|
||||||
let hasEmbeddingApiKey = false;
|
let hasEmbeddingApiKey = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user