Initial commit
This commit is contained in:
159
src/config/api-profiles.ts
Normal file
159
src/config/api-profiles.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { LlmConfig } from "./env.js";
|
||||
import { loadLlmConfigOptional } from "./env.js";
|
||||
import { ensureUserDataDirs, getUserDataDir } from "./user-data-dir.js";
|
||||
|
||||
export type ApiProfile = {
|
||||
id: string;
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type ApiProfilesFile = {
|
||||
version: 1;
|
||||
profiles: ApiProfile[];
|
||||
};
|
||||
|
||||
const FILE_NAME = "profiles.json";
|
||||
|
||||
function profilesPath(): string {
|
||||
return path.join(getUserDataDir(), FILE_NAME);
|
||||
}
|
||||
|
||||
function readFile(): ApiProfilesFile {
|
||||
ensureUserDataDirs();
|
||||
try {
|
||||
const raw = readFileSync(profilesPath(), "utf8");
|
||||
const parsed = JSON.parse(raw) as ApiProfilesFile;
|
||||
if (parsed.version !== 1 || !Array.isArray(parsed.profiles)) {
|
||||
return { version: 1, profiles: [] };
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return { version: 1, profiles: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function writeFile(data: ApiProfilesFile): void {
|
||||
ensureUserDataDirs();
|
||||
writeFileSync(profilesPath(), JSON.stringify(data, null, 2), "utf8");
|
||||
}
|
||||
|
||||
function seedFromEnvIfEmpty(data: ApiProfilesFile): ApiProfilesFile {
|
||||
if (data.profiles.length > 0) return data;
|
||||
const env = loadLlmConfigOptional();
|
||||
if (!env) return data;
|
||||
const now = new Date().toISOString();
|
||||
const profile: ApiProfile = {
|
||||
id: randomUUID(),
|
||||
name: "环境变量 (.env)",
|
||||
baseUrl: env.baseUrl,
|
||||
apiKey: env.apiKey,
|
||||
model: env.model,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
return { version: 1, profiles: [profile] };
|
||||
}
|
||||
|
||||
export function listApiProfiles(): ApiProfile[] {
|
||||
const data = seedFromEnvIfEmpty(readFile());
|
||||
if (data.profiles.length !== readFile().profiles.length) {
|
||||
writeFile(data);
|
||||
}
|
||||
return data.profiles;
|
||||
}
|
||||
|
||||
export function getApiProfile(id: string): ApiProfile | null {
|
||||
return listApiProfiles().find((p) => p.id === id) ?? null;
|
||||
}
|
||||
|
||||
export function createApiProfile(input: {
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
}): ApiProfile {
|
||||
const data = readFile();
|
||||
const now = new Date().toISOString();
|
||||
const profile: ApiProfile = {
|
||||
id: randomUUID(),
|
||||
name: input.name.trim() || "未命名",
|
||||
baseUrl: input.baseUrl.trim() || "https://api.deepseek.com",
|
||||
apiKey: input.apiKey.trim(),
|
||||
model: input.model.trim() || "deepseek-v4-pro",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
data.profiles.push(profile);
|
||||
writeFile(data);
|
||||
return profile;
|
||||
}
|
||||
|
||||
export function updateApiProfile(
|
||||
id: string,
|
||||
input: Partial<Pick<ApiProfile, "name" | "baseUrl" | "apiKey" | "model">>,
|
||||
): ApiProfile {
|
||||
const data = readFile();
|
||||
const index = data.profiles.findIndex((p) => p.id === id);
|
||||
if (index < 0) throw new Error("API 配置不存在");
|
||||
const current = data.profiles[index];
|
||||
const updated: ApiProfile = {
|
||||
...current,
|
||||
name: input.name?.trim() || current.name,
|
||||
baseUrl: input.baseUrl?.trim() || current.baseUrl,
|
||||
apiKey: input.apiKey !== undefined && input.apiKey.trim() !== ""
|
||||
? input.apiKey.trim()
|
||||
: current.apiKey,
|
||||
model: input.model?.trim() || current.model,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
data.profiles[index] = updated;
|
||||
writeFile(data);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function deleteApiProfile(id: string): void {
|
||||
const data = readFile();
|
||||
data.profiles = data.profiles.filter((p) => p.id !== id);
|
||||
writeFile(data);
|
||||
}
|
||||
|
||||
export function profileToLlmConfig(profile: ApiProfile): LlmConfig {
|
||||
return {
|
||||
baseUrl: profile.baseUrl,
|
||||
apiKey: profile.apiKey,
|
||||
model: profile.model,
|
||||
};
|
||||
}
|
||||
|
||||
export async function testApiProfile(profile: ApiProfile): Promise<{
|
||||
ok: boolean;
|
||||
message: string;
|
||||
}> {
|
||||
const url = `${profile.baseUrl.replace(/\/$/, "")}/models`;
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${profile.apiKey}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
return { ok: true, message: "连接成功" };
|
||||
}
|
||||
const body = await response.text();
|
||||
return {
|
||||
ok: false,
|
||||
message: `HTTP ${response.status}: ${body.slice(0, 200)}`,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
message: err instanceof Error ? err.message : "连接失败",
|
||||
};
|
||||
}
|
||||
}
|
||||
56
src/config/env.ts
Normal file
56
src/config/env.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export type LlmConfig = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
export function loadLlmConfig(): LlmConfig {
|
||||
const baseUrl =
|
||||
process.env.OPENAI_BASE_URL ?? "https://api.deepseek.com";
|
||||
const apiKey = process.env.OPENAI_API_KEY ?? "";
|
||||
const model = process.env.OPENAI_MODEL ?? "deepseek-v4-pro";
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
"Missing OPENAI_API_KEY. Set it in environment or use --mock for offline demo.",
|
||||
);
|
||||
}
|
||||
|
||||
return { baseUrl, apiKey, model };
|
||||
}
|
||||
|
||||
export function loadLlmConfigOptional(): LlmConfig | null {
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
if (!apiKey) return null;
|
||||
return {
|
||||
baseUrl: process.env.OPENAI_BASE_URL ?? "https://api.deepseek.com",
|
||||
apiKey,
|
||||
model: process.env.OPENAI_MODEL ?? "deepseek-v4-pro",
|
||||
};
|
||||
}
|
||||
|
||||
export function loadDotEnv(cwd = process.cwd()): void {
|
||||
const envPath = path.join(cwd, ".env");
|
||||
if (!existsSync(envPath)) return;
|
||||
|
||||
const content = readFileSync(envPath, "utf8");
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq <= 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
if (process.env[key] !== undefined) continue;
|
||||
let value = trimmed.slice(eq + 1).trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
101
src/config/settings.ts
Normal file
101
src/config/settings.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { listApiProfiles } from "./api-profiles.js";
|
||||
import { loadLlmConfigOptional } from "./env.js";
|
||||
import { ensureUserDataDirs, getUserDataDir } from "./user-data-dir.js";
|
||||
|
||||
export type AppSettings = {
|
||||
version: 1;
|
||||
activeProfileId: string | null;
|
||||
activePresetId: string | null;
|
||||
};
|
||||
|
||||
const FILE_NAME = "settings.json";
|
||||
|
||||
function settingsPath(): string {
|
||||
return path.join(getUserDataDir(), FILE_NAME);
|
||||
}
|
||||
|
||||
function defaultSettings(): AppSettings {
|
||||
return {
|
||||
version: 1,
|
||||
activeProfileId: null,
|
||||
activePresetId: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadAppSettings(): AppSettings {
|
||||
ensureUserDataDirs();
|
||||
try {
|
||||
const raw = readFileSync(settingsPath(), "utf8");
|
||||
const parsed = JSON.parse(raw) as AppSettings;
|
||||
if (parsed.version !== 1) return defaultSettings();
|
||||
return {
|
||||
version: 1,
|
||||
activeProfileId: parsed.activeProfileId ?? null,
|
||||
activePresetId: parsed.activePresetId ?? null,
|
||||
};
|
||||
} catch {
|
||||
return defaultSettings();
|
||||
}
|
||||
}
|
||||
|
||||
export function saveAppSettings(settings: AppSettings): void {
|
||||
ensureUserDataDirs();
|
||||
writeFileSync(settingsPath(), JSON.stringify(settings, null, 2), "utf8");
|
||||
}
|
||||
|
||||
export function setActiveProfileId(id: string | null): AppSettings {
|
||||
const settings = loadAppSettings();
|
||||
settings.activeProfileId = id;
|
||||
saveAppSettings(settings);
|
||||
return settings;
|
||||
}
|
||||
|
||||
export function setActivePresetId(id: string | null): AppSettings {
|
||||
const settings = loadAppSettings();
|
||||
settings.activePresetId = id;
|
||||
saveAppSettings(settings);
|
||||
return settings;
|
||||
}
|
||||
|
||||
/** 解析当前应使用的 API profile:settings > 首个 profile > .env */
|
||||
export function resolveActiveProfile() {
|
||||
const settings = loadAppSettings();
|
||||
const profiles = listApiProfiles();
|
||||
|
||||
if (settings.activeProfileId) {
|
||||
const found = profiles.find((p) => p.id === settings.activeProfileId);
|
||||
if (found?.apiKey?.trim()) return found;
|
||||
}
|
||||
|
||||
if (profiles.length > 0) {
|
||||
const withKey = profiles.find((p) => p.apiKey?.trim());
|
||||
if (withKey) return withKey;
|
||||
}
|
||||
|
||||
const env = loadLlmConfigOptional();
|
||||
if (env) {
|
||||
return {
|
||||
id: "__env__",
|
||||
name: "环境变量 (.env)",
|
||||
baseUrl: env.baseUrl,
|
||||
apiKey: env.apiKey,
|
||||
model: env.model,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ensureActiveProfileDefault(): AppSettings {
|
||||
const settings = loadAppSettings();
|
||||
const profiles = listApiProfiles();
|
||||
if (!settings.activeProfileId && profiles.length > 0) {
|
||||
settings.activeProfileId = profiles[0].id;
|
||||
saveAppSettings(settings);
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
25
src/config/user-data-dir.ts
Normal file
25
src/config/user-data-dir.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
/** 本地用户数据目录(不进 git、不同步) */
|
||||
export function getUserDataDir(): string {
|
||||
if (process.env.WRITING_AGENT_DATA_DIR) {
|
||||
return path.resolve(process.env.WRITING_AGENT_DATA_DIR);
|
||||
}
|
||||
return path.join(os.homedir(), ".writing-agent");
|
||||
}
|
||||
|
||||
export function getPresetsDir(): string {
|
||||
return path.join(getUserDataDir(), "presets");
|
||||
}
|
||||
|
||||
export function getBooksDir(): string {
|
||||
return path.join(getUserDataDir(), "books");
|
||||
}
|
||||
|
||||
export function ensureUserDataDirs(): void {
|
||||
mkdirSync(getPresetsDir(), { recursive: true });
|
||||
mkdirSync(getBooksDir(), { recursive: true });
|
||||
mkdirSync(path.join(getUserDataDir(), "stats"), { recursive: true });
|
||||
}
|
||||
Reference in New Issue
Block a user