0.1.11 ai修复与全屏

This commit is contained in:
liaibo
2026-01-10 10:35:21 +08:00
parent e74219c802
commit 0bcdc3e730
55 changed files with 6597 additions and 174 deletions
@@ -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);
// 兼容格式:
// apikeyxxxx(可选)
// 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);
// 兼容格式:
// apikeyxxxx
// 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 };
};