mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 14:12:25 -05:00
feat: replace the AI Thinking toggle with a per-connection reasoning effort
The Thinking switch had two states: send nothing, or send `reasoning_effort: "none"`. That suits qwen3, which stops thinking on "none", but not gpt-oss, which cannot stop reasoning, ignores "none" and only takes low/medium/high. With Thinking off, gpt-oss:20b reasoned more than with it on and ran out of the 1024-token categorisation budget three times as often, and there was no way to ask it for "low". Each AI connection now has a reasoning effort: the provider's default (send nothing), none, low, medium or high. A migration keeps every existing connection sending exactly what it sent before: Thinking on becomes the provider default, off becomes "none". A provider that answers 400 naming reasoning_effort is still asked again without it, whichever value was set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7abb673524
commit
b30b0cefd8
11 files changed
+156
-80
No files matched your search
@@ -0,0 +1,11 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ReasoningEffort" AS ENUM ('none', 'low', 'medium', 'high');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "AiConnection" ADD COLUMN "reasoningEffort" "ReasoningEffort";
|
||||
|
||||
-- Thinking on sent no reasoning_effort and off sent "none", so each connection keeps sending what it did.
|
||||
UPDATE "AiConnection" SET "reasoningEffort" = 'none' WHERE "thinking" = false;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "AiConnection" DROP COLUMN "thinking";
|
||||
@@ -19,6 +19,14 @@ enum CategorySource {
|
||||
LEGACY
|
||||
}
|
||||
|
||||
/// What an AI connection sends as reasoning_effort, spelled the way providers expect it.
|
||||
enum ReasoningEffort {
|
||||
none
|
||||
low
|
||||
medium
|
||||
high
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
@@ -530,20 +538,20 @@ model Setting {
|
||||
|
||||
/// An OpenAI-compatible AI service. Features use the default one unless set to another.
|
||||
model AiConnection {
|
||||
id String @id @default(cuid())
|
||||
baseUrl String
|
||||
id String @id @default(cuid())
|
||||
baseUrl String
|
||||
/// Write-only: the API only ever says whether one is set.
|
||||
apiKey String?
|
||||
model String
|
||||
timeoutMs Int @default(60000)
|
||||
apiKey String?
|
||||
model String
|
||||
timeoutMs Int @default(60000)
|
||||
/// How many requests may wait on this provider at once.
|
||||
concurrency Int @default(1)
|
||||
/// Off asks a reasoning model to skip thinking, which on a CPU takes minutes.
|
||||
thinking Boolean @default(true)
|
||||
isDefault Boolean @default(false)
|
||||
position Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
concurrency Int @default(1)
|
||||
/// Sent as reasoning_effort. Null sends nothing, leaving it to the provider.
|
||||
reasoningEffort ReasoningEffort?
|
||||
isDefault Boolean @default(false)
|
||||
position Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
/// An OpenID Connect identity provider (Authentik, PocketID, ...) people can sign in with.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AiConnection } from "@prisma/client";
|
||||
import type { AiConnection, ReasoningEffort } from "@prisma/client";
|
||||
import { currentStorageMove, startStorageMove, type StorageMoveRun } from "../services/storageMoveService";
|
||||
import { assertSavableStorageTemplate, storageTemplateProblems } from "../services/storageTemplateRules";
|
||||
import { Router } from "express";
|
||||
@@ -834,7 +834,7 @@ function aiConnectionOut(connection: AiConnection) {
|
||||
has_api_key: Boolean(connection.apiKey),
|
||||
timeout_ms: connection.timeoutMs,
|
||||
concurrency: connection.concurrency,
|
||||
thinking: connection.thinking,
|
||||
reasoning_effort: connection.reasoningEffort,
|
||||
is_default: connection.isDefault,
|
||||
};
|
||||
}
|
||||
@@ -854,13 +854,20 @@ const aiConnectionFields = {
|
||||
api_key: z.string().nullable().optional(),
|
||||
timeout_ms: z.number().int().min(1000).max(300000),
|
||||
concurrency: z.number().int().min(1).max(4),
|
||||
thinking: z.boolean(),
|
||||
// Null sends no reasoning_effort at all.
|
||||
reasoning_effort: z.enum(["none", "low", "medium", "high"]).nullable(),
|
||||
};
|
||||
|
||||
/** A saved AI (or the defaults, for a new one) with the form's values on top. */
|
||||
async function providerFrom(
|
||||
id: string | undefined,
|
||||
body: Partial<{ base_url: string; model: string; api_key: string | null; timeout_ms: number; thinking: boolean }>,
|
||||
body: Partial<{
|
||||
base_url: string;
|
||||
model: string;
|
||||
api_key: string | null;
|
||||
timeout_ms: number;
|
||||
reasoning_effort: ReasoningEffort | null;
|
||||
}>,
|
||||
) {
|
||||
const saved = id ? await getAiConnection(id) : null;
|
||||
return {
|
||||
@@ -868,7 +875,10 @@ async function providerFrom(
|
||||
model: body.model ?? saved?.model ?? null,
|
||||
apiKey: body.api_key !== undefined ? body.api_key || null : (saved?.apiKey ?? DEFAULT_AI_PROVIDER.apiKey),
|
||||
timeoutMs: body.timeout_ms ?? saved?.timeoutMs ?? DEFAULT_AI_PROVIDER.timeoutMs,
|
||||
thinking: body.thinking ?? saved?.thinking ?? DEFAULT_AI_PROVIDER.thinking,
|
||||
reasoningEffort:
|
||||
body.reasoning_effort !== undefined
|
||||
? body.reasoning_effort
|
||||
: (saved?.reasoningEffort ?? DEFAULT_AI_PROVIDER.reasoningEffort),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -884,7 +894,7 @@ const aiConnectionCreateSchema = z
|
||||
...aiConnectionFields,
|
||||
timeout_ms: aiConnectionFields.timeout_ms.optional(),
|
||||
concurrency: aiConnectionFields.concurrency.optional(),
|
||||
thinking: aiConnectionFields.thinking.optional(),
|
||||
reasoning_effort: aiConnectionFields.reasoning_effort.optional(),
|
||||
})
|
||||
.strict();
|
||||
router.post(
|
||||
@@ -899,7 +909,7 @@ router.post(
|
||||
model: provider.model!,
|
||||
apiKey: provider.apiKey,
|
||||
timeoutMs: provider.timeoutMs,
|
||||
thinking: provider.thinking,
|
||||
reasoningEffort: provider.reasoningEffort,
|
||||
concurrency: body.concurrency ?? DEFAULT_AI_PROVIDER.concurrency,
|
||||
});
|
||||
res.status(201).json(aiConnectionOut(connection));
|
||||
@@ -913,7 +923,7 @@ router.patch(
|
||||
asyncHandler(async (req, res) => {
|
||||
const body = parseBody(aiConnectionUpdateSchema, req.body);
|
||||
const provider = await providerFrom(req.params.id, body);
|
||||
// Concurrency and thinking don't change whether it answers.
|
||||
// Concurrency and reasoning effort don't change whether it answers.
|
||||
if (
|
||||
body.base_url !== undefined ||
|
||||
body.model !== undefined ||
|
||||
@@ -928,7 +938,7 @@ router.patch(
|
||||
...(body.api_key !== undefined ? { apiKey: provider.apiKey } : {}),
|
||||
...(body.timeout_ms !== undefined ? { timeoutMs: body.timeout_ms } : {}),
|
||||
...(body.concurrency !== undefined ? { concurrency: body.concurrency } : {}),
|
||||
...(body.thinking !== undefined ? { thinking: body.thinking } : {}),
|
||||
...(body.reasoning_effort !== undefined ? { reasoningEffort: body.reasoning_effort } : {}),
|
||||
});
|
||||
res.json(aiConnectionOut(connection));
|
||||
}),
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { AiConnection, Prisma } from "@prisma/client";
|
||||
import type { AiConnection, Prisma, ReasoningEffort } from "@prisma/client";
|
||||
import { prisma } from "../db";
|
||||
import { HttpError } from "../utils/fileUtils";
|
||||
import { normalizeAiBaseUrl } from "./settingsService";
|
||||
|
||||
/** What a request to a provider needs, whether the connection is saved or only typed into the form. */
|
||||
export type AiProvider = Pick<AiConnection, "baseUrl" | "apiKey" | "model" | "timeoutMs" | "concurrency" | "thinking">;
|
||||
export type AiProvider = Pick<
|
||||
AiConnection,
|
||||
"baseUrl" | "apiKey" | "model" | "timeoutMs" | "concurrency" | "reasoningEffort"
|
||||
>;
|
||||
|
||||
export type AiConnectionInput = {
|
||||
baseUrl: string;
|
||||
@@ -12,14 +15,14 @@ export type AiConnectionInput = {
|
||||
model: string;
|
||||
timeoutMs: number;
|
||||
concurrency: number;
|
||||
thinking: boolean;
|
||||
reasoningEffort: ReasoningEffort | null;
|
||||
};
|
||||
|
||||
export const DEFAULT_AI_PROVIDER: Omit<AiProvider, "baseUrl" | "model"> = {
|
||||
apiKey: null,
|
||||
timeoutMs: 60000,
|
||||
concurrency: 1,
|
||||
thinking: true,
|
||||
reasoningEffort: null,
|
||||
};
|
||||
|
||||
// Any number, as long as only the default's writers take it. Two AIs added at once would otherwise both count no
|
||||
@@ -65,7 +68,7 @@ export async function resolveAiProvider(id: string | null | undefined): Promise<
|
||||
model: connection.model,
|
||||
timeoutMs: connection.timeoutMs,
|
||||
concurrency: connection.concurrency,
|
||||
thinking: connection.thinking,
|
||||
reasoningEffort: connection.reasoningEffort,
|
||||
connectionId: connection.id,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ export async function askProvider<T>(options: {
|
||||
const { settings, content, schemaName, schema, maxTokens, attempts = 3, log: requestLog, noAnswer, read } = options;
|
||||
const { stream } = options;
|
||||
const connection = `${settings.baseUrl}|${settings.model}`;
|
||||
let skipThinking = !settings.thinking && !reasoningEffortRejected.has(connection);
|
||||
let reasoningEffort = reasoningEffortRejected.has(connection) ? null : settings.reasoningEffort;
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
const last = attempt === attempts - 1;
|
||||
const started = Date.now();
|
||||
@@ -208,17 +208,18 @@ export async function askProvider<T>(options: {
|
||||
max_tokens: maxTokens,
|
||||
...(stream ? { stream: true, stream_options: { include_usage: true } } : {}),
|
||||
response_format: { type: "json_schema", json_schema: { name: schemaName, strict: true, schema } },
|
||||
// A reasoning model (qwen3) on a CPU spends minutes thinking; these tasks rarely need it.
|
||||
...(skipThinking ? { reasoning_effort: "none" } : {}),
|
||||
// These tasks rarely need much thought, and a reasoning model spends max_tokens on it before answering.
|
||||
// Models differ in what they take: "none" stops qwen3 thinking, but gpt-oss ignores it and needs "low".
|
||||
...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
|
||||
messages: [{ role: "user", content }],
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
// Strict providers reject the field outright, e.g. OpenAI for a model that doesn't reason: ask again
|
||||
// without it, which doesn't count as an attempt. Only when the error names it, so another bad request
|
||||
// isn't taken for proof that the connection can't skip thinking.
|
||||
if (response.status === 400 && skipThinking && /reasoning/i.test(await response.clone().text())) {
|
||||
skipThinking = false;
|
||||
// isn't taken for proof that the connection can't take a reasoning effort.
|
||||
if (response.status === 400 && reasoningEffort && /reasoning/i.test(await response.clone().text())) {
|
||||
reasoningEffort = null;
|
||||
reasoningEffortRejected.add(connection);
|
||||
requestLog.warn("provider rejected reasoning_effort, asking again without it");
|
||||
attempt--;
|
||||
|
||||
@@ -141,7 +141,7 @@ async function enable(baseUrl: string, patch: Partial<AiCategorizationPrefs & Ai
|
||||
model: "local",
|
||||
timeoutMs: 1000,
|
||||
concurrency: 1,
|
||||
thinking: true,
|
||||
reasoningEffort: null,
|
||||
...connection,
|
||||
});
|
||||
await setAiCategorizationPrefs({ enabled: true, mode, threshold, onImport, sendImage, aiConnectionId: null });
|
||||
@@ -363,12 +363,18 @@ describe("AI categorization", () => {
|
||||
const tuned = await request(app)
|
||||
.patch(`/api/settings/ai-connections/${saved.body.id}`)
|
||||
.set(auth())
|
||||
.send({ concurrency: 2, thinking: false });
|
||||
expect(tuned.body).toMatchObject({ base_url: liveUrl, concurrency: 2, thinking: false });
|
||||
.send({ concurrency: 2, reasoning_effort: "low" });
|
||||
expect(tuned.body).toMatchObject({ base_url: liveUrl, concurrency: 2, reasoning_effort: "low" });
|
||||
});
|
||||
|
||||
it("makes exactly one of two AIs added at the same moment the default", async () => {
|
||||
const fields = { apiKey: null, timeoutMs: 1000, concurrency: 1, thinking: true, baseUrl: "http://127.0.0.1:9/v1" };
|
||||
const fields = {
|
||||
apiKey: null,
|
||||
timeoutMs: 1000,
|
||||
concurrency: 1,
|
||||
reasoningEffort: null,
|
||||
baseUrl: "http://127.0.0.1:9/v1",
|
||||
};
|
||||
const added = await Promise.all([
|
||||
createAiConnection({ ...fields, model: "first" }),
|
||||
createAiConnection({ ...fields, model: "second" }),
|
||||
@@ -380,7 +386,7 @@ describe("AI categorization", () => {
|
||||
it("makes the first AI the default, moves the role on delete, and lets categorization pick one", async () => {
|
||||
const defaultUrl = await startProvider(() => ({ category_id: null, confidence: 1, reason: "default" }));
|
||||
const first = await enable(defaultUrl);
|
||||
const fields = { apiKey: null, timeoutMs: 1000, concurrency: 1, thinking: true };
|
||||
const fields = { apiKey: null, timeoutMs: 1000, concurrency: 1, reasoningEffort: null };
|
||||
const second = await createAiConnection({ ...fields, baseUrl: "http://127.0.0.1:9/v1", model: "other" });
|
||||
expect([first.isDefault, second.isDefault]).toEqual([true, false]);
|
||||
|
||||
@@ -438,7 +444,7 @@ describe("AI categorization", () => {
|
||||
model: "qwen3:8b",
|
||||
timeoutMs: 1000,
|
||||
concurrency: 1,
|
||||
thinking: true,
|
||||
reasoningEffort: null,
|
||||
});
|
||||
const listed = await request(app).post("/api/settings/ai-connections/models").set(auth()).send({ id: saved.id });
|
||||
// Sorted, without duplicates or entries that aren't models.
|
||||
@@ -801,7 +807,7 @@ describe("AI categorization", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("asks a reasoning model not to think only when thinking is turned off", async () => {
|
||||
it("sends the connection's reasoning effort, and nothing when it is left to the provider", async () => {
|
||||
const bodies: Record<string, unknown>[] = [];
|
||||
const baseUrl = await startProvider((body) => {
|
||||
bodies.push(body);
|
||||
@@ -816,17 +822,26 @@ describe("AI categorization", () => {
|
||||
.send({})
|
||||
).status;
|
||||
|
||||
const setEffort = (reasoning_effort: unknown) =>
|
||||
request(app).patch(`/api/settings/ai-connections/${connection.id}`).set(auth()).send({ reasoning_effort });
|
||||
|
||||
expect(await recategorize()).toBe(200);
|
||||
expect(bodies[0]).not.toHaveProperty("reasoning_effort");
|
||||
|
||||
const off = await request(app)
|
||||
.patch(`/api/settings/ai-connections/${connection.id}`)
|
||||
.set(auth())
|
||||
.send({ thinking: false });
|
||||
expect(off.status).toBe(200);
|
||||
expect(off.body.thinking).toBe(false);
|
||||
// gpt-oss can't stop reasoning and ignores "none", so it needs one of the others.
|
||||
for (const effort of ["none", "low", "medium", "high"]) {
|
||||
const set = await setEffort(effort);
|
||||
expect(set.status).toBe(200);
|
||||
expect(set.body.reasoning_effort).toBe(effort);
|
||||
expect(await recategorize()).toBe(200);
|
||||
expect(bodies.at(-1)?.reasoning_effort).toBe(effort);
|
||||
}
|
||||
|
||||
expect((await setEffort("max")).status).toBe(400);
|
||||
const cleared = await setEffort(null);
|
||||
expect(cleared.body.reasoning_effort).toBeNull();
|
||||
expect(await recategorize()).toBe(200);
|
||||
expect(bodies[1].reasoning_effort).toBe("none");
|
||||
expect(bodies.at(-1)).not.toHaveProperty("reasoning_effort");
|
||||
});
|
||||
|
||||
it("asks again without reasoning_effort when a strict provider rejects it, and remembers that", async () => {
|
||||
@@ -838,7 +853,7 @@ describe("AI categorization", () => {
|
||||
? { status: 400, content: "Unsupported parameter: 'reasoning_effort' is not supported with this model." }
|
||||
: { category_id: null, confidence: 1, reason: "none" };
|
||||
});
|
||||
await enable(baseUrl, { thinking: false });
|
||||
await enable(baseUrl, { reasoningEffort: "low" });
|
||||
const recategorize = async () =>
|
||||
(
|
||||
await request(app)
|
||||
@@ -859,7 +874,7 @@ describe("AI categorization", () => {
|
||||
bodies.push(body);
|
||||
return { status: 400, content: "The model `missing` does not exist" };
|
||||
});
|
||||
await enable(baseUrl, { thinking: false });
|
||||
await enable(baseUrl, { reasoningEffort: "none" });
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const failed = await request(app)
|
||||
.post(`/api/print/${(await print()).id}/recategorize`)
|
||||
|
||||
@@ -84,7 +84,14 @@ async function stopProvider() {
|
||||
/** One model, the default, and the feature on with it. */
|
||||
async function enable(baseUrl: string, patch: Partial<AiDescriptionPrefs> = {}) {
|
||||
await prisma.aiConnection.deleteMany();
|
||||
await createAiConnection({ baseUrl, apiKey: null, model: "local", timeoutMs: 2000, concurrency: 1, thinking: true });
|
||||
await createAiConnection({
|
||||
baseUrl,
|
||||
apiKey: null,
|
||||
model: "local",
|
||||
timeoutMs: 2000,
|
||||
concurrency: 1,
|
||||
reasoningEffort: null,
|
||||
});
|
||||
await setAiDescriptionPrefs({ ...DEFAULT_AI_DESCRIPTION_PREFS, enabled: true, onImport: false, ...patch });
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@ import { apiBase, readErrorMessage, UnauthorizedError } from "./client";
|
||||
import { invalidateAiCategorizationStatus } from "./aiCategorization";
|
||||
import { invalidateAiDescriptionStatus } from "./aiDescriptions";
|
||||
|
||||
/** What a connection may send as reasoning_effort; null sends nothing. */
|
||||
export const REASONING_EFFORTS = ["none", "low", "medium", "high"] as const;
|
||||
export type ReasoningEffort = (typeof REASONING_EFFORTS)[number];
|
||||
|
||||
export type AiConnection = {
|
||||
id: string;
|
||||
base_url: string;
|
||||
@@ -10,7 +14,7 @@ export type AiConnection = {
|
||||
has_api_key: boolean;
|
||||
timeout_ms: number;
|
||||
concurrency: number;
|
||||
thinking: boolean;
|
||||
reasoning_effort: ReasoningEffort | null;
|
||||
is_default: boolean;
|
||||
};
|
||||
|
||||
@@ -21,7 +25,7 @@ export type AiConnectionInput = {
|
||||
api_key?: string | null;
|
||||
timeout_ms: number;
|
||||
concurrency: number;
|
||||
thinking: boolean;
|
||||
reasoning_effort: ReasoningEffort | null;
|
||||
};
|
||||
|
||||
export type AiConnectionTest = { ok: boolean; latency_ms?: number; model?: string; error?: string };
|
||||
|
||||
@@ -902,8 +902,15 @@
|
||||
"keyOptional": "Optional. Local servers such as Ollama usually don't need one.",
|
||||
"concurrency": "Concurrent requests (1-4)",
|
||||
"timeout": "Request timeout (ms)",
|
||||
"thinking": "Let the model think before answering",
|
||||
"thinkingHint": "Turn off to speed up a reasoning model (such as qwen3) on slow hardware. If the provider doesn't support turning thinking off, the request is sent without it.",
|
||||
"reasoningEffort": "Reasoning effort",
|
||||
"reasoningEffortHint": "Sent to the provider as reasoning_effort. Leave it on the provider's default unless a reasoning model is slow or runs out of tokens: None stops qwen3 thinking, but gpt-oss can't stop and needs Low. If the provider rejects the setting, the request is sent without it.",
|
||||
"reasoningEffortDefault": "Provider default (send nothing)",
|
||||
"reasoningEffortLevels": {
|
||||
"none": "None",
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High"
|
||||
},
|
||||
"test": "Test connection",
|
||||
"testing": "Testing…",
|
||||
"testSuccess": "Connected to {{model}} in {{latency}} ms.",
|
||||
|
||||
@@ -912,8 +912,15 @@
|
||||
"keyOptional": "Neprivaloma. Vietiniams serveriams, tokiems kaip Ollama, jo paprastai nereikia.",
|
||||
"concurrency": "Lygiagrečios užklausos (1-4)",
|
||||
"timeout": "Užklausos skirtasis laikas (ms)",
|
||||
"thinking": "Leisti modeliui pagalvoti prieš atsakant",
|
||||
"thinkingHint": "Išjunkite, kad samprotaujantis modelis (pvz., qwen3) lėtoje įrangoje atsakytų greičiau. Jei tiekėjas nepalaiko samprotavimo išjungimo, užklausa siunčiama be šio nustatymo.",
|
||||
"reasoningEffort": "Samprotavimo lygis",
|
||||
"reasoningEffortHint": "Siunčiama tiekėjui kaip reasoning_effort. Palikite tiekėjo numatytąjį, nebent samprotaujantis modelis veikia lėtai arba jam pritrūksta žetonų: „Nėra“ išjungia qwen3 samprotavimą, o gpt-oss jo išjungti negali ir jam reikia „Žemas“. Jei tiekėjas šio nustatymo nepriima, užklausa siunčiama be jo.",
|
||||
"reasoningEffortDefault": "Tiekėjo numatytasis (nieko nesiųsti)",
|
||||
"reasoningEffortLevels": {
|
||||
"none": "Nėra",
|
||||
"low": "Žemas",
|
||||
"medium": "Vidutinis",
|
||||
"high": "Aukštas"
|
||||
},
|
||||
"test": "Tikrinti ryšį",
|
||||
"testing": "Tikrinama…",
|
||||
"testSuccess": "Prisijungta prie {{model}} per {{latency}} ms.",
|
||||
|
||||
@@ -2,21 +2,24 @@ import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import Autocomplete from "@mui/material/Autocomplete";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import InputAdornment from "@mui/material/InputAdornment";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Switch from "@mui/material/Switch";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { UnauthorizedError } from "../../api/client";
|
||||
import { aiConnectionsApi, type AiConnection, type AiConnectionInput } from "../../api/aiConnections";
|
||||
import {
|
||||
aiConnectionsApi,
|
||||
REASONING_EFFORTS,
|
||||
type AiConnection,
|
||||
type AiConnectionInput,
|
||||
type ReasoningEffort,
|
||||
} from "../../api/aiConnections";
|
||||
import { useConfirm } from "../../components/ConfirmProvider";
|
||||
import { AiLogo } from "./aiLogos";
|
||||
|
||||
@@ -37,7 +40,7 @@ const draftOf = (connection: AiConnection | null): Draft => ({
|
||||
api_key: "",
|
||||
timeout_ms: connection?.timeout_ms ?? 60000,
|
||||
concurrency: connection?.concurrency ?? 1,
|
||||
thinking: connection?.thinking ?? true,
|
||||
reasoning_effort: connection?.reasoning_effort ?? null,
|
||||
});
|
||||
|
||||
/** Adds or edits one AI. Saving tests it first, as the backend only keeps an AI that answers. */
|
||||
@@ -108,7 +111,7 @@ export default function AiConnectionDialog({ connection, onClose, onChanged, onU
|
||||
model: draft.model.trim(),
|
||||
timeout_ms: Number(draft.timeout_ms),
|
||||
concurrency: Number(draft.concurrency),
|
||||
thinking: draft.thinking,
|
||||
reasoning_effort: draft.reasoning_effort,
|
||||
};
|
||||
try {
|
||||
if (connection) {
|
||||
@@ -140,7 +143,7 @@ export default function AiConnectionDialog({ connection, onClose, onChanged, onU
|
||||
base_url: draft.base_url.trim(),
|
||||
model: draft.model.trim(),
|
||||
timeout_ms: Number(draft.timeout_ms),
|
||||
thinking: draft.thinking,
|
||||
reasoning_effort: draft.reasoning_effort,
|
||||
...keyPayload,
|
||||
});
|
||||
if (result.ok)
|
||||
@@ -286,23 +289,23 @@ export default function AiConnectionDialog({ connection, onClose, onChanged, onU
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={draft.thinking}
|
||||
onChange={(event) => update("thinking", event.target.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box>
|
||||
<Typography variant="body2">{t("adminSettings.ai.connection.thinking")}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t("adminSettings.ai.connection.thinkingHint")}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
{/* A select can't hold null, so "" stands for sending nothing. */}
|
||||
<TextField
|
||||
select
|
||||
label={t("adminSettings.ai.connection.reasoningEffort")}
|
||||
value={draft.reasoning_effort ?? ""}
|
||||
onChange={(event) => update("reasoning_effort", (event.target.value || null) as ReasoningEffort | null)}
|
||||
helperText={t("adminSettings.ai.connection.reasoningEffortHint")}
|
||||
disabled={disabled}
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="">{t("adminSettings.ai.connection.reasoningEffortDefault")}</MenuItem>
|
||||
{REASONING_EFFORTS.map((effort) => (
|
||||
<MenuItem key={effort} value={effort}>
|
||||
{t(`adminSettings.ai.connection.reasoningEffortLevels.${effort}`)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
{status && <Alert severity="success">{status}</Alert>}
|
||||
{error && <Alert severity="error">{error}</Alert>}
|
||||
</Stack>
|
||||
|
||||
Reference in new issue
Block a user