hob-bot 0.0.0-stage → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +111 -2
- package/README.zh-CN.md +112 -0
- package/bin/hob.mjs +7 -0
- package/dist/web/assets/index-D88l35PH.css +1 -0
- package/dist/web/assets/index-DUs8nN3E.js +218 -0
- package/dist/web/favicon.ico +0 -0
- package/dist/web/fonts/inter-latin.woff2 +0 -0
- package/dist/web/fonts/jetbrains-mono-latin.woff2 +0 -0
- package/dist/web/icons/apple-touch-icon-180.png +0 -0
- package/dist/web/icons/favicon-32.png +0 -0
- package/dist/web/icons/icon-192.png +0 -0
- package/dist/web/icons/icon-512.png +0 -0
- package/dist/web/icons/icon-maskable-512.png +0 -0
- package/dist/web/index.html +32 -0
- package/dist/web/manifest.webmanifest +16 -0
- package/dist/web/sw.js +131 -0
- package/package.json +62 -4
- package/server/src/actions.ts +230 -0
- package/server/src/boot.ts +85 -0
- package/server/src/bus.ts +21 -0
- package/server/src/config.ts +123 -0
- package/server/src/cron-text.ts +49 -0
- package/server/src/delegations.ts +113 -0
- package/server/src/files.ts +142 -0
- package/server/src/http.ts +802 -0
- package/server/src/ids.ts +19 -0
- package/server/src/image.ts +218 -0
- package/server/src/imports.ts +302 -0
- package/server/src/jobs.ts +730 -0
- package/server/src/main.ts +69 -0
- package/server/src/memory.ts +272 -0
- package/server/src/models.ts +358 -0
- package/server/src/overview.ts +116 -0
- package/server/src/pages.ts +54 -0
- package/server/src/password.ts +23 -0
- package/server/src/pi.ts +273 -0
- package/server/src/prompt.ts +227 -0
- package/server/src/providers.ts +532 -0
- package/server/src/push.ts +285 -0
- package/server/src/router-tools.ts +549 -0
- package/server/src/router.ts +841 -0
- package/server/src/routines.ts +129 -0
- package/server/src/runtime.ts +104 -0
- package/server/src/search.ts +91 -0
- package/server/src/secrets.ts +46 -0
- package/server/src/settings.ts +452 -0
- package/server/src/skills.ts +150 -0
- package/server/src/store.ts +324 -0
- package/server/src/text.ts +14 -0
- package/server/src/time.ts +42 -0
- package/server/src/transcript.ts +272 -0
- package/server/src/worker-tools.ts +129 -0
- package/shared/command.ts +42 -0
- package/shared/files.ts +141 -0
- package/shared/media.ts +17 -0
- package/shared/model-note.ts +32 -0
- package/shared/page-shell.ts +108 -0
- package/shared/search.ts +19 -0
- package/shared/skills.ts +37 -0
- package/shared/sprites.ts +422 -0
- package/shared/thinking.ts +118 -0
- package/shared/types.ts +631 -0
- package/vendor/lore-pi-extension/README.md +28 -0
- package/vendor/lore-pi-extension/api.ts +140 -0
- package/vendor/lore-pi-extension/formatters.ts +204 -0
- package/vendor/lore-pi-extension/hooks.ts +195 -0
- package/vendor/lore-pi-extension/index.ts +25 -0
- package/vendor/lore-pi-extension/package.json +23 -0
- package/vendor/lore-pi-extension/skills.ts +447 -0
- package/vendor/lore-pi-extension/tools.ts +344 -0
- package/vendor/lore-pi-extension/uri.ts +67 -0
- package/vendor/lore-pi-extension/vendor/skill-workcopy/index.d.mts +266 -0
- package/vendor/lore-pi-extension/vendor/skill-workcopy/index.mjs +1328 -0
- package/vendor/lore-pi-extension/vendor/skill-workcopy/skills-sync.d.mts +33 -0
- package/vendor/lore-pi-extension/vendor/skill-workcopy/skills-sync.mjs +202 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
4
|
+
|
|
5
|
+
export function newId(prefix: string, length = 6): string {
|
|
6
|
+
const bytes = randomBytes(length);
|
|
7
|
+
let out = "";
|
|
8
|
+
for (let i = 0; i < length; i += 1) out += ALPHABET[bytes[i] % ALPHABET.length];
|
|
9
|
+
return `${prefix}${out}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** 中文 slug:英文/数字保留,其余按拼音做不到就退回随机后缀。 */
|
|
13
|
+
export function slugify(name: string): string {
|
|
14
|
+
const base = name
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
17
|
+
.replace(/^-|-$/g, "");
|
|
18
|
+
return base || newId("a-", 4);
|
|
19
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// 图像生成:OpenAI 兼容接口(Key 存 secrets.json,只写不读回界面)。
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, extname, join } from "node:path";
|
|
4
|
+
import type { ImageSettings } from "../../shared/types.ts";
|
|
5
|
+
import { IMAGE_TYPES } from "../../shared/media.ts";
|
|
6
|
+
import { newId } from "./ids.ts";
|
|
7
|
+
import { readSecrets } from "./secrets.ts";
|
|
8
|
+
|
|
9
|
+
/** secrets.json 里 OpenAI 兼容接口的 Key。 */
|
|
10
|
+
export const IMAGE_KEY_SECRET = "imageApiKey";
|
|
11
|
+
|
|
12
|
+
/** 生成图像可能很慢(一张图几十秒),别让它挂死一个任务。 */
|
|
13
|
+
const TIMEOUT_MS = 180_000;
|
|
14
|
+
|
|
15
|
+
const MIME_BY_EXT = IMAGE_TYPES;
|
|
16
|
+
|
|
17
|
+
const EXT_BY_MIME: Record<string, string> = {
|
|
18
|
+
"image/png": "png",
|
|
19
|
+
"image/jpeg": "jpg",
|
|
20
|
+
"image/webp": "webp",
|
|
21
|
+
"image/gif": "gif",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** 配置不合法就抛错,界面把原因原样显示。 */
|
|
25
|
+
export function checkImageSettings(raw: unknown): ImageSettings {
|
|
26
|
+
const input = (raw ?? {}) as Record<string, unknown>;
|
|
27
|
+
const source = input.source;
|
|
28
|
+
if (source === "off") return { source: "off" };
|
|
29
|
+
if (source === "openai") {
|
|
30
|
+
const baseUrl = typeof input.baseUrl === "string" ? input.baseUrl.trim().replace(/\/+$/, "") : "";
|
|
31
|
+
if (!baseUrl) throw new Error("请填写服务地址,如 http://127.0.0.1:8080/v1。");
|
|
32
|
+
let url: URL;
|
|
33
|
+
try {
|
|
34
|
+
url = new URL(baseUrl);
|
|
35
|
+
} catch {
|
|
36
|
+
throw new Error("服务地址无效:需以 http:// 或 https:// 开头,如 http://127.0.0.1:8080/v1。");
|
|
37
|
+
}
|
|
38
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
39
|
+
throw new Error("服务地址协议无效:仅支持 http:// 或 https://。");
|
|
40
|
+
}
|
|
41
|
+
const model = typeof input.model === "string" ? input.model.trim() : "";
|
|
42
|
+
if (!model) throw new Error("请填写图像模型名称,如 gpt-image-1。");
|
|
43
|
+
const size = typeof input.size === "string" ? input.size.trim() : "";
|
|
44
|
+
return { source: "openai", baseUrl, model, ...(size ? { size } : {}) };
|
|
45
|
+
}
|
|
46
|
+
throw new Error("图片来源无效:可选值为关闭或 OpenAI 兼容接口。");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 读存盘的配置:坏的就退回上一次的值,不因为一个字段把整份设置弄坏。 */
|
|
50
|
+
export function normalizeImageSettings(raw: unknown, fallback: ImageSettings): ImageSettings {
|
|
51
|
+
try {
|
|
52
|
+
return checkImageSettings(raw);
|
|
53
|
+
} catch {
|
|
54
|
+
return fallback;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function imageConfigured(image: ImageSettings): boolean {
|
|
59
|
+
return image.source !== "off";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** OpenAI 兼容接口的 Key(没配就是空字符串)。 */
|
|
63
|
+
export function imageKey(home: string): string {
|
|
64
|
+
return readSecrets(home)[IMAGE_KEY_SECRET]?.trim() ?? "";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 生成图片的上下文。 */
|
|
68
|
+
export interface ImageContext {
|
|
69
|
+
/** HOB_HOME:OpenAI 兼容接口的 Key 从 secrets.json 读 */
|
|
70
|
+
home: string;
|
|
71
|
+
/** 生成的图片存这个目录 */
|
|
72
|
+
dir: string;
|
|
73
|
+
config: ImageSettings;
|
|
74
|
+
/** 给了就用它,不给从 secrets.json 读 */
|
|
75
|
+
apiKey?: string;
|
|
76
|
+
/** 测试注入假服务器 */
|
|
77
|
+
fetchImpl?: typeof fetch;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface GenerateImageInput {
|
|
81
|
+
prompt: string;
|
|
82
|
+
size?: string;
|
|
83
|
+
refs?: string[]; // 参考图(绝对路径)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface ImageBytes {
|
|
87
|
+
data: Buffer;
|
|
88
|
+
mimeType: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function mimeOfPath(path: string): string {
|
|
92
|
+
const mime = MIME_BY_EXT[extname(path).toLowerCase()];
|
|
93
|
+
if (!mime) throw new Error(`参考图格式不支持:仅支持 png、jpg、jpeg、gif、webp,当前文件为 ${path}。`);
|
|
94
|
+
return mime;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 生成一张图,存进目录,返回绝对路径。失败抛错,原因直接给用户看。 */
|
|
98
|
+
export async function generateImage(ctx: ImageContext, input: GenerateImageInput): Promise<string> {
|
|
99
|
+
const prompt = input.prompt.trim();
|
|
100
|
+
if (!prompt) throw new Error("生成失败:提示词为空。");
|
|
101
|
+
if (ctx.config.source !== "openai") {
|
|
102
|
+
throw new Error("尚未配置图像生成:请在「设置 → 图像生成」中选择 OpenAI 兼容接口。");
|
|
103
|
+
}
|
|
104
|
+
const bytes: ImageBytes = await openAIImage(ctx, ctx.config, prompt, input);
|
|
105
|
+
if (!bytes.data.length) throw new Error("生成失败:接口返回了空图片。");
|
|
106
|
+
mkdirSync(ctx.dir, { recursive: true });
|
|
107
|
+
const ext = EXT_BY_MIME[bytes.mimeType.toLowerCase()] ?? "png";
|
|
108
|
+
const path = join(ctx.dir, `${newId("img-")}.${ext}`);
|
|
109
|
+
writeFileSync(path, bytes.data);
|
|
110
|
+
return path;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async function openAIImage(
|
|
115
|
+
ctx: ImageContext,
|
|
116
|
+
config: Extract<ImageSettings, { source: "openai" }>,
|
|
117
|
+
prompt: string,
|
|
118
|
+
input: GenerateImageInput,
|
|
119
|
+
): Promise<ImageBytes> {
|
|
120
|
+
const key = ctx.apiKey?.trim() || imageKey(ctx.home);
|
|
121
|
+
if (!key) throw new Error("尚未填写 API Key:请在「设置 → 图像生成」中填写后重试。");
|
|
122
|
+
const size = input.size?.trim() || config.size?.trim() || "";
|
|
123
|
+
const refs = input.refs ?? [];
|
|
124
|
+
const fetchImpl = ctx.fetchImpl ?? fetch;
|
|
125
|
+
const endpoint = `${config.baseUrl}${refs.length ? "/images/edits" : "/images/generations"}`;
|
|
126
|
+
|
|
127
|
+
let request: RequestInit;
|
|
128
|
+
if (refs.length) {
|
|
129
|
+
// 有参考图:multipart 打到 /images/edits
|
|
130
|
+
const form = new FormData();
|
|
131
|
+
form.append("model", config.model);
|
|
132
|
+
form.append("prompt", prompt);
|
|
133
|
+
if (size) form.append("size", size);
|
|
134
|
+
for (const ref of refs) {
|
|
135
|
+
const mimeType = mimeOfPath(ref);
|
|
136
|
+
form.append(refs.length > 1 ? "image[]" : "image", new Blob([readFileSync(ref)], { type: mimeType }), basename(ref));
|
|
137
|
+
}
|
|
138
|
+
request = { method: "POST", headers: { authorization: `Bearer ${key}` }, body: form };
|
|
139
|
+
} else {
|
|
140
|
+
request = {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
|
|
143
|
+
// 不传 response_format:有的服务只给 URL,两种都支持,别逼它
|
|
144
|
+
body: JSON.stringify({ model: config.model, prompt, ...(size ? { size } : {}), n: 1 }),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const response = await send(fetchImpl, endpoint, request, config.baseUrl);
|
|
149
|
+
const body = (await response.json().catch(() => undefined)) as unknown;
|
|
150
|
+
const first = firstImage(body);
|
|
151
|
+
if (!first) throw new Error(`生成失败:接口未返回图片:${snippet(body)}`);
|
|
152
|
+
if (first.b64_json) return { data: Buffer.from(first.b64_json, "base64"), mimeType: "image/png" };
|
|
153
|
+
const fetched = await send(fetchImpl, first.url!, { method: "GET" }, first.url!);
|
|
154
|
+
const mimeType = fetched.headers.get("content-type")?.split(";")[0]?.trim() || "image/png";
|
|
155
|
+
return { data: Buffer.from(await fetched.arrayBuffer()), mimeType };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** OpenAI 兼容接口的返回里挑第一张图(base64 或 URL 都行)。 */
|
|
159
|
+
export function firstImage(body: unknown): { b64_json?: string; url?: string } | undefined {
|
|
160
|
+
const data = (body as { data?: unknown } | undefined)?.data;
|
|
161
|
+
if (!Array.isArray(data) || !data.length) return undefined;
|
|
162
|
+
const first = data[0] as { b64_json?: unknown; url?: unknown } | undefined;
|
|
163
|
+
if (!first || typeof first !== "object") return undefined;
|
|
164
|
+
const b64 = typeof first.b64_json === "string" && first.b64_json ? first.b64_json : undefined;
|
|
165
|
+
const url = typeof first.url === "string" && first.url ? first.url : undefined;
|
|
166
|
+
return b64 || url ? { ...(b64 ? { b64_json: b64 } : {}), ...(url ? { url } : {}) } : undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function send(fetchImpl: typeof fetch, url: string, init: RequestInit, label: string): Promise<Response> {
|
|
170
|
+
let response: Response;
|
|
171
|
+
try {
|
|
172
|
+
response = await fetchImpl(url, { ...init, signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
173
|
+
} catch (error) {
|
|
174
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
175
|
+
if (/timeout|timed out|abort/i.test(reason)) throw new Error(`${label} 请求超时:${TIMEOUT_MS / 1000} 秒内未响应。`);
|
|
176
|
+
throw new Error(`无法连接 ${label}:${reason}`);
|
|
177
|
+
}
|
|
178
|
+
if (!response.ok) throw new Error(await describeFailure(response, label));
|
|
179
|
+
return response;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function describeFailure(response: Response, label: string): Promise<string> {
|
|
183
|
+
const detail = errorText(await response.text().catch(() => ""));
|
|
184
|
+
const suffix = detail ? `:${detail}` : "";
|
|
185
|
+
if (response.status === 401 || response.status === 403) {
|
|
186
|
+
return `${label} 拒绝该 API Key(${response.status})${suffix}`;
|
|
187
|
+
}
|
|
188
|
+
if (response.status === 404) {
|
|
189
|
+
return `${label} 未提供该接口或模型(404)${suffix}。请检查服务地址是否需要包含 /v1,以及模型名称是否正确。`;
|
|
190
|
+
}
|
|
191
|
+
return `${label} 返回状态码 ${response.status}${suffix}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** 从错误体里抠出人能看懂的一句话。 */
|
|
195
|
+
function errorText(body: string): string {
|
|
196
|
+
const text = body.trim();
|
|
197
|
+
if (!text) return "";
|
|
198
|
+
try {
|
|
199
|
+
const parsed = JSON.parse(text) as { error?: unknown; message?: unknown };
|
|
200
|
+
const error = parsed.error;
|
|
201
|
+
if (typeof error === "string") return error;
|
|
202
|
+
if (error && typeof error === "object") {
|
|
203
|
+
const message = (error as { message?: unknown }).message;
|
|
204
|
+
if (typeof message === "string") return message;
|
|
205
|
+
}
|
|
206
|
+
if (typeof parsed.message === "string") return parsed.message;
|
|
207
|
+
} catch {
|
|
208
|
+
// 不是 JSON:原样给一小段
|
|
209
|
+
}
|
|
210
|
+
return snippet(text);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function snippet(body: unknown): string {
|
|
214
|
+
const text = typeof body === "string" ? body : JSON.stringify(body ?? "");
|
|
215
|
+
const clean = text.replace(/\s+/g, " ").trim();
|
|
216
|
+
if (!clean) return "(空)";
|
|
217
|
+
return clean.length > 200 ? `${clean.slice(0, 199)}…` : clean;
|
|
218
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// 从本机已有的配置导入服务商和 Lore 连接:只读来源文件,绝不写回、不改权限和时间戳。
|
|
2
|
+
// Key / Token 只在内存里传给调用方,任何接口返回值和日志里都不出现明文。
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { THINKING_LEVELS, THINKING_TEXT_MAX } from "../../shared/thinking.ts";
|
|
7
|
+
import type {
|
|
8
|
+
LoreImportView,
|
|
9
|
+
ProviderApi,
|
|
10
|
+
ProviderImportItem,
|
|
11
|
+
ProviderImportKey,
|
|
12
|
+
ProviderModelSetting,
|
|
13
|
+
ProviderSettings,
|
|
14
|
+
ThinkingLevelMapSetting,
|
|
15
|
+
} from "../../shared/types.ts";
|
|
16
|
+
|
|
17
|
+
const MAX_BYTES = 256 * 1024;
|
|
18
|
+
const SLUG = /^[a-z0-9][a-z0-9._-]*$/;
|
|
19
|
+
/** 整个值就是一个环境变量引用的写法(和引擎的解析规则一致:`$NAME` / `${NAME}`)。 */
|
|
20
|
+
const SINGLE_ENV_REF = /^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?$/;
|
|
21
|
+
/** 服务商里由 Hob 自己管的字段,其余(compat、headers、authHeader…)原样透传给引擎。 */
|
|
22
|
+
const PROVIDER_OWNED_KEYS = new Set(["name", "baseUrl", "api", "apiKey", "models"]);
|
|
23
|
+
/** 模型里由 Hob 自己映射的字段,其余进模型的 extra;thinkingLevelMap 进 Hob 的自定义思考档。 */
|
|
24
|
+
const MODEL_OWNED_KEYS = new Set(["id", "name", "input", "reasoning", "contextWindow", "maxTokens", "thinkingLevelMap"]);
|
|
25
|
+
|
|
26
|
+
const COMMAND_KEY_NOTE = "Key 需通过命令获取,请导入后手动填写。";
|
|
27
|
+
|
|
28
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
29
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function text(value: unknown): string {
|
|
33
|
+
return typeof value === "string" ? value.trim() : "";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function positive(value: unknown): number | undefined {
|
|
37
|
+
const n = Number(value);
|
|
38
|
+
return Number.isFinite(n) && n > 0 ? Math.round(n) : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readJson(path: string): Record<string, unknown> | undefined {
|
|
42
|
+
if (!existsSync(path)) return undefined;
|
|
43
|
+
try {
|
|
44
|
+
const raw = readFileSync(path, "utf8");
|
|
45
|
+
if (Buffer.byteLength(raw, "utf8") > MAX_BYTES) return undefined;
|
|
46
|
+
const parsed: unknown = JSON.parse(raw);
|
|
47
|
+
return isRecord(parsed) ? parsed : undefined;
|
|
48
|
+
} catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 本机原有的引擎目录(启动时记下的原值,没启动过就用默认位置)。 */
|
|
54
|
+
export function localPiDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
55
|
+
return text(env.HOB_LOCAL_PI_DIR) || join(homedir(), ".pi", "agent");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 本机原有的 Lore 目录。 */
|
|
59
|
+
export function localLoreHome(env: NodeJS.ProcessEnv = process.env): string {
|
|
60
|
+
return text(env.HOB_LOCAL_LORE_HOME) || join(homedir(), ".lore");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ImportKey {
|
|
64
|
+
kind: ProviderImportKey;
|
|
65
|
+
/** 字面值或环境变量的值;命令不执行,所以没有 */
|
|
66
|
+
value?: string;
|
|
67
|
+
note?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 把本机配置里的 Key 解析成能直接用的样子:字面值直接用,环境变量取值,命令不执行。 */
|
|
71
|
+
export function resolveImportKey(raw: unknown, env: NodeJS.ProcessEnv = process.env): ImportKey {
|
|
72
|
+
const value = text(raw);
|
|
73
|
+
if (!value) return { kind: "none" };
|
|
74
|
+
if (value.startsWith("!")) return { kind: "command", note: COMMAND_KEY_NOTE };
|
|
75
|
+
const reference = SINGLE_ENV_REF.exec(value);
|
|
76
|
+
if (reference) {
|
|
77
|
+
const name = reference[1]!;
|
|
78
|
+
const resolved = text(env[name]);
|
|
79
|
+
if (resolved) return { kind: "env", value: resolved };
|
|
80
|
+
return { kind: "none", note: `Key 引用的环境变量 ${name} 未设置,请导入后手动填写。` };
|
|
81
|
+
}
|
|
82
|
+
return { kind: "literal", value };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 本机那份 thinkingLevelMap 在导入那一刻原样复制进 Hob 的自定义档位:
|
|
87
|
+
* 写明的字符串和 null 都带上(null = 这一档不要),没写的键不补。
|
|
88
|
+
* 没写的档之后按 Hob 的默认规则算(默认范围里引擎默认不开的 xhigh / max 会自动开),
|
|
89
|
+
* 不再拿档名去补齐——写了字符串的档引擎会原样发出去,那不是每个协议都收的值。
|
|
90
|
+
* 返回 undefined 表示本机没写思考档(用默认规则)。
|
|
91
|
+
*/
|
|
92
|
+
function importThinking(raw: unknown): ThinkingLevelMapSetting | undefined {
|
|
93
|
+
if (!isRecord(raw)) return undefined;
|
|
94
|
+
const map: ThinkingLevelMapSetting = {};
|
|
95
|
+
for (const level of THINKING_LEVELS) {
|
|
96
|
+
if (!(level in raw)) continue;
|
|
97
|
+
const value = raw[level];
|
|
98
|
+
if (value === null) {
|
|
99
|
+
// 关闭这一档不能删(Hob 的规矩),本机写 null 就当没改
|
|
100
|
+
if (level !== "off") map[level] = null;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const text = typeof value === "string" ? value.trim() : "";
|
|
104
|
+
// 读不懂的值当没写,不因此把这一档删掉
|
|
105
|
+
if (!text || text.length > THINKING_TEXT_MAX) continue;
|
|
106
|
+
map[level] = text;
|
|
107
|
+
}
|
|
108
|
+
return Object.keys(map).length ? map : undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function mapModel(raw: unknown): { model: ProviderModelSetting; thinking?: ThinkingLevelMapSetting } | undefined {
|
|
112
|
+
if (!isRecord(raw)) return undefined;
|
|
113
|
+
const id = text(raw.id);
|
|
114
|
+
if (!id) return undefined;
|
|
115
|
+
const extra: Record<string, unknown> = {};
|
|
116
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
117
|
+
if (!MODEL_OWNED_KEYS.has(key)) extra[key] = value;
|
|
118
|
+
}
|
|
119
|
+
const name = text(raw.name);
|
|
120
|
+
const input = Array.isArray(raw.input)
|
|
121
|
+
? raw.input.filter((kind): kind is "text" | "image" => kind === "text" || kind === "image")
|
|
122
|
+
: [];
|
|
123
|
+
const contextWindow = positive(raw.contextWindow);
|
|
124
|
+
const maxTokens = positive(raw.maxTokens);
|
|
125
|
+
const reasoning = typeof raw.reasoning === "boolean" ? raw.reasoning : false;
|
|
126
|
+
// 本机那份的思考档只复制这一次:之后只看 Hob 设置里存的,和本机再无关系
|
|
127
|
+
const thinking = reasoning ? importThinking(raw.thinkingLevelMap) : undefined;
|
|
128
|
+
return {
|
|
129
|
+
model: {
|
|
130
|
+
id,
|
|
131
|
+
...(name ? { name } : {}),
|
|
132
|
+
...(input.length ? { input } : {}),
|
|
133
|
+
...(typeof raw.reasoning === "boolean" ? { reasoning: raw.reasoning } : {}),
|
|
134
|
+
...(contextWindow ? { contextWindow } : {}),
|
|
135
|
+
...(maxTokens ? { maxTokens } : {}),
|
|
136
|
+
...(Object.keys(extra).length ? { extra } : {}),
|
|
137
|
+
},
|
|
138
|
+
...(thinking ? { thinking } : {}),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 一个候选:界面看到的那一项 + 真正要写进设置的内容。 */
|
|
143
|
+
export interface ProviderImportCandidate {
|
|
144
|
+
item: ProviderImportItem;
|
|
145
|
+
/** 可导入时有值:要写进 settings.json 的服务商(Key 不在里面) */
|
|
146
|
+
provider?: ProviderSettings;
|
|
147
|
+
/** 要写进 secrets.json 的 Key(命令和缺失的环境变量没有) */
|
|
148
|
+
key?: string;
|
|
149
|
+
/** 导入那一刻从本机复制的思考档,键是模型 ref(之后只看 Hob 设置) */
|
|
150
|
+
thinking?: Record<string, ThinkingLevelMapSetting>;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface ListProviderImportsOptions {
|
|
154
|
+
/** 本机引擎目录(HOB_LOCAL_PI_DIR) */
|
|
155
|
+
source: string;
|
|
156
|
+
/** 已经配置过的服务商 id(界面上标「将覆盖」) */
|
|
157
|
+
existing: Set<string>;
|
|
158
|
+
/** 引擎自带的服务商 id(auth.json 里的条目只有引擎认识才列出来) */
|
|
159
|
+
builtinIds: ReadonlySet<string>;
|
|
160
|
+
/** 引擎自带的模型数(内置服务商那一项显示用) */
|
|
161
|
+
builtinModelCount?: (id: string) => number;
|
|
162
|
+
/** 支持哪些协议(和注册进引擎时一致) */
|
|
163
|
+
apis: readonly ProviderApi[];
|
|
164
|
+
env?: NodeJS.ProcessEnv;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function customCandidate(
|
|
168
|
+
id: string,
|
|
169
|
+
raw: unknown,
|
|
170
|
+
options: ListProviderImportsOptions,
|
|
171
|
+
): ProviderImportCandidate | undefined {
|
|
172
|
+
if (!isRecord(raw)) return undefined;
|
|
173
|
+
const knownApi = options.apis.includes(text(raw.api) as ProviderApi);
|
|
174
|
+
const api = text(raw.api) as ProviderApi;
|
|
175
|
+
const baseUrl = text(raw.baseUrl).replace(/\/+$/, "");
|
|
176
|
+
const mapped = Array.isArray(raw.models) ? raw.models.map(mapModel).filter((entry) => entry !== undefined) : [];
|
|
177
|
+
const models = mapped.map((entry) => entry.model);
|
|
178
|
+
const thinking: Record<string, ThinkingLevelMapSetting> = {};
|
|
179
|
+
for (const entry of mapped) {
|
|
180
|
+
if (entry.thinking) thinking[`${id}/${entry.model.id}`] = entry.thinking;
|
|
181
|
+
}
|
|
182
|
+
const extra: Record<string, unknown> = {};
|
|
183
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
184
|
+
if (!PROVIDER_OWNED_KEYS.has(key)) extra[key] = value;
|
|
185
|
+
}
|
|
186
|
+
const key = resolveImportKey(raw.apiKey, options.env ?? process.env);
|
|
187
|
+
const usable = SLUG.test(id) && knownApi && baseUrl !== "" && models.length > 0;
|
|
188
|
+
const note = usable
|
|
189
|
+
? key.note
|
|
190
|
+
: !SLUG.test(id)
|
|
191
|
+
? "服务商标识不符合要求,无法导入。"
|
|
192
|
+
: !knownApi
|
|
193
|
+
? "缺少协议或协议不支持,无法导入。"
|
|
194
|
+
: baseUrl === ""
|
|
195
|
+
? "缺少服务地址,无法导入。"
|
|
196
|
+
: "没有模型列表,无法导入。";
|
|
197
|
+
const item: ProviderImportItem = {
|
|
198
|
+
id,
|
|
199
|
+
kind: "custom",
|
|
200
|
+
...(knownApi ? { api } : {}),
|
|
201
|
+
...(baseUrl ? { baseUrl } : {}),
|
|
202
|
+
models: models.length,
|
|
203
|
+
key: key.kind,
|
|
204
|
+
exists: options.existing.has(id),
|
|
205
|
+
selectable: usable,
|
|
206
|
+
...(note ? { note } : {}),
|
|
207
|
+
};
|
|
208
|
+
if (!usable) return { item };
|
|
209
|
+
const provider: ProviderSettings = {
|
|
210
|
+
kind: "custom",
|
|
211
|
+
id,
|
|
212
|
+
// 显示名就用 id:本机那份的 name 只是那边起的名字,换成 id 两边好对应
|
|
213
|
+
name: id,
|
|
214
|
+
api,
|
|
215
|
+
baseUrl,
|
|
216
|
+
models,
|
|
217
|
+
...(Object.keys(extra).length ? { extra } : {}),
|
|
218
|
+
};
|
|
219
|
+
return { item, provider, ...(key.value ? { key: key.value } : {}), ...(Object.keys(thinking).length ? { thinking } : {}) };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function builtinCandidate(
|
|
223
|
+
id: string,
|
|
224
|
+
raw: unknown,
|
|
225
|
+
options: ListProviderImportsOptions,
|
|
226
|
+
): ProviderImportCandidate | undefined {
|
|
227
|
+
if (!options.builtinIds.has(id) || !isRecord(raw)) return undefined;
|
|
228
|
+
const models = options.builtinModelCount?.(id) ?? 0;
|
|
229
|
+
const credentialType = text(raw.type);
|
|
230
|
+
if (credentialType !== "api_key") {
|
|
231
|
+
return {
|
|
232
|
+
item: {
|
|
233
|
+
id,
|
|
234
|
+
kind: "builtin",
|
|
235
|
+
models,
|
|
236
|
+
key: "none",
|
|
237
|
+
exists: options.existing.has(id),
|
|
238
|
+
selectable: false,
|
|
239
|
+
note: "登录授权类凭据无法导入。",
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
const key = resolveImportKey(raw.key, options.env ?? process.env);
|
|
244
|
+
return {
|
|
245
|
+
item: {
|
|
246
|
+
id,
|
|
247
|
+
kind: "builtin",
|
|
248
|
+
models,
|
|
249
|
+
key: key.kind,
|
|
250
|
+
exists: options.existing.has(id),
|
|
251
|
+
selectable: true,
|
|
252
|
+
...(key.note ? { note: key.note } : {}),
|
|
253
|
+
},
|
|
254
|
+
provider: { kind: "builtin", id },
|
|
255
|
+
...(key.value ? { key: key.value } : {}),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* 读本机的 models.json(兼容接口)和 auth.json(内置服务商),列出能导入的项。
|
|
261
|
+
* 同一 id 只列一次,models.json 的先列。
|
|
262
|
+
*/
|
|
263
|
+
export function listProviderImports(options: ListProviderImportsOptions): ProviderImportCandidate[] {
|
|
264
|
+
const modelsFile = readJson(join(options.source, "models.json"));
|
|
265
|
+
const authFile = readJson(join(options.source, "auth.json"));
|
|
266
|
+
const out: ProviderImportCandidate[] = [];
|
|
267
|
+
const seen = new Set<string>();
|
|
268
|
+
const push = (candidate: ProviderImportCandidate | undefined) => {
|
|
269
|
+
if (!candidate || seen.has(candidate.item.id)) return;
|
|
270
|
+
seen.add(candidate.item.id);
|
|
271
|
+
out.push(candidate);
|
|
272
|
+
};
|
|
273
|
+
const providers = modelsFile?.providers;
|
|
274
|
+
if (isRecord(providers)) {
|
|
275
|
+
for (const [id, raw] of Object.entries(providers)) push(customCandidate(id, raw, options));
|
|
276
|
+
}
|
|
277
|
+
if (authFile) {
|
|
278
|
+
for (const [id, raw] of Object.entries(authFile)) push(builtinCandidate(id, raw, options));
|
|
279
|
+
}
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** 读本机的 Lore 连接(只读)。token 只在导入时用,不返回给页面。 */
|
|
284
|
+
export function readLocalLoreConnection(loreHome: string): { view: LoreImportView; token?: string } {
|
|
285
|
+
const document = readJson(join(loreHome, "config.json")) ?? {};
|
|
286
|
+
const baseUrl = text(document.base_url).replace(/\/+$/, "");
|
|
287
|
+
const token = text(document.api_token);
|
|
288
|
+
return {
|
|
289
|
+
view: {
|
|
290
|
+
source: loreHome,
|
|
291
|
+
found: baseUrl !== "",
|
|
292
|
+
...(baseUrl ? { baseUrl } : {}),
|
|
293
|
+
tokenPresent: token !== "",
|
|
294
|
+
},
|
|
295
|
+
...(token ? { token } : {}),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** 本机 Lore 连接的页面视图(不带 Token)。 */
|
|
300
|
+
export function readLocalLore(loreHome: string): LoreImportView {
|
|
301
|
+
return readLocalLoreConnection(loreHome).view;
|
|
302
|
+
}
|