0.1.11 ai修复与全屏
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
|
||||
export type LocalAiConfig = {
|
||||
baseUrl: string; // 形如 http(s)://host/v1
|
||||
apiKey: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
const tryReadText = async (file: string) => {
|
||||
try {
|
||||
return await fs.readFile(file, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const findConfigText = async () => {
|
||||
const cwd = process.cwd();
|
||||
const candidates = [
|
||||
path.join(cwd, "ai.local.md"),
|
||||
path.join(cwd, "ai-local.md"),
|
||||
path.join(cwd, "..", "ai.local.md"),
|
||||
path.join(cwd, "..", "ai-local.md"),
|
||||
path.join(cwd, "..", "..", "ai.local.md"),
|
||||
path.join(cwd, "..", "..", "ai-local.md"),
|
||||
];
|
||||
for (const f of candidates) {
|
||||
const text = await tryReadText(f);
|
||||
if (text) return text;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const parseAiMd = (raw: string): LocalAiConfig | null => {
|
||||
const lines = raw
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// 兼容格式:
|
||||
// apikey:xxxx(可选)
|
||||
// http://.../v1
|
||||
// model-name
|
||||
const apiLine = lines.find((l) => /^apikey[::]/i.test(l));
|
||||
const apiKey = apiLine ? apiLine.replace(/^apikey[::]\s*/i, "").trim() : "";
|
||||
const baseUrl = lines.find((l) => /^https?:\/\//i.test(l)) ?? "";
|
||||
const model =
|
||||
(lines.findLast?.((l) => !/^apikey[::]/i.test(l) && !/^https?:\/\//i.test(l)) ??
|
||||
lines.find((l) => !/^apikey[::]/i.test(l) && !/^https?:\/\//i.test(l)) ??
|
||||
"").trim();
|
||||
|
||||
if (!baseUrl || !model) return null;
|
||||
return {
|
||||
apiKey,
|
||||
baseUrl: baseUrl.replace(/\/+$/, ""),
|
||||
model,
|
||||
};
|
||||
};
|
||||
|
||||
export const loadLocalAiConfig = async (): Promise<LocalAiConfig | null> => {
|
||||
// 环境变量优先(方便部署),其次读取 ai.local.md / ai-local.md
|
||||
const envBase = (process.env.LOCAL_AI_BASE_URL ?? "").trim();
|
||||
const envModel = (process.env.LOCAL_AI_MODEL ?? "").trim();
|
||||
const envKey = (process.env.LOCAL_AI_API_KEY ?? "").trim();
|
||||
if (envBase && envModel) {
|
||||
return {
|
||||
baseUrl: envBase.replace(/\/+$/, ""),
|
||||
apiKey: envKey,
|
||||
model: envModel,
|
||||
};
|
||||
}
|
||||
|
||||
const text = await findConfigText();
|
||||
if (!text) return null;
|
||||
return parseAiMd(text);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
|
||||
export type OnlineAiConfig = {
|
||||
baseUrl: string; // 形如 http(s)://host/v1
|
||||
apiKey: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
const tryReadText = async (file: string) => {
|
||||
try {
|
||||
return await fs.readFile(file, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const findConfigText = async () => {
|
||||
const cwd = process.cwd();
|
||||
const candidates = [
|
||||
path.join(cwd, "ai.md"),
|
||||
path.join(cwd, "..", "ai.md"),
|
||||
path.join(cwd, "..", "..", "ai.md"),
|
||||
];
|
||||
for (const f of candidates) {
|
||||
const text = await tryReadText(f);
|
||||
if (text) return text;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const parseAiMd = (raw: string): OnlineAiConfig | null => {
|
||||
const lines = raw
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// 兼容格式:
|
||||
// apikey:xxxx
|
||||
// http://.../v1
|
||||
// model-name
|
||||
const apiLine = lines.find((l) => /^apikey[::]/i.test(l));
|
||||
const apiKey = apiLine ? apiLine.replace(/^apikey[::]\s*/i, "").trim() : "";
|
||||
const baseUrl = lines.find((l) => /^https?:\/\//i.test(l)) ?? "";
|
||||
// model 行通常是最后一行
|
||||
const model = (lines.findLast?.((l) => !/^apikey[::]/i.test(l) && !/^https?:\/\//i.test(l)) ??
|
||||
lines.find((l) => !/^apikey[::]/i.test(l) && !/^https?:\/\//i.test(l)) ??
|
||||
"").trim();
|
||||
|
||||
if (!apiKey || !baseUrl || !model) return null;
|
||||
|
||||
// Cloudflare 场景下 http 可能只允许 GET(/models),但 POST(/chat/completions)会被拦截;
|
||||
// 对非本机地址默认升级到 https,确保在线推理可用。
|
||||
let normalizedBaseUrl = baseUrl.replace(/\/+$/, "");
|
||||
try {
|
||||
const u = new URL(normalizedBaseUrl);
|
||||
if (u.protocol === "http:" && u.hostname !== "127.0.0.1" && u.hostname !== "localhost") {
|
||||
u.protocol = "https:";
|
||||
normalizedBaseUrl = u.toString().replace(/\/+$/, "");
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return {
|
||||
apiKey,
|
||||
baseUrl: normalizedBaseUrl,
|
||||
model,
|
||||
};
|
||||
};
|
||||
|
||||
export const loadOnlineAiConfig = async (): Promise<OnlineAiConfig | null> => {
|
||||
// 环境变量优先(方便生产/部署),其次读取 ai.md(本地开发快捷配置)
|
||||
const envKey = (process.env.ONLINE_AI_API_KEY ?? "").trim();
|
||||
const envBase = (process.env.ONLINE_AI_BASE_URL ?? "").trim();
|
||||
const envModel = (process.env.ONLINE_AI_MODEL ?? "").trim();
|
||||
if (envKey && envBase && envModel) {
|
||||
// 同 parseAiMd:默认把非本机 http 升级为 https
|
||||
let normalizedBaseUrl = envBase.replace(/\/+$/, "");
|
||||
try {
|
||||
const u = new URL(normalizedBaseUrl);
|
||||
if (u.protocol === "http:" && u.hostname !== "127.0.0.1" && u.hostname !== "localhost") {
|
||||
u.protocol = "https:";
|
||||
normalizedBaseUrl = u.toString().replace(/\/+$/, "");
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { apiKey: envKey, baseUrl: normalizedBaseUrl, model: envModel };
|
||||
}
|
||||
|
||||
const text = await findConfigText();
|
||||
if (!text) return null;
|
||||
return parseAiMd(text);
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
export type OpenAiCompatibleChatMessage = {
|
||||
role: "system" | "user" | "assistant";
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type OpenAiCompatibleChatOptions = {
|
||||
baseUrl: string; // 形如 https://host/v1
|
||||
apiKey: string;
|
||||
model: string;
|
||||
timeoutMs?: number;
|
||||
maxTokens?: number;
|
||||
// 某些 OpenAI 兼容网关使用 max_completion_tokens 字段;如需可传入该值
|
||||
maxCompletionTokens?: number;
|
||||
responseFormat?: "json_object";
|
||||
};
|
||||
|
||||
export const tryExtractJsonObject = (value: string): Record<string, unknown> | null => {
|
||||
const s = value ?? "";
|
||||
const start = s.indexOf("{");
|
||||
const end = s.lastIndexOf("}");
|
||||
if (start === -1 || end === -1 || end <= start) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(s.slice(start, end + 1));
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const openAiCompatibleChat = async (
|
||||
messages: OpenAiCompatibleChatMessage[],
|
||||
opts: OpenAiCompatibleChatOptions,
|
||||
): Promise<{ text: string; raw: unknown }> => {
|
||||
const timeoutMs = Math.max(500, Math.min(120_000, opts.timeoutMs ?? 20_000));
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const url = `${opts.baseUrl.replace(/\/+$/, "")}/chat/completions`;
|
||||
const maxCompletionTokens =
|
||||
typeof opts.maxCompletionTokens === "number" && Number.isFinite(opts.maxCompletionTokens)
|
||||
? Math.max(64, Math.min(16_000, Math.floor(opts.maxCompletionTokens)))
|
||||
: undefined;
|
||||
const maxTokens =
|
||||
typeof opts.maxTokens === "number" && Number.isFinite(opts.maxTokens)
|
||||
? Math.max(64, Math.min(16_000, Math.floor(opts.maxTokens)))
|
||||
: undefined;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${opts.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: opts.model,
|
||||
stream: false,
|
||||
temperature: 0.2,
|
||||
...(maxTokens ? { max_tokens: maxTokens } : {}),
|
||||
...(maxCompletionTokens ? { max_completion_tokens: maxCompletionTokens } : {}),
|
||||
...(opts.responseFormat === "json_object" ? { response_format: { type: "json_object" } } : {}),
|
||||
messages,
|
||||
}),
|
||||
});
|
||||
|
||||
const raw = (await res.json().catch(() => null)) as unknown;
|
||||
if (!res.ok) {
|
||||
const errText =
|
||||
typeof raw === "object" && raw && "error" in (raw as any)
|
||||
? String((raw as any).error?.message ?? (raw as any).error)
|
||||
: `HTTP ${res.status}`;
|
||||
throw new Error(`在线 AI 调用失败:${errText}`);
|
||||
}
|
||||
|
||||
const choiceText =
|
||||
(raw as any)?.choices?.[0]?.message?.content ??
|
||||
(raw as any)?.choices?.[0]?.text ??
|
||||
"";
|
||||
const text = String(choiceText ?? "");
|
||||
return { text, raw };
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
};
|
||||
|
||||
export const openAiCompatibleChatJson = async (
|
||||
messages: OpenAiCompatibleChatMessage[],
|
||||
opts: OpenAiCompatibleChatOptions,
|
||||
): Promise<{ json: Record<string, unknown>; text: string; raw: unknown }> => {
|
||||
const { text, raw } = await openAiCompatibleChat(messages, opts);
|
||||
const json = tryExtractJsonObject(text);
|
||||
if (!json) {
|
||||
const preview = String(text || "").slice(0, 220).replace(/\s+/g, " ").trim();
|
||||
throw new Error(`在线 AI 未返回可解析的 JSON 对象:${preview || "(empty)"}`);
|
||||
}
|
||||
return { json, text, raw };
|
||||
};
|
||||
Reference in New Issue
Block a user