Initial commit

This commit is contained in:
2026-07-10 08:31:27 +08:00
commit 2b74c30d36
134 changed files with 21801 additions and 0 deletions

159
src/config/api-profiles.ts Normal file
View 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
View 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
View 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 profilesettings > 首个 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;
}

View 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 });
}