feat: ability to link multiple AIs
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';
|
||||
@@ -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())
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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 } });
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" }} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||