diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index 2ddc9bdb..139d0d22 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -2,7 +2,13 @@ import { readFile, access } from "node:fs/promises"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; import { config as loadEnv } from "dotenv"; -import { createLLMClient, StateManager, type ProjectConfig, ProjectConfigSchema, type PipelineConfig } from "@actalk/inkos-core"; +import { + createLLMClient, + StateManager, + type ProjectConfig, + ProjectConfigSchema, + type PipelineConfig, +} from "@actalk/inkos-core"; export const GLOBAL_CONFIG_DIR = join(homedir(), ".inkos"); export const GLOBAL_ENV_PATH = join(GLOBAL_CONFIG_DIR, ".env"); @@ -12,9 +18,11 @@ export async function resolveContext(opts: { readonly contextFile?: string; }): Promise { if (opts.context) return opts.context; + if (opts.contextFile) { return readFile(resolve(opts.contextFile), "utf-8"); } + // Read from stdin if piped (non-TTY) if (!process.stdin.isTTY) { const chunks: Buffer[] = []; @@ -24,6 +32,7 @@ export async function resolveContext(opts: { const text = Buffer.concat(chunks).toString("utf-8").trim(); if (text.length > 0) return text; } + return undefined; } @@ -56,30 +65,68 @@ export async function loadConfig(): Promise { try { config = JSON.parse(raw); } catch { - throw new Error(`inkos.json in ${root} is not valid JSON. Check the file for syntax errors.`); + throw new Error( + `inkos.json in ${root} is not valid JSON.\nCheck the file for syntax errors.`, + ); } // .env overrides inkos.json for LLM settings const env = process.env; const llm = (config.llm ?? {}) as Record; + if (env.INKOS_LLM_PROVIDER) llm.provider = env.INKOS_LLM_PROVIDER; if (env.INKOS_LLM_BASE_URL) llm.baseUrl = env.INKOS_LLM_BASE_URL; if (env.INKOS_LLM_MODEL) llm.model = env.INKOS_LLM_MODEL; - if (env.INKOS_LLM_TEMPERATURE) llm.temperature = parseFloat(env.INKOS_LLM_TEMPERATURE); - if (env.INKOS_LLM_MAX_TOKENS) llm.maxTokens = parseInt(env.INKOS_LLM_MAX_TOKENS, 10); - if (env.INKOS_LLM_THINKING_BUDGET) llm.thinkingBudget = parseInt(env.INKOS_LLM_THINKING_BUDGET, 10); + if (env.INKOS_LLM_TEMPERATURE) { + llm.temperature = parseFloat(env.INKOS_LLM_TEMPERATURE); + } + if (env.INKOS_LLM_MAX_TOKENS) { + llm.maxTokens = parseInt(env.INKOS_LLM_MAX_TOKENS, 10); + } + if (env.INKOS_LLM_THINKING_BUDGET) { + llm.thinkingBudget = parseInt(env.INKOS_LLM_THINKING_BUDGET, 10); + } if (env.INKOS_LLM_API_FORMAT) llm.apiFormat = env.INKOS_LLM_API_FORMAT; + config.llm = llm; // API key ONLY from env — never stored in inkos.json const apiKey = env.INKOS_LLM_API_KEY; if (!apiKey) { throw new Error( - "INKOS_LLM_API_KEY not set. Run 'inkos config set-global' or add it to project .env file.", + "INKOS_LLM_API_KEY not set.\nRun 'inkos config set-global' or add it to project .env file.", ); } llm.apiKey = apiKey; + // Build notify channels from env and append them to inkos.json notify[] + const envNotify: unknown[] = []; + + if (env.INKOS_SERVERCHAN_SEND_URL) { + envNotify.push({ + type: "serverchan", + sendUrl: env.INKOS_SERVERCHAN_SEND_URL, + }); + } + + if (env.INKOS_BARK_DEVICE_KEY) { + envNotify.push({ + type: "bark", + serverUrl: env.INKOS_BARK_SERVER_URL ?? "https://api.day.app", + deviceKey: env.INKOS_BARK_DEVICE_KEY, + group: env.INKOS_BARK_GROUP || "InkOS", + level: env.INKOS_BARK_LEVEL ?? "active", + sound: env.INKOS_BARK_SOUND || undefined, + icon: env.INKOS_BARK_ICON || undefined, + url: env.INKOS_BARK_URL || undefined, + }); + } + + config.notify = [ + ...(((config.notify ?? []) as unknown[]) || []), + ...envNotify, + ]; + return ProjectConfigSchema.parse(config); } @@ -90,7 +137,7 @@ export function createClient(config: ProjectConfig) { export function buildPipelineConfig( config: ProjectConfig, root: string, - extra?: Partial>, + extra?: Partial, ): PipelineConfig { return { client: createLLMClient(config.llm), @@ -135,12 +182,14 @@ export async function resolveBookId( if (books.length === 0) { throw new Error( - "No books found. Create one first:\n inkos book create --title '...' --genre xuanhuan", + "No books found.\nCreate one first:\n inkos book create --title '...' --genre xuanhuan", ); } + if (books.length === 1) { return books[0]!; } + throw new Error( `Multiple books found: ${books.join(", ")}\nPlease specify a book-id.`, ); diff --git a/packages/core/src/models/project.ts b/packages/core/src/models/project.ts index 06df00b4..03b657ca 100644 --- a/packages/core/src/models/project.ts +++ b/packages/core/src/models/project.ts @@ -20,20 +20,41 @@ export const NotifyChannelSchema = z.discriminatedUnion("type", [ botToken: z.string().min(1), chatId: z.string().min(1), }), + z.object({ type: z.literal("wechat-work"), webhookUrl: z.string().url(), }), + z.object({ type: z.literal("feishu"), webhookUrl: z.string().url(), }), + z.object({ type: z.literal("webhook"), url: z.string().url(), secret: z.string().optional(), events: z.array(z.string()).default([]), }), + + z.object({ + type: z.literal("serverchan"), + sendUrl: z.string().url(), + }), + + z.object({ + type: z.literal("bark"), + serverUrl: z.string().url().default("https://api.day.app"), + deviceKey: z.string().min(1), + group: z.string().optional(), + url: z.string().min(1).optional(), + sound: z.string().optional(), + icon: z.string().url().optional(), + level: z + .enum(["active", "timeSensitive", "passive", "critical"]) + .optional(), + }), ]); export type NotifyChannel = z.infer; @@ -77,37 +98,44 @@ export const ProjectConfigSchema = z.object({ notify: z.array(NotifyChannelSchema).default([]), detection: DetectionConfigSchema.optional(), modelOverrides: z.record(z.string(), ModelOverrideValueSchema).optional(), - daemon: z.object({ - schedule: z.object({ - radarCron: z.string().default("0 */6 * * *"), - writeCron: z.string().default("*/15 * * * *"), + daemon: z + .object({ + schedule: z + .object({ + radarCron: z.string().default("0 */6 * * *"), + writeCron: z.string().default("*/15 * * * *"), + }) + .default({ + radarCron: "0 */6 * * *", + writeCron: "*/15 * * * *", + }), + maxConcurrentBooks: z.number().int().min(1).default(3), + chaptersPerCycle: z.number().int().min(1).max(20).default(1), + retryDelayMs: z.number().int().min(0).default(30_000), + cooldownAfterChapterMs: z.number().int().min(0).default(10_000), + maxChaptersPerDay: z.number().int().min(1).default(50), + qualityGates: QualityGatesSchema.default({ + maxAuditRetries: 2, + pauseAfterConsecutiveFailures: 3, + retryTemperatureStep: 0.1, + }), + }) + .default({ + schedule: { + radarCron: "0 */6 * * *", + writeCron: "*/15 * * * *", + }, + maxConcurrentBooks: 3, + chaptersPerCycle: 1, + retryDelayMs: 30_000, + cooldownAfterChapterMs: 10_000, + maxChaptersPerDay: 50, + qualityGates: { + maxAuditRetries: 2, + pauseAfterConsecutiveFailures: 3, + retryTemperatureStep: 0.1, + }, }), - maxConcurrentBooks: z.number().int().min(1).default(3), - chaptersPerCycle: z.number().int().min(1).max(20).default(1), - retryDelayMs: z.number().int().min(0).default(30_000), - cooldownAfterChapterMs: z.number().int().min(0).default(10_000), - maxChaptersPerDay: z.number().int().min(1).default(50), - qualityGates: QualityGatesSchema.default({ - maxAuditRetries: 2, - pauseAfterConsecutiveFailures: 3, - retryTemperatureStep: 0.1, - }), - }).default({ - schedule: { - radarCron: "0 */6 * * *", - writeCron: "*/15 * * * *", - }, - maxConcurrentBooks: 3, - chaptersPerCycle: 1, - retryDelayMs: 30_000, - cooldownAfterChapterMs: 10_000, - maxChaptersPerDay: 50, - qualityGates: { - maxAuditRetries: 2, - pauseAfterConsecutiveFailures: 3, - retryTemperatureStep: 0.1, - }, - }), }); export type ProjectConfig = z.infer; diff --git a/packages/core/src/notify/bark.ts b/packages/core/src/notify/bark.ts new file mode 100644 index 00000000..d155a65d --- /dev/null +++ b/packages/core/src/notify/bark.ts @@ -0,0 +1,43 @@ +export interface BarkConfig { + readonly serverUrl: string; + readonly deviceKey: string; + readonly group?: string; + readonly url?: string; + readonly sound?: string; + readonly icon?: string; + readonly level?: "active" | "timeSensitive" | "passive" | "critical"; +} + +export async function sendBark( + config: BarkConfig, + title: string, + body: string, +): Promise { + const serverUrl = config.serverUrl.replace(/\/+$/, ""); + + const payload: Record = { + device_key: config.deviceKey, + title, + body, + }; + + if (config.group) payload.group = config.group; + if (config.url) payload.url = config.url; + if (config.sound) payload.sound = config.sound; + if (config.icon) payload.icon = config.icon; + if (config.level) payload.level = config.level; + + const response = await fetch(`${serverUrl}/push`, { + method: "POST", + headers: { + "Content-Type": "application/json; charset=utf-8", + }, + body: JSON.stringify(payload), + }); + + const text = await response.text(); + + if (!response.ok) { + throw new Error(`Bark send failed: ${response.status} ${text}`); + } +} diff --git a/packages/core/src/notify/dispatcher.ts b/packages/core/src/notify/dispatcher.ts index d5e05dd4..d22700c5 100644 --- a/packages/core/src/notify/dispatcher.ts +++ b/packages/core/src/notify/dispatcher.ts @@ -3,6 +3,8 @@ import { sendTelegram } from "./telegram.js"; import { sendFeishu } from "./feishu.js"; import { sendWechatWork } from "./wechat-work.js"; import { sendWebhook, type WebhookPayload } from "./webhook.js"; +import { sendServerChan } from "./serverchan.js"; +import { sendBark } from "./bark.js"; export interface NotifyMessage { readonly title: string; @@ -24,6 +26,7 @@ export async function dispatchNotification( fullText, ); break; + case "feishu": await sendFeishu( { webhookUrl: channel.webhookUrl }, @@ -31,31 +34,62 @@ export async function dispatchNotification( message.body, ); break; + case "wechat-work": await sendWechatWork( { webhookUrl: channel.webhookUrl }, fullText, ); break; + case "webhook": // Webhook channels are handled by dispatchWebhookEvent for structured events. // For generic text notifications, send as a pipeline-complete event. await sendWebhook( - { url: channel.url, secret: channel.secret, events: channel.events }, + { + url: channel.url, + secret: channel.secret, + events: channel.events, + }, { event: "pipeline-complete", bookId: "", timestamp: new Date().toISOString(), - data: { title: message.title, body: message.body }, + data: { + title: message.title, + body: message.body, + }, + }, + ); + break; + + case "serverchan": + await sendServerChan( + { sendUrl: channel.sendUrl }, + message.title, + message.body, + ); + break; + + case "bark": + await sendBark( + { + serverUrl: channel.serverUrl, + deviceKey: channel.deviceKey, + group: channel.group, + url: channel.url, + sound: channel.sound, + icon: channel.icon, + level: channel.level, }, + message.title, + message.body, ); break; } } catch (e) { // Log but don't throw — notification failure shouldn't block pipeline - process.stderr.write( - `[notify] ${channel.type} failed: ${e}\n`, - ); + process.stderr.write(`[notify] ${channel.type} failed: ${e}\n`); } }); diff --git a/packages/core/src/notify/serverchan.ts b/packages/core/src/notify/serverchan.ts new file mode 100644 index 00000000..78169117 --- /dev/null +++ b/packages/core/src/notify/serverchan.ts @@ -0,0 +1,46 @@ +export interface ServerChanConfig { + readonly sendUrl: string; +} + +interface ServerChanResponse { + readonly code: number; + readonly message: string; + readonly data?: unknown; +} + +export async function sendServerChan( + config: ServerChanConfig, + title: string, + content: string, +): Promise { + const response = await fetch(config.sendUrl, { + method: "POST", + headers: { + "Content-Type": "application/json; charset=utf-8", + }, + body: JSON.stringify({ + title, + desp: content, + }), + }); + + const text = await response.text(); + + if (!response.ok) { + throw new Error(`ServerChan send failed: ${response.status} ${text}`); + } + + try { + const result = JSON.parse(text) as ServerChanResponse; + if (typeof result.code === "number" && result.code !== 0) { + throw new Error( + `ServerChan send failed: ${result.code} ${result.message}`, + ); + } + } catch (error) { + if (error instanceof Error && error.message.startsWith("ServerChan send failed:")) { + throw error; + } + // 部分兼容网关可能不返回标准 JSON,这里 HTTP 200 就视为成功 + } +}