feat: ability to link multiple AIs

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-14 15:20:56 +02:00
1 parent 8718d82ab3
commit 56f173a20b
63 files changed
+1694 -549

No files matched your search

@@ -0,0 +1,39 @@
-- CreateTable
CREATE TABLE "AiConnection" (
"id" TEXT NOT NULL,
"baseUrl" TEXT NOT NULL,
"apiKey" TEXT,
"model" TEXT NOT NULL,
"timeoutMs" INTEGER NOT NULL DEFAULT 60000,
"concurrency" INTEGER NOT NULL DEFAULT 1,
"thinking" BOOLEAN NOT NULL DEFAULT true,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"position" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AiConnection_pkey" PRIMARY KEY ("id")
);
-- The single connection stored with the categorization settings becomes the first AI, and the default.
INSERT INTO "AiConnection" ("id", "baseUrl", "apiKey", "model", "timeoutMs", "concurrency", "thinking", "isDefault", "position", "updatedAt")
SELECT
'ai' || substr(md5(random()::text || clock_timestamp()::text), 1, 23),
"value"->>'baseUrl',
NULLIF(btrim("value"->>'apiKey'), ''),
btrim("value"->>'model'),
CASE WHEN jsonb_typeof("value"->'timeoutMs') = 'number' THEN ("value"->>'timeoutMs')::numeric::integer ELSE 60000 END,
CASE WHEN jsonb_typeof("value"->'concurrency') = 'number' THEN ("value"->>'concurrency')::numeric::integer ELSE 1 END,
CASE WHEN jsonb_typeof("value"->'thinking') = 'boolean' THEN ("value"->>'thinking')::boolean ELSE true END,
true,
0,
CURRENT_TIMESTAMP
FROM "Setting"
WHERE "key" = 'ai_categorization'
AND COALESCE(btrim("value"->>'baseUrl'), '') <> ''
AND COALESCE(btrim("value"->>'model'), '') <> '';
-- What's left there is how categorization behaves, not which AI it talks to.
UPDATE "Setting"
SET "value" = "value" - 'baseUrl' - 'apiKey' - 'model' - 'timeoutMs' - 'concurrency' - 'thinking'
WHERE "key" = 'ai_categorization' AND jsonb_typeof("value") = 'object';
+18
View File
@@ -516,6 +516,24 @@ model Setting {
value Json
}
/// An OpenAI-compatible AI service. Features use the default one unless set to another.
model AiConnection {
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)
/// 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
}
/// Admin audit trail. Written fire-and-forget so logging never breaks the action.
model Log {
id String @id @default(cuid())
+2 -1
View File
@@ -12,13 +12,14 @@ import {
aiCategorizationCounts,
cancelAiCategorizationRun,
categorizePrint,
aiCategorizationEnabled,
currentAiCategorizationRun,
getAiCategorizationSettings,
queueAiCategorization,
rejectAiSuggestion,
startAiCategorizationRun,
type AiCategorizationRun,
} from "../services/aiCategorizationService";
import { aiCategorizationEnabled, getAiCategorizationSettings } from "../services/settingsService";
const router = Router();
router.use(requireAuth);
+174 -70
View File
@@ -1,3 +1,4 @@
import type { AiConnection } from "@prisma/client";
import { Router } from "express";
import { z } from "zod";
import { requireAdmin, requireAuth } from "../auth";
@@ -22,8 +23,9 @@ import {
setSmtpSettings,
setThingiverseAccessToken,
type SmtpSettings,
getAiCategorizationSettings,
setAiCategorizationSettings,
getAiCategorizationPrefs,
setAiCategorizationPrefs,
type AiCategorizationPrefs,
} from "../services/settingsService";
import { getDatabaseInfo, testAndSwitchDatabase } from "../services/databaseSettingsService";
import {
@@ -65,7 +67,16 @@ import { getUserAuthorPreviewEnabled, setUserAuthorPreviewEnabled } from "../ser
import { checkForUpdates } from "../services/versionService";
import { getWeatherLocation, setWeatherLocation, weatherLocationSchema } from "../services/weatherLocationService";
import { dropPreviewsAffectedBySimplification } from "../services/modelPreviewCache";
import { listAiProviderModels, testAiCategorizationProvider } from "../services/aiCategorizationService";
import { listAiProviderModels, testAiProvider } from "../services/aiCategorizationService";
import {
createAiConnection,
DEFAULT_AI_PROVIDER,
deleteAiConnection,
getAiConnection,
listAiConnections,
setDefaultAiConnection,
updateAiConnection,
} from "../services/aiConnectionService";
import { testFlaresolverr } from "../services/flaresolverr";
const router = Router();
@@ -572,18 +583,13 @@ router.patch(
}),
);
function aiCategorizationSettingsOut(settings: Awaited<ReturnType<typeof getAiCategorizationSettings>>) {
function aiCategorizationPrefsOut(prefs: AiCategorizationPrefs) {
return {
mode: settings.mode,
base_url: settings.baseUrl,
model: settings.model,
has_api_key: Boolean(settings.apiKey),
threshold: settings.threshold,
on_import: settings.onImport,
concurrency: settings.concurrency,
send_image: settings.sendImage,
thinking: settings.thinking,
timeout_ms: settings.timeoutMs,
mode: prefs.mode,
threshold: prefs.threshold,
on_import: prefs.onImport,
send_image: prefs.sendImage,
ai_connection_id: prefs.aiConnectionId,
};
}
@@ -591,95 +597,193 @@ router.get(
"/settings/ai-categorization",
requireAdmin,
asyncHandler(async (_req, res) => {
res.json(aiCategorizationSettingsOut(await getAiCategorizationSettings()));
res.json(aiCategorizationPrefsOut(await getAiCategorizationPrefs()));
}),
);
const aiCategorizationSettingsSchema = z
const aiCategorizationPrefsSchema = z
.object({
mode: z.enum(["off", "suggest", "auto"]).optional(),
base_url: z.string().trim().nullable().optional(),
api_key: z.string().nullable().optional(),
model: z.string().trim().nullable().optional(),
threshold: z.number().min(0).max(1).optional(),
on_import: z.boolean().optional(),
concurrency: z.number().int().min(1).max(4).optional(),
send_image: z.boolean().optional(),
thinking: z.boolean().optional(),
timeout_ms: z.number().int().min(1).max(300000).optional(),
// Null uses the default AI.
ai_connection_id: z.string().nullable().optional(),
})
.strict();
router.patch(
"/settings/ai-categorization",
requireAdmin,
asyncHandler(async (req, res) => {
const body = parseBody(aiCategorizationSettingsSchema, req.body);
const connection = {
...(body.base_url !== undefined ? { baseUrl: normalizeAiBaseUrl(body.base_url) } : {}),
...(body.api_key !== undefined ? { apiKey: body.api_key } : {}),
...(body.model !== undefined ? { model: body.model || null } : {}),
...(body.timeout_ms !== undefined ? { timeoutMs: body.timeout_ms } : {}),
};
// A changed connection is only saved once it answers; clearing the URL or model disconnects without a test.
if (Object.keys(connection).length) {
const next = { ...(await getAiCategorizationSettings()), ...connection };
if (next.baseUrl && next.model) {
const result = await testAiCategorizationProvider(connection);
if (!result.ok)
throw new HttpError(422, `Could not connect to the AI provider: ${result.error}`, "ai_connection_failed");
}
}
const settings = await setAiCategorizationSettings({
...connection,
const body = parseBody(aiCategorizationPrefsSchema, req.body);
if (body.ai_connection_id) await getAiConnection(body.ai_connection_id);
const prefs = await setAiCategorizationPrefs({
...(body.mode !== undefined ? { mode: body.mode } : {}),
...(body.threshold !== undefined ? { threshold: body.threshold } : {}),
...(body.on_import !== undefined ? { onImport: body.on_import } : {}),
...(body.concurrency !== undefined ? { concurrency: body.concurrency } : {}),
...(body.send_image !== undefined ? { sendImage: body.send_image } : {}),
...(body.thinking !== undefined ? { thinking: body.thinking } : {}),
...(body.ai_connection_id !== undefined ? { aiConnectionId: body.ai_connection_id } : {}),
});
res.json(aiCategorizationSettingsOut(settings));
res.json(aiCategorizationPrefsOut(prefs));
}),
);
// Omitted fields use the saved value; api_key null lists without a key.
const aiModelListSchema = z
.object({ base_url: z.string().trim().min(1).optional(), api_key: z.string().nullable().optional() })
.strict();
router.post(
"/settings/ai-categorization/models",
function aiConnectionOut(connection: AiConnection) {
return {
id: connection.id,
base_url: connection.baseUrl,
model: connection.model,
has_api_key: Boolean(connection.apiKey),
timeout_ms: connection.timeoutMs,
concurrency: connection.concurrency,
thinking: connection.thinking,
is_default: connection.isDefault,
};
}
router.get(
"/settings/ai-connections",
requireAdmin,
asyncHandler(async (req, res) => {
const body = parseBody(aiModelListSchema, req.body ?? {});
res.json(
await listAiProviderModels({
...(body.base_url !== undefined ? { baseUrl: normalizeAiBaseUrl(body.base_url) } : {}),
...(body.api_key !== undefined ? { apiKey: body.api_key || null } : {}),
}),
);
asyncHandler(async (_req, res) => {
res.json((await listAiConnections()).map(aiConnectionOut));
}),
);
// Omitted fields use the saved value; api_key null tests without a key.
const aiCategorizationTestSchema = z
// api_key: omitted keeps the saved one, null or "" means none.
const aiConnectionFields = {
base_url: z.string().trim().min(1),
model: z.string().trim().min(1),
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(),
};
/** 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 }>,
) {
const saved = id ? await getAiConnection(id) : null;
return {
baseUrl: body.base_url !== undefined ? normalizeAiBaseUrl(body.base_url) : (saved?.baseUrl ?? null),
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,
};
}
/** An AI is only saved once it answers, so a feature never switches to one that can't. */
async function requireAnswer(provider: Awaited<ReturnType<typeof providerFrom>>): Promise<void> {
const result = await testAiProvider(provider);
if (!result.ok)
throw new HttpError(422, `Could not connect to the AI provider: ${result.error}`, "ai_connection_failed");
}
const aiConnectionCreateSchema = z
.object({
base_url: z.string().trim().min(1).optional(),
model: z.string().trim().min(1).optional(),
api_key: z.string().nullable().optional(),
timeout_ms: z.number().int().min(1).max(300000).optional(),
...aiConnectionFields,
timeout_ms: aiConnectionFields.timeout_ms.optional(),
concurrency: aiConnectionFields.concurrency.optional(),
thinking: aiConnectionFields.thinking.optional(),
})
.strict();
router.post(
"/settings/ai-categorization/test",
"/settings/ai-connections",
requireAdmin,
asyncHandler(async (req, res) => {
const body = parseBody(aiCategorizationTestSchema, req.body ?? {});
const result = await testAiCategorizationProvider({
...(body.base_url !== undefined ? { baseUrl: normalizeAiBaseUrl(body.base_url) } : {}),
...(body.model !== undefined ? { model: body.model } : {}),
...(body.api_key !== undefined ? { apiKey: body.api_key || null } : {}),
...(body.timeout_ms !== undefined ? { timeoutMs: body.timeout_ms } : {}),
const body = parseBody(aiConnectionCreateSchema, req.body);
const provider = await providerFrom(undefined, body);
await requireAnswer(provider);
const connection = await createAiConnection({
baseUrl: provider.baseUrl!,
model: provider.model!,
apiKey: provider.apiKey,
timeoutMs: provider.timeoutMs,
thinking: provider.thinking,
concurrency: body.concurrency ?? DEFAULT_AI_PROVIDER.concurrency,
});
res.status(201).json(aiConnectionOut(connection));
}),
);
const aiConnectionUpdateSchema = z.object(aiConnectionFields).partial().strict();
router.patch(
"/settings/ai-connections/:id",
requireAdmin,
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.
if (
body.base_url !== undefined ||
body.model !== undefined ||
body.api_key !== undefined ||
body.timeout_ms !== undefined
) {
await requireAnswer(provider);
}
const connection = await updateAiConnection(req.params.id, {
...(body.base_url !== undefined ? { baseUrl: provider.baseUrl! } : {}),
...(body.model !== undefined ? { model: provider.model! } : {}),
...(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 } : {}),
});
res.json(aiConnectionOut(connection));
}),
);
router.delete(
"/settings/ai-connections/:id",
requireAdmin,
asyncHandler(async (req, res) => {
await deleteAiConnection(req.params.id);
// A feature set to it goes back to the default.
if ((await getAiCategorizationPrefs()).aiConnectionId === req.params.id) {
await setAiCategorizationPrefs({ aiConnectionId: null });
}
res.status(204).end();
}),
);
router.post(
"/settings/ai-connections/:id/default",
requireAdmin,
asyncHandler(async (req, res) => {
res.json(aiConnectionOut(await setDefaultAiConnection(req.params.id)));
}),
);
// `id` is a saved AI whose values the form's fill in; without it, only the form's values count.
const aiModelListSchema = z
.object({
id: z.string().optional(),
base_url: z.string().trim().min(1).optional(),
api_key: z.string().nullable().optional(),
})
.strict();
router.post(
"/settings/ai-connections/models",
requireAdmin,
asyncHandler(async (req, res) => {
const body = parseBody(aiModelListSchema, req.body ?? {});
res.json(await listAiProviderModels(await providerFrom(body.id, body)));
}),
);
const aiConnectionTestSchema = z
.object({ id: z.string().optional(), ...aiConnectionFields })
.partial()
.strict();
router.post(
"/settings/ai-connections/test",
requireAdmin,
asyncHandler(async (req, res) => {
const body = parseBody(aiConnectionTestSchema, req.body ?? {});
const result = await testAiProvider(await providerFrom(body.id, body));
res.json(result.ok ? { ok: true, latency_ms: result.latencyMs, model: result.model } : result);
}),
);
+76 -33
View File
@@ -9,7 +9,45 @@ import { previewImagePath } from "./previewImageService";
import { createLog } from "./auditLog";
import { createNotification } from "./notificationService";
import { setPrintCategory } from "./printCategoryService";
import { aiCategorizationEnabled, getAiCategorizationSettings, type AiCategorizationSettings } from "./settingsService";
import { DEFAULT_AI_PROVIDER, resolveAiConnection, type AiProvider } from "./aiConnectionService";
import { getAiCategorizationPrefs, type AiCategorizationPrefs } from "./settingsService";
/** Categorization's own preferences with the AI it uses; base URL and model are null while there's no AI. */
export type AiCategorizationSettings = AiCategorizationPrefs &
Omit<AiProvider, "baseUrl" | "model"> & { baseUrl: string | null; model: string | null; connectionId: string | null };
/** What one request to a provider needs, saved or only typed into the settings form. */
type RequestSettings = Omit<AiProvider, "baseUrl" | "model" | "concurrency"> & {
baseUrl: string | null;
model: string | null;
sendImage: boolean;
};
export async function getAiCategorizationSettings(): Promise<AiCategorizationSettings> {
const prefs = await getAiCategorizationPrefs();
const connection = await resolveAiConnection(prefs.aiConnectionId);
return {
...prefs,
...DEFAULT_AI_PROVIDER,
baseUrl: null,
model: null,
...(connection
? {
baseUrl: connection.baseUrl,
apiKey: connection.apiKey,
model: connection.model,
timeoutMs: connection.timeoutMs,
concurrency: connection.concurrency,
thinking: connection.thinking,
}
: {}),
connectionId: connection?.id ?? null,
};
}
export function aiCategorizationEnabled(settings: AiCategorizationSettings): boolean {
return settings.mode !== "off" && Boolean(settings.baseUrl && settings.model);
}
const suggestionSchema = z.object({
categoryId: z.string(),
@@ -26,8 +64,8 @@ type Candidate = { id: string; path: string };
type ProviderResult = { categoryId: string | null; confidence: number; reason: string };
export type CategorizationOutcome = "applied" | "suggested" | "no_match" | "unchanged";
let active = 0;
const waiters: (() => void)[] = [];
// Per AI: each provider has its own limit, and a slow one shouldn't hold up another.
const slots = new Map<string, { active: number; waiters: (() => void)[] }>();
let retryDelay = (milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
/** Keeps timeout and retry tests fast without changing production backoff behavior. */
@@ -35,18 +73,23 @@ export function setAiCategorizationRetryDelayForTest(delay?: (milliseconds: numb
retryDelay = delay ?? ((milliseconds) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)));
}
async function limited<T>(limit: number, task: () => Promise<T>): Promise<T> {
// A waiter can be woken while another request takes the slot first, so it retries acquisition.
if (active >= limit) {
await new Promise<void>((resolve) => waiters.push(resolve));
return limited(limit, task);
async function limited<T>(key: string, limit: number, task: () => Promise<T>): Promise<T> {
let slot = slots.get(key);
if (!slot) {
slot = { active: 0, waiters: [] };
slots.set(key, slot);
}
active++;
// A waiter can be woken while another request takes the slot first, so it retries acquisition.
if (slot.active >= limit) {
await new Promise<void>((resolve) => slot.waiters.push(resolve));
return limited(key, limit, task);
}
slot.active++;
try {
return await task();
} finally {
active--;
waiters.shift()?.();
slot.active--;
slot.waiters.shift()?.();
}
}
@@ -116,7 +159,7 @@ function chatUrl(baseUrl: string): string {
}
/** What a failed fetch means to an admin; fetch itself only says "fetch failed" or "aborted". */
function readableNetworkError(error: unknown, settings: AiCategorizationSettings): unknown {
function readableNetworkError(error: unknown, settings: { timeoutMs: number }): unknown {
if (error instanceof Error && error.name === "AbortError")
return new Error(`The AI provider did not answer within ${Math.round(settings.timeoutMs / 1000)} s`);
if (error instanceof TypeError) return new Error("Could not reach the AI provider. Check the API base URL.");
@@ -176,7 +219,7 @@ const reasoningEffortRejected = new Set<string>();
async function requestProvider(
print: Print,
categories: Candidate[],
settings: AiCategorizationSettings,
settings: RequestSettings,
attempts = 3,
): Promise<ProviderResult> {
const candidates = withLabels(categories);
@@ -421,7 +464,9 @@ async function categorizeOne(
return "no_match";
}
const answer = await limited(settings.concurrency, () => requestProvider(print, candidates, settings));
const answer = await limited(settings.connectionId ?? "", settings.concurrency, () =>
requestProvider(print, candidates, settings),
);
const expected = { categoryId: print.categoryId, categorySource: print.categorySource };
if (answer.categoryId === null) {
await clearSuggestion(print.id, expected);
@@ -779,17 +824,17 @@ export function cancelAiCategorizationRun(userId: string): AiCategorizationRun |
const MODEL_LIST_TIMEOUT_MS = 10_000;
/**
* The models the provider serves, from the OpenAI-style `GET /models` that Ollama, LM Studio, vLLM, OpenAI, OpenRouter
* and Gemini all answer, so the settings form can suggest them. Given values fall back to the saved ones, like a test.
* The models a provider serves, from the OpenAI-style `GET /models` that Ollama, LM Studio, vLLM, OpenAI, OpenRouter
* and Gemini all answer, so the settings form can suggest them.
*/
export async function listAiProviderModels(
overrides: Partial<Pick<AiCategorizationSettings, "baseUrl" | "apiKey">> = {},
): Promise<{ ok: true; models: string[] } | { ok: false; error: string }> {
const settings = { ...(await getAiCategorizationSettings()), ...overrides };
if (!settings.baseUrl) return { ok: false, error: "Set the API base URL first" };
export async function listAiProviderModels(provider: {
baseUrl: string | null;
apiKey: string | null;
}): Promise<{ ok: true; models: string[] } | { ok: false; error: string }> {
if (!provider.baseUrl) return { ok: false, error: "Set the API base URL first" };
try {
const response = await fetch(`${settings.baseUrl.replace(/\/+$/, "")}/models`, {
headers: settings.apiKey ? { authorization: `Bearer ${settings.apiKey}` } : {},
const response = await fetch(`${provider.baseUrl.replace(/\/+$/, "")}/models`, {
headers: provider.apiKey ? { authorization: `Bearer ${provider.apiKey}` } : {},
signal: AbortSignal.timeout(MODEL_LIST_TIMEOUT_MS),
});
if (!response.ok) return { ok: false, error: `AI provider returned HTTP ${response.status}` };
@@ -807,17 +852,15 @@ export async function listAiProviderModels(
error instanceof Error && error.name === "TimeoutError"
? `The AI provider did not answer within ${MODEL_LIST_TIMEOUT_MS / 1000} s`
: noSecretError(error).message;
return { ok: false, error: settings.apiKey ? message.replaceAll(settings.apiKey, "[redacted]") : message };
return { ok: false, error: provider.apiKey ? message.replaceAll(provider.apiKey, "[redacted]") : message };
}
}
/** Tests the given connection values, falling back to the saved ones, so a connection can be checked before saving. */
export async function testAiCategorizationProvider(
overrides: Partial<Pick<AiCategorizationSettings, "baseUrl" | "model" | "apiKey" | "timeoutMs">> = {},
/** Asks a provider to categorize a calibration cube, so a connection can be checked before it's saved. */
export async function testAiProvider(
provider: Omit<AiProvider, "baseUrl" | "model" | "concurrency"> & { baseUrl: string | null; model: string | null },
): Promise<{ ok: true; latencyMs: number; model: string } | { ok: false; error: string }> {
const settings = { ...(await getAiCategorizationSettings()), ...overrides };
// The connection is tested on its own, so categorization may still be off.
if (!settings.baseUrl || !settings.model) return { ok: false, error: "Set the API base URL and model name first" };
if (!provider.baseUrl || !provider.model) return { ok: false, error: "Set the API base URL and model name first" };
const start = Date.now();
try {
const fake = {
@@ -840,13 +883,13 @@ export async function testAiCategorizationProvider(
{ id: "a", path: "Calibration" },
{ id: "b", path: "Other" },
],
settings,
{ ...provider, sendImage: false },
// A test answers the admin who is waiting on it; one attempt says whether the connection works.
1,
);
return { ok: true, latencyMs: Date.now() - start, model: settings.model! };
return { ok: true, latencyMs: Date.now() - start, model: provider.model };
} catch (error) {
const message = noSecretError(error).message;
return { ok: false, error: settings.apiKey ? message.replaceAll(settings.apiKey, "[redacted]") : message };
return { ok: false, error: provider.apiKey ? message.replaceAll(provider.apiKey, "[redacted]") : message };
}
}
@@ -0,0 +1,98 @@
import type { AiConnection, Prisma } 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 AiConnectionInput = {
baseUrl: string;
apiKey: string | null;
model: string;
timeoutMs: number;
concurrency: number;
thinking: boolean;
};
export const DEFAULT_AI_PROVIDER: Omit<AiProvider, "baseUrl" | "model"> = {
apiKey: null,
timeoutMs: 60000,
concurrency: 1,
thinking: true,
};
// Any number, as long as only the default's writers take it. Two AIs added at once would otherwise both count no
// default under Read Committed, and both become it.
const DEFAULT_LOCK = 74_210_301;
async function lockDefault(tx: Prisma.TransactionClient): Promise<void> {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(${DEFAULT_LOCK})`;
}
export async function listAiConnections(): Promise<AiConnection[]> {
return prisma.aiConnection.findMany({ orderBy: [{ position: "asc" }, { createdAt: "asc" }] });
}
export async function getAiConnection(id: string): Promise<AiConnection> {
const connection = await prisma.aiConnection.findUnique({ where: { id } });
if (!connection) throw new HttpError(404, "AI not found", "ai_not_found");
return connection;
}
/** The one a feature set to `id` uses: that AI while it exists, otherwise the default. */
export async function resolveAiConnection(id: string | null | undefined): Promise<AiConnection | null> {
if (id) {
const chosen = await prisma.aiConnection.findUnique({ where: { id } });
if (chosen) return chosen;
}
return prisma.aiConnection.findFirst({ where: { isDefault: true } });
}
/** The first AI becomes the default, so a feature always has one to use. */
export async function createAiConnection(input: AiConnectionInput): Promise<AiConnection> {
return prisma.$transaction(async (tx) => {
await lockDefault(tx);
const last = await tx.aiConnection.findFirst({ orderBy: { position: "desc" } });
const hasDefault = await tx.aiConnection.count({ where: { isDefault: true } });
return tx.aiConnection.create({
data: {
...input,
baseUrl: normalizeAiBaseUrl(input.baseUrl) ?? input.baseUrl,
isDefault: !hasDefault,
position: last ? last.position + 1 : 0,
},
});
});
}
export async function updateAiConnection(id: string, patch: Partial<AiConnectionInput>): Promise<AiConnection> {
await getAiConnection(id);
return prisma.aiConnection.update({
where: { id },
data: { ...patch, ...(patch.baseUrl !== undefined ? { baseUrl: normalizeAiBaseUrl(patch.baseUrl) ?? "" } : {}) },
});
}
export async function setDefaultAiConnection(id: string): Promise<AiConnection> {
await getAiConnection(id);
return prisma.$transaction(async (tx) => {
await lockDefault(tx);
await tx.aiConnection.updateMany({ where: { isDefault: true, NOT: { id } }, data: { isDefault: false } });
return tx.aiConnection.update({ where: { id }, data: { isDefault: true } });
});
}
/** Deleting the default hands the role to the next AI in order, so features keep working while any is left. */
export async function deleteAiConnection(id: string): Promise<void> {
await prisma.$transaction(async (tx) => {
await lockDefault(tx);
// Read under the lock: a default made meanwhile must still be handed on.
const connection = await tx.aiConnection.findUnique({ where: { id } });
if (!connection) throw new HttpError(404, "AI not found", "ai_not_found");
await tx.aiConnection.delete({ where: { id } });
if (!connection.isDefault) return;
const next = await tx.aiConnection.findFirst({ orderBy: [{ position: "asc" }, { createdAt: "asc" }] });
if (next) await tx.aiConnection.update({ where: { id: next.id }, data: { isDefault: true } });
});
}
+13 -37
View File
@@ -197,31 +197,23 @@ export async function setFlaresolverrUrl(value: string | null): Promise<string |
}
export type AiCategorizationMode = "off" | "suggest" | "auto";
export type AiCategorizationSettings = {
/** How categorization behaves; which AI it talks to is an AiConnection (aiConnectionService.ts). */
export type AiCategorizationPrefs = {
mode: AiCategorizationMode;
baseUrl: string | null;
apiKey: string | null;
model: string | null;
threshold: number;
onImport: boolean;
concurrency: number;
sendImage: boolean;
thinking: boolean;
timeoutMs: number;
/** Null uses the default AI. */
aiConnectionId: string | null;
};
const AI_CATEGORIZATION_KEY = "ai_categorization";
export const DEFAULT_AI_CATEGORIZATION_SETTINGS: AiCategorizationSettings = {
export const DEFAULT_AI_CATEGORIZATION_PREFS: AiCategorizationPrefs = {
mode: "off",
baseUrl: null,
apiKey: null,
model: null,
threshold: 0.8,
onImport: true,
concurrency: 1,
sendImage: false,
thinking: true,
timeoutMs: 60000,
aiConnectionId: null,
};
/** Ollama, LM Studio, vLLM and OpenAI serve the API under /v1, so a bare origin gets it added. Any other path is
@@ -236,41 +228,29 @@ export function normalizeAiBaseUrl(value: string | null | undefined): string | n
return trimmed;
}
function validAiSettings(value: unknown): Partial<AiCategorizationSettings> {
function validAiPrefs(value: unknown): Partial<AiCategorizationPrefs> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const raw = value as Record<string, unknown>;
return {
...(raw.mode === "off" || raw.mode === "suggest" || raw.mode === "auto" ? { mode: raw.mode } : {}),
...(typeof raw.baseUrl === "string" || raw.baseUrl === null ? { baseUrl: normalizeAiBaseUrl(raw.baseUrl) } : {}),
...(typeof raw.apiKey === "string" || raw.apiKey === null ? { apiKey: raw.apiKey?.trim() || null } : {}),
...(typeof raw.model === "string" || raw.model === null ? { model: raw.model?.trim() || null } : {}),
...(typeof raw.threshold === "number" && raw.threshold >= 0 && raw.threshold <= 1
? { threshold: raw.threshold }
: {}),
...(typeof raw.onImport === "boolean" ? { onImport: raw.onImport } : {}),
...(typeof raw.concurrency === "number" &&
Number.isInteger(raw.concurrency) &&
raw.concurrency >= 1 &&
raw.concurrency <= 4
? { concurrency: raw.concurrency }
: {}),
...(typeof raw.sendImage === "boolean" ? { sendImage: raw.sendImage } : {}),
...(typeof raw.thinking === "boolean" ? { thinking: raw.thinking } : {}),
...(typeof raw.timeoutMs === "number" && Number.isInteger(raw.timeoutMs) && raw.timeoutMs > 0
? { timeoutMs: raw.timeoutMs }
...(typeof raw.aiConnectionId === "string" || raw.aiConnectionId === null
? { aiConnectionId: raw.aiConnectionId || null }
: {}),
};
}
export async function getAiCategorizationSettings(): Promise<AiCategorizationSettings> {
export async function getAiCategorizationPrefs(): Promise<AiCategorizationPrefs> {
const row = await prisma.setting.findUnique({ where: { key: AI_CATEGORIZATION_KEY } });
return { ...DEFAULT_AI_CATEGORIZATION_SETTINGS, ...validAiSettings(row?.value) };
return { ...DEFAULT_AI_CATEGORIZATION_PREFS, ...validAiPrefs(row?.value) };
}
export async function setAiCategorizationSettings(
patch: Partial<AiCategorizationSettings>,
): Promise<AiCategorizationSettings> {
const next = { ...(await getAiCategorizationSettings()), ...patch };
export async function setAiCategorizationPrefs(patch: Partial<AiCategorizationPrefs>): Promise<AiCategorizationPrefs> {
const next = { ...(await getAiCategorizationPrefs()), ...patch };
await prisma.setting.upsert({
where: { key: AI_CATEGORIZATION_KEY },
create: { key: AI_CATEGORIZATION_KEY, value: next as unknown as Prisma.InputJsonValue },
@@ -279,10 +259,6 @@ export async function setAiCategorizationSettings(
return next;
}
export function aiCategorizationEnabled(settings: AiCategorizationSettings): boolean {
return settings.mode !== "off" && Boolean(settings.baseUrl && settings.model);
}
const AUTH_TOKEN_TTL_KEY = "auth_token_ttl_seconds";
const DEFAULT_AUTH_TOKEN_TTL_SECONDS = 43200;
+123 -45
View File
@@ -3,7 +3,12 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest
import request from "supertest";
import { createApp } from "../src/app";
import { prisma } from "../src/db";
import { DEFAULT_AI_CATEGORIZATION_SETTINGS, setAiCategorizationSettings } from "../src/services/settingsService";
import {
DEFAULT_AI_CATEGORIZATION_PREFS,
setAiCategorizationPrefs,
type AiCategorizationPrefs,
} from "../src/services/settingsService";
import { createAiConnection, type AiConnectionInput } from "../src/services/aiConnectionService";
import { aiCategoryPaths, setAiCategorizationRetryDelayForTest } from "../src/services/aiCategorizationService";
const app = createApp();
@@ -126,19 +131,26 @@ function runScope(include: Partial<Record<"uncategorized" | "folders" | "ai" | "
return Object.fromEntries(Object.entries(scope).map(([key, value]) => [`include_${key}`, value]));
}
async function enable(baseUrl: string, patch: Partial<typeof DEFAULT_AI_CATEGORIZATION_SETTINGS> = {}) {
await setAiCategorizationSettings({
mode: "suggest",
/** One AI, the default, and categorization turned on with it. */
async function enable(baseUrl: string, patch: Partial<AiCategorizationPrefs & AiConnectionInput> = {}) {
const { mode = "suggest", threshold = 0.8, onImport = false, sendImage = false, ...connection } = patch;
await prisma.aiConnection.deleteMany();
const created = await createAiConnection({
baseUrl,
apiKey: null,
model: "local",
threshold: 0.8,
onImport: false,
concurrency: 1,
sendImage: false,
timeoutMs: 1000,
...patch,
concurrency: 1,
thinking: true,
...connection,
});
await setAiCategorizationPrefs({ mode, threshold, onImport, sendImage, aiConnectionId: null });
return created;
}
async function disable() {
await prisma.aiConnection.deleteMany();
await setAiCategorizationPrefs(DEFAULT_AI_CATEGORIZATION_PREFS);
}
async function categoryFor(ownerId: string, name: string, kind = "category") {
@@ -217,12 +229,14 @@ beforeAll(async () => {
userId = registered.body.user.id;
await prisma.user.update({ where: { id: userId }, data: { role: "ADMIN" } });
token = (await request(app).post("/api/login").send({ email, password: "password123" })).body.token;
// AIs and the categorization settings are instance-wide, so each test starts without any.
await disable();
});
afterEach(async () => {
setAiCategorizationRetryDelayForTest();
await stopProvider();
await setAiCategorizationSettings(DEFAULT_AI_CATEGORIZATION_SETTINGS);
await disable();
});
afterAll(async () => {
@@ -234,16 +248,24 @@ afterAll(async () => {
describe("AI categorization", () => {
it("keeps API keys write-only and exposes legacy category provenance", async () => {
const baseUrl = await startProvider(() => ({ category_id: null, confidence: 1, reason: "none" }));
const patched = await request(app)
.patch("/api/settings/ai-categorization")
const added = await request(app)
.post("/api/settings/ai-connections")
.set(auth())
.send({ mode: "auto", base_url: baseUrl, model: "local-model", api_key: "private-key" });
expect(patched.status).toBe(200);
expect(patched.body).toMatchObject({ mode: "auto", has_api_key: true });
expect(JSON.stringify(patched.body)).not.toContain("private-key");
const read = await request(app).get("/api/settings/ai-categorization").set(auth());
expect(read.body).toMatchObject({ has_api_key: true, base_url: baseUrl, model: "local-model" });
expect(JSON.stringify(read.body)).not.toContain("private-key");
.send({ base_url: baseUrl, model: "local-model", api_key: "private-key" });
expect(added.status).toBe(201);
expect(added.body).toMatchObject({ base_url: baseUrl, model: "local-model", has_api_key: true });
expect(JSON.stringify(added.body)).not.toContain("private-key");
const listed = await request(app).get("/api/settings/ai-connections").set(auth());
expect(listed.body).toHaveLength(1);
expect(JSON.stringify(listed.body)).not.toContain("private-key");
const prefs = await request(app).patch("/api/settings/ai-categorization").set(auth()).send({ mode: "auto" });
expect(prefs.body).toEqual({
mode: "auto",
threshold: 0.8,
on_import: true,
send_image: false,
ai_connection_id: null,
});
const legacyCategory = await category("Legacy");
const legacy = await print(legacyCategory.id, "LEGACY");
@@ -251,28 +273,26 @@ describe("AI categorization", () => {
expect(detail.body.category_source).toBe("legacy");
});
it("saves a connection only after it answers, and tests unsaved values", async () => {
await setAiCategorizationSettings(DEFAULT_AI_CATEGORIZATION_SETTINGS);
it("adds an AI only after it answers, and tests unsaved values", async () => {
const deadUrl = await startProvider(() => ({ category_id: null, confidence: 1, reason: "none" }));
await stopProvider();
const tested = await request(app)
.post("/api/settings/ai-categorization/test")
.post("/api/settings/ai-connections/test")
.set(auth())
.send({ base_url: deadUrl, model: "local-model", timeout_ms: 1000 });
expect(tested.body).toMatchObject({ ok: false, error: expect.stringContaining("Could not reach the AI provider") });
const refused = await request(app)
.patch("/api/settings/ai-categorization")
.post("/api/settings/ai-connections")
.set(auth())
.send({ base_url: deadUrl, model: "local-model", timeout_ms: 1000 });
expect(refused.status).toBe(422);
expect(refused.body.code).toBe("ai_connection_failed");
const unchanged = await request(app).get("/api/settings/ai-categorization").set(auth());
expect(unchanged.body.base_url).toBeNull();
expect((await request(app).get("/api/settings/ai-connections").set(auth())).body).toEqual([]);
// A test makes one attempt rather than retrying a failing provider.
const failingUrl = await startProvider(() => ({ status: 500 }));
const failing = await request(app)
.post("/api/settings/ai-categorization/test")
.post("/api/settings/ai-connections/test")
.set(auth())
.send({ base_url: failingUrl, model: "local-model" });
expect(failing.body).toMatchObject({ ok: false, error: "AI provider returned HTTP 500" });
@@ -281,25 +301,76 @@ describe("AI categorization", () => {
const liveUrl = await startProvider(() => ({ category_id: null, confidence: 1, reason: "none" }));
const draft = await request(app)
.post("/api/settings/ai-categorization/test")
.post("/api/settings/ai-connections/test")
.set(auth())
.send({ base_url: liveUrl, model: "local-model" });
expect(draft.body).toMatchObject({ ok: true, model: "local-model" });
// A bare origin gets /v1 added, as Ollama and most local servers need.
const saved = await request(app)
.patch("/api/settings/ai-categorization")
.post("/api/settings/ai-connections")
.set(auth())
.send({ base_url: liveUrl.replace(/\/v1$/, "/"), model: "local-model" });
expect(saved.status).toBe(200);
expect(saved.body.base_url).toBe(liveUrl);
expect(saved.status).toBe(201);
expect(saved.body).toMatchObject({ base_url: liveUrl, is_default: true });
// An edit that changes where it connects is tested too; one that doesn't isn't.
await stopProvider();
const cleared = await request(app)
const moved = await request(app)
.patch(`/api/settings/ai-connections/${saved.body.id}`)
.set(auth())
.send({ base_url: deadUrl });
expect(moved.status).toBe(422);
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 });
});
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 added = await Promise.all([
createAiConnection({ ...fields, model: "first" }),
createAiConnection({ ...fields, model: "second" }),
]);
expect(added.filter((ai) => ai.isDefault)).toHaveLength(1);
expect(new Set(added.map((ai) => ai.position)).size).toBe(2);
});
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 second = await createAiConnection({ ...fields, baseUrl: "http://127.0.0.1:9/v1", model: "other" });
expect([first.isDefault, second.isDefault]).toEqual([true, false]);
const made = await request(app).post(`/api/settings/ai-connections/${second.id}/default`).set(auth());
expect(made.body.is_default).toBe(true);
const listed = await request(app).get("/api/settings/ai-connections").set(auth());
expect(listed.body.map((ai: { is_default: boolean }) => ai.is_default)).toEqual([false, true]);
// Categorization set to the first one uses it, not the default.
await request(app).patch("/api/settings/ai-categorization").set(auth()).send({ ai_connection_id: first.id });
const used = await request(app)
.post(`/api/print/${(await print()).id}/recategorize`)
.set(auth())
.send({});
expect(used.status).toBe(200);
expect(providerRequests).toBe(1);
// Deleting the AI it was set to sends it back to the default; deleting the default hands the role on.
expect((await request(app).delete(`/api/settings/ai-connections/${first.id}`).set(auth())).status).toBe(204);
expect((await request(app).get("/api/settings/ai-categorization").set(auth())).body.ai_connection_id).toBeNull();
const third = await createAiConnection({ ...fields, baseUrl: defaultUrl, model: "third" });
await request(app).delete(`/api/settings/ai-connections/${second.id}`).set(auth());
const remaining = await request(app).get("/api/settings/ai-connections").set(auth());
expect(remaining.body).toMatchObject([{ id: third.id, is_default: true }]);
const unknown = await request(app)
.patch("/api/settings/ai-categorization")
.set(auth())
.send({ base_url: null, model: null });
expect(cleared.status).toBe(200);
expect(cleared.body).toMatchObject({ base_url: null, model: null });
.send({ ai_connection_id: "nope" });
expect(unknown.status).toBe(404);
});
it("lists the provider's models for the settings form, with the saved key when none is typed", async () => {
@@ -317,28 +388,32 @@ describe("AI categorization", () => {
if (!address || typeof address === "string") throw new Error("Server did not bind a TCP port");
const baseUrl = `http://127.0.0.1:${address.port}/v1`;
try {
await setAiCategorizationSettings({ baseUrl, apiKey: "sk-saved" });
const listed = await request(app).post("/api/settings/ai-categorization/models").set(auth()).send({});
const saved = await createAiConnection({
baseUrl,
apiKey: "sk-saved",
model: "qwen3:8b",
timeoutMs: 1000,
concurrency: 1,
thinking: true,
});
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.
expect(listed.body).toEqual({ ok: true, models: ["gemma3:4b", "qwen3:8b"] });
expect(seen[0]).toEqual({ path: "/v1/models", authorization: "Bearer sk-saved" });
// Typed values win, so the list follows the form before it's saved.
await request(app)
.post("/api/settings/ai-categorization/models")
.post("/api/settings/ai-connections/models")
.set(auth())
.send({ base_url: baseUrl, api_key: null });
expect(seen[1].authorization).toBeUndefined();
status = 401;
const refused = await request(app).post("/api/settings/ai-categorization/models").set(auth()).send({});
const refused = await request(app).post("/api/settings/ai-connections/models").set(auth()).send({ id: saved.id });
expect(refused.body).toEqual({ ok: false, error: "AI provider returned HTTP 401" });
const member = await newUser();
const forbidden = await request(app)
.post("/api/settings/ai-categorization/models")
.set(auth(member.token))
.send({});
const forbidden = await request(app).post("/api/settings/ai-connections/models").set(auth(member.token)).send({});
expect(forbidden.status).toBe(403);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
@@ -684,7 +759,7 @@ describe("AI categorization", () => {
bodies.push(body);
return { category_id: null, confidence: 1, reason: "none" };
});
await enable(baseUrl);
const connection = await enable(baseUrl);
const recategorize = async () =>
(
await request(app)
@@ -696,7 +771,10 @@ describe("AI categorization", () => {
expect(await recategorize()).toBe(200);
expect(bodies[0]).not.toHaveProperty("reasoning_effort");
const off = await request(app).patch("/api/settings/ai-categorization").set(auth()).send({ thinking: false });
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);
expect(await recategorize()).toBe(200);
@@ -1092,7 +1170,7 @@ describe("AI categorization", () => {
await enable(baseUrl);
await request(app).post("/api/ai-categorization/run").set(auth(runner.token)).send(runScope());
await arrivedAtProvider;
await setAiCategorizationSettings(DEFAULT_AI_CATEGORIZATION_SETTINGS);
await setAiCategorizationPrefs({ mode: "off" });
release();
const stopped = await waitForRun(runner.token);
expect(stopped.body.run).toMatchObject({ total: 2, done: 2, failed: 0 });
+65
View File
@@ -0,0 +1,65 @@
import { authHeaders } from "../utils/auth";
import { apiBase, readErrorMessage, UnauthorizedError } from "./client";
import { invalidateAiCategorizationStatus } from "./aiCategorization";
export type AiConnection = {
id: string;
base_url: string;
model: string;
has_api_key: boolean;
timeout_ms: number;
concurrency: number;
thinking: boolean;
is_default: boolean;
};
/** api_key: omitted keeps the saved one, null or "" removes it. */
export type AiConnectionInput = {
base_url: string;
model: string;
api_key?: string | null;
timeout_ms: number;
concurrency: number;
thinking: boolean;
};
export type AiConnectionTest = { ok: boolean; latency_ms?: number; model?: string; error?: string };
export type AiModelList = { ok: true; models: string[] } | { ok: false; error: string };
async function request<T>(path: string, method = "GET", payload?: unknown): Promise<T> {
const res = await fetch(`${apiBase()}${path}`, {
method,
headers: authHeaders(payload === undefined ? undefined : { "Content-Type": "application/json" }),
...(payload === undefined ? {} : { body: JSON.stringify(payload) }),
});
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) throw new Error(await readErrorMessage(res, `Request failed (${res.status})`));
return (res.status === 204 ? undefined : await res.json()) as T;
}
/** The instance's AIs. Adding, deleting or changing the default can turn AI features on or off, so the cached
* status is dropped after each. */
export const aiConnectionsApi = {
list: (): Promise<AiConnection[]> => request("/settings/ai-connections"),
create: async (input: AiConnectionInput): Promise<AiConnection> => {
const created = await request<AiConnection>("/settings/ai-connections", "POST", input);
invalidateAiCategorizationStatus();
return created;
},
update: (id: string, input: Partial<AiConnectionInput>): Promise<AiConnection> =>
request(`/settings/ai-connections/${encodeURIComponent(id)}`, "PATCH", input),
remove: async (id: string): Promise<void> => {
await request<void>(`/settings/ai-connections/${encodeURIComponent(id)}`, "DELETE");
invalidateAiCategorizationStatus();
},
makeDefault: async (id: string): Promise<AiConnection> => {
const made = await request<AiConnection>(`/settings/ai-connections/${encodeURIComponent(id)}/default`, "POST");
invalidateAiCategorizationStatus();
return made;
},
/** `id` fills in what the form leaves out from that saved AI, such as its key. */
test: (payload: Partial<AiConnectionInput> & { id?: string }): Promise<AiConnectionTest> =>
request("/settings/ai-connections/test", "POST", payload),
models: (payload: { id?: string; base_url?: string; api_key?: string | null }): Promise<AiModelList> =>
request("/settings/ai-connections/models", "POST", payload),
};
+4 -37
View File
@@ -58,22 +58,17 @@ export type AuthSettings = {
token_ttl_seconds: number;
};
/** How categorization behaves; the AIs themselves are in api/aiConnections.ts. */
export type AiCategorizationSettings = {
mode: "off" | "suggest" | "auto";
base_url: string | null;
model: string | null;
has_api_key: boolean;
threshold: number;
on_import: boolean;
concurrency: number;
send_image: boolean;
thinking: boolean;
timeout_ms: number;
/** Null uses the default AI. */
ai_connection_id: string | null;
};
export type AiCategorizationSettingsInput = Partial<Omit<AiCategorizationSettings, "has_api_key">> & {
api_key?: string | null;
};
export type AiCategorizationSettingsInput = Partial<AiCategorizationSettings>;
export type SmtpSettings = {
host: string | null;
@@ -137,34 +132,6 @@ export const settingsApi = {
return res.json();
},
// Omitted fields use the saved value, so a connection can be tested before saving.
testAiCategorization: async (
payload: Pick<AiCategorizationSettingsInput, "base_url" | "model" | "api_key" | "timeout_ms"> = {},
): Promise<{ ok: boolean; latency_ms?: number; model?: string; error?: string }> => {
const res = await fetch(`${apiBase()}/settings/ai-categorization/test`, {
method: "POST",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(payload),
});
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to test AI connection"));
return res.json();
},
/** The models the provider serves, for the model field's suggestions; omitted values use the saved ones. */
listAiModels: async (
payload: Pick<AiCategorizationSettingsInput, "base_url" | "api_key"> = {},
): Promise<{ ok: true; models: string[] } | { ok: false; error: string }> => {
const res = await fetch(`${apiBase()}/settings/ai-categorization/models`, {
method: "POST",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(payload),
});
if (res.status === 401) throw new UnauthorizedError();
if (!res.ok) throw new Error(await readErrorMessage(res, "Failed to list AI models"));
return res.json();
},
getVersionCheck: async (): Promise<VersionCheck> => {
const res = await fetch(`${apiBase()}/settings/version-check`, { headers: authHeaders() });
assertOk(res, "Failed to check for updates");
+24
View File
@@ -0,0 +1,24 @@
The SVG logos in this folder are from LobeHub Icons (@lobehub/icons-static-svg 1.95.1),
https://github.com/lobehub/lobe-icons. The logos themselves are trademarks of their owners.
MIT License
Copyright (c) 2023 LobeHub
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>AI21</title><path d="M6.47 17l-.367-1.189H2.718L2.35 17H0l3.398-9.789h2.026L8.864 17H6.47zm-2.052-6.993l-1.17 4.028H5.56l-1.142-4.028zm4.707-2.796h2.23V17h-2.23V7.211zM11.955 15c.1-.483.277-.946.524-1.37.214-.359.482-.68.795-.951.32-.273.658-.52 1.013-.741.28-.168.54-.33.781-.483.222-.14.433-.296.632-.468.172-.148.317-.325.428-.525.107-.199.16-.423.157-.65 0-.392-.104-.674-.313-.846a1.176 1.176 0 00-.775-.259 1.207 1.207 0 00-.863.329c-.231.219-.347.585-.347 1.098H11.8a3.387 3.387 0 01.224-1.245c.146-.377.371-.716.66-.993.306-.29.667-.514 1.06-.657A4.04 4.04 0 0115.183 7c.42-.002.84.057 1.244.175.376.107.73.287 1.04.531.305.246.55.562.714.923.185.419.275.875.265 1.335.005.39-.084.774-.259 1.12-.167.328-.38.63-.632.894-.246.259-.517.49-.808.693-.29.2-.554.37-.789.51-.326.224-.596.417-.809.58a3.872 3.872 0 00-.51.455 1.229 1.229 0 00-.265.434 1.633 1.633 0 00-.074.517h4.078V17h-6.606a9.24 9.24 0 01.183-2zM18.8 8.93a5.05 5.05 0 001.135-.105c.25-.049.484-.156.686-.314.163-.139.28-.324.34-.532.068-.25.1-.51.095-.77H23V17h-2.243v-6.475H18.8V8.93z"></path></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"></path></svg>

After

Width:  |  Height:  |  Size: 368 B

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Baichuan</title><defs><linearGradient id="lobe-icons-baichuan-_R_0_" x1="17.764%" x2="100%" y1="8.678%" y2="91.322%"><stop offset="0%" stop-color="#FEC13E"></stop><stop offset="100%" stop-color="#FF6933"></stop></linearGradient></defs><path d="M7.333 2h-3.2l-2 4.333V17.8L0 22h5.2l2.028-4.2L7.333 2zm7.334 0h-5.2v20h5.2V2zM16.8 7.733H22V22h-5.2V7.733zM22 2h-5.2v4.133H22V2z" fill="url(#lobe-icons-baichuan-_R_0_)" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 571 B

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Baidu</title><path d="M8.859 11.735c1.017-1.71 4.059-3.083 6.202.286 1.579 2.284 4.284 4.397 4.284 4.397s2.027 1.601.73 4.684c-1.24 2.956-5.64 1.607-6.005 1.49l-.024-.009s-1.746-.568-3.776-.112c-2.026.458-3.773.286-3.773.286l-.045-.001c-.328-.01-2.38-.187-3.001-2.968-.675-3.028 2.365-4.687 2.592-4.968.226-.288 1.802-1.37 2.816-3.085zm.986 1.738v2.032h-1.64s-1.64.138-2.213 2.014c-.2 1.252.177 1.99.242 2.148.067.157.596 1.073 1.927 1.342h3.078v-7.514l-1.394-.022zm3.588 2.191l-1.44.024v3.956s.064.985 1.44 1.344h3.541v-5.3h-1.528v3.979h-1.46s-.466-.068-.553-.447v-3.556zM9.82 16.715v3.06H8.58s-.863-.045-1.126-1.049c-.136-.445.02-.959.088-1.16.063-.203.353-.671.951-.85H9.82zm9.525-9.036c2.086 0 2.646 2.06 2.646 2.742 0 .688.284 3.597-2.309 3.655-2.595.057-2.704-1.77-2.704-3.08 0-1.374.277-3.317 2.367-3.317zM4.24 6.08c1.523-.135 2.645 1.55 2.762 2.513.07.625.393 3.486-1.975 4-2.364.515-3.244-2.249-2.984-3.544 0 0 .28-2.797 2.197-2.969zm8.847-1.483c.14-1.31 1.69-3.316 2.931-3.028 1.236.285 2.367 1.944 2.137 3.37-.224 1.428-1.345 3.313-3.095 3.082-1.748-.226-2.143-1.823-1.973-3.424zM9.425 1c1.307 0 2.364 1.519 2.364 3.398 0 1.879-1.057 3.4-2.364 3.4s-2.367-1.521-2.367-3.4C7.058 2.518 8.118 1 9.425 1z" fill="#2932E1" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cohere</title><path clip-rule="evenodd" d="M8.128 14.099c.592 0 1.77-.033 3.398-.703 1.897-.781 5.672-2.2 8.395-3.656 1.905-1.018 2.74-2.366 2.74-4.18A4.56 4.56 0 0018.1 1H7.549A6.55 6.55 0 001 7.55c0 3.617 2.745 6.549 7.128 6.549z" fill="#39594D" fill-rule="evenodd"></path><path clip-rule="evenodd" d="M9.912 18.61a4.387 4.387 0 012.705-4.052l3.323-1.38c3.361-1.394 7.06 1.076 7.06 4.715a5.104 5.104 0 01-5.105 5.104l-3.597-.001a4.386 4.386 0 01-4.386-4.387z" fill="#D18EE2" fill-rule="evenodd"></path><path d="M4.776 14.962A3.775 3.775 0 001 18.738v.489a3.776 3.776 0 007.551 0v-.49a3.775 3.775 0 00-3.775-3.775z" fill="#FF7759"></path></svg>

After

Width:  |  Height:  |  Size: 769 B

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Doubao</title><path d="M5.31 15.756c.172-3.75 1.883-5.999 2.549-6.739-3.26 2.058-5.425 5.658-6.358 8.308v1.12C1.501 21.513 4.226 24 7.59 24a6.59 6.59 0 002.2-.375c.353-.12.7-.248 1.039-.378.913-.899 1.65-1.91 2.243-2.992-4.877 2.431-7.974.072-7.763-4.5l.002.001z" fill="#1E37FC"></path><path d="M22.57 10.283c-1.212-.901-4.109-2.404-7.397-2.8.295 3.792.093 8.766-2.1 12.773a12.782 12.782 0 01-2.244 2.992c3.764-1.448 6.746-3.457 8.596-5.219 2.82-2.683 3.353-5.178 3.361-6.66a2.737 2.737 0 00-.216-1.084v-.002z" fill="#37E1BE"></path><path d="M14.303 1.867C12.955.7 11.248 0 9.39 0 7.532 0 5.883.677 4.545 1.807 2.791 3.29 1.627 5.557 1.5 8.125v9.201c.932-2.65 3.097-6.25 6.357-8.307.5-.318 1.025-.595 1.569-.829 1.883-.801 3.878-.932 5.746-.706-.222-2.83-.718-5.002-.87-5.617h.001z" fill="#A569FF"></path><path d="M17.305 4.961a199.47 199.47 0 01-1.08-1.094c-.202-.213-.398-.419-.586-.622l-1.333-1.378c.151.615.648 2.786.869 5.617 3.288.395 6.185 1.898 7.396 2.8-1.306-1.275-3.475-3.487-5.266-5.323z" fill="#1E37FC"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Fireworks</title><path clip-rule="evenodd" d="M14.8 5l-2.801 6.795L9.195 5H7.397l3.072 7.428a1.64 1.64 0 003.038.002L16.598 5H14.8zm1.196 10.352l5.124-5.244-.699-1.669-5.596 5.739a1.664 1.664 0 00-.343 1.807 1.642 1.642 0 001.516 1.012L16 17l8-.02-.699-1.669-7.303.041h-.002zM2.88 10.104l.699-1.669 5.596 5.739c.468.479.603 1.189.343 1.807a1.643 1.643 0 01-1.516 1.012l-8-.018-.002.002.699-1.669 7.303.042-5.122-5.246z" fill="#5019C5" fill-rule="evenodd"></path></svg>

After

Width:  |  Height:  |  Size: 592 B

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-0-_R_0_)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-1-_R_0_)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-2-_R_0_)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-0-_R_0_" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-1-_R_0_" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-2-_R_0_" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemma</title><defs><linearGradient id="lobe-icons-gemma-_R_0_" x1="24.419%" x2="75.194%" y1="75.581%" y2="25.194%"><stop offset="0%" stop-color="#446EFF"></stop><stop offset="36.661%" stop-color="#2E96FF"></stop><stop offset="83.221%" stop-color="#B1C5FF"></stop></linearGradient></defs><path d="M12.34 5.953a8.233 8.233 0 01-.247-1.125V3.72a8.25 8.25 0 015.562 2.232H12.34zm-.69 0c.113-.373.199-.755.257-1.145V3.72a8.25 8.25 0 00-5.562 2.232h5.304zm-5.433.187h5.373a7.98 7.98 0 01-.267.696 8.41 8.41 0 01-1.76 2.65L6.216 6.14zm-.264-.187H2.977v.187h2.915a8.436 8.436 0 00-2.357 5.767H0v.186h3.535a8.436 8.436 0 002.357 5.767H2.977v.186h2.976v2.977h.187v-2.915a8.436 8.436 0 005.767 2.357V24h.186v-3.535a8.436 8.436 0 005.767-2.357v2.915h.186v-2.977h2.977v-.186h-2.915a8.436 8.436 0 002.357-5.767H24v-.186h-3.535a8.436 8.436 0 00-2.357-5.767h2.915v-.187h-2.977V2.977h-.186v2.915a8.436 8.436 0 00-5.767-2.357V0h-.186v3.535A8.436 8.436 0 006.14 5.892V2.977h-.187v2.976zm6.14 14.326a8.25 8.25 0 005.562-2.233H12.34c-.108.367-.19.743-.247 1.126v1.107zm-.186-1.087a8.015 8.015 0 00-.258-1.146H6.345a8.25 8.25 0 005.562 2.233v-1.087zm-8.186-7.285h1.107a8.23 8.23 0 001.125-.247V6.345a8.25 8.25 0 00-2.232 5.562zm1.087.186H3.72a8.25 8.25 0 002.232 5.562v-5.304a8.012 8.012 0 00-1.145-.258zm15.47-.186a8.25 8.25 0 00-2.232-5.562v5.315c.367.108.743.19 1.126.247h1.107zm-1.086.186c-.39.058-.772.144-1.146.258v5.304a8.25 8.25 0 002.233-5.562h-1.087zm-1.332 5.69V12.41a7.97 7.97 0 00-.696.267 8.409 8.409 0 00-2.65 1.76l3.346 3.346zm0-6.18v-5.45l-.012-.013h-5.451c.076.235.162.468.26.696a8.698 8.698 0 001.819 2.688 8.698 8.698 0 002.688 1.82c.228.097.46.183.696.259zM6.14 17.848V12.41c.235.078.468.167.696.267a8.403 8.403 0 012.688 1.799 8.404 8.404 0 011.799 2.688c.1.228.19.46.267.696H6.152l-.012-.012zm0-6.245V6.326l3.29 3.29a8.716 8.716 0 01-2.594 1.728 8.14 8.14 0 01-.696.259zm6.257 6.257h5.277l-3.29-3.29a8.716 8.716 0 00-1.728 2.594 8.135 8.135 0 00-.259.696zm-2.347-7.81a9.435 9.435 0 01-2.88 1.96 9.14 9.14 0 012.88 1.94 9.14 9.14 0 011.94 2.88 9.435 9.435 0 011.96-2.88 9.14 9.14 0 012.88-1.94 9.435 9.435 0 01-2.88-1.96 9.434 9.434 0 01-1.96-2.88 9.14 9.14 0 01-1.94 2.88z" fill="url(#lobe-icons-gemma-_R_0_)" fill-rule="evenodd"></path></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Grok</title><path d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"></path></svg>

After

Width:  |  Height:  |  Size: 756 B

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Groq</title><path d="M12.036 2c-3.853-.035-7 3-7.036 6.781-.035 3.782 3.055 6.872 6.908 6.907h2.42v-2.566h-2.292c-2.407.028-4.38-1.866-4.408-4.23-.029-2.362 1.901-4.298 4.308-4.326h.1c2.407 0 4.358 1.915 4.365 4.278v6.305c0 2.342-1.944 4.25-4.323 4.279a4.375 4.375 0 01-3.033-1.252l-1.851 1.818A7 7 0 0012.029 22h.092c3.803-.056 6.858-3.083 6.879-6.816v-6.5C18.907 4.963 15.817 2 12.036 2z"></path></svg>

After

Width:  |  Height:  |  Size: 568 B

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>HuggingFace</title><path d="M2.25 11.535c0-3.407 1.847-6.554 4.844-8.258a9.822 9.822 0 019.687 0c2.997 1.704 4.844 4.851 4.844 8.258 0 5.266-4.337 9.535-9.687 9.535S2.25 16.8 2.25 11.535z" fill="#FF9D0B"></path><path d="M11.938 20.086c4.797 0 8.687-3.829 8.687-8.551 0-4.722-3.89-8.55-8.687-8.55-4.798 0-8.688 3.828-8.688 8.55 0 4.722 3.89 8.55 8.688 8.55z" fill="#FFD21E"></path><path d="M11.875 15.113c2.457 0 3.25-2.156 3.25-3.263 0-.576-.393-.394-1.023-.089-.582.283-1.365.675-2.224.675-1.798 0-3.25-1.693-3.25-.586 0 1.107.79 3.263 3.25 3.263h-.003z" fill="#FF323D"></path><path d="M14.76 9.21c.32.108.445.753.767.585.447-.233.707-.708.659-1.204a1.235 1.235 0 00-.879-1.059 1.262 1.262 0 00-1.33.394c-.322.384-.377.92-.14 1.36.153.283.638-.177.925-.079l-.002.003zm-5.887 0c-.32.108-.448.753-.768.585a1.226 1.226 0 01-.658-1.204c.048-.495.395-.913.878-1.059a1.262 1.262 0 011.33.394c.322.384.377.92.14 1.36-.152.283-.64-.177-.925-.079l.003.003zm1.12 5.34a2.166 2.166 0 011.325-1.106c.07-.02.144.06.219.171l.192.306c.069.1.139.175.209.175.074 0 .15-.074.223-.172l.205-.302c.08-.11.157-.188.234-.165.537.168.986.536 1.25 1.026.932-.724 1.275-1.905 1.275-2.633 0-.508-.306-.426-.81-.19l-.616.296c-.52.24-1.148.48-1.824.48-.676 0-1.302-.24-1.823-.48l-.589-.283c-.52-.248-.838-.342-.838.177 0 .703.32 1.831 1.187 2.56l.18.14z" fill="#3A3B45"></path><path d="M17.812 10.366a.806.806 0 00.813-.8c0-.441-.364-.8-.813-.8a.806.806 0 00-.812.8c0 .442.364.8.812.8zm-11.624 0a.806.806 0 00.812-.8c0-.441-.364-.8-.812-.8a.806.806 0 00-.813.8c0 .442.364.8.813.8zM4.515 13.073c-.405 0-.765.162-1.017.46a1.455 1.455 0 00-.333.925 1.801 1.801 0 00-.485-.074c-.387 0-.737.146-.985.409a1.41 1.41 0 00-.2 1.722 1.302 1.302 0 00-.447.694c-.06.222-.12.69.2 1.166a1.267 1.267 0 00-.093 1.236c.238.533.81.958 1.89 1.405l.24.096c.768.3 1.473.492 1.478.494.89.243 1.808.375 2.732.394 1.465 0 2.513-.443 3.115-1.314.93-1.342.842-2.575-.274-3.763l-.151-.154c-.692-.684-1.155-1.69-1.25-1.912-.195-.655-.71-1.383-1.562-1.383-.46.007-.889.233-1.15.605-.25-.31-.495-.553-.715-.694a1.87 1.87 0 00-.993-.312zm14.97 0c.405 0 .767.162 1.017.46.216.262.333.588.333.925.158-.047.322-.071.487-.074.388 0 .738.146.985.409a1.41 1.41 0 01.2 1.722c.22.178.377.422.445.694.06.222.12.69-.2 1.166.244.37.279.836.093 1.236-.238.533-.81.958-1.889 1.405l-.239.096c-.77.3-1.475.492-1.48.494-.89.243-1.808.375-2.732.394-1.465 0-2.513-.443-3.115-1.314-.93-1.342-.842-2.575.274-3.763l.151-.154c.695-.684 1.157-1.69 1.252-1.912.195-.655.708-1.383 1.56-1.383.46.007.889.233 1.15.605.25-.31.495-.553.718-.694.244-.162.523-.265.814-.3l.176-.012z" fill="#FF9D0B"></path><path d="M9.785 20.132c.688-.994.638-1.74-.305-2.667-.945-.928-1.495-2.288-1.495-2.288s-.205-.788-.672-.714c-.468.074-.81 1.25.17 1.971.977.721-.195 1.21-.573.534-.375-.677-1.405-2.416-1.94-2.751-.532-.332-.907-.148-.782.541.125.687 2.357 2.35 2.14 2.707-.218.362-.983-.42-.983-.42S2.953 14.9 2.43 15.46c-.52.558.398 1.026 1.7 1.803 1.308.778 1.41.985 1.225 1.28-.187.295-3.07-2.1-3.34-1.083-.27 1.011 2.943 1.304 2.745 2.006-.2.7-2.265-1.324-2.685-.537-.425.79 2.913 1.718 2.94 1.725 1.075.276 3.813.859 4.77-.522zm4.432 0c-.687-.994-.64-1.74.305-2.667.943-.928 1.493-2.288 1.493-2.288s.205-.788.675-.714c.465.074.807 1.25-.17 1.971-.98.721.195 1.21.57.534.377-.677 1.407-2.416 1.94-2.751.532-.332.91-.148.782.541-.125.687-2.355 2.35-2.137 2.707.215.362.98-.42.98-.42S21.05 14.9 21.57 15.46c.52.558-.395 1.026-1.7 1.803-1.308.778-1.408.985-1.225 1.28.187.295 3.07-2.1 3.34-1.083.27 1.011-2.94 1.304-2.743 2.006.2.7 2.263-1.324 2.685-.537.423.79-2.912 1.718-2.94 1.725-1.077.276-3.815.859-4.77-.522z" fill="#FFD21E"></path></svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Hunyuan</title><circle cx="12" cy="12" fill="#0055E9" r="12"></circle><path d="M12 0c.518 0 1.028.033 1.528.096A6.188 6.188 0 0112.12 12.28l-.12.001c-2.99 0-5.242 2.179-5.554 5.11-.223 2.086.353 4.412 2.242 6.146C3.672 22.1 0 17.479 0 12 0 5.373 5.373 0 12 0z" fill="#A8DFF5"></path><path d="M5.286 5a2.438 2.438 0 01.682 3.38c-3.962 5.966-3.215 10.743 2.648 15.136C3.636 22.056 0 17.452 0 12c0-1.787.39-3.482 1.09-5.006.253-.435.525-.872.817-1.311A2.438 2.438 0 015.286 5z" fill="#0055E9"></path><path d="M12.98.04c.272.021.543.053.81.093.583.106 1.117.254 1.538.44 6.638 2.927 8.07 10.052 1.748 15.642a4.125 4.125 0 01-5.822-.358c-1.51-1.706-1.3-4.184.357-5.822.858-.848 3.108-1.223 4.045-2.441 1.257-1.634 2.122-6.009-2.523-7.506L12.98.039z" fill="#00BCFF"></path><path d="M13.528.096A6.187 6.187 0 0112 12.281a5.75 5.75 0 00-1.71.255c.147-.905.595-1.784 1.321-2.501.858-.848 3.108-1.223 4.045-2.441 1.27-1.651 2.14-6.104-2.676-7.554.184.014.367.033.548.056z" fill="#ECECEE"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>IBM</title><path clip-rule="evenodd" d="M24 16.333V17h-3.158v-.667H24zm-7.579 0V17h-3.158v-.667h3.158zm2.464 0L18.63 17l-.25-.667h.504zm-7.075 0a2.528 2.528 0 01-1.717.667h-5.04v-.667h6.757zm-7.389 0V17H0v-.667h4.421zm12-1.333v.667h-3.158V15h3.158zm2.958 0l-.246.667h-1L17.885 15h1.494zm-6.937 0c-.057.237-.148.46-.265.667H5.053V15h7.39zm-8.02 0v.667H0V15h4.421zM24 15v.667h-3.158V15H24zm-1.263-1.333v.666h-1.895v-.666h1.895zm-6.316 0v.666h-1.895v-.666h1.895zm3.453 0l-.248.666h-1.989l-.25-.666h2.487zm-7.52 0c.056.212.088.435.088.666h-2.337v-.666h2.249zm-4.143 0v.666H6.316v-.666H8.21zm-5.053 0v.666H1.263v-.666h1.895zm19.579-1.334V13h-1.895v-.667h1.895zm-6.316 0V13h-1.895v-.667h1.895zm3.948 0l-.247.667h-2.987l-.245-.667h3.48zm-8.792 0c.218.188.405.414.55.667H6.315v-.667h5.26zm-8.42 0V13H1.264v-.667h1.895zM18.456 11l.177.539.176-.539h3.929v.667h-1.895v-.613l-.215.613H16.63l-.209-.613v.613h-1.895V11h3.929zM3.158 11v.667H1.263V11h1.895zm8.968 0a2.555 2.555 0 01-.55.667h-5.26V11h5.81zm10.61-1.333v.666h-3.709l.224-.666h3.486zm-4.722 0l.224.666h-3.712v-.666h3.488zm-5.572 0c0 .23-.032.454-.088.666h-2.249v-.666h2.337zm-4.231 0v.666H6.316v-.666H8.21zm-5.053 0v.666H1.263v-.666h1.895zm14.419-1.334l.22.667h-4.534v-.667h4.314zm6.423 0V9h-4.536l.229-.667H24zm-11.823 0c.117.206.208.43.265.667h-7.39v-.667h7.125zm-7.756 0V9H0v-.667h4.421zM17.133 7l.224.667h-4.094V7h3.87zM24 7v.667h-4.089L20.13 7H24zM10.093 7c.662 0 1.264.253 1.717.667H5.053V7h5.04zM4.42 7v.667H0V7h4.421z"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>InternLM</title><path d="M5.54 19.662s2.24-.25 2.365-.29c.125-.042 2.45.082 2.45.082l1.493.374 1.37.498.373.125 2.033-.748 2.45-.373 1.659.041 1.286.166.54.166-.042 2.242-1.12-.291-2.159-.166-1.494.124-1.286.291-1.161.374-.83.332-1.744-.664-2.116-.416H7.158l-1.618.291v-2.158z" fill="#858599" fill-opacity=".5"></path><path d="M13.46 23c-3.773-2.078-7.78-.832-7.82-.819l-.453.144v-2.86l.24-.078c.174-.056 4.324-1.354 8.366.871l-.333.607c-3.244-1.786-6.66-1.115-7.58-.885V21.4c1.203-.286 4.595-.834 7.913.993L13.46 23V23z" fill="#858599"></path><path d="M13.413 23l-.334-.607c3.319-1.827 6.71-1.279 7.914-.993v-1.419c-.92-.231-4.337-.9-7.58.885l-.334-.607c4.042-2.225 8.192-.927 8.366-.87l.24.077v2.862l-.452-.147c-.04-.013-4.046-1.258-7.82.819z" fill="#858599"></path><path d="M17.756 16.476a.904.904 0 00-.264-.651.822.822 0 00-.632-.255.997.997 0 00-.644.293c-.175.212-.26.433-.259.685a.874.874 0 00.256.632c.175.187.39.272.644.255.246 0 .459-.1.633-.293a.97.97 0 00.268-.667l-.002.001zM10.769 17.143a.928.928 0 00.264-.658.993.993 0 00-.262-.679.908.908 0 00-.633-.227 1.089 1.089 0 00-.644.274 1.005 1.005 0 00-.265.685c0 .25.089.465.264.643.17.187.383.271.636.255.26-.008.471-.1.639-.293z" fill="#858599" fill-opacity=".5"></path><path d="M13.4 19.65c-.693 0-1.374-.058-1.895-.192-1.403-.36-2.52-1.01-3.413-1.986-.929-1.017-.988-1.996-1.05-3.033-.008-.133-.016-.267-.026-.405-.083-1.147-.626-2.941-.631-2.958l-.043-.141.086-.119A6.966 6.966 0 019.44 8.441c1.128-.47 2.47-.716 4.105-.752 2.721-.159 5.688.973 7.056 2.692l.086.109-.62 2.89c-.006.155-.052.968-.276 2.63-.263 1.947-2.668 3.061-3.903 3.377-.628.162-1.57.262-2.488.262zm-6.41-8.603c.134.46.538 1.912.613 2.944.01.139.018.277.026.412.06 1.01.109 1.809.896 2.672.815.89 1.837 1.484 3.125 1.814 1.092.28 3.061.194 4.091-.071.887-.228 3.24-1.19 3.468-2.889.24-1.786.272-2.593.273-2.6v-.026l.572-2.664c-.9-1.728-1.433-2.082-2.578-2.514-1.206-.455-2.677.075-3.903.148h-.01c-3.126.068-5.418-.935-6.574 2.773h.001z" fill="#858599"></path><path d="M19.545 10.862c.223.573.317.86.526 1.386 0 0 .552-.887.727-1.522.175-.641.068-1.458-.07-2.221-.344-1.282-.612-1.381-1.647-2.14a8.668 8.668 0 00-1.565-.84 12.303 12.303 0 00-3.83-.8 12.175 12.175 0 00-3.842.57 8.258 8.258 0 00-1.602.745c-1.075.695-1.823 1.674-2.24 2.932-.112.473-.29.927-.245 1.365.2 2.01 1.512 2.893 1.422 2.409-.117-.625-.042-.994.119-1.592.304-1.13 1.025-1.965 2.163-2.497h.004c.221-.062 1.344-.7 2.834.062.396.17 1.088.629 1.434.618.347-.01.846-.345 1.21-.572.683-.428 1.33-.574 2.184-.303 1.243.39 2.005 1.274 2.418 2.401z" fill="#858599"></path><path d="M1.217 9.86l.934.274 1.071-.055.77-.522.825-.742.604-.825.77-.797.741-.467 1.016-.22-.687 1.209-.577.576-1.209 1.676-.549.825-.632.44-.907.604s-.797.165-.879.192c-.081.028-.741.138-.741.138l-.577-.028-.44-.137.412-.825.056-.604v-.714l-.001.002z" fill="#858599" fill-opacity=".5"></path><path d="M3.607 5.108l-.083.63-.22.441-.274.412-.688.412.825.412.907.357.934.193.412.028.99-.688 1.043-.934-.357-.11-1.29-.137-.963-.358-.687-.356-.55-.302z" fill="#858599" fill-opacity=".5"></path><path d="M1.521 12.399c-.29 0-.596-.018-.917-.051L0 12.278l.298-.19.159-.173c.022-.033.085-.144.19-.377v-.002c.17-.365.228-.975.177-1.813l-.036-.564.435.36c.454.376 1.024.466 1.74.272.64-.182 1.337-.78 2.068-1.774.673-.968 1.64-1.624 2.88-1.952l.116-.031.783.61-.704-.05a2.182 2.182 0 00-.191.206l-.014.017-.018.016c-.404.718-.982 1.426-1.56 2.201l-.008.014c-.089.156-.18.317-.279.477v.009l-.032.051c-.592.962-1.243 1.683-1.935 2.145-.635.445-1.49.67-2.548.67v-.001zm-.5-.505c1.194.077 2.124-.112 2.77-.567l.005-.003c.626-.417 1.223-1.078 1.773-1.964a.244.244 0 01.041-.074c.098-.159.19-.321.28-.48v-.001c.392-.75.842-1.417 1.343-1.988-.748.33-1.341.82-1.8 1.481l-.004.006c-.808 1.101-1.572 1.742-2.333 1.958-.673.182-1.263.15-1.764-.095.01.69-.068 1.207-.237 1.573-.026.058-.05.108-.072.153h-.002z" fill="#858599"></path><path d="M1.577 12.399c-.29 0-.596-.018-.917-.051l.05-.486c1.371.145 2.425-.035 3.136-.535l.006-.003c.626-.417 1.222-1.078 1.772-1.963a.277.277 0 01.079-.116l.32.37a.22.22 0 00.072-.141l-.005.058-.03.052c-.592.962-1.243 1.683-1.935 2.145-.636.445-1.49.67-2.548.67zM5.751 7.717a9.381 9.381 0 01-.311.016c-.823.029-1.7-.2-2.615-.682.792-.512 1.013-1.096.982-1.621.189.15.386.281.591.393 1.345.758 2.766.878 5.195.198l-.165-.46c-2.308.637-3.558.532-4.793-.165a3.803 3.803 0 01-.967-.759l-.033-.036-.5-.542.094.697c.014.058.3 1.417-1.01 2.08l-.398.201.383.228c1.073.638 2.114.96 3.094.96l.453-.507v-.001zM11.93 15.21a.405.405 0 01-.366-.253c-.326-.745-.582-1.055-.969-1.525-.39-.474-1.223-1.277-1.36-1.393-.184-.156-.236-.368-.126-.515.055-.072.219-.219.556.005.683.452 2.198 1.812 2.608 2.904.15.397 0 .667-.201.749a.367.367 0 01-.143.028h.001zM14.877 15.23a.37.37 0 01-.146-.03c-.201-.085-.346-.357-.192-.752.424-1.086 1.957-2.427 2.645-2.87.34-.22.502-.072.556.002.109.147.054.358-.131.512Line truncated

After

Width:  |  Height:  |  Size: 8.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M21.846 0a1.923 1.923 0 110 3.846H20.15a.226.226 0 01-.227-.226V1.923C19.923.861 20.784 0 21.846 0z" fill="#1783FF"></path><path d="M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 00-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 01.205-.023l6.484 4.772a7.677 7.677 0 003.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 01-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z" fill="#fff"></path></svg>

After

Width:  |  Height:  |  Size: 773 B

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>LG AI</title><path d="M12 0a12 12 0 110 24 12 12 0 010-24zm7.167 19.18a10.082 10.082 0 002.971-7.169v-.548l-.499.002h-6.68v1.12h6.038l-.002.034a9.038 9.038 0 01-8.993 8.41 8.957 8.957 0 01-6.375-2.642 8.963 8.963 0 01-2.64-6.376c0-2.406.939-4.67 2.64-6.373A8.961 8.961 0 0112 2.998l.572.007V1.882l-.57-.006A10.15 10.15 0 001.864 12.01c0 2.708 1.055 5.253 2.97 7.17A10.078 10.078 0 0012 22.15a10.08 10.08 0 007.171-2.97m-6.6-2.942V6.657h-1.14v10.705h3.529v-1.123H12.57zM9.704 8.183a1.533 1.533 0 10-3.067-.01 1.533 1.533 0 003.067.01z" fill="#C00C3F"></path></svg>

After

Width:  |  Height:  |  Size: 687 B

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Liquid</title><path d="M12.028 8.546l-.008.005 3.03 5.25a3.94 3.94 0 01.643 2.162c0 .754-.212 1.46-.58 2.062l6.173-1.991L11.63 0 9.304 3.872l2.724 4.674zM6.837 24l4.85-4.053h-.013c-2.219 0-4.017-1.784-4.017-3.984 0-.794.235-1.534.64-2.156l2.865-4.976-2.381-4.087L2 16.034 6.83 24h.007zM13.737 19.382h-.001L8.222 24h8.182l4.148-6.769-6.815 2.151z"></path></svg>

After

Width:  |  Height:  |  Size: 524 B

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>LLaVA</title><path d="M13.976 11.91s-.055-2.284 1.1-3.644c1.154-1.359 2.638-2.393 3.572-3.045.935-.652 2.254-1.469 2.089-2.637-.165-1.17-1.088-1.945-5.1-1.945s-6.77.369-9.327 1.7c-2.556 1.332-1.472 3.054-.384 3.345.96.257 2.183.36 3.152 1.383 1.758 1.853 1.573 4.707 1.573 4.707l3.325.136z" fill="url(#lobe-icons-l-la-va-0-_R_0_)"></path><path d="M7.38 12.834s-3.132 4.297-3.958 5.358c-.826 1.06-2.528 2.8-2.417 3.017.11.217 1.402.734 1.402.734s6.54.924 7.061.924c.522 0 11.239-3.017 11.239-3.017s-3.764-7.968-4.975-7.506c-1.21.463-8.352.49-8.352.49z" fill="#717578"></path><path d="M12.491 15.39s-.934.815-1.373 1.251c-.44.437-1.67 1.415-1.51 1.577.13.133 1.987-.702 2.18-.798.166-.081 2.216-.835 2.353-.643.137.191-.46.643-.659.924-.185.261-3.458 3.524-4.231 3.725-.556.146-3.93.327-4.04.054-.111-.27 1.208-2.12.88-2.393-.33-.272-1.678.603-2.474 1.066-.795.462-2.032 1.054-2.225 1.082-.193.028-.385-.028-.385-.028s-.058.34 1.65 1.114c1.345.61 2.445 1.17 6.129 1.523 3.683.353 6.32.028 7.858-.191 1.54-.218 4.369-1.089 5.112-1.414.741-.326 1.315-.663 1.237-.896-.028-.082-4.314-5.71-4.314-5.71l-2.313-3.396-3.875 3.152z" fill="#474B4E"></path><path d="M9.79 9.829c-.855.126-1.16 1.086-2.05 2.206-.513.646-.952 1.084-1.196 1.38-.274.332-.637 1.018-.274 1.211.362.193.922-.384 1.075-.703.152-.317.377-.642.589-.419.211.223.225.612-.317 1.06-.543.449-1.178.971-1.117 1.435.06.465.376.598.739.493.362-.104 1.238-1.106 1.57-1.599.333-.492.86-1.345 1.223-1.345.363 0 .357.747-.362 1.584-.83.967-1.235 1.302-2.22 2.183-1.023.913-.89 1.24-.709 1.419.182.18.695.223 1.434-.403.74-.628 1.783-1.807 2.236-2.332.453-.522.86-1.09 1.33-1.06.468.03.346.492.406.718.06.223.06.641.618.641s.86-.568 1.042-1.016c.18-.449.165-1.046.527-1.106.363-.06.407.299.695.867.286.568 1.39 2.927 1.662 3.346.272.419.574 1.21 1.464.94.89-.268-.256-1.897-.528-2.301-.272-.403-1.057-1.764-1.36-2.675-.301-.91-.301-1.196-.104-1.345.195-.15.528.269.967 1.076.437.807.816 1.555 1.162 1.854.346.299.648.269.8.133.15-.133.211-.642-.182-1.03a11.076 11.076 0 01-1.259-1.537 42.745 42.745 0 01-1.488-2.396c-.362-.627-.57-1.058-1.063-1.134-.639-.097-1.098.375-1.46.628-.363.253-.86.389-1.36.359-.498-.03-1.162-.568-1.434-.807-.27-.235-.542-.399-1.055-.325z" fill="url(#lobe-icons-l-la-va-1-_R_0_)"></path><path d="M10.236 10.638s.342.072.592.257c.25.186.864.682 1.663.67.8-.01 1.245-.39 1.567-.576.323-.185.623-.154.623-.154s-.542 2.091-2.151 2.153c-2.105.082-2.294-2.35-2.294-2.35z" fill="#EC5F32"></path><path d="M20.242 10.921c-.294.042-.141.459-.1 1.443.043.985.033 1.4.506 1.401.593.002.407-1.235.294-1.61-.11-.375-.346-1.284-.7-1.234z" fill="url(#lobe-icons-l-la-va-2-_R_0_)"></path><path d="M20.171 6.12c-.129.255.28.542.659.985.255.3.63 1.04 1.15 1.096.52.055.58-.562.322-.917-.252-.347-.687-.694-1.178-.943-.49-.249-.854-.416-.953-.221z" fill="url(#lobe-icons-l-la-va-3-_R_0_)"></path><path d="M13.539 3.649c.155.07.308-.028.364-.236.037-.137.657-1.134 1.571-1.747.743-.5 1.094-.5 1.473-.652.23-.094.435-.445.322-.708-.107-.25-.337-.346-.715-.29-.24.035-1.114.256-1.978 1.18-1.065 1.137-1.122 1.928-1.15 2.066-.028.138-.036.319.113.387z" fill="url(#lobe-icons-l-la-va-4-_R_0_)"></path><path d="M11.49 3.774c-.288.076-.533-.333-1.05-.86-.52-.529-.982-.597-1.236-.918-.253-.32-.209-.68.015-.874.223-.194.616-.222.966.041s.8.764 1.122 1.457c.175.375.451 1.084.183 1.154z" fill="url(#lobe-icons-l-la-va-5-_R_0_)"></path><path d="M4.68 5.89c-.203-.189-1.012.146-1.633.662-.296.246-.594.537-.717.842-.102.259-.098.587.143.747.218.143.484.076.67-.066.255-.197.432-.594.581-.813.262-.383.697-.747.84-.869.13-.114.27-.36.117-.502z" fill="url(#lobe-icons-l-la-va-6-_R_0_)"></path><path d="M5.352 11.13c-.234-.071-.585.182-.879 1.294-.26.984-.34 1.871.315 1.887.688.018.402-.974.483-1.87.08-.894.314-1.24.08-1.31z" fill="url(#lobe-icons-l-la-va-7-_R_0_)"></path><path d="M5.29 9.839c.384.076.585-.213.664-.478.062-.212.326-1.312 1.7-1.435 1.329-.12 2.28 1.203 2.417 1.349.138.145.32.171.422.068.091-.09.073-.255-.018-.479-.094-.239-1.192-2.106-3.142-1.861-1.847.231-2.485 1.64-2.469 2.178.014.377.123.598.425.658z" fill="url(#lobe-icons-l-la-va-8-_R_0_)"></path><path d="M14.046 9.598c.25.082.367-.194.457-.323.091-.13.572-1.042 1.428-1.479.858-.436 1.503-.484 2.31-.121.43.193.732.715.915 1.034.246.428.588.542.914.363.327-.178.375-.712-.106-1.35-.39-.516-1.26-1.397-2.802-1.243-2.457.243-3.12 2.373-3.184 2.577-.07.235-.103.486.068.542z" fill="url(#lobe-icons-l-la-va-9-_R_0_)"></path><path d="M12.862 10.058c.256.026.322-.086.393-.524.16-.987.409-2.85 1.73-4.357 1.043-1.19 2.217-1.046 2.735-.727.453.279.564.546.747.74.183.193.552.424.882.193.276-.192.345-.68-.135-1.214-.479-.534-.968-.97-2.17-1.068-1.203-.098-2.391.667-3.03 1.59-.638.923-1.104 2.232-1.25 3.12-.148.886-.16 1.673-.16 1.857 0 .18.049.368.258.39z" fill="url(#lobe-icons-l-la-va-10-_R_0_)"></path><path d="M11.837 10.06c.268-.04.306-.243.27-.558-.03Line truncated

After

Width:  |  Height:  |  Size: 9.9 KiB

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>LM Studio</title><path d="M2.84 2a1.273 1.273 0 100 2.547h14.107a1.273 1.273 0 100-2.547H2.84zM7.935 5.33a1.273 1.273 0 000 2.548H22.04a1.274 1.274 0 000-2.547H7.935zM3.624 9.935c0-.704.57-1.274 1.274-1.274h14.106a1.274 1.274 0 010 2.547H4.898c-.703 0-1.274-.57-1.274-1.273zM1.273 12.188a1.273 1.273 0 100 2.547H15.38a1.274 1.274 0 000-2.547H1.273zM3.624 16.792c0-.704.57-1.274 1.274-1.274h14.106a1.273 1.273 0 110 2.547H4.898c-.703 0-1.274-.57-1.274-1.273zM13.029 18.849a1.273 1.273 0 100 2.547h9.698a1.273 1.273 0 100-2.547h-9.698z" fill-opacity=".3"></path><path d="M2.84 2a1.273 1.273 0 100 2.547h10.287a1.274 1.274 0 000-2.547H2.84zM7.935 5.33a1.273 1.273 0 000 2.548H18.22a1.274 1.274 0 000-2.547H7.935zM3.624 9.935c0-.704.57-1.274 1.274-1.274h10.286a1.273 1.273 0 010 2.547H4.898c-.703 0-1.274-.57-1.274-1.273zM1.273 12.188a1.273 1.273 0 100 2.547H11.56a1.274 1.274 0 000-2.547H1.273zM3.624 16.792c0-.704.57-1.274 1.274-1.274h10.286a1.273 1.273 0 110 2.547H4.898c-.703 0-1.274-.57-1.274-1.273zM13.029 18.849a1.273 1.273 0 100 2.547h5.78a1.273 1.273 0 100-2.547h-5.78z"></path></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Meta</title><path d="M6.897 4h-.024l-.031 2.615h.022c1.715 0 3.046 1.357 5.94 6.246l.175.297.012.02 1.62-2.438-.012-.019a48.763 48.763 0 00-1.098-1.716 28.01 28.01 0 00-1.175-1.629C10.413 4.932 8.812 4 6.896 4z" fill="url(#lobe-icons-meta-0-_R_0_)"></path><path d="M6.873 4C4.95 4.01 3.247 5.258 2.02 7.17a4.352 4.352 0 00-.01.017l2.254 1.231.011-.017c.718-1.083 1.61-1.774 2.568-1.785h.021L6.896 4h-.023z" fill="url(#lobe-icons-meta-1-_R_0_)"></path><path d="M2.019 7.17l-.011.017C1.2 8.447.598 9.995.274 11.664l-.005.022 2.534.6.004-.022c.27-1.467.786-2.828 1.456-3.845l.011-.017L2.02 7.17z" fill="url(#lobe-icons-meta-2-_R_0_)"></path><path d="M2.807 12.264l-2.533-.6-.005.022c-.177.918-.267 1.851-.269 2.786v.023l2.598.233v-.023a12.591 12.591 0 01.21-2.44z" fill="url(#lobe-icons-meta-3-_R_0_)"></path><path d="M2.677 15.537a5.462 5.462 0 01-.079-.813v-.022L0 14.468v.024a8.89 8.89 0 00.146 1.652l2.535-.585a4.106 4.106 0 01-.004-.022z" fill="url(#lobe-icons-meta-4-_R_0_)"></path><path d="M3.27 16.89c-.284-.31-.484-.756-.589-1.328l-.004-.021-2.535.585.004.021c.192 1.01.568 1.85 1.106 2.487l.014.017 2.018-1.745a2.106 2.106 0 01-.015-.016z" fill="url(#lobe-icons-meta-5-_R_0_)"></path><path d="M10.78 9.654c-1.528 2.35-2.454 3.825-2.454 3.825-2.035 3.2-2.739 3.917-3.871 3.917a1.545 1.545 0 01-1.186-.508l-2.017 1.744.014.017C2.01 19.518 3.058 20 4.356 20c1.963 0 3.374-.928 5.884-5.33l1.766-3.13a41.283 41.283 0 00-1.227-1.886z" fill="#0082FB"></path><path d="M13.502 5.946l-.016.016c-.4.43-.786.908-1.16 1.416.378.483.768 1.024 1.175 1.63.48-.743.928-1.345 1.367-1.807l.016-.016-1.382-1.24z" fill="url(#lobe-icons-meta-6-_R_0_)"></path><path d="M20.918 5.713C19.853 4.633 18.583 4 17.225 4c-1.432 0-2.637.787-3.723 1.944l-.016.016 1.382 1.24.016-.017c.715-.747 1.408-1.12 2.176-1.12.826 0 1.6.39 2.27 1.075l.015.016 1.589-1.425-.016-.016z" fill="#0082FB"></path><path d="M23.998 14.125c-.06-3.467-1.27-6.566-3.064-8.396l-.016-.016-1.588 1.424.015.016c1.35 1.392 2.277 3.98 2.361 6.971v.023h2.292v-.022z" fill="url(#lobe-icons-meta-7-_R_0_)"></path><path d="M23.998 14.15v-.023h-2.292v.022c.004.14.006.282.006.424 0 .815-.121 1.474-.368 1.95l-.011.022 1.708 1.782.013-.02c.62-.96.946-2.293.946-3.91 0-.083 0-.165-.002-.247z" fill="url(#lobe-icons-meta-8-_R_0_)"></path><path d="M21.344 16.52l-.011.02c-.214.402-.519.67-.917.787l.778 2.462a3.493 3.493 0 00.438-.182 3.558 3.558 0 001.366-1.218l.044-.065.012-.02-1.71-1.784z" fill="url(#lobe-icons-meta-9-_R_0_)"></path><path d="M19.92 17.393c-.262 0-.492-.039-.718-.14l-.798 2.522c.449.153.927.222 1.46.222.492 0 .943-.073 1.352-.215l-.78-2.462c-.167.05-.341.075-.517.073z" fill="url(#lobe-icons-meta-10-_R_0_)"></path><path d="M18.323 16.534l-.014-.017-1.836 1.914.016.017c.637.682 1.246 1.105 1.937 1.337l.797-2.52c-.291-.125-.573-.353-.9-.731z" fill="url(#lobe-icons-meta-11-_R_0_)"></path><path d="M18.309 16.515c-.55-.642-1.232-1.712-2.303-3.44l-1.396-2.336-.011-.02-1.62 2.438.012.02.989 1.668c.959 1.61 1.74 2.774 2.493 3.585l.016.016 1.834-1.914a2.353 2.353 0 01-.014-.017z" fill="url(#lobe-icons-meta-12-_R_0_)"></path><defs><linearGradient id="lobe-icons-meta-0-_R_0_" x1="75.897%" x2="26.312%" y1="89.199%" y2="12.194%"><stop offset=".06%" stop-color="#0867DF"></stop><stop offset="45.39%" stop-color="#0668E1"></stop><stop offset="85.91%" stop-color="#0064E0"></stop></linearGradient><linearGradient id="lobe-icons-meta-1-_R_0_" x1="21.67%" x2="97.068%" y1="75.874%" y2="23.985%"><stop offset="13.23%" stop-color="#0064DF"></stop><stop offset="99.88%" stop-color="#0064E0"></stop></linearGradient><linearGradient id="lobe-icons-meta-2-_R_0_" x1="38.263%" x2="60.895%" y1="89.127%" y2="16.131%"><stop offset="1.47%" stop-color="#0072EC"></stop><stop offset="68.81%" stop-color="#0064DF"></stop></linearGradient><linearGradient id="lobe-icons-meta-3-_R_0_" x1="47.032%" x2="52.15%" y1="90.19%" y2="15.745%"><stop offset="7.31%" stop-color="#007CF6"></stop><stop offset="99.43%" stop-color="#0072EC"></stop></linearGradient><linearGradient id="lobe-icons-meta-4-_R_0_" x1="52.155%" x2="47.591%" y1="58.301%" y2="37.004%"><stop offset="7.31%" stop-color="#007FF9"></stop><stop offset="100%" stop-color="#007CF6"></stop></linearGradient><linearGradient id="lobe-icons-meta-5-_R_0_" x1="37.689%" x2="61.961%" y1="12.502%" y2="63.624%"><stop offset="7.31%" stop-color="#007FF9"></stop><stop offset="100%" stop-color="#0082FB"></stop></linearGradient><linearGradient id="lobe-icons-meta-6-_R_0_" x1="34.808%" x2="62.313%" y1="68.859%" y2="23.174%"><stop offset="27.99%" stop-color="#007FF8"></stop><stop offset="91.41%" stop-color="#0082FB"></stop></linearGradient><linearGradient id="lobe-icons-meta-7-_R_0_" x1="43.762%" x2="57.602%" y1="6.235%" y2="98.514%"><stop offset="0%" stop-color="#0082FB"></stop><stop offset="99.95%" stop-color="#0081FA"></stop></linearGradient><linearGradient id="lobe-icons-meta-8-_R_Line truncated

After

Width:  |  Height:  |  Size: 5.9 KiB

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Azure</title><path d="M11.49 2H2v9.492h9.492V2h-.002z" fill="#F25022"></path><path d="M22 2h-9.492v9.492H22V2z" fill="#7FBA00"></path><path d="M11.49 12.508H2V22h9.492v-9.492h-.002z" fill="#00A4EF"></path><path d="M22 12.508h-9.492V22H22v-9.492z" fill="#FFB900"></path></svg>

After

Width:  |  Height:  |  Size: 399 B

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Minimax</title><defs><linearGradient id="lobe-icons-minimax-_R_0_" x1="0%" x2="100.182%" y1="50.057%" y2="50.057%"><stop offset="0%" stop-color="#E2167E"></stop><stop offset="100%" stop-color="#FE603C"></stop></linearGradient></defs><path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z" fill="url(#lobe-icons-minimax-_R_0_)" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Mistral</title><path d="M3.428 3.4h3.429v3.428H3.428V3.4zm13.714 0h3.43v3.428h-3.43V3.4z" fill="gold"></path><path d="M3.428 6.828h6.857v3.429H3.429V6.828zm10.286 0h6.857v3.429h-6.857V6.828z" fill="#FFAF00"></path><path d="M3.428 10.258h17.144v3.428H3.428v-3.428z" fill="#FF8205"></path><path d="M3.428 13.686h3.429v3.428H3.428v-3.428zm6.858 0h3.429v3.428h-3.429v-3.428zm6.856 0h3.43v3.428h-3.43v-3.428z" fill="#FA500F"></path><path d="M0 17.114h10.286v3.429H0v-3.429zm13.714 0H24v3.429H13.714v-3.429z" fill="#E10500"></path></svg>

After

Width:  |  Height:  |  Size: 655 B

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>NousResearch</title><path d="M5.938 12.835c.127-.039.285.02.373.143.028.038.036.092.046.14.003.014-.02.033-.04.05-.124-.098-.24-.194-.354-.291-.011-.01-.016-.027-.025-.042zM8.396 9.412c.195-.032.39-.06.588-.05a.54.54 0 01.148.026c.202.071.402.147.601.224.028.01.05.036.075.055l-.013.027a9.203 9.203 0 01-.26-.089c-.115-.038-.213-.077-.315-.098-.25-.05-.25-.046-.292-.014l.574.144c.275.139.55.276.823.417.042.022.09.057.107.098.026.06.063.076.117.072.066-.006.132-.017.213-.027l-.04.086c.051.08.142.02.216.064-.074.13-.247.09-.334.199l.061.074-.12.087c0 .106-.038.168-.306.243l.026.085-.196.042.07.124h-.25l-.007.137c-.081-.01-.161-.018-.244-.027l-.053.123c-.027-.008-.052-.011-.073-.023-.067-.038-.128-.056-.195.006-.019.017-.063.014-.093.008-.026-.006-.05-.029-.07-.042-.11.095-.11.095-.208.003-.057.046-.12.074-.186.011-.063.027-.123-.02-.178-.014-.07.007-.097-.035-.133-.07l-.13.033c-.013-.236-.194-.19-.34-.203.005-.072.05-.092.095-.094a.474.474 0 01.159.022c.164.05.32.12.496.138.203.021.405.029.601-.015.265-.059.52-.149.707-.365.049-.056.083-.127.117-.195.019-.038.02-.084-.02-.116a1.397 1.397 0 00-.382-.217c.024.12-.031.182-.115.221 0 .014-.004.025 0 .03.08.115.084.16-.007.267a1.39 1.39 0 01-.218.211.477.477 0 01-.641-.05 1.36 1.36 0 01-.133-.152c-.078-.107-.076-.108-.033-.236-.165-.08-.128-.226-.104-.364.008-.05.028-.096.049-.163-.04.014-.067.017-.087.032a.897.897 0 00-.316.357c-.007.016-.01.034-.02.047-.012.015-.034.038-.045.035-.02-.006-.037-.027-.05-.045-.008-.012-.007-.032-.012-.057h-.126l.053-.172a14.82 14.82 0 00-.039-.049l.11-.284c-.06.026-.091.044-.124.051-.03.007-.064 0-.095 0 0-.031-.01-.07.004-.092.149-.22.305-.428.593-.476z"></path><path d="M8.06 10.788c-.003-.038-.004-.075.037-.062.016.006.034.048.028.067-.01.04-.038.032-.064-.005z"></path><path clip-rule="evenodd" d="M11.981.009c.226-.012.453-.011.679 0 .247.01.495.024.74.062.401.064.798.157 1.19.273.463.138.92.299 1.356.511a7.31 7.31 0 012.948 2.642c.292.469.536.963.739 1.479.219.556.446 1.11.623 1.683.204.654.329 1.326.458 1.997.097.504.182 1.01.29 1.511.156.722.329 1.44.494 2.16.186.812.4 1.615.63 2.415.102.355.193.713.282 1.072.11.436.202.876.254 1.323.031.278.066.557.073.837a7.56 7.56 0 01-.017.88c-.037.413-.1.818-.226 1.212a5.017 5.017 0 01-.915 1.649l-.13.156.018.023c.043-.023.088-.041.127-.068.2-.138.373-.307.531-.49.4-.46.721-.973.975-1.529a3.59 3.59 0 00.325-1.72c-.024-.424-.097-.834-.3-1.213-.013-.027-.015-.06-.03-.121.05.035.082.048.101.072.107.13.22.258.315.398.33.494.46 1.052.486 1.64a3.75 3.75 0 01-.47 1.97c-.36.655-.887 1.14-1.526 1.506-.193.111-.394.21-.595.308-.157.078-.248.211-.318.365a.522.522 0 00-.033.406.359.359 0 01.013.139c-.005.077-.077.155-.14.162-.054.006-.125-.043-.15-.116a1.206 1.206 0 01-.06-.233c-.04-.314-.155-.6-.308-.87a3.906 3.906 0 00-.73-.91 2.129 2.129 0 00-.897-.524 4.093 4.093 0 00-.692-.131c-.075-.008-.15-.04-.22.01.18.06.363.11.538.18.434.173.82.43 1.18.728.308.255.58.543.794.884.098.155.186.315.227.496.027.123.042.25.067.375.013.062-.002.109-.053.144-.047.033-.122.034-.163-.01a.455.455 0 01-.08-.14c-.03-.073-.038-.159-.078-.225a7.314 7.314 0 00-1.423-1.664c-.16-.137-.329-.26-.537-.323-.376-.114-.753-.203-1.15-.154-.213.025-.427.032-.64.053a1.6 1.6 0 00-.736.278 5.14 5.14 0 00-.834.72c-.329.342-.642.699-.955 1.055-.136.155-.264.319-.314.531a5.227 5.227 0 00-.012.051.096.096 0 01-.09.076h-.31c-.046 0-.082-.048-.072-.094.023-.108.045-.216.07-.324.075-.325.19-.635.368-.917.024-.039.04-.088.104-.08l.01.049.027.077c.28-.435.571-.834.996-1.135.283-.204.584-.378.89-.55a.196.196 0 00-.098-.002c-.162.043-.325.084-.485.134-.402.124-.764.33-1.11.566-.147.1-.298.193-.414.333a7.314 7.314 0 00-1.07 1.767.845.845 0 00-.04.12.075.075 0 01-.072.056h-.494c-.04 0-.062-.051-.036-.082.123-.14.246-.282.377-.415.275-.281.58-.532.777-.884.027-.048.063-.09.095-.135.238-.333.54-.607.818-.902.082-.086.175-.16.26-.24.029-.027.053-.057.079-.085l-.018-.025-.135.041c-.034.017-.07.031-.102.05-.248.144-.494.292-.743.433-.408.23-.825.439-1.209.711-.281.2-.591.358-.889.533-.02.012-.044.015-.08.028-.015-.135.143-.201.108-.336-.033.014-.064.02-.085.038-.111.096-.227.19-.328.296-.148.157-.284.325-.425.488-.125.143-.25.286-.373.431A.153.153 0 019.89 24H8.762a.316.316 0 00.016-.042c.028-.09.085-.172.083-.28-.091-.018-.162.001-.212.077a4.45 4.45 0 00-.136.215c-.01.016-.024.03-.042.03h-.093c-.019 0-.029-.022-.017-.037.071-.088.14-.178.209-.268.001-.002-.006-.012-.012-.024-.014.004-.03.006-.045.013-.176.09-.352.181-.527.274a.363.363 0 01-.168.042H5.202c-.026 0-.039-.036-.019-.053.21-.178.402-.374.558-.605.335-.496.538-1.047.667-1.629.004-.02-.003-.043-.006-.091-.037.048-.059.072-.076.1a1.943 1.943 0 01-.334.415c-.28.258-.59.448-.983.464-.297.012-.588 0-.865-.127-.46-.21-.722-.57-.794-1.072-.025-.17-.017-.171-.182-.219A3.513 3.513 0 011.97 20.6a2.286 2.286 0 01-.808-1.13 3.569 3.569 0 01-.16Line truncated

After

Width:  |  Height:  |  Size: 19 KiB

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Nvidia</title><path d="M10.212 8.976V7.62c.127-.01.256-.017.388-.021 3.596-.117 5.957 3.184 5.957 3.184s-2.548 3.647-5.282 3.647a3.227 3.227 0 01-1.063-.175v-4.109c1.4.174 1.681.812 2.523 2.258l1.873-1.627a4.905 4.905 0 00-3.67-1.846 6.594 6.594 0 00-.729.044m0-4.476v2.025c.13-.01.259-.019.388-.024 5.002-.174 8.261 4.226 8.261 4.226s-3.743 4.69-7.643 4.69c-.338 0-.675-.031-1.007-.092v1.25c.278.038.558.057.838.057 3.629 0 6.253-1.91 8.794-4.169.421.347 2.146 1.193 2.501 1.564-2.416 2.083-8.048 3.763-11.24 3.763-.308 0-.603-.02-.894-.048V19.5H24v-15H10.21zm0 9.756v1.068c-3.356-.616-4.287-4.21-4.287-4.21a7.173 7.173 0 014.287-2.138v1.172h-.005a3.182 3.182 0 00-2.502 1.178s.615 2.276 2.507 2.931m-5.961-3.3c1.436-1.935 3.604-3.148 5.961-3.336V6.523C5.81 6.887 2 10.723 2 10.723s2.158 6.427 8.21 7.015v-1.166C5.77 16 4.25 10.958 4.25 10.958h-.002z" fill="#74B71B" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Ollama</title><path d="M7.905 1.09c.216.085.411.225.588.41.295.306.544.744.734 1.263.191.522.315 1.1.362 1.68a5.054 5.054 0 012.049-.636l.051-.004c.87-.07 1.73.087 2.48.474.101.053.2.11.297.17.05-.569.172-1.134.36-1.644.19-.52.439-.957.733-1.264a1.67 1.67 0 01.589-.41c.257-.1.53-.118.796-.042.401.114.745.368 1.016.737.248.337.434.769.561 1.287.23.934.27 2.163.115 3.645l.053.04.026.019c.757.576 1.284 1.397 1.563 2.35.435 1.487.216 3.155-.534 4.088l-.018.021.002.003c.417.762.67 1.567.724 2.4l.002.03c.064 1.065-.2 2.137-.814 3.19l-.007.01.01.024c.472 1.157.62 2.322.438 3.486l-.006.039a.651.651 0 01-.747.536.648.648 0 01-.54-.742c.167-1.033.01-2.069-.48-3.123a.643.643 0 01.04-.617l.004-.006c.604-.924.854-1.83.8-2.72-.046-.779-.325-1.544-.8-2.273a.644.644 0 01.18-.886l.009-.006c.243-.159.467-.565.58-1.12a4.229 4.229 0 00-.095-1.974c-.205-.7-.58-1.284-1.105-1.683-.595-.454-1.383-.673-2.38-.61a.653.653 0 01-.632-.371c-.314-.665-.772-1.141-1.343-1.436a3.288 3.288 0 00-1.772-.332c-1.245.099-2.343.801-2.67 1.686a.652.652 0 01-.61.425c-1.067.002-1.893.252-2.497.703-.522.39-.878.935-1.066 1.588a4.07 4.07 0 00-.068 1.886c.112.558.331 1.02.582 1.269l.008.007c.212.207.257.53.109.785-.36.622-.629 1.549-.673 2.44-.05 1.018.186 1.902.719 2.536l.016.019a.643.643 0 01.095.69c-.576 1.236-.753 2.252-.562 3.052a.652.652 0 01-1.269.298c-.243-1.018-.078-2.184.473-3.498l.014-.035-.008-.012a4.339 4.339 0 01-.598-1.309l-.005-.019a5.764 5.764 0 01-.177-1.785c.044-.91.278-1.842.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.545l-.005-.024a5.352 5.352 0 01.093-2.49c.262-.915.777-1.701 1.536-2.269.06-.045.123-.09.186-.132-.159-1.493-.119-2.73.112-3.67.127-.518.314-.95.562-1.287.27-.368.614-.622 1.015-.737.266-.076.54-.059.797.042zm4.116 9.09c.936 0 1.8.313 2.446.855.63.527 1.005 1.235 1.005 1.94 0 .888-.406 1.58-1.133 2.022-.62.375-1.451.557-2.403.557-1.009 0-1.871-.259-2.493-.734-.617-.47-.963-1.13-.963-1.845 0-.707.398-1.417 1.056-1.946.668-.537 1.55-.849 2.485-.849zm0 .896a3.07 3.07 0 00-1.916.65c-.461.37-.722.835-.722 1.25 0 .428.21.829.61 1.134.455.347 1.124.548 1.943.548.799 0 1.473-.147 1.932-.426.463-.28.7-.686.7-1.257 0-.423-.246-.89-.683-1.256-.484-.405-1.14-.643-1.864-.643zm.662 1.21l.004.004c.12.151.095.37-.056.49l-.292.23v.446a.375.375 0 01-.376.373.375.375 0 01-.376-.373v-.46l-.271-.218a.347.347 0 01-.052-.49.353.353 0 01.494-.051l.215.172.22-.174a.353.353 0 01.49.051zm-5.04-1.919c.478 0 .867.39.867.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zm8.706 0c.48 0 .868.39.868.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zM7.44 2.3l-.003.002a.659.659 0 00-.285.238l-.005.006c-.138.189-.258.467-.348.832-.17.692-.216 1.631-.124 2.782.43-.128.899-.208 1.404-.237l.01-.001.019-.034c.046-.082.095-.161.148-.239.123-.771.022-1.692-.253-2.444-.134-.364-.297-.65-.453-.813a.628.628 0 00-.107-.09L7.44 2.3zm9.174.04l-.002.001a.628.628 0 00-.107.09c-.156.163-.32.45-.453.814-.29.794-.387 1.776-.23 2.572l.058.097.008.014h.03a5.184 5.184 0 011.466.212c.086-1.124.038-2.043-.128-2.722-.09-.365-.21-.643-.349-.832l-.004-.006a.659.659 0 00-.285-.239h-.004z"></path></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenRouter</title><path d="M18.654 3.87a5.087 5.087 0 110 10.174L23.7 19.09c.64.641.187 1.737-.72 1.737H8.48a8.479 8.479 0 010-16.958h10.175zM8.479 7.26a5.087 5.087 0 100 10.176 5.087 5.087 0 000-10.175z" fill="#C8FF00"></path></svg>

After

Width:  |  Height:  |  Size: 357 B

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Perplexity</title><path d="M19.785 0v7.272H22.5V17.62h-2.935V24l-7.037-6.194v6.145h-1.091v-6.152L4.392 24v-6.465H1.5V7.188h2.884V0l7.053 6.494V.19h1.09v6.49L19.786 0zm-7.257 9.044v7.319l5.946 5.234V14.44l-5.946-5.397zm-1.099-.08l-5.946 5.398v7.235l5.946-5.234V8.965zm8.136 7.58h1.844V8.349H13.46l6.105 5.54v2.655zm-8.982-8.28H2.59v8.195h1.8v-2.576l6.192-5.62zM5.475 2.476v4.71h5.115l-5.115-4.71zm13.219 0l-5.115 4.71h5.115v-4.71z" fill="#22B8CD" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 603 B

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qwen</title><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z" fill="url(#lobe-icons-qwen-_R_0_)" fill-rule="nonzero"></path><defs><linearGradient id="lobe-icons-qwen-_R_0_" x1="0%" x2="100%" y1="0%" y2="0%"><stop offset="0%" stop-color="#6336E7" stop-opacity=".84"></stop><stop offset="100%" stop-color="#6F69F7" stop-opacity=".84"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Stepfun</title><path d="M22.012 0h1.032v.927H24v.968h-.956V3.78h-1.032V1.896h-1.878v-.97h1.878V0zM2.6 12.371V1.87h.969v10.502h-.97zm10.423.66h10.95v.918h-6.208v9.579h-4.742V13.03zM5.629 3.333v12.356H0v4.51h10.386V8L20.859 8l-.003-4.668-15.227.001z" fill="url(#lobe-icons-stepfun-_R_0_)" fill-rule="evenodd"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-stepfun-_R_0_" x1="1.646" x2="18.342" y1="1.916" y2="22.091"><stop stop-color="#01A9FF"></stop><stop offset="1" stop-color="#0160FF"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 676 B

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Technology Innovation Institute</title><path d="M11.098 7.563H4.75V9.77h1.77v6.668h2.807V9.77h1.77V7.563zM14.812 7.563h-2.807v8.875h2.807V7.563zM19.433 7.563h-2.807v8.875h2.807V7.563z" fill="#6400FF"></path><path d="M12 24v-1.055c3.01 0 5.613-1.079 7.737-3.208 2.123-2.13 3.209-4.727 3.209-7.737 0-3.009-1.08-5.607-3.209-7.736-2.128-2.13-4.732-3.21-7.736-3.21V0c3.294 0 6.148 1.184 8.482 3.518C22.817 5.852 24 8.706 24 12c0 3.295-1.183 6.155-3.518 8.483C18.147 22.811 15.3 24 12.001 24z" fill="#6400FF"></path></svg>

After

Width:  |  Height:  |  Size: 640 B

@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>together.ai</title><path d="M23.197 4.503A6 6 0 0015 2.307a5.973 5.973 0 00-2.995 4.933l5.996.008v.515h-5.996c.039.937.298 1.87.8 2.74a6 6 0 1010.39-6z" fill="#EF2CC1"></path><path d="M.805 4.5A6 6 0 003 12.697a5.972 5.972 0 005.77.127L5.779 7.627l.446-.257 2.997 5.192A6 6 0 10.804 4.5z" fill="#CAAEF5"></path><path d="M12 23.894a6 6 0 005.999-6c0-2.13-1.1-3.996-2.775-5.06l-3.005 5.189-.444-.258 2.997-5.192A6 6 0 1012 23.894z" fill="#FC4C02"></path></svg>

After

Width:  |  Height:  |  Size: 582 B

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Upsate</title><path d="M19.763 0l-.373 1.297h2.594L22.354 0h-2.591zM16.192 2.27l-.376 1.298h5.52l.37-1.298h-5.514zM12.897 4.54l-.376 1.298h8.166l.37-1.298h-8.16zM2.85 6.81l-.377 1.298h17.565l.37-1.297H2.848zM3.884 9.081l-.376 1.297H19.39l.37-1.297H3.882zM4.088 24l.376-1.297H1.866L1.5 24h2.588zM7.662 21.73l.376-1.297H2.515L2.15 21.73h5.513zM10.957 19.459l.376-1.297h-8.17l-.366 1.297h8.16zM21.005 17.189l.376-1.297H3.812l-.366 1.297h17.559zM19.967 14.919l.376-1.297H4.461l-.366 1.297h15.872zM18.786 12.649l.376-1.297H4.26l-.366 1.297h14.893z" fill="url(#lobe-icons-upsate-_R_0_)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-upsate-_R_0_" x1="11.927" x2="11.927" y2="24"><stop offset="0" stop-color="#AEBCFE"></stop><stop offset="1" stop-color="#805DFA"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 946 B

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>vLLM</title><path d="M0 4.973h9.324V23L0 4.973z" fill="#FDB515"></path><path d="M13.986 4.351L22.378 0l-6.216 23H9.324l4.662-18.649z" fill="#30A2FF"></path></svg>

After

Width:  |  Height:  |  Size: 286 B

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Grok</title><path d="M6.469 8.776L16.512 23h-4.464L2.005 8.776H6.47zm-.004 7.9l2.233 3.164L6.467 23H2l4.465-6.324zM22 2.582V23h-3.659V7.764L22 2.582zM22 1l-9.952 14.095-2.233-3.163L17.533 1H22z"></path></svg>

After

Width:  |  Height:  |  Size: 372 B

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>XiaomiMiMo</title><path d="M.958 15.936a.459.459 0 01.459.44v2.729a.46.46 0 01-.918 0v-2.729a.459.459 0 01.459-.44zm4.814-2.035a.46.46 0 01.553.45v4.754a.458.458 0 11-.918 0V15.48L3.74 17.202a.462.462 0 01-.655.016.462.462 0 01-.065-.082L.628 14.67a.459.459 0 01.658-.637l2.124 2.187 2.127-2.188a.46.46 0 01.235-.13zm2.068.004a.46.46 0 01.458.445v4.755a.46.46 0 01-.458.458.459.459 0 01-.458-.458V14.35a.459.459 0 01.458-.445zm1.973 2.014a.46.46 0 01.46.457v2.729a.46.46 0 01-.784.324.46.46 0 01-.134-.324v-2.729a.46.46 0 01.458-.458zm.002-2.045a.458.458 0 01.328.157l2.127 2.19 2.125-2.19a.459.459 0 01.784.318v4.756a.46.46 0 01-.455.458.46.46 0 01-.458-.458V15.48l-1.667 1.723a.46.46 0 01-.65.008l-.005-.005c0-.002-.002-.002-.004-.003l-2.455-2.534a.46.46 0 01-.008-.667.461.461 0 01.338-.128zm6.797 1.206a.46.46 0 01.53.651A1.966 1.966 0 0019.81 18.4a.462.462 0 01.623.18.46.46 0 01-.181.624 2.863 2.863 0 01-1.38.353l-.142-.004a2.88 2.88 0 01-2.393-4.263.461.461 0 01.274-.21zm.864-.931a2.884 2.884 0 013.915 3.914.46.46 0 01-.402.24l-.057-.004a.458.458 0 01-.164-.055.46.46 0 01-.182-.622 1.967 1.967 0 00-2.669-2.67.459.459 0 11-.441-.803zM9.59 6.368c1.481 0 1.696 1.202 1.696 1.654v2.648h-.917v-.432c-.26.346-.792.535-1.36.535-.133 0-1.289-.03-1.384-1.136-.082-.932.675-1.61 2.053-1.61h.691c0-.563-.367-.886-.983-.886-.44.013-.864.174-1.2.458l-.36-.664c.484-.379 1.012-.567 1.764-.567zm4.427.1c1.263 0 2.082.97 2.083 2.15 0 1.181-.824 2.154-2.083 2.154-1.26 0-2.084-.972-2.084-2.152 0-1.18.82-2.153 2.084-2.153zm6.801.015c.68 0 1.202.465 1.197 1.548v2.642H21.1V8.29c0-.312-.002-.98-.63-.98s-.628.667-.628.838v2.524h-.89V8.148c0-.17-.001-.838-.63-.838-.628 0-.628.668-.628.98v2.383h-.917v-4.03h.917V7a1.22 1.22 0 01.947-.516c.398 0 .76.193.982.686a1.321 1.321 0 011.195-.686zm-18.093.872l1.457-1.772H5.32L3.311 8.07l2.14 2.602H4.24L2.725 8.796 1.21 10.672H0L2.138 8.07.13 5.583h1.138l1.458 1.772zm4.149 3.317h-.916V6.644h.916v4.028zm16.99 0h-.916V6.644h.916v4.028zM9.925 8.71c-1.055 0-1.359.412-1.326.742.032.329.324.537.757.537a1.013 1.013 0 001.014-.968l.002-.31h-.447zM14.018 7.3c-.663 0-1.184.487-1.184 1.32 0 .832.52 1.32 1.184 1.32.662 0 1.182-.49 1.182-1.32 0-.832-.52-1.32-1.182-1.32zM6.417 5.001a.568.568 0 01.587.582.588.588 0 01-1.175 0A.57.57 0 016.417 5zm16.991 0a.57.57 0 01.592.582.588.588 0 01-1.174 0 .57.57 0 01.357-.542.572.572 0 01.225-.04z"></path></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Yi</title><path d="M18.62 13.927c.611 0 1.107.505 1.107 1.128v5.817c0 .623-.496 1.128-1.108 1.128a1.118 1.118 0 01-1.108-1.128v-5.817c0-.623.496-1.128 1.108-1.128zM16.59 3.052a1.094 1.094 0 011.562-.129c.466.404.522 1.116.126 1.59l-5.938 7.111v9.147c0 .624-.496 1.129-1.108 1.129a1.118 1.118 0 01-1.108-1.129v-9.477l.003-.088.01-.087c.015-.232.102-.462.261-.654l6.192-7.413zM2.906 2.256a1.094 1.094 0 011.559.157l4.387 5.45a1.142 1.142 0 01-.155 1.587 1.094 1.094 0 01-1.559-.157l-4.387-5.45a1.144 1.144 0 01.06-1.498l.095-.09z"></path><ellipse cx="20.146" cy="10.692" fill="#00FF25" rx="1.354" ry="1.379"></ellipse></svg>

After

Width:  |  Height:  |  Size: 786 B

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Zhipu</title><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z" fill="#3859FF" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -17,6 +17,8 @@ export function AppGrid({ children }: { children: React.ReactNode }) {
type TileProps = {
name: string;
/** A smaller line under the name, e.g. to tell two tiles with the same name apart. */
subtitle?: string;
logo: React.ReactNode;
/** Coloured when on, grey when off. */
on: boolean;
@@ -29,11 +31,14 @@ type TileProps = {
onContextMenu: (event: React.MouseEvent<HTMLElement>) => void;
/** Double-click or Enter. */
onActivate: () => void;
/** Each click, double-clicks included; `event.detail` says which click it is. */
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
};
/** An app icon with its name under it. */
export function AppTile({
name,
subtitle,
logo,
on,
badge,
@@ -42,11 +47,13 @@ export function AppTile({
ariaLabel,
onContextMenu,
onActivate,
onClick,
}: TileProps) {
return (
<Tooltip title={tooltip ?? ""} placement="bottom">
<ButtonBase
onContextMenu={onContextMenu}
onClick={onClick}
onDoubleClick={onActivate}
onKeyDown={(event) => {
if (event.key === "Enter") onActivate();
@@ -102,6 +109,23 @@ export function AppTile({
>
{name}
</Typography>
{subtitle && (
<Typography
variant="caption"
color="text.secondary"
sx={{
fontSize: "0.62rem",
lineHeight: 1.2,
mt: -0.5,
maxWidth: "100%",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{subtitle}
</Typography>
)}
</ButtonBase>
</Tooltip>
);
+26 -8
View File
@@ -610,8 +610,24 @@
"ai": {
"heading": "AI",
"connection": {
"heading": "Connect AI",
"subtitle": "An OpenAI-compatible service that Thingport's AI features use.",
"heading": "AIs",
"subtitle": "OpenAI-compatible services Thingport's AI features use. A feature uses the default AI unless it's set to another.",
"gridHint": "Click an AI to make it the default. Double-click to edit or test it, or right-click for more.",
"add": "Add AI",
"addTitle": "Add AI",
"editTitle": "Edit AI",
"edit": "Edit",
"makeDefault": "Make default",
"delete": "Delete",
"deleteTitle": "Delete {{model}}?",
"deleteMessage": "Features set to this AI will use the default one instead.",
"deleteDefaultMessage": "This is the default AI. The next one becomes the default, and features set to this AI will use it.",
"defaultTooltip": "Default AI · {{host}}",
"tileTooltip": "{{host}} · Click to make it the default",
"defaultAria": "{{model}}, {{host}}, default AI",
"defaultStatus": "Default · {{host}}",
"emptyTitle": "No AI yet",
"emptyBody": "Add your first AI, such as a model on your own Ollama server or a cloud API, to start using AI features like categorization.",
"baseUrl": "API base URL",
"baseUrlHint": "e.g. http://localhost:11434 for Ollama. /v1 is added when the URL has no path.",
"model": "Model name",
@@ -622,23 +638,27 @@
"keySet": "A key is saved. Type a new one to replace it.",
"clearKey": "Clear API key",
"keyWillClear": "The key will be removed when you save",
"keyOptional": "Optional. Local servers such as Ollama usually don't need one.",
"concurrency": "Concurrent requests (1-4)",
"timeout": "Request timeout (ms)",
"writeOnly": "The saved API key is never shown. Leave the field blank to keep it.",
"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.",
"test": "Test connection",
"testing": "Testing…",
"testSuccess": "Connected to {{model}} in {{latency}} ms.",
"testFailed": "Connection test failed.",
"save": "Save connection",
"save": "Save",
"saving": "Checking connection…",
"saved": "AI connection saved.",
"failed": "Failed to load or save the AI connection."
"failed": "Failed to load or save the AI."
}
},
"aiCategorization": {
"heading": "AI model categorization",
"subtitle": "Sort models into their owner's categories with the connected AI.",
"privacy": "With a third-party API, model titles, descriptions and tags leave this server. Images are sent only when enabled.",
"useAi": "Use AI",
"defaultAi": "Default AI ({{model}})",
"noAi": "Add an AI above to use categorization.",
"mode": "Mode",
"off": "Off",
"suggest": "Suggest for review",
@@ -646,8 +666,6 @@
"threshold": "Auto-apply confidence threshold",
"onImport": "Categorize new imports",
"sendImage": "Send the first preview image",
"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.",
"save": "Save settings",
"saved": "AI categorization settings saved.",
"failed": "Failed to load or save AI categorization settings."
+26 -8
View File
@@ -612,8 +612,24 @@
"ai": {
"heading": "DI",
"connection": {
"heading": "DI prijungimas",
"subtitle": "Su OpenAI suderinama paslauga, kurią naudoja Thingport DI funkcijos.",
"heading": "DI",
"subtitle": "Su OpenAI suderinamos paslaugos, kurias naudoja Thingport DI funkcijos. Funkcija naudoja numatytąjį DI, nebent nustatytas kitas.",
"gridHint": "Spustelėkite DI, kad jis taptų numatytuoju. Dukart spustelėkite, kad redaguotumėte ar patikrintumėte, arba spustelėkite dešiniuoju mygtuku, kad pamatytumėte daugiau.",
"add": "Pridėti DI",
"addTitle": "Pridėti DI",
"editTitle": "Redaguoti DI",
"edit": "Redaguoti",
"makeDefault": "Padaryti numatytuoju",
"delete": "Ištrinti",
"deleteTitle": "Ištrinti {{model}}?",
"deleteMessage": "Funkcijos, nustatytos naudoti šį DI, naudos numatytąjį.",
"deleteDefaultMessage": "Tai numatytasis DI. Numatytuoju taps kitas, o funkcijos, nustatytos naudoti šį DI, naudos jį.",
"defaultTooltip": "Numatytasis DI · {{host}}",
"tileTooltip": "{{host}} · Spustelėkite, kad taptų numatytuoju",
"defaultAria": "{{model}}, {{host}}, numatytasis DI",
"defaultStatus": "Numatytasis · {{host}}",
"emptyTitle": "DI dar nėra",
"emptyBody": "Pridėkite pirmąjį DI, pavyzdžiui, modelį savo Ollama serveryje arba debesų API, kad galėtumėte naudoti DI funkcijas, tokias kaip kategorizavimas.",
"baseUrl": "API bazinis URL",
"baseUrlHint": "pvz., http://localhost:11434 Ollama atveju. Jei URL neturi kelio, pridedamas /v1.",
"model": "Modelio pavadinimas",
@@ -626,23 +642,27 @@
"keySet": "Raktas išsaugotas. Įveskite naują, kad jį pakeistumėte.",
"clearKey": "Pašalinti API raktą",
"keyWillClear": "Raktas bus pašalintas išsaugojus",
"keyOptional": "Neprivaloma. Vietiniams serveriams, tokiems kaip Ollama, jo paprastai nereikia.",
"concurrency": "Lygiagrečios užklausos (1-4)",
"timeout": "Užklausos skirtasis laikas (ms)",
"writeOnly": "Išsaugotas API raktas niekada nerodomas. Palikite lauką tuščią, kad jį išsaugotumėte.",
"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.",
"test": "Tikrinti ryšį",
"testing": "Tikrinama…",
"testSuccess": "Prisijungta prie {{model}} per {{latency}} ms.",
"testFailed": "Ryšio patikra nepavyko.",
"save": "Išsaugoti prijungimą",
"save": "Išsaugoti",
"saving": "Tikrinamas ryšys…",
"saved": "DI prijungimas išsaugotas.",
"failed": "Nepavyko įkelti arba išsaugoti DI prijungimo."
"failed": "Nepavyko įkelti arba išsaugoti DI."
}
},
"aiCategorization": {
"heading": "DI modelių kategorizavimas",
"subtitle": "Rūšiuokite modelius į jų savininko kategorijas naudodami prijungtą DI.",
"privacy": "Naudojant trečiosios šalies API, modelių pavadinimai, aprašymai ir žymos perduodami už šio serverio ribų. Paveikslėliai siunčiami tik įjungus šią parinktį.",
"useAi": "Naudoti DI",
"defaultAi": "Numatytasis DI ({{model}})",
"noAi": "Pridėkite DI aukščiau, kad galėtumėte naudoti kategorizavimą.",
"mode": "Režimas",
"off": "Išjungta",
"suggest": "Siūlyti peržiūrai",
@@ -650,8 +670,6 @@
"threshold": "Automatinio taikymo patikimumo riba",
"onImport": "Kategorizuoti naujus importus",
"sendImage": "Siųsti pirmą peržiūros paveikslėlį",
"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.",
"save": "Išsaugoti nustatymus",
"saved": "DI kategorizavimo nustatymai išsaugoti.",
"failed": "Nepavyko įkelti arba išsaugoti DI kategorizavimo nustatymų."
@@ -1,7 +1,6 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import CircularProgress from "@mui/material/CircularProgress";
import FormControl from "@mui/material/FormControl";
@@ -13,24 +12,25 @@ import Select from "@mui/material/Select";
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 { settingsApi, type AiCategorizationSettings } from "../../api/settings";
import type { AiConnection } from "../../api/aiConnections";
import { hostOf } from "./aiLogos";
import SectionHeader from "../../components/SectionHeader";
type Props = { onUnauthorized?: () => void };
type Draft = Pick<AiCategorizationSettings, "mode" | "threshold" | "on_import" | "send_image" | "thinking">;
const defaults: Draft = { mode: "off", threshold: 0.8, on_import: true, send_image: false, thinking: true };
type Props = { connections: AiConnection[]; onUnauthorized?: () => void };
type Draft = Pick<AiCategorizationSettings, "mode" | "threshold" | "on_import" | "send_image" | "ai_connection_id">;
const defaults: Draft = { mode: "off", threshold: 0.8, on_import: true, send_image: false, ai_connection_id: null };
const pick = (settings: AiCategorizationSettings): Draft => ({
mode: settings.mode,
threshold: settings.threshold,
on_import: settings.on_import,
send_image: settings.send_image,
thinking: settings.thinking,
ai_connection_id: settings.ai_connection_id,
});
export default function AiCategorizationSection({ onUnauthorized }: Props) {
export default function AiCategorizationSection({ connections, onUnauthorized }: Props) {
const { t } = useTranslation("app");
const [draft, setDraft] = useState<Draft>(defaults);
const [loading, setLoading] = useState(true);
@@ -85,6 +85,34 @@ export default function AiCategorizationSection({ onUnauthorized }: Props) {
<Paper variant="outlined" sx={{ p: 2.5 }}>
<Stack spacing={2}>
<Alert severity="info">{t("adminSettings.aiCategorization.privacy")}</Alert>
{!connections.length && <Alert severity="warning">{t("adminSettings.aiCategorization.noAi")}</Alert>}
<FormControl fullWidth>
<InputLabel id="ai-connection-label">{t("adminSettings.aiCategorization.useAi")}</InputLabel>
<Select
labelId="ai-connection-label"
label={t("adminSettings.aiCategorization.useAi")}
// A saved choice that's gone (deleted meanwhile) reads as the default, as the backend treats it.
value={
draft.ai_connection_id && connections.some((connection) => connection.id === draft.ai_connection_id)
? draft.ai_connection_id
: ""
}
displayEmpty
onChange={(event) => update("ai_connection_id", event.target.value || null)}
disabled={loading || saving || !connections.length}
>
<MenuItem value="">
{t("adminSettings.aiCategorization.defaultAi", {
model: connections.find((connection) => connection.is_default)?.model ?? "—",
})}
</MenuItem>
{connections.map((connection) => (
<MenuItem key={connection.id} value={connection.id}>
{connection.model} · {hostOf(connection.base_url)}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel id="ai-mode-label">{t("adminSettings.aiCategorization.mode")}</InputLabel>
<Select
@@ -127,23 +155,6 @@ export default function AiCategorizationSection({ onUnauthorized }: Props) {
}
label={t("adminSettings.aiCategorization.sendImage")}
/>
<FormControlLabel
control={
<Switch
checked={draft.thinking}
onChange={(event) => update("thinking", event.target.checked)}
disabled={loading || saving}
/>
}
label={
<Box>
<Typography variant="body2">{t("adminSettings.aiCategorization.thinking")}</Typography>
<Typography variant="caption" color="text.secondary">
{t("adminSettings.aiCategorization.thinkingHint")}
</Typography>
</Box>
}
/>
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap" useFlexGap>
<Button variant="contained" onClick={() => void save()} disabled={loading || saving}>
{t("adminSettings.aiCategorization.save")}
@@ -0,0 +1,337 @@
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 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 { useConfirm } from "../../components/ConfirmProvider";
import { AiLogo } from "./aiLogos";
type Props = {
/** The AI to edit, or null to add one. */
connection: AiConnection | null;
onClose: () => void;
/** After a save or delete; the list reloads. */
onChanged: () => void;
onUnauthorized?: () => void;
};
type Draft = Omit<AiConnectionInput, "api_key"> & { api_key: string };
const draftOf = (connection: AiConnection | null): Draft => ({
base_url: connection?.base_url ?? "",
model: connection?.model ?? "",
api_key: "",
timeout_ms: connection?.timeout_ms ?? 60000,
concurrency: connection?.concurrency ?? 1,
thinking: connection?.thinking ?? true,
});
/** Adds or edits one AI. Saving tests it first, as the backend only keeps an AI that answers. */
export default function AiConnectionDialog({ connection, onClose, onChanged, onUnauthorized }: Props) {
const { t } = useTranslation(["app", "common"]);
const confirm = useConfirm();
const [draft, setDraft] = useState<Draft>(() => draftOf(connection));
const [clearKey, setClearKey] = useState(false);
const [busy, setBusy] = useState<"save" | "test" | "delete" | null>(null);
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [models, setModels] = useState<string[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
const [modelsError, setModelsError] = useState<string | null>(null);
const hasApiKey = Boolean(connection?.has_api_key);
const update = <K extends keyof Draft>(key: K, value: Draft[K]) =>
setDraft((current) => ({ ...current, [key]: value }));
// A blank key field keeps the saved key, unless it is about to be cleared.
const keyPayload = clearKey ? { api_key: null } : draft.api_key ? { api_key: draft.api_key } : {};
const fail = (err: unknown, fallback: string) => {
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setError(err instanceof Error ? err.message : fallback);
};
// The provider's own list, refetched a moment after the URL or key stops changing; the field still takes any name.
useEffect(() => {
const baseUrl = draft.base_url.trim();
if (!baseUrl) {
setModels([]);
setModelsError(null);
return;
}
let active = true;
const timer = window.setTimeout(() => {
setModelsLoading(true);
void aiConnectionsApi
.models({
...(connection ? { id: connection.id } : {}),
base_url: baseUrl,
...(clearKey ? { api_key: null } : draft.api_key ? { api_key: draft.api_key } : {}),
})
.then((result) => {
if (!active) return;
setModels(result.ok ? result.models : []);
setModelsError(result.ok ? null : result.error);
})
.catch((err: unknown) => {
if (err instanceof UnauthorizedError) onUnauthorized?.();
else if (active) setModelsError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (active) setModelsLoading(false);
});
}, 600);
return () => {
active = false;
window.clearTimeout(timer);
};
}, [connection, draft.base_url, draft.api_key, clearKey, onUnauthorized]);
const save = async () => {
setBusy("save");
setStatus(null);
setError(null);
const fields = {
base_url: draft.base_url.trim(),
model: draft.model.trim(),
timeout_ms: Number(draft.timeout_ms),
concurrency: Number(draft.concurrency),
thinking: draft.thinking,
};
try {
if (connection) {
// Only what changed: the backend tests the AI again when where or how it connects changes.
const before = draftOf(connection);
const changed = Object.fromEntries(
Object.entries(fields).filter(([key, value]) => before[key as keyof Draft] !== value),
);
await aiConnectionsApi.update(connection.id, { ...changed, ...keyPayload });
} else {
await aiConnectionsApi.create({ ...fields, ...keyPayload });
}
onChanged();
onClose();
} catch (err) {
fail(err, t("adminSettings.ai.connection.failed"));
} finally {
setBusy(null);
}
};
const test = async () => {
setBusy("test");
setStatus(null);
setError(null);
try {
const result = await aiConnectionsApi.test({
...(connection ? { id: connection.id } : {}),
base_url: draft.base_url.trim(),
model: draft.model.trim(),
timeout_ms: Number(draft.timeout_ms),
thinking: draft.thinking,
...keyPayload,
});
if (result.ok)
setStatus(t("adminSettings.ai.connection.testSuccess", { model: result.model, latency: result.latency_ms }));
else setError(result.error || t("adminSettings.ai.connection.testFailed"));
} catch (err) {
fail(err, t("adminSettings.ai.connection.testFailed"));
} finally {
setBusy(null);
}
};
const remove = async () => {
if (!connection) return;
const ok = await confirm({
title: t("adminSettings.ai.connection.deleteTitle", { model: connection.model }),
message: connection.is_default
? t("adminSettings.ai.connection.deleteDefaultMessage")
: t("adminSettings.ai.connection.deleteMessage"),
confirmLabel: t("adminSettings.ai.connection.delete"),
destructive: true,
});
if (!ok) return;
setBusy("delete");
setError(null);
try {
await aiConnectionsApi.remove(connection.id);
onChanged();
onClose();
} catch (err) {
fail(err, t("adminSettings.ai.connection.failed"));
} finally {
setBusy(null);
}
};
const ready = Boolean(draft.base_url.trim() && draft.model.trim());
const showSavedKey = hasApiKey && !clearKey && !draft.api_key;
const disabled = busy !== null;
return (
<Dialog open onClose={disabled ? undefined : onClose} fullWidth maxWidth="sm">
<DialogTitle>
<Stack direction="row" spacing={1.5} alignItems="center">
<AiLogo model={draft.model} baseUrl={draft.base_url} size={24} />
<span>
{connection ? t("adminSettings.ai.connection.editTitle") : t("adminSettings.ai.connection.addTitle")}
</span>
</Stack>
</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ pt: 1 }}>
<TextField
label={t("adminSettings.ai.connection.baseUrl")}
value={draft.base_url}
onChange={(event) => update("base_url", event.target.value)}
helperText={t("adminSettings.ai.connection.baseUrlHint")}
disabled={disabled}
fullWidth
// oxlint-disable-next-line jsx-a11y/no-autofocus
autoFocus={!connection}
/>
<Autocomplete
freeSolo
options={models}
inputValue={draft.model}
onInputChange={(_event, value) => update("model", value)}
loading={modelsLoading}
disabled={disabled}
fullWidth
renderInput={(params) => (
<TextField
{...params}
label={t("adminSettings.ai.connection.model")}
helperText={
modelsError
? t("adminSettings.ai.connection.modelsUnavailable", { error: modelsError })
: models.length
? t("adminSettings.ai.connection.modelsAvailable", { count: models.length })
: undefined
}
/>
)}
/>
<TextField
type="password"
label={t("adminSettings.ai.connection.apiKey")}
value={draft.api_key}
onChange={(event) => {
update("api_key", event.target.value);
setClearKey(false);
}}
helperText={
clearKey
? t("adminSettings.ai.connection.keyWillClear")
: hasApiKey
? t("adminSettings.ai.connection.keySet")
: t("adminSettings.ai.connection.keyOptional")
}
disabled={disabled}
autoComplete="new-password"
fullWidth
placeholder={showSavedKey ? "••••••••••••••••" : undefined}
slotProps={{
inputLabel: showSavedKey ? { shrink: true } : undefined,
input: {
endAdornment:
hasApiKey && !clearKey ? (
<InputAdornment position="end">
<Button
size="small"
color="error"
onClick={() => {
setClearKey(true);
update("api_key", "");
}}
disabled={disabled}
>
{t("adminSettings.ai.connection.clearKey")}
</Button>
</InputAdornment>
) : undefined,
},
}}
/>
<Stack direction="row" spacing={2}>
<TextField
type="number"
label={t("adminSettings.ai.connection.timeout")}
value={draft.timeout_ms}
inputProps={{ min: 1000, max: 300000, step: 1000 }}
onChange={(event) => update("timeout_ms", Number(event.target.value))}
disabled={disabled}
fullWidth
/>
<TextField
type="number"
label={t("adminSettings.ai.connection.concurrency")}
value={draft.concurrency}
inputProps={{ min: 1, max: 4, step: 1 }}
onChange={(event) => update("concurrency", Number(event.target.value))}
disabled={disabled}
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>
}
/>
{status && <Alert severity="success">{status}</Alert>}
{error && <Alert severity="error">{error}</Alert>}
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
{connection && (
<Button color="error" onClick={() => void remove()} disabled={disabled} sx={{ mr: "auto" }}>
{t("adminSettings.ai.connection.delete")}
</Button>
)}
<Button
onClick={() => void test()}
disabled={disabled || !ready}
startIcon={busy === "test" ? <CircularProgress size={14} color="inherit" /> : undefined}
>
{busy === "test" ? t("adminSettings.ai.connection.testing") : t("adminSettings.ai.connection.test")}
</Button>
<Button onClick={onClose} disabled={disabled}>
{t("common:cancel")}
</Button>
<Button
variant="contained"
onClick={() => void save()}
disabled={disabled || !ready}
startIcon={busy === "save" ? <CircularProgress size={14} color="inherit" /> : undefined}
>
{busy === "save" ? t("adminSettings.ai.connection.saving") : t("adminSettings.ai.connection.save")}
</Button>
</DialogActions>
</Dialog>
);
}
@@ -1,283 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import Alert from "@mui/material/Alert";
import Autocomplete from "@mui/material/Autocomplete";
import Button from "@mui/material/Button";
import CircularProgress from "@mui/material/CircularProgress";
import InputAdornment from "@mui/material/InputAdornment";
import Paper from "@mui/material/Paper";
import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import Typography from "@mui/material/Typography";
import { UnauthorizedError } from "../../api/client";
import { settingsApi, type AiCategorizationSettings } from "../../api/settings";
import SectionHeader from "../../components/SectionHeader";
type Props = { onUnauthorized?: () => void };
type Draft = Pick<AiCategorizationSettings, "concurrency" | "timeout_ms"> & {
base_url: string;
model: string;
api_key: string;
};
const defaults: Draft = { base_url: "", model: "", api_key: "", concurrency: 1, timeout_ms: 60000 };
const pick = (settings: AiCategorizationSettings): Draft => ({
base_url: settings.base_url ?? "",
model: settings.model ?? "",
api_key: "",
concurrency: settings.concurrency,
timeout_ms: settings.timeout_ms,
});
export default function AiConnectionSection({ onUnauthorized }: Props) {
const { t } = useTranslation("app");
const [draft, setDraft] = useState<Draft>(defaults);
const [hasApiKey, setHasApiKey] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [clearKey, setClearKey] = useState(false);
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [models, setModels] = useState<string[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
const [modelsError, setModelsError] = useState<string | null>(null);
useEffect(() => {
let active = true;
void settingsApi
.getAiCategorization()
.then((settings) => {
if (active) {
setDraft(pick(settings));
setHasApiKey(settings.has_api_key);
}
})
.catch((err: unknown) => {
if (err instanceof UnauthorizedError) onUnauthorized?.();
else if (active) setError(err instanceof Error ? err.message : t("adminSettings.ai.connection.failed"));
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [onUnauthorized, t]);
// The provider's own list, refetched a moment after the URL or key stops changing; the field still takes any name.
useEffect(() => {
const baseUrl = draft.base_url.trim();
if (loading || !baseUrl) {
setModels([]);
setModelsError(null);
return;
}
let active = true;
const timer = window.setTimeout(() => {
setModelsLoading(true);
void settingsApi
.listAiModels({
base_url: baseUrl,
...(clearKey ? { api_key: null } : draft.api_key ? { api_key: draft.api_key } : {}),
})
.then((result) => {
if (!active) return;
setModels(result.ok ? result.models : []);
setModelsError(result.ok ? null : result.error);
})
.catch((err: unknown) => {
if (err instanceof UnauthorizedError) onUnauthorized?.();
else if (active) setModelsError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (active) setModelsLoading(false);
});
}, 600);
return () => {
active = false;
window.clearTimeout(timer);
};
}, [loading, draft.base_url, draft.api_key, clearKey, onUnauthorized]);
const update = <K extends keyof Draft>(key: K, value: Draft[K]) =>
setDraft((current) => ({ ...current, [key]: value }));
const save = async () => {
setSaving(true);
setStatus(null);
setError(null);
try {
const saved = await settingsApi.updateAiCategorization({
base_url: draft.base_url.trim() || null,
model: draft.model.trim() || null,
concurrency: Number(draft.concurrency),
timeout_ms: Number(draft.timeout_ms),
...(clearKey ? { api_key: null } : draft.api_key ? { api_key: draft.api_key } : {}),
});
setDraft(pick(saved));
setHasApiKey(saved.has_api_key);
setClearKey(false);
setStatus(t("adminSettings.ai.connection.saved"));
} catch (err) {
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setError(err instanceof Error ? err.message : t("adminSettings.ai.connection.failed"));
} finally {
setSaving(false);
}
};
const testConnection = async () => {
setTesting(true);
setStatus(null);
setError(null);
try {
const result = await settingsApi.testAiCategorization({
base_url: draft.base_url.trim(),
model: draft.model.trim(),
timeout_ms: Number(draft.timeout_ms),
// A blank key field tests with the saved key, unless it is about to be cleared.
...(clearKey ? { api_key: null } : draft.api_key ? { api_key: draft.api_key } : {}),
});
if (result.ok)
setStatus(t("adminSettings.ai.connection.testSuccess", { model: result.model, latency: result.latency_ms }));
else setError(result.error || t("adminSettings.ai.connection.testFailed"));
} catch (err) {
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setError(err instanceof Error ? err.message : t("adminSettings.ai.connection.testFailed"));
} finally {
setTesting(false);
}
};
const canTest = Boolean(draft.base_url.trim() && draft.model.trim());
const showSavedKey = hasApiKey && !clearKey && !draft.api_key;
// Saving tests the connection too, so either request locks the whole form.
const busy = loading || saving || testing;
return (
<Stack spacing={3}>
<SectionHeader
title={t("adminSettings.ai.connection.heading")}
subtitle={t("adminSettings.ai.connection.subtitle")}
/>
<Paper variant="outlined" sx={{ p: 2.5 }}>
<Stack spacing={2}>
<TextField
label={t("adminSettings.ai.connection.baseUrl")}
value={draft.base_url}
onChange={(event) => update("base_url", event.target.value)}
helperText={t("adminSettings.ai.connection.baseUrlHint")}
disabled={busy}
fullWidth
/>
<Autocomplete
freeSolo
options={models}
inputValue={draft.model}
onInputChange={(_event, value) => update("model", value)}
loading={modelsLoading}
disabled={busy}
fullWidth
renderInput={(params) => (
<TextField
{...params}
label={t("adminSettings.ai.connection.model")}
helperText={
modelsError
? t("adminSettings.ai.connection.modelsUnavailable", { error: modelsError })
: models.length
? t("adminSettings.ai.connection.modelsAvailable", { count: models.length })
: undefined
}
/>
)}
/>
<TextField
type="password"
label={t("adminSettings.ai.connection.apiKey")}
value={draft.api_key}
onChange={(event) => {
update("api_key", event.target.value);
setClearKey(false);
}}
helperText={
clearKey
? t("adminSettings.ai.connection.keyWillClear")
: hasApiKey
? t("adminSettings.ai.connection.keySet")
: undefined
}
disabled={busy}
autoComplete="new-password"
fullWidth
placeholder={showSavedKey ? "••••••••••••••••" : undefined}
slotProps={{
inputLabel: showSavedKey ? { shrink: true } : undefined,
input: {
endAdornment:
hasApiKey && !clearKey ? (
<InputAdornment position="end">
<Button
size="small"
color="error"
onClick={() => {
setClearKey(true);
update("api_key", "");
}}
disabled={busy}
>
{t("adminSettings.ai.connection.clearKey")}
</Button>
</InputAdornment>
) : undefined,
},
}}
/>
<TextField
type="number"
label={t("adminSettings.ai.connection.concurrency")}
value={draft.concurrency}
inputProps={{ min: 1, max: 4, step: 1 }}
onChange={(event) => update("concurrency", Number(event.target.value))}
disabled={busy}
/>
<TextField
type="number"
label={t("adminSettings.ai.connection.timeout")}
value={draft.timeout_ms}
inputProps={{ min: 1000, step: 1000 }}
onChange={(event) => update("timeout_ms", Number(event.target.value))}
disabled={busy}
/>
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap" useFlexGap>
<Button
variant="contained"
onClick={() => void save()}
disabled={busy}
startIcon={saving ? <CircularProgress size={14} color="inherit" /> : undefined}
>
{saving ? t("adminSettings.ai.connection.saving") : t("adminSettings.ai.connection.save")}
</Button>
{canTest && (
<Button
variant="outlined"
onClick={() => void testConnection()}
disabled={busy}
startIcon={testing ? <CircularProgress size={14} color="inherit" /> : undefined}
>
{testing ? t("adminSettings.ai.connection.testing") : t("adminSettings.ai.connection.test")}
</Button>
)}
{loading && <CircularProgress size={14} />}
</Stack>
{status && <Alert severity="success">{status}</Alert>}
{error && <Alert severity="error">{error}</Alert>}
<Typography variant="caption" color="text.secondary">
{t("adminSettings.ai.connection.writeOnly")}
</Typography>
</Stack>
</Paper>
</Stack>
);
}
@@ -0,0 +1,348 @@
import React, { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import AddIcon from "@mui/icons-material/Add";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import LightbulbOutlinedIcon from "@mui/icons-material/LightbulbOutlined";
import NetworkCheckIcon from "@mui/icons-material/NetworkCheck";
import SmartToyOutlinedIcon from "@mui/icons-material/SmartToyOutlined";
import StarOutlineIcon from "@mui/icons-material/StarOutline";
import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import CircularProgress from "@mui/material/CircularProgress";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { UnauthorizedError } from "../../api/client";
import { aiConnectionsApi, type AiConnection } from "../../api/aiConnections";
import { useConfirm } from "../../components/ConfirmProvider";
import SectionHeader from "../../components/SectionHeader";
import { AppGrid, AppTile, menuPosition, TileMenuHeader } from "../../components/Layout/ConfigurationDialog/AppTiles";
import AiConnectionDialog from "./AiConnectionDialog";
import { AiLogo, hostOf } from "./aiLogos";
type Props = {
connections: AiConnection[] | null;
/** Reloads the list after a change. */
onChanged: () => void;
onUnauthorized?: () => void;
};
// Long enough for the second click of a double-click to cancel the first one's "make default".
const DOUBLE_CLICK_MS = 250;
/** The instance's AIs as app icons: click one to make it the default, double-click to edit it. */
export default function AiConnectionsSection({ connections, onChanged, onUnauthorized }: Props) {
const { t } = useTranslation(["app", "common"]);
const confirm = useConfirm();
const [editing, setEditing] = useState<AiConnection | "new" | null>(null);
const [menu, setMenu] = useState<{ connection: AiConnection; top: number; left: number } | null>(null);
const [notice, setNotice] = useState<{ severity: "success" | "error"; text: string } | null>(null);
const [testing, setTesting] = useState<string | null>(null);
const clickTimer = useRef<number | undefined>(undefined);
const fail = (err: unknown) => {
if (err instanceof UnauthorizedError) onUnauthorized?.();
else
setNotice({
severity: "error",
text: err instanceof Error ? err.message : t("adminSettings.ai.connection.failed"),
});
};
const makeDefault = async (connection: AiConnection) => {
if (connection.is_default) return;
setNotice(null);
try {
await aiConnectionsApi.makeDefault(connection.id);
onChanged();
} catch (err) {
fail(err);
}
};
// A keyboard "click" (detail 0) is left to Enter, which edits; the menu offers "Make default".
const onTileClick = (connection: AiConnection, event: React.MouseEvent<HTMLElement>) => {
window.clearTimeout(clickTimer.current);
if (event.detail !== 1) return;
clickTimer.current = window.setTimeout(() => void makeDefault(connection), DOUBLE_CLICK_MS);
};
const edit = (connection: AiConnection) => {
window.clearTimeout(clickTimer.current);
setNotice(null);
setEditing(connection);
};
const test = async (connection: AiConnection) => {
setTesting(connection.id);
setNotice(null);
try {
const result = await aiConnectionsApi.test({ id: connection.id });
setNotice(
result.ok
? {
severity: "success",
text: t("adminSettings.ai.connection.testSuccess", { model: result.model, latency: result.latency_ms }),
}
: { severity: "error", text: result.error || t("adminSettings.ai.connection.testFailed") },
);
} catch (err) {
fail(err);
} finally {
setTesting(null);
}
};
const remove = async (connection: AiConnection) => {
const ok = await confirm({
title: t("adminSettings.ai.connection.deleteTitle", { model: connection.model }),
message: connection.is_default
? t("adminSettings.ai.connection.deleteDefaultMessage")
: t("adminSettings.ai.connection.deleteMessage"),
confirmLabel: t("adminSettings.ai.connection.delete"),
destructive: true,
});
if (!ok) return;
try {
await aiConnectionsApi.remove(connection.id);
onChanged();
} catch (err) {
fail(err);
}
};
// The domain tells apart two AIs running the same model.
const repeated = new Set(
(connections ?? [])
.map((connection) => connection.model)
.filter((model, index, all) => all.indexOf(model) !== index),
);
const menuConnection = menu?.connection;
return (
<Stack spacing={3}>
<SectionHeader
title={t("adminSettings.ai.connection.heading")}
subtitle={t("adminSettings.ai.connection.subtitle")}
/>
{connections === null ? (
<CircularProgress size={20} />
) : connections.length === 0 ? (
<EmptyState onAdd={() => setEditing("new")} />
) : (
<Box>
<AppGrid>
{connections.map((connection) => {
const host = hostOf(connection.base_url);
return (
<AppTile
key={connection.id}
name={connection.model}
subtitle={repeated.has(connection.model) ? host : undefined}
logo={
testing === connection.id ? (
<CircularProgress size={22} />
) : (
<AiLogo model={connection.model} baseUrl={connection.base_url} size={26} />
)
}
on
badge={connection.is_default ? true : undefined}
highlighted={menuConnection?.id === connection.id}
tooltip={
connection.is_default
? t("adminSettings.ai.connection.defaultTooltip", { host })
: t("adminSettings.ai.connection.tileTooltip", { host })
}
ariaLabel={
connection.is_default
? t("adminSettings.ai.connection.defaultAria", { model: connection.model, host })
: `${connection.model}, ${host}`
}
onClick={(event) => onTileClick(connection, event)}
onActivate={() => edit(connection)}
onContextMenu={(event) => setMenu({ connection, ...menuPosition(event) })}
/>
);
})}
<AppTile
name={t("adminSettings.ai.connection.add")}
logo={<AddIcon sx={{ color: "#6b7280" }} />}
on
ariaLabel={t("adminSettings.ai.connection.add")}
onClick={() => setEditing("new")}
onActivate={() => setEditing("new")}
onContextMenu={(event) => event.preventDefault()}
/>
</AppGrid>
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1.5 }}>
{t("adminSettings.ai.connection.gridHint")}
</Typography>
</Box>
)}
<Menu
open={Boolean(menu)}
onClose={() => setMenu(null)}
anchorReference="anchorPosition"
anchorPosition={menu ? { top: menu.top, left: menu.left } : undefined}
slotProps={{ paper: { sx: { minWidth: 220 } } }}
>
{menuConnection && (
<TileMenuHeader
logo={<AiLogo model={menuConnection.model} baseUrl={menuConnection.base_url} size={20} />}
name={menuConnection.model}
status={
menuConnection.is_default
? t("adminSettings.ai.connection.defaultStatus", { host: hostOf(menuConnection.base_url) })
: hostOf(menuConnection.base_url)
}
/>
)}
{menuConnection && [
<MenuItem
key="edit"
onClick={() => {
setMenu(null);
edit(menuConnection);
}}
>
<ListItemIcon>
<EditOutlinedIcon fontSize="small" />
</ListItemIcon>
<ListItemText>{t("adminSettings.ai.connection.edit")}</ListItemText>
</MenuItem>,
<MenuItem
key="default"
disabled={menuConnection.is_default}
onClick={() => {
setMenu(null);
void makeDefault(menuConnection);
}}
>
<ListItemIcon>
<StarOutlineIcon fontSize="small" />
</ListItemIcon>
<ListItemText>{t("adminSettings.ai.connection.makeDefault")}</ListItemText>
</MenuItem>,
<MenuItem
key="test"
disabled={testing !== null}
onClick={() => {
setMenu(null);
void test(menuConnection);
}}
>
<ListItemIcon>
<NetworkCheckIcon fontSize="small" />
</ListItemIcon>
<ListItemText>{t("adminSettings.ai.connection.test")}</ListItemText>
</MenuItem>,
<MenuItem
key="delete"
onClick={() => {
setMenu(null);
void remove(menuConnection);
}}
>
<ListItemIcon>
<DeleteOutlineIcon fontSize="small" color="error" />
</ListItemIcon>
<ListItemText sx={{ color: "error.main" }}>{t("adminSettings.ai.connection.delete")}</ListItemText>
</MenuItem>,
]}
</Menu>
{notice && (
<Alert severity={notice.severity} onClose={() => setNotice(null)}>
{notice.text}
</Alert>
)}
{editing && (
<AiConnectionDialog
// Starts afresh for each AI.
key={editing === "new" ? "new" : editing.id}
connection={editing === "new" ? null : editing}
onClose={() => setEditing(null)}
onChanged={onChanged}
onUnauthorized={onUnauthorized}
/>
)}
</Stack>
);
}
/** A robot with an idea in a speech bubble: there's no AI yet, and adding one is the way in. */
function EmptyState({ onAdd }: { onAdd: () => void }) {
const { t } = useTranslation("app");
return (
<Stack direction="row" spacing={2.5} alignItems="center" sx={{ py: 2 }}>
<Box
sx={{
flexShrink: 0,
width: 72,
height: 72,
borderRadius: "18px",
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: "#ffffff",
boxShadow: "inset 0 0 0 1px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04)",
}}
>
<SmartToyOutlinedIcon sx={{ fontSize: 44, color: "#6b7280" }} />
</Box>
<Box
sx={{
position: "relative",
px: 2,
py: 1.5,
border: 1,
borderColor: "divider",
borderRadius: "14px",
bgcolor: "background.paper",
maxWidth: 420,
// The tail, pointing back at the robot.
"&::before, &::after": {
content: '""',
position: "absolute",
top: "50%",
transform: "translateY(-50%)",
borderStyle: "solid",
},
"&::before": {
left: -9,
borderWidth: "8px 9px 8px 0",
borderColor: (theme) => `transparent ${theme.palette.divider} transparent transparent`,
},
"&::after": {
left: -7,
borderWidth: "7px 8px 7px 0",
borderColor: (theme) => `transparent ${theme.palette.background.paper} transparent transparent`,
},
}}
>
<Stack direction="row" spacing={1} alignItems="flex-start">
<LightbulbOutlinedIcon sx={{ color: "warning.main", mt: 0.25 }} fontSize="small" />
<Box>
<Typography variant="body2" fontWeight={600}>
{t("adminSettings.ai.connection.emptyTitle")}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
{t("adminSettings.ai.connection.emptyBody")}
</Typography>
<Button size="small" variant="contained" startIcon={<AddIcon />} onClick={onAdd} sx={{ mt: 1.5 }}>
{t("adminSettings.ai.connection.add")}
</Button>
</Box>
</Stack>
</Box>
</Stack>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { aiLogoName } from "./aiLogos";
const named = (model: string, baseUrl = "http://192.168.1.10:8080/v1") => aiLogoName(model, baseUrl);
describe("aiLogoName", () => {
it("knows a model by its family, wherever it runs", () => {
expect(named("qwen3:8b")).toBe("qwen");
expect(named("qwen/qwen3-8b", "https://openrouter.ai/api/v1")).toBe("qwen");
expect(named("llama3.2:3b")).toBe("meta");
expect(named("llava:7b")).toBe("llava");
expect(named("gemma3:4b")).toBe("gemma");
expect(named("gpt-4o-mini")).toBe("openai");
expect(named("o3-mini")).toBe("openai");
expect(named("phi4:14b")).toBe("microsoft");
expect(named("mimo-v2.6-flash")).toBe("xiaomimimo");
});
it("falls back to the provider, then to nothing", () => {
expect(named("my-finetune", "http://localhost:11434/v1")).toBe("ollama");
expect(named("custom", "https://api.openai.com/v1")).toBe("openai");
expect(named("my-finetune")).toBeNull();
});
it("doesn't take a word that merely contains a family name for it", () => {
expect(named("dolphin-mistral")).toBe("mistral");
expect(named("graphite-7b")).toBeNull();
});
});
+171
View File
@@ -0,0 +1,171 @@
import SmartToyOutlinedIcon from "@mui/icons-material/SmartToyOutlined";
import ai21 from "../../assets/ai-logos/ai21.svg";
import anthropic from "../../assets/ai-logos/anthropic.svg";
import baichuan from "../../assets/ai-logos/baichuan.svg";
import baidu from "../../assets/ai-logos/baidu.svg";
import claude from "../../assets/ai-logos/claude.svg";
import cohere from "../../assets/ai-logos/cohere.svg";
import deepseek from "../../assets/ai-logos/deepseek.svg";
import doubao from "../../assets/ai-logos/doubao.svg";
import fireworks from "../../assets/ai-logos/fireworks.svg";
import gemini from "../../assets/ai-logos/gemini.svg";
import gemma from "../../assets/ai-logos/gemma.svg";
import grok from "../../assets/ai-logos/grok.svg";
import groq from "../../assets/ai-logos/groq.svg";
import huggingface from "../../assets/ai-logos/huggingface.svg";
import hunyuan from "../../assets/ai-logos/hunyuan.svg";
import ibm from "../../assets/ai-logos/ibm.svg";
import internlm from "../../assets/ai-logos/internlm.svg";
import kimi from "../../assets/ai-logos/kimi.svg";
import lg from "../../assets/ai-logos/lg.svg";
import liquid from "../../assets/ai-logos/liquid.svg";
import llava from "../../assets/ai-logos/llava.svg";
import lmstudio from "../../assets/ai-logos/lmstudio.svg";
import meta from "../../assets/ai-logos/meta.svg";
import microsoft from "../../assets/ai-logos/microsoft.svg";
import minimax from "../../assets/ai-logos/minimax.svg";
import mistral from "../../assets/ai-logos/mistral.svg";
import nousresearch from "../../assets/ai-logos/nousresearch.svg";
import nvidia from "../../assets/ai-logos/nvidia.svg";
import ollama from "../../assets/ai-logos/ollama.svg";
import openai from "../../assets/ai-logos/openai.svg";
import openrouter from "../../assets/ai-logos/openrouter.svg";
import perplexity from "../../assets/ai-logos/perplexity.svg";
import qwen from "../../assets/ai-logos/qwen.svg";
import stepfun from "../../assets/ai-logos/stepfun.svg";
import tii from "../../assets/ai-logos/tii.svg";
import together from "../../assets/ai-logos/together.svg";
import upstage from "../../assets/ai-logos/upstage.svg";
import vllm from "../../assets/ai-logos/vllm.svg";
import xai from "../../assets/ai-logos/xai.svg";
import xiaomimimo from "../../assets/ai-logos/xiaomimimo.svg";
import yi from "../../assets/ai-logos/yi.svg";
import zhipu from "../../assets/ai-logos/zhipu.svg";
const LOGOS = {
ai21,
anthropic,
baichuan,
baidu,
claude,
cohere,
deepseek,
doubao,
fireworks,
gemini,
gemma,
grok,
groq,
huggingface,
hunyuan,
ibm,
internlm,
kimi,
lg,
liquid,
llava,
lmstudio,
meta,
microsoft,
minimax,
mistral,
nousresearch,
nvidia,
ollama,
openai,
openrouter,
perplexity,
qwen,
stepfun,
tii,
together,
upstage,
vllm,
xai,
xiaomimimo,
yi,
zhipu,
};
export type AiLogoName = keyof typeof LOGOS;
// Logos from LobeHub Icons (MIT, see assets/ai-logos/LICENSE). Checked in order: a model's family says more than
// where it runs, so "qwen/qwen3-8b" on OpenRouter shows Qwen. Ids may carry an owner prefix, hence no anchors.
const BY_MODEL: [RegExp, AiLogoName][] = [
[/llava/, "llava"],
[/qwen|qwq/, "qwen"],
[/llama/, "meta"],
[/gemma/, "gemma"],
[/gemini/, "gemini"],
[/mistral|mixtral|ministral|magistral|codestral|devstral|pixtral/, "mistral"],
[/deepseek/, "deepseek"],
[/(^|[/:_-])phi\d?([.:_-]|$)/, "microsoft"],
[/claude/, "claude"],
[/mimo/, "xiaomimimo"],
[/grok/, "grok"],
[/(^|\/)(gpt|chatgpt|o[134])(-|$)/, "openai"],
[/command|(^|\/)aya|cohere/, "cohere"],
[/granite/, "ibm"],
[/nemotron/, "nvidia"],
[/kimi|moonshot/, "kimi"],
[/glm|zhipu/, "zhipu"],
[/(^|\/)yi[-:]/, "yi"],
[/minimax|abab/, "minimax"],
[/internlm/, "internlm"],
[/baichuan/, "baichuan"],
[/doubao/, "doubao"],
[/hunyuan/, "hunyuan"],
[/(^|\/)step[-:]/, "stepfun"],
[/falcon/, "tii"],
[/(^|\/)lfm|liquid/, "liquid"],
[/solar/, "upstage"],
[/hermes|nous/, "nousresearch"],
[/jamba/, "ai21"],
[/exaone/, "lg"],
[/ernie/, "baidu"],
[/sonar|perplexity/, "perplexity"],
];
const BY_HOST: [RegExp, AiLogoName][] = [
[/openai\.com/, "openai"],
[/anthropic\.com/, "anthropic"],
[/googleapis\.com/, "gemini"],
[/openrouter\.ai/, "openrouter"],
[/groq\.com/, "groq"],
[/together\.(ai|xyz)/, "together"],
[/fireworks\.ai/, "fireworks"],
[/deepseek\.com/, "deepseek"],
[/mistral\.ai/, "mistral"],
[/x\.ai/, "xai"],
[/huggingface\.co/, "huggingface"],
[/nvidia\.com/, "nvidia"],
[/perplexity\.ai/, "perplexity"],
// Ports the local servers listen on out of the box.
[/:11434(\/|$)/, "ollama"],
[/:1234(\/|$)/, "lmstudio"],
[/vllm/, "vllm"],
];
/** What tells two AIs with the same model apart. */
export function hostOf(baseUrl: string): string {
try {
return new URL(baseUrl).host;
} catch {
return baseUrl;
}
}
/** Which logo an AI shows, by name; null for the robot head. */
export function aiLogoName(model: string, baseUrl: string): AiLogoName | null {
const id = model.toLowerCase();
const host = baseUrl.toLowerCase();
return (
BY_MODEL.find(([pattern]) => pattern.test(id))?.[1] ?? BY_HOST.find(([pattern]) => pattern.test(host))?.[1] ?? null
);
}
/** The model's or provider's logo, or a robot head for one it doesn't know. */
export function AiLogo({ model, baseUrl, size }: { model: string; baseUrl: string; size: number }) {
const name = aiLogoName(model, baseUrl);
if (!name) return <SmartToyOutlinedIcon sx={{ fontSize: size, color: "#6b7280" }} />;
return <img src={LOGOS[name]} alt="" width={size} height={size} style={{ display: "block" }} />;
}
+20 -3
View File
@@ -1,6 +1,9 @@
import { useCallback, useEffect, useState } from "react";
import Divider from "@mui/material/Divider";
import Stack from "@mui/material/Stack";
import AiConnectionSection from "./AiConnectionSection";
import { UnauthorizedError } from "../../api/client";
import { aiConnectionsApi, type AiConnection } from "../../api/aiConnections";
import AiConnectionsSection from "./AiConnectionsSection";
import AiCategorizationSection from "./AiCategorizationSection";
type Props = {
@@ -8,10 +11,24 @@ type Props = {
};
export default function AiPage({ onUnauthorized }: Props) {
// Shared: categorization picks from the same list the grid shows.
const [connections, setConnections] = useState<AiConnection[] | null>(null);
const load = useCallback(() => {
void aiConnectionsApi
.list()
.then(setConnections)
.catch((err: unknown) => {
if (err instanceof UnauthorizedError) onUnauthorized?.();
else setConnections((current) => current ?? []);
});
}, [onUnauthorized]);
useEffect(load, [load]);
return (
<Stack spacing={4} divider={<Divider />}>
<AiConnectionSection onUnauthorized={onUnauthorized} />
<AiCategorizationSection onUnauthorized={onUnauthorized} />
<AiConnectionsSection connections={connections} onChanged={load} onUnauthorized={onUnauthorized} />
<AiCategorizationSection connections={connections ?? []} onUnauthorized={onUnauthorized} />
</Stack>
);
}