Исправлено: - Chat.tsx: убрана хардкодированная модель "qwen2.5:7b" из мутации — теперь оркестратор использует модель из конфига БД (minimax-m2.7) - Chat.tsx: добавлен Streamdown для markdown рендеринга ответов оркестратора - Подтверждено: tool calling работает — команда "Покажи файлы проекта" вызывает file_list и возвращает структуру проекта - Подтверждено: model в header показывает "minimax-m2.7" из БД - TypeScript: 0 ошибок (pnpm tsc --noEmit) - Тесты: 24/24 passed
170 lines
4.2 KiB
TypeScript
170 lines
4.2 KiB
TypeScript
/**
|
|
* Ollama API Client — серверный прокси для безопасного доступа к Ollama Cloud API.
|
|
* Хранит API-ключ на сервере, обходит CORS, предоставляет типизированные функции.
|
|
*/
|
|
import { ENV } from "./_core/env";
|
|
|
|
const TIMEOUT_MS = 30_000;
|
|
|
|
interface OllamaModel {
|
|
id: string;
|
|
object: string;
|
|
created: number;
|
|
owned_by: string;
|
|
}
|
|
|
|
interface OllamaModelsResponse {
|
|
object: string;
|
|
data: OllamaModel[];
|
|
}
|
|
|
|
interface ChatMessage {
|
|
role: "system" | "user" | "assistant" | "tool" | "function";
|
|
content: string | null;
|
|
tool_call_id?: string;
|
|
name?: string;
|
|
tool_calls?: ToolCall[];
|
|
}
|
|
|
|
interface ToolFunction {
|
|
name: string;
|
|
description?: string;
|
|
parameters?: Record<string, unknown>;
|
|
}
|
|
|
|
interface Tool {
|
|
type: "function";
|
|
function: ToolFunction;
|
|
}
|
|
|
|
interface ToolCallFunction {
|
|
name: string;
|
|
arguments: string;
|
|
}
|
|
|
|
interface ToolCall {
|
|
id: string;
|
|
type: "function";
|
|
function: ToolCallFunction;
|
|
}
|
|
|
|
interface ChatChoice {
|
|
index: number;
|
|
message: {
|
|
role: string;
|
|
content: string | null;
|
|
tool_calls?: ToolCall[];
|
|
};
|
|
finish_reason: string;
|
|
}
|
|
|
|
interface ChatCompletionResponse {
|
|
id: string;
|
|
object: string;
|
|
created: number;
|
|
model: string;
|
|
choices: ChatChoice[];
|
|
usage?: {
|
|
prompt_tokens: number;
|
|
completion_tokens: number;
|
|
total_tokens: number;
|
|
};
|
|
}
|
|
|
|
function getHeaders(): Record<string, string> {
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": "application/json",
|
|
};
|
|
if (ENV.ollamaApiKey) {
|
|
headers["Authorization"] = `Bearer ${ENV.ollamaApiKey}`;
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
function getBaseUrl(): string {
|
|
return ENV.ollamaBaseUrl.replace(/\/$/, "");
|
|
}
|
|
|
|
/**
|
|
* Проверка доступности Ollama API
|
|
*/
|
|
export async function checkOllamaHealth(): Promise<{
|
|
connected: boolean;
|
|
latencyMs: number;
|
|
error?: string;
|
|
}> {
|
|
const start = Date.now();
|
|
try {
|
|
const res = await fetch(`${getBaseUrl()}/models`, {
|
|
method: "GET",
|
|
headers: getHeaders(),
|
|
signal: AbortSignal.timeout(10_000),
|
|
});
|
|
const latencyMs = Date.now() - start;
|
|
if (res.ok) {
|
|
return { connected: true, latencyMs };
|
|
}
|
|
const text = await res.text();
|
|
return { connected: false, latencyMs, error: `HTTP ${res.status}: ${text}` };
|
|
} catch (err: any) {
|
|
return { connected: false, latencyMs: Date.now() - start, error: err.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Получение списка доступных моделей (OpenAI-совместимый формат)
|
|
*/
|
|
export async function listModels(): Promise<OllamaModelsResponse> {
|
|
const res = await fetch(`${getBaseUrl()}/models`, {
|
|
method: "GET",
|
|
headers: getHeaders(),
|
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
throw new Error(`Ollama API error (${res.status}): ${text}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
/**
|
|
* Отправка сообщения в чат (OpenAI-совместимый формат, без стриминга)
|
|
* Поддерживает tool calling для оркестратора.
|
|
*/
|
|
export async function chatCompletion(
|
|
model: string,
|
|
messages: ChatMessage[],
|
|
options?: {
|
|
temperature?: number;
|
|
max_tokens?: number;
|
|
tools?: Tool[];
|
|
tool_choice?: "auto" | "none" | "required";
|
|
}
|
|
): Promise<ChatCompletionResponse> {
|
|
const body: Record<string, unknown> = {
|
|
model,
|
|
messages,
|
|
stream: false,
|
|
};
|
|
if (options?.temperature !== undefined) body.temperature = options.temperature;
|
|
if (options?.max_tokens !== undefined) body.max_tokens = options.max_tokens;
|
|
if (options?.tools?.length) {
|
|
body.tools = options.tools;
|
|
body.tool_choice = options.tool_choice ?? "auto";
|
|
}
|
|
|
|
const res = await fetch(`${getBaseUrl()}/chat/completions`, {
|
|
method: "POST",
|
|
headers: getHeaders(),
|
|
body: JSON.stringify(body),
|
|
signal: AbortSignal.timeout(120_000), // LLM может думать долго
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
throw new Error(`Ollama Chat API error (${res.status}): ${text}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
export type { OllamaModel, OllamaModelsResponse, ChatMessage, ChatCompletionResponse };
|