diff --git a/README.md b/README.md index 264b4978..207610f6 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ **English** · [简体中文](README.zh-CN.md) · [日本語](README.ja.md) -> Papyrus Desktop **v2.0.0-beta.14** — TypeScript / Fastify backend, React 19 frontend, Electron 41 desktop shell. +> Papyrus Desktop **v2.0.0-beta.16** — TypeScript / Fastify backend, React 19 frontend, Electron 41 desktop shell. -![Version](https://img.shields.io/badge/version-v2.0.0--beta.14-blue) +![Version](https://img.shields.io/badge/version-v2.0.0--beta.16-blue) ![Node.js](https://img.shields.io/badge/Node.js-24-339933) ![TypeScript](https://img.shields.io/badge/TypeScript-5-3178C6) ![Fastify](https://img.shields.io/badge/Fastify-5-000000) @@ -45,7 +45,7 @@ Pre-built installers are published on the [Releases](https://github.com/PapyrusO | macOS | arm64 | DMG (`.dmg`), ZIP (`.zip`) | | Linux | x64 | AppImage, DEB (`.deb`), TAR.GZ | -> ⚠️ `v2.0.0-beta.14` is a beta. The data schema is stable, but the UI and APIs may still evolve before `v2.0.0`. +> ⚠️ `v2.0.0-beta.16` is a beta. The data schema is stable, but the UI and APIs may still evolve before `v2.0.0`. --- diff --git a/backend/package-lock.json b/backend/package-lock.json index 3dd0e497..f18904a9 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "papyrus-backend", - "version": "2.0.0-beta.14", + "version": "2.0.0-beta.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "papyrus-backend", - "version": "2.0.0-beta.14", + "version": "2.0.0-beta.16", "dependencies": { "@fastify/cors": "^11.0.1", "@fastify/rate-limit": "^10.3.0", diff --git a/backend/package.json b/backend/package.json index 4bb4ade9..7153ff7f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "papyrus-backend", - "version": "2.0.0-beta.14", + "version": "2.0.0-beta.16", "description": "Papyrus Desktop TypeScript backend", "type": "module", "main": "dist/api/server.js", diff --git a/backend/src/ai/provider.ts b/backend/src/ai/provider.ts index 778b6bad..f0b847de 100644 --- a/backend/src/ai/provider.ts +++ b/backend/src/ai/provider.ts @@ -54,7 +54,7 @@ export type ReasoningEffort = 'low' | 'medium' | 'high' | 'very_high'; export type ReasoningKind = false | 'reasoning_effort' | 'thinking' | 'thinking_config'; export type ProviderModality = 'openai-compat' | 'ollama' | 'text-only'; -interface ProviderMessage { +export interface ProviderMessage { role: string; content: string | Array>; images?: string[]; @@ -63,6 +63,20 @@ interface ProviderMessage { name?: string; } +export interface StandaloneAgentToolCall { + id: string; + name: string; + params: Record; +} + +export interface StandaloneAgentTurnResult { + content: string; + reasoning: string; + toolCalls: StandaloneAgentToolCall[]; + model: string; + provider: string; +} + type RequestParamsWithReasoning = OpenAI.Chat.ChatCompletionCreateParamsStreaming & { thinking?: { type: 'enabled'; budget_tokens: number }; thinking_config?: { thinking_budget: number }; @@ -1036,6 +1050,105 @@ export class AIManager { // ==================== Stream ==================== + /** + * 执行一轮不关联聊天会话的 Agent 推理。 + * 原因:自动化输出属于独立审核记录,不能写入用户当前聊天上下文。 + * 未复用 chatStream:该方法强制读取活动会话并持久化用户消息。 + */ + async standaloneAgentTurn(input: { + messages: ProviderMessage[]; + allowedToolNames: ReadonlySet; + overrideProvider?: string; + overrideModel?: string; + reasoning?: unknown; + signal?: AbortSignal; + }): Promise { + const providerName = input.overrideProvider || this.config.config.current_provider; + if (!providerName.trim()) { + throw new Error('尚未配置 AI Provider,请先在设置中添加并启用一个提供商'); + } + const providerConfig = getProviderConfigFromDB(providerName); + if (!providerConfig) { + throw new Error(`未知 provider: ${providerName}`); + } + const model = input.overrideModel || this.config.config.current_model; + if (!model) { + throw new Error('AI 模型未配置'); + } + const params = this.config.config.parameters; + const stream = providerName === 'ollama' + ? this.chatStreamOllama( + input.messages, + model, + params, + providerConfig, + 'agent', + input.signal, + input.allowedToolNames, + ) + : this.chatStreamOpenAI( + input.messages, + model, + params, + providerConfig, + providerName, + 'agent', + normalizeReasoning(input.reasoning), + input.signal, + input.allowedToolNames, + ); + const contentParts: string[] = []; + const reasoningParts: string[] = []; + const toolCalls: StandaloneAgentToolCall[] = []; + for await (const chunk of stream) { + if (chunk.type === 'content' && typeof chunk.data === 'string') { + contentParts.push(chunk.data); + } else if (chunk.type === 'reasoning' && typeof chunk.data === 'string') { + reasoningParts.push(chunk.data); + } else if (chunk.type === 'tool_start' && typeof chunk.data === 'object') { + const data = chunk.data; + const func = data.function; + if (func !== null && typeof func === 'object') { + const functionData = func as Record; + const name = typeof functionData.name === 'string' ? functionData.name : ''; + const paramsValue = data.args ?? functionData.arguments; + let callParams: Record = {}; + if (paramsValue !== null && typeof paramsValue === 'object') { + // Provider 工具参数已通过对象边界检查,可安全收窄为键值记录。 + // 未直接断言原始 chunk:Ollama 和 OpenAI 的 arguments 形状不同。 + callParams = paramsValue as Record; + } else if (typeof paramsValue === 'string' && paramsValue.trim()) { + try { + const parsed: unknown = JSON.parse(paramsValue); + if (parsed !== null && typeof parsed === 'object') { + // JSON.parse 返回 unknown;对象检查后才收窄为工具参数记录。 + callParams = parsed as Record; + } + } catch { + throw new Error(`工具参数 JSON 解析失败: ${name}`); + } + } + if (name) { + toolCalls.push({ + id: typeof data.id === 'string' ? data.id : '', + name, + params: callParams, + }); + } + } + } else if (chunk.type === 'error') { + throw new Error(typeof chunk.data === 'string' ? chunk.data : 'AI 调用失败'); + } + } + return { + content: contentParts.join(''), + reasoning: reasoningParts.join(''), + toolCalls, + model, + provider: providerName, + }; + } + async *chatStream( userMessage: string, systemPrompt?: string, @@ -1310,6 +1423,7 @@ Output only the translation, no explanations.`; mode?: string, reasoning: ReasoningEffort | false = false, signal?: AbortSignal, + allowedToolNames?: ReadonlySet, ): AsyncGenerator { const rawBaseUrl = (providerConfig.base_url || '').replace(/\/$/, ''); const baseUrl = providerName === 'gemini' ? `${rawBaseUrl}/openai` : rawBaseUrl; @@ -1362,9 +1476,11 @@ Output only the translation, no explanations.`; if (mode === 'agent') { const cardTools = new PapyrusTools(); - const tools: OpenAIToolDef[] = cardTools.getToolsForOpenAI(); - requestParams.tools = tools as unknown as OpenAI.Chat.ChatCompletionTool[]; - requestParams.tool_choice = 'auto'; + const tools: OpenAIToolDef[] = cardTools.getToolsForOpenAI(allowedToolNames); + if (tools.length > 0) { + requestParams.tools = tools as unknown as OpenAI.Chat.ChatCompletionTool[]; + requestParams.tool_choice = 'auto'; + } } if (reasoning) { @@ -1455,6 +1571,7 @@ Output only the translation, no explanations.`; providerConfig: { base_url: string }, mode?: string, signal?: AbortSignal, + allowedToolNames?: ReadonlySet, ): AsyncGenerator { const urlError = validateProviderBaseUrl(providerConfig.base_url, 'ollama'); if (urlError) { @@ -1462,12 +1579,15 @@ Output only the translation, no explanations.`; } const baseUrl = providerConfig.base_url.replace(/\/$/, ''); - const enrichedMessages = mode === 'agent' - ? this.injectOllamaToolPrompt(messages) + const hasTools = mode === 'agent' && (allowedToolNames === undefined || allowedToolNames.size > 0); + const enrichedMessages = hasTools + ? this.injectOllamaToolPrompt(messages, allowedToolNames) : messages; // Build native tools for Ollama (OpenAI-compatible format) - const ollamaTools = mode === 'agent' ? new PapyrusTools().getToolsForOpenAI() : undefined; + const ollamaTools = hasTools + ? new PapyrusTools().getToolsForOpenAI(allowedToolNames) + : undefined; const response = await fetch(`${baseUrl}/api/chat`, { method: 'POST', @@ -1534,9 +1654,12 @@ Output only the translation, no explanations.`; } } - private injectOllamaToolPrompt(messages: ProviderMessage[]): ProviderMessage[] { + private injectOllamaToolPrompt( + messages: ProviderMessage[], + allowedToolNames?: ReadonlySet, + ): ProviderMessage[] { const cardTools = new PapyrusTools(); - const toolHint = cardTools.getToolsDefinition(); + const toolHint = cardTools.getToolsDefinition(allowedToolNames); const out = [...messages]; const sysIdx = out.findIndex(m => m.role === 'system'); if (sysIdx >= 0) { diff --git a/backend/src/ai/tools/index.ts b/backend/src/ai/tools/index.ts index 9a990ac6..f822db44 100644 --- a/backend/src/ai/tools/index.ts +++ b/backend/src/ai/tools/index.ts @@ -25,11 +25,29 @@ export class PapyrusTools { this.logger?.logEvent(eventType, data, level); } - getToolsForOpenAI(): OpenAIToolDef[] { - return TOOL_LIST.map(d => d.openai); + /** + * 返回 OpenAI 工具定义,可按显式名称白名单过滤。 + * 原因:无人值守自动化只能向模型暴露用户授权的工具。 + * 未在调用后再过滤:模型看见未授权工具会产生无意义且危险的调用尝试。 + */ + getToolsForOpenAI(toolNames?: ReadonlySet): OpenAIToolDef[] { + return TOOL_LIST + .filter((descriptor) => toolNames === undefined || toolNames.has(descriptor.name)) + .map((descriptor) => descriptor.openai); } - getToolsDefinition(): string { + /** + * 生成文本模型使用的工具提示,可按相同白名单过滤。 + * 原因:Ollama 的文本提示必须与原生 tools 参数保持一致。 + * 未继续拼接分类总提示:分类提示可能包含未授权工具。 + */ + getToolsDefinition(toolNames?: ReadonlySet): string { + if (toolNames !== undefined) { + const allowed = TOOL_LIST.filter((descriptor) => toolNames.has(descriptor.name)); + return `你只能使用以下工具:\n${allowed.map((descriptor) => ( + `- ${descriptor.name}: ${descriptor.openai.function.description}` + )).join('\n')}\n\n不得调用列表之外的工具。`; + } const sections: string[] = []; for (const [, hint] of Object.entries(PROMPT_HINTS)) { sections.push(hint); @@ -96,4 +114,3 @@ ${sections.join('\n\n')} } } - diff --git a/backend/src/api/routes/automations.ts b/backend/src/api/routes/automations.ts new file mode 100644 index 00000000..b5e04548 --- /dev/null +++ b/backend/src/api/routes/automations.ts @@ -0,0 +1,239 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { TOOL_LIST, TOOL_REGISTRY } from '../../ai/tools.js'; +import { getAutomationScheduler } from '../../core/automation-scheduler.js'; +import { + createAutomation, + deleteAutomation, + getAutomation, + getAutomationRun, + hasActiveAutomationRun, + listAutomationRuns, + listAutomations, + listRecentAutomationRuns, + updateAutomation, +} from '../../core/automations.js'; + +const HourlyScheduleSchema = z.object({ + kind: z.literal('hourly'), + intervalHours: z.number().int().min(1).max(24), + minute: z.number().int().min(0).max(59), +}); + +const DailyScheduleSchema = z.object({ + kind: z.literal('daily'), + hour: z.number().int().min(0).max(23), + minute: z.number().int().min(0).max(59), +}); + +const WeeklyScheduleSchema = z.object({ + kind: z.literal('weekly'), + daysOfWeek: z.array(z.number().int().min(0).max(6)).min(1).max(7) + .transform((days) => [...new Set(days)].sort((left, right) => left - right)), + hour: z.number().int().min(0).max(23), + minute: z.number().int().min(0).max(59), +}); + +const AutomationScheduleSchema = z.discriminatedUnion('kind', [ + HourlyScheduleSchema, + DailyScheduleSchema, + WeeklyScheduleSchema, +]); + +const AutomationInputBaseSchema = z.object({ + name: z.string().trim().min(1).max(100), + prompt: z.string().trim().min(1).max(20_000), + schedule: AutomationScheduleSchema, + timezone: z.string().trim().min(1).max(100).optional(), + enabled: z.boolean().default(true), + allowedTools: z.array(z.string().min(1)).max(100).optional(), + providerOverride: z.string().trim().max(100).nullable().optional(), + modelOverride: z.string().trim().max(200).nullable().optional(), + reasoningOverride: z.boolean().nullable().optional(), +}); + +/** + * 校验 Provider 与模型覆盖必须作为一个完整目标同时保存或同时继承。 + * 原因:模型 ID 只在所属 Provider 内有意义,拆开更新会制造不可执行配置。 + * 未自动猜测 Provider:不同 Provider 可以拥有相同模型 ID,服务端不能安全推断。 + */ +function validateAutomationTargetPair( + value: { providerOverride?: string | null; modelOverride?: string | null }, + context: z.RefinementCtx, +): void { + const provider = value.providerOverride?.trim() || null; + const model = value.modelOverride?.trim() || null; + if ((provider === null) !== (model === null)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['modelOverride'], + message: 'Provider 与模型覆盖必须同时设置或同时清空', + }); + } +} + +const AutomationInputSchema = AutomationInputBaseSchema.superRefine(validateAutomationTargetPair); +const AutomationPatchSchema = AutomationInputBaseSchema.partial() + .refine((value) => Object.keys(value).length > 0, { message: '至少提供一个更新字段' }) + .superRefine(validateAutomationTargetPair); + +/** + * 返回当前后端系统时区。 + * 原因:首版计划只在本机执行,不允许客户端伪造其他时区语义。 + * 未硬编码 Asia/Shanghai:Papyrus 支持不同地区的桌面用户。 + */ +function getSystemTimezone(): string { + return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; +} + +/** + * 校验工具白名单并返回去重后的稳定顺序。 + * 原因:API 必须在保存前拒绝未知工具,执行器还会再次校验。 + * 未相信前端目录:本地 API 仍可能被其他授权客户端调用。 + */ +function normalizeAllowedTools(toolNames?: string[]): string[] { + const source = toolNames ?? TOOL_LIST + .filter((descriptor) => descriptor.sideEffect === 'read') + .map((descriptor) => descriptor.name); + const unique = [...new Set(source)]; + const unknown = unique.find((name) => TOOL_REGISTRY[name] === undefined); + if (unknown) throw new Error(`未知工具: ${unknown}`); + return unique; +} + +/** + * 注册自动化 CRUD、执行和审核记录路由。 + * 原因:独立前缀可保持调度领域与聊天、UI 设置职责分离。 + * 未合入 AI 路由:自动化同时拥有持久化、计划和运行历史,不只是一次模型请求。 + */ +export default async function automationRoutes(fastify: FastifyInstance): Promise { + fastify.get('/', async (_request, reply) => { + reply.send({ success: true, automations: listAutomations() }); + }); + + fastify.get('/runs/recent', async (request, reply) => { + const query = request.query as { limit?: string }; + const parsedLimit = Number.parseInt(query.limit ?? '100', 10); + const limit = Number.isInteger(parsedLimit) ? Math.min(Math.max(parsedLimit, 1), 200) : 100; + reply.send({ success: true, runs: listRecentAutomationRuns(limit) }); + }); + + fastify.get('/runs/:runId', async (request, reply) => { + const { runId } = request.params as { runId: string }; + const run = getAutomationRun(runId); + if (!run) { + reply.status(404).send({ success: false, error: '自动化运行记录不存在' }); + return; + } + reply.send({ success: true, run }); + }); + + fastify.get('/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + const automation = getAutomation(id); + if (!automation) { + reply.status(404).send({ success: false, error: '自动化不存在' }); + return; + } + reply.send({ success: true, automation }); + }); + + fastify.get('/:id/runs', async (request, reply) => { + const { id } = request.params as { id: string }; + if (!getAutomation(id)) { + reply.status(404).send({ success: false, error: '自动化不存在' }); + return; + } + const query = request.query as { limit?: string }; + const parsedLimit = Number.parseInt(query.limit ?? '100', 10); + const limit = Number.isInteger(parsedLimit) ? Math.min(Math.max(parsedLimit, 1), 200) : 100; + reply.send({ success: true, runs: listAutomationRuns(id, limit) }); + }); + + fastify.post('/', async (request, reply) => { + const parsed = AutomationInputSchema.safeParse(request.body); + if (!parsed.success) { + reply.status(400).send({ success: false, error: parsed.error.issues[0]?.message ?? '自动化配置无效' }); + return; + } + try { + const automation = createAutomation({ + ...parsed.data, + timezone: getSystemTimezone(), + allowedTools: normalizeAllowedTools(parsed.data.allowedTools), + providerOverride: parsed.data.providerOverride || null, + modelOverride: parsed.data.modelOverride || null, + reasoningOverride: parsed.data.reasoningOverride ?? null, + }); + getAutomationScheduler().notifyScheduleChanged(); + reply.status(201).send({ success: true, automation }); + } catch (error) { + reply.status(400).send({ success: false, error: error instanceof Error ? error.message : String(error) }); + } + }); + + fastify.patch('/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + const parsed = AutomationPatchSchema.safeParse(request.body); + if (!parsed.success) { + reply.status(400).send({ success: false, error: parsed.error.issues[0]?.message ?? '自动化配置无效' }); + return; + } + try { + const allowedTools = parsed.data.allowedTools === undefined + ? undefined + : normalizeAllowedTools(parsed.data.allowedTools); + const targetProvided = parsed.data.providerOverride !== undefined + || parsed.data.modelOverride !== undefined; + const targetPatch = targetProvided + ? { + providerOverride: parsed.data.providerOverride || null, + modelOverride: parsed.data.modelOverride || null, + } + : {}; + const automation = updateAutomation(id, { + ...parsed.data, + ...(allowedTools === undefined ? {} : { allowedTools }), + ...(parsed.data.timezone === undefined ? {} : { timezone: getSystemTimezone() }), + ...targetPatch, + }); + if (!automation) { + reply.status(404).send({ success: false, error: '自动化不存在' }); + return; + } + getAutomationScheduler().notifyScheduleChanged(); + reply.send({ success: true, automation }); + } catch (error) { + reply.status(400).send({ success: false, error: error instanceof Error ? error.message : String(error) }); + } + }); + + fastify.delete('/:id', async (request, reply) => { + const { id } = request.params as { id: string }; + if (hasActiveAutomationRun(id)) { + reply.status(409).send({ success: false, error: '自动化正在运行,暂时不能删除' }); + return; + } + if (!deleteAutomation(id)) { + reply.status(404).send({ success: false, error: '自动化不存在' }); + return; + } + getAutomationScheduler().notifyScheduleChanged(); + reply.send({ success: true }); + }); + + fastify.post('/:id/run', async (request, reply) => { + const { id } = request.params as { id: string }; + if (!getAutomation(id)) { + reply.status(404).send({ success: false, error: '自动化不存在' }); + return; + } + const run = getAutomationScheduler().enqueueManual(id); + if (!run) { + reply.status(409).send({ success: false, error: '自动化已有排队或运行中的任务' }); + return; + } + reply.status(202).send({ success: true, run }); + }); +} + diff --git a/backend/src/api/server.ts b/backend/src/api/server.ts index b34bec39..43ac71df 100644 --- a/backend/src/api/server.ts +++ b/backend/src/api/server.ts @@ -170,7 +170,7 @@ export async function initApp(): Promise { const { default: reviewRoutes } = await import('./routes/review.js'); const { default: notesRoutes } = await import('./routes/notes.js'); const { default: searchRoutes } = await import('./routes/search.js'); - const { default: aiRoutes } = await import('./routes/ai.js'); + const { default: aiRoutes, aiManager } = await import('./routes/ai.js'); const { default: dataRoutes } = await import('./routes/data.js'); const { default: progressRoutes } = await import('./routes/progress.js'); const { default: logsRoutes } = await import('./routes/logs.js'); @@ -186,6 +186,9 @@ export async function initApp(): Promise { const { default: cliRoutes } = await import('./routes/cli.js'); const { default: uiSettingsRoutes } = await import('./routes/ui-settings.js'); const { default: knowledgeVersionRoutes } = await import('./routes/knowledge-versions.js'); + const { default: automationRoutes } = await import('./routes/automations.js'); + const { initializeAutomationScheduler } = await import('../core/automation-scheduler.js'); + initializeAutomationScheduler(aiManager, logger); app.register(cardsRoutes, { prefix: '/api/cards' }); app.register(reviewRoutes, { prefix: '/api/review' }); @@ -207,6 +210,7 @@ export async function initApp(): Promise { app.register(cliRoutes, { prefix: '/api/cli' }); app.register(uiSettingsRoutes, { prefix: '/api/ui-settings' }); app.register(knowledgeVersionRoutes, { prefix: '/api' }); + app.register(automationRoutes, { prefix: '/api/automations' }); } let mcpServer: MCPServer | null = null; @@ -224,6 +228,8 @@ export async function start(): Promise { startFileWatching((eventType, filePath) => { logger.info(`文件${eventType}: ${filePath}`); }); + const { getAutomationScheduler } = await import('../core/automation-scheduler.js'); + getAutomationScheduler().start(); } catch (err) { logger.error(`Failed to start server: ${err}`); throw err; @@ -238,6 +244,8 @@ async function gracefulShutdown(signal: string) { mcpServer = null; } stopFileWatching(); + const { getAutomationScheduler } = await import('../core/automation-scheduler.js'); + getAutomationScheduler().stop(); await app.close(); closeDb(); logger.info('Graceful shutdown complete'); diff --git a/backend/src/cli/papyrus-cli.ts b/backend/src/cli/papyrus-cli.ts index 788ff6fd..cd64ed7f 100644 --- a/backend/src/cli/papyrus-cli.ts +++ b/backend/src/cli/papyrus-cli.ts @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import { pathToFileURL } from 'node:url'; type JsonObject = Record; @@ -14,7 +15,7 @@ const API_BASE = normalizeApiBase(process.env.PAPYRUS_API_URL ?? 'http://127.0.0 // 规范化 Desktop API 根地址,输入为环境变量中的 API 地址,输出为始终带 `/api` 前缀的可访问基地址。 // 原因:调用方可能传入 `http://127.0.0.1:8000` 或 `http://127.0.0.1:8000/api`,统一归一化可以避免每个命令分支重复拼接判断。 // 未把路径判断散落到各命令:分散处理更容易产生 `//api/api/...` 之类的拼接错误,排障也更难。 -function normalizeApiBase(rawBase: string): string { +export function normalizeApiBase(rawBase: string): string { const trimmedBase = rawBase.replace(/\/+$/, ''); return trimmedBase.endsWith('/api') ? trimmedBase : `${trimmedBase}/api`; } @@ -26,7 +27,7 @@ function isJsonObject(value: unknown): value is JsonObject { // 从命令行参数中抽取 `--json`、键值选项和 `--params` JSON,输入为原始 argv,输出为解析后的旗标结构。 // 原因:内置 CLI 首要任务是稳定代理 Desktop API,先支持当前设计文档需要的最小参数集即可满足自动化调用。 // 未引入参数解析库:这里只需要少量受控命令,手写解析更轻量,也避免为打包版增加额外依赖。 -function parseFlags(args: string[]): ParsedFlags { +export function parseFlags(args: string[]): ParsedFlags { const values: Record = {}; let json = false; let params: JsonObject = {}; @@ -68,7 +69,7 @@ function parseFlags(args: string[]): ParsedFlags { // 删除 CLI 参数中的选项片段,输入为原始 argv,输出为仅保留命令路径和位置参数的数组。 // 原因:命令分发只关心主命令和少量位置参数,先去掉旗标能让分支判断更清晰。 // 未在遍历时同步分发命令:先规整数据再分发更容易维护,也方便未来扩展更多命令。 -function stripFlags(args: string[]): string[] { +export function stripFlags(args: string[]): string[] { const stripped: string[] = []; for (let index = 0; index < args.length; index += 1) { const current = args[index]; @@ -122,7 +123,7 @@ async function callMcpTool(tool: string, params: JsonObject): Promise { }); } -async function executeCommand(rawArgs: string[]): Promise { +export async function executeCommand(rawArgs: string[]): Promise { const flags = parseFlags(rawArgs); const args = stripFlags(rawArgs); const primary = args[0]; @@ -366,12 +367,29 @@ function writeJson(value: unknown): void { process.stdout.write(`${JSON.stringify(value)}\n`); } -try { - const result = await executeCommand(process.argv.slice(2)); - writeJson(result); - process.exit(0); -} catch (error) { - const message = error instanceof Error ? error.message : String(error); - writeJson({ success: false, error: message }); - process.exit(1); +// 判断当前模块是否是 Node 直接启动的 CLI 入口,输入来自进程 argv,输出为是否应接管 stdout/exit。 +// 原因:命令分发函数需要能在 Jest 中直接验证真实请求契约,只有直接执行时才允许结束进程。 +// 未依赖环境变量开关:URL 与入口路径的比较和 Node ESM 语义一致,不会让生产启动遗漏 CLI 主流程。 +function isDirectExecution(): boolean { + const entryPath = process.argv[1]; + return entryPath !== undefined && import.meta.url === pathToFileURL(path.resolve(entryPath)).href; +} + +// 执行 CLI 主流程并输出稳定 JSON,输入为命令参数,直接执行成功退出 0、失败退出 1。 +// 原因:集中保留原有进程行为,同时让模块导入只暴露可测试的命令分发函数。 +// 未让 executeCommand 直接退出:业务函数结束进程会阻止调用方测试多个命令,也无法复用返回值。 +export async function runCliMain(rawArgs: string[] = process.argv.slice(2)): Promise { + try { + const result = await executeCommand(rawArgs); + writeJson(result); + process.exit(0); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + writeJson({ success: false, error: message }); + process.exit(1); + } +} + +if (isDirectExecution()) { + void runCliMain(); } diff --git a/backend/src/core/automation-agent-runner.ts b/backend/src/core/automation-agent-runner.ts new file mode 100644 index 00000000..4bad7673 --- /dev/null +++ b/backend/src/core/automation-agent-runner.ts @@ -0,0 +1,141 @@ +import type { AIManager, ProviderMessage } from '../ai/provider.js'; +import { PapyrusTools, TOOL_REGISTRY } from '../ai/tools.js'; +import type { ToolResult } from '../ai/tools.js'; +import type { Automation, AutomationToolCall } from './automation-types.js'; + +const MAX_AGENT_TURNS = 8; +const MAX_TOOL_CALLS = 20; +const RUN_TIMEOUT_MS = 10 * 60 * 1000; + +export interface AutomationAgentResult { + output: string; + reasoning: string; + toolCalls: AutomationToolCall[]; + model: string; + provider: string; +} + +interface AutomationToolExecutor { + executeTool(toolName: string, params: Record): ToolResult; +} + +/** + * 执行自动化专属的有界 Agent 循环。 + * 原因:工具结果需要继续返回模型,直到生成可审核的最终回答。 + * 未复用 HTTP SSE 处理器:后台任务没有响应流且不能污染聊天记录。 + */ +export class AutomationAgentRunner { + private readonly tools: AutomationToolExecutor; + + constructor( + private readonly aiManager: Pick, + tools?: AutomationToolExecutor, + ) { + this.tools = tools ?? new PapyrusTools(); + } + + /** + * 按自动化白名单执行 Agent,并限制回合、工具次数和总时长。 + * 原因:无人值守任务必须有明确资源与副作用边界。 + * 未修改全局 ToolManager:普通聊天的审批策略不能被后台任务临时覆盖。 + */ + async run(automation: Automation): Promise { + const allowedToolNames = new Set(automation.allowedTools); + const invalidTool = automation.allowedTools.find((name) => TOOL_REGISTRY[name] === undefined); + if (invalidTool) { + throw new Error(`自动化包含未知工具: ${invalidTool}`); + } + + const messages: ProviderMessage[] = [ + { + role: 'system', + content: [ + '你是 Papyrus 的无人值守自动化 Agent。', + '只执行用户给出的自动化指令,并只调用本次明确授权的工具。', + '写操作仅在指令明确要求时执行。完成后给出简洁、可审核的结果。', + ].join('\n'), + }, + { role: 'user', content: automation.prompt }, + ]; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), RUN_TIMEOUT_MS); + const reasoningParts: string[] = []; + const toolCallRecords: AutomationToolCall[] = []; + let finalOutput = ''; + let model = automation.modelOverride ?? ''; + let provider = ''; + let toolCallCount = 0; + + try { + for (let turn = 0; turn < MAX_AGENT_TURNS; turn += 1) { + const result = await this.aiManager.standaloneAgentTurn({ + messages, + allowedToolNames, + overrideProvider: automation.providerOverride ?? undefined, + overrideModel: automation.modelOverride ?? undefined, + reasoning: automation.reasoningOverride ?? undefined, + signal: controller.signal, + }); + model = result.model; + provider = result.provider; + if (result.reasoning) reasoningParts.push(result.reasoning); + if (result.content) finalOutput = result.content; + + if (result.toolCalls.length === 0) { + return { + output: finalOutput, + reasoning: reasoningParts.join('\n\n'), + toolCalls: toolCallRecords, + model, + provider, + }; + } + + messages.push({ + role: 'assistant', + content: result.content, + tool_calls: result.toolCalls.map((call) => ({ + id: call.id, + type: 'function', + function: { name: call.name, arguments: JSON.stringify(call.params) }, + })), + }); + + for (const call of result.toolCalls) { + toolCallCount += 1; + if (toolCallCount > MAX_TOOL_CALLS) { + throw new Error(`自动化工具调用超过上限 ${MAX_TOOL_CALLS}`); + } + if (!allowedToolNames.has(call.name) || TOOL_REGISTRY[call.name] === undefined) { + throw new Error(`Agent 尝试调用未授权工具: ${call.name}`); + } + const toolResult = this.tools.executeTool(call.name, call.params); + const success = toolResult.success !== false; + const record: AutomationToolCall = { + name: call.name, + params: call.params, + success, + ...(success + ? { result: toolResult as Record } + : { error: String(toolResult.error ?? '工具执行失败') }), + }; + toolCallRecords.push(record); + messages.push({ + role: 'tool', + content: JSON.stringify(toolResult), + tool_call_id: call.id, + name: call.name, + }); + } + } + throw new Error(`自动化 Agent 回合超过上限 ${MAX_AGENT_TURNS}`); + } catch (error) { + if (controller.signal.aborted) { + throw new Error('自动化运行超过 10 分钟,已停止'); + } + throw error; + } finally { + clearTimeout(timeout); + } + } +} diff --git a/backend/src/core/automation-schedule.ts b/backend/src/core/automation-schedule.ts new file mode 100644 index 00000000..ce2f2cde --- /dev/null +++ b/backend/src/core/automation-schedule.ts @@ -0,0 +1,48 @@ +import type { AutomationSchedule } from './automation-types.js'; + +const SECONDS_PER_DAY = 24 * 60 * 60; + +/** + * 计算给定计划在当前系统本地时区中的下一次执行时间。 + * 原因:系统 Date 原生处理本地夏令时,且首版不允许选择其他时区。 + * 未引入 Cron 或日期库:结构化三类计划只需有限的日历运算。 + */ +export function calculateNextRun(schedule: AutomationSchedule, afterTimestamp: number): number { + const after = new Date(afterTimestamp * 1000); + const candidate = new Date(after); + candidate.setSeconds(0, 0); + + if (schedule.kind === 'hourly') { + candidate.setMinutes(schedule.minute, 0, 0); + if (candidate.getTime() <= after.getTime()) { + candidate.setHours(candidate.getHours() + schedule.intervalHours, schedule.minute, 0, 0); + } + return candidate.getTime() / 1000; + } + + candidate.setHours(schedule.hour, schedule.minute, 0, 0); + if (schedule.kind === 'daily') { + if (candidate.getTime() <= after.getTime()) { + candidate.setDate(candidate.getDate() + 1); + } + return candidate.getTime() / 1000; + } + + const selectedDays = new Set(schedule.daysOfWeek); + for (let dayOffset = 0; dayOffset <= 7; dayOffset += 1) { + const weeklyCandidate = new Date(candidate); + weeklyCandidate.setDate(candidate.getDate() + dayOffset); + if ( + selectedDays.has(weeklyCandidate.getDay()) + && weeklyCandidate.getTime() > after.getTime() + ) { + return weeklyCandidate.getTime() / 1000; + } + } + + // 校验层保证至少选择一天;此回退只防止损坏的历史数据让调度循环失效。 + // 原因:返回有限未来时间比抛错阻断所有任务更可恢复。 + // 未返回当前时间:立即重复触发会形成忙循环。 + return afterTimestamp + SECONDS_PER_DAY * 7; +} + diff --git a/backend/src/core/automation-scheduler.ts b/backend/src/core/automation-scheduler.ts new file mode 100644 index 00000000..dc19398b --- /dev/null +++ b/backend/src/core/automation-scheduler.ts @@ -0,0 +1,209 @@ +import type { AIManager } from '../ai/provider.js'; +import type { PapyrusLogger } from '../utils/logger.js'; +import { AutomationAgentRunner } from './automation-agent-runner.js'; +import { + claimAutomationRun, + createAutomationRun, + enqueueScheduledAutomation, + failStaleAutomationRuns, + finishAutomationRun, + getAutomation, + getEarliestNextRunAt, + getNextQueuedAutomationRun, + hasActiveAutomationRun, + listDueAutomations, + markAutomationMissed, +} from './automations.js'; +import type { Automation, AutomationRun } from './automation-types.js'; + +const MAX_TIMER_DELAY_MS = 60_000; + +/** + * 管理自动化计划唤醒与全局串行执行队列。 + * 原因:单 Worker 可避免多个 Agent 同时修改卡片、笔记或文件。 + * 未为每个自动化创建 timer:单一可重排计时器更易处理编辑、暂停和系统时钟变化。 + */ +interface AutomationRunner { + run(automation: Automation): ReturnType; +} + +/** + * 调度器执行边界,只暴露运行单个自动化所需方法。 + * 原因:测试需要替换不可控的外部模型,同时继续使用真实队列和 SQLite。 + * 未注入整个仓储层:仓储正是调度器测试需要覆盖的生产实现。 + */ +export class AutomationScheduler { + private readonly runner: AutomationRunner; + private timer: ReturnType | null = null; + private processing = false; + private started = false; + + constructor( + aiManager: Pick, + private readonly logger?: PapyrusLogger, + runner?: AutomationRunner, + ) { + this.runner = runner ?? new AutomationAgentRunner(aiManager); + } + + /** + * 启动调度器并把崩溃遗留状态安全终止。 + * 原因:已执行一半的写操作无法判断是否可重放。 + * 未自动重试遗留任务:重复写入比明确失败更危险。 + */ + start(): void { + if (this.started) return; + this.started = true; + const staleCount = failStaleAutomationRuns(); + if (staleCount > 0) this.logger?.warning(`自动化: 标记 ${staleCount} 个遗留运行为失败`); + + const now = Date.now() / 1000; + for (const automation of listDueAutomations(now)) { + markAutomationMissed(automation, now); + } + this.scheduleNextWake(); + void this.processQueue(); + } + + /** + * 停止未来唤醒。 + * 原因:后端关闭时不能留下引用进程的计时器。 + * 未强制中断正在执行的 Provider 请求:优雅关闭由进程生命周期统一处理。 + */ + stop(): void { + this.started = false; + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + } + + /** + * 通知调度器配置已变化并重排下一次唤醒。 + * 原因:创建、编辑、启停后旧 timeout 可能不再正确。 + * 未等待当前运行:配置变化只影响后续计划。 + */ + notifyScheduleChanged(): void { + if (this.started) this.scheduleNextWake(); + } + + /** + * 创建手动运行并立即唤醒串行 Worker。 + * 原因:API 应快速返回 202,而不是占用请求直到 AI 完成。 + * 未要求自动化启用:暂停计划后仍允许用户显式测试。 + */ + enqueueManual(automationId: string): AutomationRun | null { + if (!getAutomation(automationId) || hasActiveAutomationRun(automationId)) return null; + const run = createAutomationRun(automationId, 'manual', null); + void this.processQueue(); + return run; + } + + /** + * 到点后原子推进计划并加入执行队列。 + * 原因:先推进再执行可确保失败不会形成密集重试。 + * 未在 setTimeout 回调中直接执行单个任务:统一队列保持全局串行。 + */ + private async wake(): Promise { + const now = Date.now() / 1000; + for (const automation of listDueAutomations(now)) { + enqueueScheduledAutomation(automation, now); + } + this.scheduleNextWake(); + await this.processQueue(); + } + + /** + * 根据数据库最早计划设置有上限的 timeout。 + * 原因:一分钟上限让系统时钟或休眠恢复后能及时重新校准。 + * 未使用固定秒级轮询:空闲时无需高频查询 SQLite。 + */ + private scheduleNextWake(): void { + if (!this.started) return; + if (this.timer) clearTimeout(this.timer); + const nextRunAt = getEarliestNextRunAt(); + const desiredDelay = nextRunAt === null + ? MAX_TIMER_DELAY_MS + : Math.max(50, (nextRunAt - Date.now() / 1000) * 1000); + const delay = Math.min(MAX_TIMER_DELAY_MS, desiredDelay); + this.timer = setTimeout(() => { + this.timer = null; + void this.wake().catch((error: unknown) => { + this.logger?.error(`自动化调度失败: ${error instanceof Error ? error.message : String(error)}`); + this.scheduleNextWake(); + }); + }, delay); + } + + /** + * 逐个认领排队任务并写入终态。 + * 原因:循环读取队首允许手动和计划任务共用 FIFO 队列。 + * 未并行 Promise.all:自动化可能调用有副作用的写工具。 + */ + private async processQueue(): Promise { + if (this.processing) return; + this.processing = true; + try { + while (true) { + const queued = getNextQueuedAutomationRun(); + if (!queued) break; + if (!claimAutomationRun(queued.id)) continue; + const automation = getAutomation(queued.automationId); + if (!automation) continue; + try { + const result = await this.runner.run(automation); + finishAutomationRun({ + runId: queued.id, + status: 'succeeded', + output: result.output, + reasoning: result.reasoning, + toolCalls: result.toolCalls, + error: null, + model: result.model, + provider: result.provider, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + finishAutomationRun({ + runId: queued.id, + status: 'failed', + output: '', + reasoning: '', + toolCalls: [], + error: message, + model: automation.modelOverride ?? '', + provider: automation.providerOverride ?? '', + }); + this.logger?.error(`自动化运行失败 (${automation.id}): ${message}`); + } + } + } finally { + this.processing = false; + } + } +} + +let scheduler: AutomationScheduler | null = null; + +/** + * 初始化全局调度器单例。 + * 原因:API、server 生命周期和测试必须引用同一个队列。 + * 未在模块加载时构造:AIManager 依赖完成后的配置实例。 + */ +export function initializeAutomationScheduler( + aiManager: AIManager, + logger?: PapyrusLogger, +): AutomationScheduler { + if (!scheduler) scheduler = new AutomationScheduler(aiManager, logger); + return scheduler; +} + +/** + * 返回已初始化的全局调度器。 + * 原因:路由需要在不创建第二个 Worker 的情况下入队。 + * 未静默创建:缺少 AIManager 时自动构造会产生配置分叉。 + */ +export function getAutomationScheduler(): AutomationScheduler { + if (!scheduler) throw new Error('自动化调度器尚未初始化'); + return scheduler; +} diff --git a/backend/src/core/automation-types.ts b/backend/src/core/automation-types.ts new file mode 100644 index 00000000..3e119c22 --- /dev/null +++ b/backend/src/core/automation-types.ts @@ -0,0 +1,118 @@ +/** + * 描述每小时自动化的本地时间锚点。 + * 原因:结构化字段比 Cron 更容易校验和本地化。 + * 未使用 RRULE:首版只覆盖小时、每日和每周三种明确频率。 + */ +export interface HourlyAutomationSchedule { + kind: 'hourly'; + intervalHours: number; + minute: number; +} + +/** + * 描述每日自动化的本地执行时间。 + * 原因:小时和分钟分离后无需解析用户字符串。 + * 未存储完整日期:每日任务不应绑定创建日期。 + */ +export interface DailyAutomationSchedule { + kind: 'daily'; + hour: number; + minute: number; +} + +/** + * 描述每周自动化的星期与本地执行时间。 + * 原因:允许多选星期可覆盖工作日和周末等常见组合。 + * 未使用位掩码:数组在 API 和前端表单中更清晰。 + */ +export interface WeeklyAutomationSchedule { + kind: 'weekly'; + daysOfWeek: number[]; + hour: number; + minute: number; +} + +export type AutomationSchedule = + | HourlyAutomationSchedule + | DailyAutomationSchedule + | WeeklyAutomationSchedule; + +export type AutomationRunStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'missed'; +export type AutomationRunTrigger = 'manual' | 'scheduled' | 'missed'; + +/** + * 描述一次自动化工具调用的可审核快照。 + * 原因:运行记录需要保留参数、结果和错误,而不依赖全局 ToolManager 内存状态。 + * 未复用 ToolCallRecord:后者是会话级可变状态且不会跨进程持久化。 + */ +export interface AutomationToolCall { + name: string; + params: Record; + success: boolean; + result?: Record; + error?: string; +} + +/** + * 描述持久化后的自动化配置。 + * 原因:前后端共用稳定的 JSON 形状可减少字段映射分叉。 + * 未暴露数据库布尔整数和 JSON 字符串:这些属于 SQLite 实现细节。 + */ +export interface Automation { + id: string; + name: string; + prompt: string; + schedule: AutomationSchedule; + timezone: string; + enabled: boolean; + allowedTools: string[]; + providerOverride: string | null; + modelOverride: string | null; + reasoningOverride: boolean | null; + nextRunAt: number | null; + lastRunAt: number | null; + createdAt: number; + updatedAt: number; +} + +/** + * 描述自动化执行的审核记录。 + * 原因:输出、推理和工具调用必须与普通聊天历史隔离。 + * 未只保存最终文本:失败诊断和写操作审核需要完整元数据。 + */ +export interface AutomationRun { + id: string; + automationId: string; + trigger: AutomationRunTrigger; + status: AutomationRunStatus; + scheduledFor: number | null; + output: string; + reasoning: string; + toolCalls: AutomationToolCall[]; + error: string | null; + model: string; + provider: string; + startedAt: number | null; + finishedAt: number | null; + createdAt: number; +} + +/** + * 描述创建自动化所需字段。 + * 原因:服务端生成 ID 和时间戳,避免信任客户端主键。 + * 未复用 Automation:持久化派生字段不能由调用方覆盖。 + */ +export interface CreateAutomationInput { + name: string; + prompt: string; + schedule: AutomationSchedule; + timezone: string; + enabled: boolean; + allowedTools: string[]; + providerOverride: string | null; + modelOverride: string | null; + reasoningOverride: boolean | null; +} + +export type UpdateAutomationInput = Partial; + diff --git a/backend/src/core/automations.ts b/backend/src/core/automations.ts new file mode 100644 index 00000000..80128088 --- /dev/null +++ b/backend/src/core/automations.ts @@ -0,0 +1,451 @@ +import { randomUUID } from 'node:crypto'; +import { getDb, runInTransaction } from '../db/database.js'; +import { calculateNextRun } from './automation-schedule.js'; +import type { + Automation, + AutomationRun, + AutomationRunStatus, + AutomationRunTrigger, + AutomationSchedule, + AutomationToolCall, + CreateAutomationInput, + UpdateAutomationInput, +} from './automation-types.js'; + +interface AutomationRow { + id: string; + name: string; + prompt: string; + schedule_json: string; + timezone: string; + enabled: number; + allowed_tools: string; + provider_override: string | null; + model_override: string | null; + reasoning_override: number | null; + next_run_at: number | null; + last_run_at: number | null; + created_at: number; + updated_at: number; +} + +interface AutomationRunRow { + id: string; + automation_id: string; + trigger: AutomationRunTrigger; + status: AutomationRunStatus; + scheduled_for: number | null; + output: string; + reasoning: string; + tool_calls_json: string; + error: string | null; + model: string; + provider: string; + started_at: number | null; + finished_at: number | null; + created_at: number; +} + +/** + * 安全解析数据库中的 JSON 数组。 + * 原因:旧数据或手工修改不应让整个自动化页面无法加载。 + * 未直接 JSON.parse 后断言:运行时校验可隔离损坏字段。 + */ +function parseStringArray(value: string): string[] { + try { + const parsed: unknown = JSON.parse(value); + return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : []; + } catch { + return []; + } +} + +/** + * 将数据库行转换为公开自动化类型。 + * 原因:集中处理 snake_case、布尔整数和 JSON 字段可保持 API 一致。 + * 未把数据库行直接返回:会泄露存储实现并降低类型安全。 + */ +function automationFromRow(row: AutomationRow): Automation { + return { + id: row.id, + name: row.name, + prompt: row.prompt, + schedule: JSON.parse(row.schedule_json) as AutomationSchedule, + timezone: row.timezone, + enabled: row.enabled === 1, + allowedTools: parseStringArray(row.allowed_tools), + providerOverride: row.provider_override, + modelOverride: row.model_override, + reasoningOverride: row.reasoning_override === null ? null : row.reasoning_override === 1, + nextRunAt: row.next_run_at, + lastRunAt: row.last_run_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +/** + * 将工具调用 JSON 转为审核记录。 + * 原因:运行详情必须容忍历史记录中的未知字段。 + * 未复用字符串数组解析:工具调用是对象结构,需要独立守卫。 + */ +function parseToolCalls(value: string): AutomationToolCall[] { + try { + const parsed: unknown = JSON.parse(value); + if (!Array.isArray(parsed)) return []; + return parsed.filter((item): item is AutomationToolCall => { + if (item === null || typeof item !== 'object') return false; + const candidate = item as Record; + return typeof candidate.name === 'string' + && candidate.params !== null + && typeof candidate.params === 'object' + && typeof candidate.success === 'boolean'; + }); + } catch { + return []; + } +} + +/** + * 将数据库行转换为公开运行类型。 + * 原因:运行记录需要稳定的 camelCase API。 + * 未内联在查询函数中:多个列表和详情接口必须共享同一转换规则。 + */ +function runFromRow(row: AutomationRunRow): AutomationRun { + return { + id: row.id, + automationId: row.automation_id, + trigger: row.trigger, + status: row.status, + scheduledFor: row.scheduled_for, + output: row.output, + reasoning: row.reasoning, + toolCalls: parseToolCalls(row.tool_calls_json), + error: row.error, + model: row.model, + provider: row.provider, + startedAt: row.started_at, + finishedAt: row.finished_at, + createdAt: row.created_at, + }; +} + +/** + * 返回全部自动化,按最近更新排序。 + * 原因:管理页优先展示用户刚编辑的项目。 + * 未按下次运行排序:暂停项目没有下次时间,会造成列表跳动。 + */ +export function listAutomations(): Automation[] { + const rows = getDb().prepare('SELECT * FROM automations ORDER BY updated_at DESC').all() as unknown as AutomationRow[]; + return rows.map(automationFromRow); +} + +/** + * 按 ID 获取自动化。 + * 原因:API、调度器和执行器都需要同一来源的最新配置。 + * 未缓存配置:编辑后的权限必须立即生效。 + */ +export function getAutomation(id: string): Automation | null { + const row = getDb().prepare('SELECT * FROM automations WHERE id = ?').get(id) as AutomationRow | undefined; + return row ? automationFromRow(row) : null; +} + +/** + * 创建自动化并计算首次执行时间。 + * 原因:nextRunAt 由服务端统一生成,避免客户端时钟漂移。 + * 未接受客户端 ID:随机 UUID 可防止覆盖现有记录。 + */ +export function createAutomation(input: CreateAutomationInput): Automation { + const now = Date.now() / 1000; + const id = randomUUID(); + const nextRunAt = input.enabled ? calculateNextRun(input.schedule, now) : null; + getDb().prepare(` + INSERT INTO automations + (id, name, prompt, schedule_json, timezone, enabled, allowed_tools, + provider_override, model_override, reasoning_override, next_run_at, last_run_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?) + `).run( + id, + input.name, + input.prompt, + JSON.stringify(input.schedule), + input.timezone, + input.enabled ? 1 : 0, + JSON.stringify(input.allowedTools), + input.providerOverride, + input.modelOverride, + input.reasoningOverride === null ? null : input.reasoningOverride ? 1 : 0, + nextRunAt, + now, + now, + ); + const created = getAutomation(id); + if (!created) throw new Error('自动化创建后无法读取'); + return created; +} + +/** + * 更新自动化并在计划或启停变化时重算下次运行。 + * 原因:全量写入合并后的已知字段可避免动态 SQL 注入风险。 + * 未保留旧 nextRunAt:计划语义变化后旧时间不再可信。 + */ +export function updateAutomation(id: string, patch: UpdateAutomationInput): Automation | null { + const current = getAutomation(id); + if (!current) return null; + const merged: CreateAutomationInput = { + name: patch.name ?? current.name, + prompt: patch.prompt ?? current.prompt, + schedule: patch.schedule ?? current.schedule, + timezone: patch.timezone ?? current.timezone, + enabled: patch.enabled ?? current.enabled, + allowedTools: patch.allowedTools ?? current.allowedTools, + providerOverride: patch.providerOverride === undefined ? current.providerOverride : patch.providerOverride, + modelOverride: patch.modelOverride === undefined ? current.modelOverride : patch.modelOverride, + reasoningOverride: patch.reasoningOverride === undefined ? current.reasoningOverride : patch.reasoningOverride, + }; + const now = Date.now() / 1000; + const shouldRecalculate = patch.schedule !== undefined || patch.enabled !== undefined; + const nextRunAt = merged.enabled + ? shouldRecalculate ? calculateNextRun(merged.schedule, now) : current.nextRunAt + : null; + getDb().prepare(` + UPDATE automations + SET name = ?, prompt = ?, schedule_json = ?, timezone = ?, enabled = ?, allowed_tools = ?, + provider_override = ?, model_override = ?, reasoning_override = ?, next_run_at = ?, updated_at = ? + WHERE id = ? + `).run( + merged.name, + merged.prompt, + JSON.stringify(merged.schedule), + merged.timezone, + merged.enabled ? 1 : 0, + JSON.stringify(merged.allowedTools), + merged.providerOverride, + merged.modelOverride, + merged.reasoningOverride === null ? null : merged.reasoningOverride ? 1 : 0, + nextRunAt, + now, + id, + ); + return getAutomation(id); +} + +/** + * 删除自动化及其级联运行记录。 + * 原因:运行记录没有脱离所属自动化的业务意义。 + * 未手工逐表删除:SQLite 外键级联可保证事务一致性。 + */ +export function deleteAutomation(id: string): boolean { + return getDb().prepare('DELETE FROM automations WHERE id = ?').run(id).changes > 0; +} + +/** + * 返回指定自动化的运行记录。 + * 原因:详情页按最近运行优先展示审核项。 + * 未无限返回:限制数量可控制大型历史的响应体。 + */ +export function listAutomationRuns(automationId: string, limit = 100): AutomationRun[] { + const rows = getDb().prepare( + 'SELECT * FROM automation_runs WHERE automation_id = ? ORDER BY created_at DESC LIMIT ?' + ).all(automationId, limit) as unknown as AutomationRunRow[]; + return rows.map(runFromRow); +} + +/** + * 返回所有自动化的最近运行记录。 + * 原因:运行记录标签需要形成统一审核队列。 + * 未在前端合并多次请求:单个有界查询更高效且排序稳定。 + */ +export function listRecentAutomationRuns(limit = 100): AutomationRun[] { + const rows = getDb().prepare( + 'SELECT * FROM automation_runs ORDER BY created_at DESC LIMIT ?' + ).all(limit) as unknown as AutomationRunRow[]; + return rows.map(runFromRow); +} + +/** + * 按 ID 获取运行记录。 + * 原因:详情抽屉需要完整输出和工具调用。 + * 未按自动化过滤:主键全局唯一且路由会额外校验所属关系。 + */ +export function getAutomationRun(id: string): AutomationRun | null { + const row = getDb().prepare('SELECT * FROM automation_runs WHERE id = ?').get(id) as AutomationRunRow | undefined; + return row ? runFromRow(row) : null; +} + +/** + * 判断自动化是否已有排队或运行中的任务。 + * 原因:同一自动化不能重入,否则写工具可能重复执行。 + * 未只依赖内存集合:数据库状态可覆盖服务重启和 API 并发。 + */ +export function hasActiveAutomationRun(automationId: string): boolean { + const row = getDb().prepare( + "SELECT 1 AS found FROM automation_runs WHERE automation_id = ? AND status IN ('queued', 'running') LIMIT 1" + ).get(automationId) as { found: number } | undefined; + return row !== undefined; +} + +/** + * 新建排队、遗漏或计划运行记录。 + * 原因:触发来源必须在执行前持久化,便于崩溃恢复和审计。 + * 未接受输出字段:只有执行完成接口可以写结果。 + */ +export function createAutomationRun( + automationId: string, + trigger: AutomationRunTrigger, + scheduledFor: number | null, + status: AutomationRunStatus = 'queued', +): AutomationRun { + const id = randomUUID(); + const now = Date.now() / 1000; + const terminal = status === 'missed' ? now : null; + getDb().prepare(` + INSERT INTO automation_runs + (id, automation_id, trigger, status, scheduled_for, finished_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(id, automationId, trigger, status, scheduledFor, terminal, now); + const run = getAutomationRun(id); + if (!run) throw new Error('自动化运行记录创建后无法读取'); + return run; +} + +/** + * 原子认领排队运行。 + * 原因:计时器和手动触发可能同时唤醒执行循环。 + * 未先读后写:带状态条件的 UPDATE 可防止重复认领。 + */ +export function claimAutomationRun(runId: string): boolean { + const now = Date.now() / 1000; + const result = getDb().prepare( + "UPDATE automation_runs SET status = 'running', started_at = ? WHERE id = ? AND status = 'queued'" + ).run(now, runId); + if (result.changes > 0) { + const run = getAutomationRun(runId); + if (run) { + getDb().prepare('UPDATE automations SET last_run_at = ?, updated_at = updated_at WHERE id = ?') + .run(now, run.automationId); + } + } + return result.changes > 0; +} + +/** + * 完成运行并持久化输出和审核元数据。 + * 原因:所有终态字段在一次更新中提交,避免部分结果。 + * 未保存原始 Provider 流:结构化摘要足以支撑当前审核界面。 + */ +export function finishAutomationRun(input: { + runId: string; + status: 'succeeded' | 'failed'; + output: string; + reasoning: string; + toolCalls: AutomationToolCall[]; + error: string | null; + model: string; + provider: string; +}): boolean { + const result = getDb().prepare(` + UPDATE automation_runs + SET status = ?, output = ?, reasoning = ?, tool_calls_json = ?, error = ?, + model = ?, provider = ?, finished_at = ? + WHERE id = ? AND status = 'running' + `).run( + input.status, + input.output, + input.reasoning, + JSON.stringify(input.toolCalls), + input.error, + input.model, + input.provider, + Date.now() / 1000, + input.runId, + ); + return result.changes > 0; +} + +/** + * 返回所有已到期自动化。 + * 原因:调度器只扫描有明确 nextRunAt 的启用任务。 + * 未包含暂停任务:启停是服务端强制边界而非 UI 提示。 + */ +export function listDueAutomations(now: number): Automation[] { + const rows = getDb().prepare( + 'SELECT * FROM automations WHERE enabled = 1 AND next_run_at IS NOT NULL AND next_run_at <= ? ORDER BY next_run_at ASC' + ).all(now) as unknown as AutomationRow[]; + return rows.map(automationFromRow); +} + +/** + * 推进计划并创建对应运行记录。 + * 原因:nextRunAt 与运行入队必须在同一事务中更新,防止重复触发。 + * 未在执行成功后推进:失败任务也不应在短时间内无限重试。 + */ +export function enqueueScheduledAutomation(automation: Automation, now: number): AutomationRun | null { + if (automation.nextRunAt === null) return null; + return runInTransaction(() => { + const nextRunAt = calculateNextRun(automation.schedule, now); + const result = getDb().prepare(` + UPDATE automations SET next_run_at = ? + WHERE id = ? AND enabled = 1 AND next_run_at = ? + `).run(nextRunAt, automation.id, automation.nextRunAt); + if (result.changes === 0 || hasActiveAutomationRun(automation.id)) return null; + return createAutomationRun(automation.id, 'scheduled', automation.nextRunAt); + }); +} + +/** + * 在启动恢复时记录一次遗漏并推进到未来计划。 + * 原因:用户选择跳过离线运行,且每个自动化只保留一次本次恢复证据。 + * 未补跑历史次数:无人值守写操作集中执行风险过高。 + */ +export function markAutomationMissed(automation: Automation, now: number): AutomationRun | null { + if (automation.nextRunAt === null || automation.nextRunAt > now) return null; + return runInTransaction(() => { + const nextRunAt = calculateNextRun(automation.schedule, now); + const result = getDb().prepare(` + UPDATE automations SET next_run_at = ? + WHERE id = ? AND enabled = 1 AND next_run_at = ? + `).run(nextRunAt, automation.id, automation.nextRunAt); + if (result.changes === 0) return null; + return createAutomationRun(automation.id, 'missed', automation.nextRunAt, 'missed'); + }); +} + +/** + * 返回最早的下次运行时间。 + * 原因:单一计时器可避免每个任务持有独立 timeout。 + * 未使用固定高频轮询:按最近时间唤醒能降低空闲开销。 + */ +export function getEarliestNextRunAt(): number | null { + const row = getDb().prepare( + 'SELECT MIN(next_run_at) AS next_run_at FROM automations WHERE enabled = 1 AND next_run_at IS NOT NULL' + ).get() as { next_run_at: number | null }; + return row.next_run_at; +} + +/** + * 返回最早排队运行。 + * 原因:全局串行 Worker 需要稳定的 FIFO 顺序。 + * 未一次加载全部:每次完成后再认领可及时响应暂停和删除。 + */ +export function getNextQueuedAutomationRun(): AutomationRun | null { + const row = getDb().prepare( + "SELECT * FROM automation_runs WHERE status = 'queued' ORDER BY created_at ASC LIMIT 1" + ).get() as AutomationRunRow | undefined; + return row ? runFromRow(row) : null; +} + +/** + * 将进程崩溃遗留的运行状态转为失败。 + * 原因:重启后无法安全恢复已进行到一半的 Agent 写操作。 + * 未重新排队:重复执行可能再次修改用户数据。 + */ +export function failStaleAutomationRuns(): number { + const result = getDb().prepare(` + UPDATE automation_runs + SET status = 'failed', error = 'Papyrus 在运行期间退出,任务未自动重试', finished_at = ? + WHERE status IN ('queued', 'running') + `).run(Date.now() / 1000); + return Number(result.changes); +} + diff --git a/backend/src/db/database.ts b/backend/src/db/database.ts index a40e3786..14008b41 100644 --- a/backend/src/db/database.ts +++ b/backend/src/db/database.ts @@ -369,12 +369,71 @@ function initSchema(database: DatabaseSync): void { updated_at REAL NOT NULL ); + CREATE TABLE IF NOT EXISTS automations ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + prompt TEXT NOT NULL, + schedule_json TEXT NOT NULL, + timezone TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + allowed_tools TEXT NOT NULL DEFAULT '[]', + provider_override TEXT, + model_override TEXT, + reasoning_override INTEGER, + next_run_at REAL, + last_run_at REAL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL + ); + + CREATE TABLE IF NOT EXISTS automation_runs ( + id TEXT PRIMARY KEY, + automation_id TEXT NOT NULL REFERENCES automations(id) ON DELETE CASCADE, + trigger TEXT NOT NULL CHECK(trigger IN ('manual', 'scheduled', 'missed')), + status TEXT NOT NULL CHECK(status IN ('queued', 'running', 'succeeded', 'failed', 'missed')), + scheduled_for REAL, + output TEXT NOT NULL DEFAULT '', + reasoning TEXT NOT NULL DEFAULT '', + tool_calls_json TEXT NOT NULL DEFAULT '[]', + error TEXT, + model TEXT NOT NULL DEFAULT '', + provider TEXT NOT NULL DEFAULT '', + started_at REAL, + finished_at REAL, + created_at REAL NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_automations_enabled_next + ON automations(enabled, next_run_at); + CREATE INDEX IF NOT EXISTS idx_automation_runs_automation_created + ON automation_runs(automation_id, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_automation_runs_status + ON automation_runs(status, created_at); + INSERT OR IGNORE INTO knowledge_branches (id, name, head_version_id, is_active, created_at, updated_at) VALUES ('main', 'main', NULL, 1, unixepoch(), unixepoch()); `); + // 为 beta.16 之前创建的自动化表补充 Provider 覆盖列。 + // 原因:CREATE TABLE IF NOT EXISTS 不会修改既有表,升级用户需要保留全部任务数据。 + // 未重建整表:单列可空迁移可由 SQLite 原子完成,风险和锁定时间都更低。 + try { + const automationColumns = database.prepare( + "SELECT name FROM pragma_table_info('automations')" + ).all() as Array<{ name: string }>; + if (!automationColumns.some((column) => column.name === 'provider_override')) { + database.exec('ALTER TABLE automations ADD COLUMN provider_override TEXT;'); + } + } catch (error) { + console.error( + '迁移 automations.provider_override 失败:', + error instanceof Error ? error.message : String(error), + ); + throw error; + } + seedDefaults(database); deduplicateData(database); @@ -1981,6 +2040,8 @@ export function clearAllData(): void { database.exec('DELETE FROM files;'); database.exec('DELETE FROM chat_messages;'); database.exec('DELETE FROM chat_sessions;'); + database.exec('DELETE FROM automation_runs;'); + database.exec('DELETE FROM automations;'); database.exec('DELETE FROM provider_models;'); database.exec('DELETE FROM api_keys;'); database.exec('DELETE FROM providers;'); diff --git a/backend/tests/integration/api.test.ts b/backend/tests/integration/api.test.ts index 6f4bf704..92337c46 100644 --- a/backend/tests/integration/api.test.ts +++ b/backend/tests/integration/api.test.ts @@ -8,10 +8,22 @@ import { patchAppInjectWithAuth } from '../test-auth.js'; describe('API Integration Tests', () => { const testDir = path.join(os.tmpdir(), `papyrus-api-test-${Date.now()}`); const originalFetch = global.fetch; + const originalDisableSystemProxy = process.env.PAPYRUS_DISABLE_SYSTEM_PROXY; + const originalProxyEnvironment = { + HTTPS_PROXY: process.env.HTTPS_PROXY, + HTTP_PROXY: process.env.HTTP_PROXY, + https_proxy: process.env.https_proxy, + http_proxy: process.env.http_proxy, + }; beforeAll(async () => { fs.mkdirSync(testDir, { recursive: true }); process.env.PAPYRUS_DATA_DIR = testDir; + process.env.PAPYRUS_DISABLE_SYSTEM_PROXY = '1'; + delete process.env.HTTPS_PROXY; + delete process.env.HTTP_PROXY; + delete process.env.https_proxy; + delete process.env.http_proxy; const { resetAIConfig } = await import('../../src/ai/config-instance.js'); resetAIConfig(testDir); await initApp(); @@ -26,6 +38,15 @@ describe('API Integration Tests', () => { closeDb(); fs.rmSync(testDir, { recursive: true, force: true }); delete process.env.PAPYRUS_DATA_DIR; + if (originalDisableSystemProxy === undefined) { + delete process.env.PAPYRUS_DISABLE_SYSTEM_PROXY; + } else { + process.env.PAPYRUS_DISABLE_SYSTEM_PROXY = originalDisableSystemProxy; + } + for (const [key, value] of Object.entries(originalProxyEnvironment)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } }); beforeEach(async () => { @@ -34,6 +55,7 @@ describe('API Integration Tests', () => { db.exec(`DELETE FROM files; DELETE FROM card_review_actions; DELETE FROM cards; DELETE FROM notes; DELETE FROM card_versions; DELETE FROM note_versions; DELETE FROM relations; + DELETE FROM automation_runs; DELETE FROM automations; DELETE FROM provider_models; DELETE FROM api_keys; DELETE FROM providers; DELETE FROM daily_progress; DELETE FROM ui_settings;`); @@ -189,6 +211,267 @@ describe('API Integration Tests', () => { }); }); + it('should create, update, list, and delete an automation', async () => { + const createResponse = await app.inject({ + method: 'POST', + url: '/api/automations', + payload: { + name: 'Daily review', + prompt: 'Summarize due cards', + schedule: { kind: 'daily', hour: 9, minute: 0 }, + enabled: true, + allowedTools: ['read_data_stats'], + providerOverride: null, + modelOverride: null, + reasoningOverride: null, + }, + }); + expect(createResponse.statusCode).toBe(201); + const created = JSON.parse(createResponse.body).automation as { + id: string; + enabled: boolean; + providerOverride: string | null; + modelOverride: string | null; + }; + expect(created.enabled).toBe(true); + expect(created.providerOverride).toBeNull(); + expect(created.modelOverride).toBeNull(); + + const listResponse = await app.inject({ method: 'GET', url: '/api/automations' }); + expect(listResponse.statusCode).toBe(200); + expect(JSON.parse(listResponse.body).automations).toHaveLength(1); + + const updateResponse = await app.inject({ + method: 'PATCH', + url: `/api/automations/${created.id}`, + payload: { + enabled: false, + providerOverride: 'ollama', + modelOverride: 'automation-test-model', + }, + }); + expect(updateResponse.statusCode).toBe(200); + const updated = JSON.parse(updateResponse.body).automation; + expect(updated.nextRunAt).toBeNull(); + expect(updated.providerOverride).toBe('ollama'); + expect(updated.modelOverride).toBe('automation-test-model'); + + const detailResponse = await app.inject({ + method: 'GET', + url: '/api/automations/' + created.id, + }); + expect(detailResponse.statusCode).toBe(200); + expect(JSON.parse(detailResponse.body).automation).toEqual(expect.objectContaining({ + providerOverride: 'ollama', + modelOverride: 'automation-test-model', + enabled: false, + })); + + const { + claimAutomationRun, + createAutomationRun, + finishAutomationRun, + getAutomationRun, + } = await import('../../src/core/automations.js'); + const completedRun = createAutomationRun(created.id, 'manual', null); + expect(claimAutomationRun(completedRun.id)).toBe(true); + finishAutomationRun({ + runId: completedRun.id, + status: 'succeeded', + output: 'done', + reasoning: '', + toolCalls: [], + error: null, + model: 'automation-test-model', + provider: 'test-provider', + }); + + const deleteResponse = await app.inject({ method: 'DELETE', url: `/api/automations/${created.id}` }); + expect(deleteResponse.statusCode).toBe(200); + expect(getAutomationRun(completedRun.id)).toBeNull(); + }); + + it('should reject an automation with an unknown tool', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/automations', + payload: { + name: 'Unsafe task', + prompt: 'Run an unknown tool', + schedule: { kind: 'hourly', intervalHours: 1, minute: 0 }, + allowedTools: ['not_registered'], + }, + }); + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body).error).toContain('未知工具'); + }); + + it('should reject an invalid schedule and an overlapping manual run', async () => { + const invalidResponse = await app.inject({ + method: 'POST', + url: '/api/automations', + payload: { + name: 'Invalid schedule', + prompt: 'Never run', + schedule: { kind: 'weekly', daysOfWeek: [], hour: 9, minute: 0 }, + }, + }); + expect(invalidResponse.statusCode).toBe(400); + + const createResponse = await app.inject({ + method: 'POST', + url: '/api/automations', + payload: { + name: 'Concurrency guard', + prompt: 'Read stats', + schedule: { kind: 'hourly', intervalHours: 2, minute: 0 }, + }, + }); + const automationId = (JSON.parse(createResponse.body).automation as { id: string }).id; + const { createAutomationRun } = await import('../../src/core/automations.js'); + createAutomationRun(automationId, 'manual', null); + + const conflictResponse = await app.inject({ + method: 'POST', + url: `/api/automations/${automationId}/run`, + }); + expect(conflictResponse.statusCode).toBe(409); + + const deleteWhileActive = await app.inject({ + method: 'DELETE', + url: '/api/automations/' + automationId, + }); + expect(deleteWhileActive.statusCode).toBe(409); + expect(JSON.parse(deleteWhileActive.body).error).toContain('正在运行'); + }); + + it('should normalize schedules and tools while defaulting to read-only permissions', async () => { + const normalizedResponse = await app.inject({ + method: 'POST', + url: '/api/automations', + payload: { + name: 'Normalized automation', + prompt: 'Read data', + schedule: { kind: 'weekly', daysOfWeek: [5, 1, 5], hour: 9, minute: 0 }, + timezone: 'Fake/Timezone', + allowedTools: ['read_data_stats', 'read_data_stats'], + }, + }); + expect(normalizedResponse.statusCode).toBe(201); + const normalized = JSON.parse(normalizedResponse.body).automation as { + schedule: { daysOfWeek: number[] }; + timezone: string; + allowedTools: string[]; + }; + expect(normalized.schedule.daysOfWeek).toEqual([1, 5]); + expect(normalized.timezone).not.toBe('Fake/Timezone'); + expect(normalized.allowedTools).toEqual(['read_data_stats']); + + const defaultResponse = await app.inject({ + method: 'POST', + url: '/api/automations', + payload: { + name: 'Default permissions', + prompt: 'Use safe defaults', + schedule: { kind: 'daily', hour: 9, minute: 0 }, + }, + }); + expect(defaultResponse.statusCode).toBe(201); + const defaultTools = (JSON.parse(defaultResponse.body).automation as { + allowedTools: string[]; + }).allowedTools; + expect(defaultTools).toContain('read_data_stats'); + expect(defaultTools).not.toContain('create_card'); + expect(new Set(defaultTools).size).toBe(defaultTools.length); + }); + + it('should reject incomplete targets and empty patches and return precise missing-resource statuses', async () => { + const incompleteCreate = await app.inject({ + method: 'POST', + url: '/api/automations', + payload: { + name: 'Incomplete target', + prompt: 'Cannot run safely', + schedule: { kind: 'daily', hour: 9, minute: 0 }, + providerOverride: 'ollama', + }, + }); + expect(incompleteCreate.statusCode).toBe(400); + expect(JSON.parse(incompleteCreate.body).error).toContain('同时设置'); + + const createResponse = await app.inject({ + method: 'POST', + url: '/api/automations', + payload: { + name: 'Patch validation', + prompt: 'Read stats', + schedule: { kind: 'daily', hour: 9, minute: 0 }, + }, + }); + const automationId = (JSON.parse(createResponse.body).automation as { id: string }).id; + const incompletePatch = await app.inject({ + method: 'PATCH', + url: '/api/automations/' + automationId, + payload: { modelOverride: 'orphan-model' }, + }); + expect(incompletePatch.statusCode).toBe(400); + const emptyPatch = await app.inject({ + method: 'PATCH', + url: '/api/automations/' + automationId, + payload: {}, + }); + expect(emptyPatch.statusCode).toBe(400); + + const missingId = 'missing-automation'; + const missingResponses = await Promise.all([ + app.inject({ method: 'GET', url: '/api/automations/' + missingId }), + app.inject({ method: 'GET', url: '/api/automations/' + missingId + '/runs' }), + app.inject({ method: 'PATCH', url: '/api/automations/' + missingId, payload: { name: 'Missing' } }), + app.inject({ method: 'DELETE', url: '/api/automations/' + missingId }), + app.inject({ method: 'POST', url: '/api/automations/' + missingId + '/run' }), + app.inject({ method: 'GET', url: '/api/automations/runs/missing-run' }), + ]); + expect(missingResponses.map((response) => response.statusCode)) + .toEqual([404, 404, 404, 404, 404, 404]); + }); + + it('should clamp run-history limits and return persisted audit rows', async () => { + const createResponse = await app.inject({ + method: 'POST', + url: '/api/automations', + payload: { + name: 'History limits', + prompt: 'Read stats', + schedule: { kind: 'daily', hour: 9, minute: 0 }, + }, + }); + const automationId = (JSON.parse(createResponse.body).automation as { id: string }).id; + const { createAutomationRun } = await import('../../src/core/automations.js'); + createAutomationRun(automationId, 'missed', 1, 'missed'); + createAutomationRun(automationId, 'missed', 2, 'missed'); + createAutomationRun(automationId, 'missed', 3, 'missed'); + + const twoRuns = await app.inject({ + method: 'GET', + url: '/api/automations/' + automationId + '/runs?limit=2', + }); + expect(twoRuns.statusCode).toBe(200); + expect(JSON.parse(twoRuns.body).runs).toHaveLength(2); + + const clampedMinimum = await app.inject({ + method: 'GET', + url: '/api/automations/' + automationId + '/runs?limit=0', + }); + expect(JSON.parse(clampedMinimum.body).runs).toHaveLength(1); + + const invalidRecent = await app.inject({ + method: 'GET', + url: '/api/automations/runs/recent?limit=not-a-number', + }); + expect(invalidRecent.statusCode).toBe(200); + expect(JSON.parse(invalidRecent.body).runs).toHaveLength(3); + }); + it('POST /api/cards should create a card', async () => { const response = await app.inject({ method: 'POST', @@ -810,14 +1093,14 @@ describe('API Integration Tests', () => { method: 'GET', url: '/api/update/check', headers: { - 'x-papyrus-app-version': '2.0.0-beta.15', + 'x-papyrus-app-version': '2.0.0-beta.16', }, }); expect(response.statusCode).toBe(200); const body = JSON.parse(response.body); expect(body.success).toBe(true); - expect(body.data.current_version).toBe('2.0.0-beta.15'); + expect(body.data.current_version).toBe('2.0.0-beta.16'); expect(body.data.latest_version).toBe('v2.0.0-beta.13'); expect(body.data.has_update).toBe(false); expect(requestedUrls[0]).toContain('/releases?per_page=30'); diff --git a/backend/tests/integration/server-auth.test.ts b/backend/tests/integration/server-auth.test.ts index e877cf8e..c00d7bd8 100644 --- a/backend/tests/integration/server-auth.test.ts +++ b/backend/tests/integration/server-auth.test.ts @@ -86,8 +86,8 @@ describe('Server Auth Hook', () => { expect(response.statusCode).toBe(200); }); - it('should reject GET /api/providers and /api/export without token', async () => { - for (const url of ['/api/providers', '/api/export']) { + it('should reject protected data and automation endpoints without token', async () => { + for (const url of ['/api/providers', '/api/export', '/api/automations']) { const response = await (app as { inject: (opts: unknown) => Promise<{ statusCode: number }> }).inject({ method: 'GET', url, diff --git a/backend/tests/security-exploit.test.ts b/backend/tests/security-exploit.test.ts index e2ca765e..22fd7dc1 100644 --- a/backend/tests/security-exploit.test.ts +++ b/backend/tests/security-exploit.test.ts @@ -271,7 +271,17 @@ describe('Security Exploit Tests', () => { } const originalFetch = global.fetch; + const originalProxyEnvironment = { + HTTPS_PROXY: process.env.HTTPS_PROXY, + HTTP_PROXY: process.env.HTTP_PROXY, + https_proxy: process.env.https_proxy, + http_proxy: process.env.http_proxy, + }; try { + delete process.env.HTTPS_PROXY; + delete process.env.HTTP_PROXY; + delete process.env.https_proxy; + delete process.env.http_proxy; global.fetch = () => Promise.resolve({ ok: true, status: 200, @@ -295,6 +305,10 @@ describe('Security Exploit Tests', () => { expect(body.data.download_url).toBe(body.data.release_url); } finally { global.fetch = originalFetch; + for (const [key, value] of Object.entries(originalProxyEnvironment)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } } }); }); diff --git a/backend/tests/unit/ai-provider-helpers.test.ts b/backend/tests/unit/ai-provider-helpers.test.ts index d6a1eb55..2018a7cb 100644 --- a/backend/tests/unit/ai-provider-helpers.test.ts +++ b/backend/tests/unit/ai-provider-helpers.test.ts @@ -48,6 +48,67 @@ describe('AI provider helpers and manager utilities', () => { return new AIManager(config); } + it('should explain how to recover when standalone Agent has no configured provider', async () => { + const config = new AIConfig(testDir); + config.config.current_provider = ''; + config.config.current_model = ''; + const manager = new AIManager(config); + + await expect(manager.standaloneAgentTurn({ + messages: [{ role: 'user', content: 'Run automation' }], + allowedToolNames: new Set(), + })).rejects.toThrow('尚未配置 AI Provider,请先在设置中添加并启用一个提供商'); + }); + + it('should use a standalone Provider/model override instead of the invalid global target', async () => { + const config = new AIConfig(testDir); + const providerId = saveProvider({ + id: 'automation-override-provider', + type: 'ollama', + name: 'Automation Override', + baseUrl: 'http://localhost:11434', + enabled: true, + isDefault: false, + }); + saveModel(providerId, { + id: 'automation-override-model-row', + name: 'Automation Override Model', + modelId: 'automation-override-model', + enabled: true, + }); + config.config.current_provider = 'missing-global-provider'; + config.config.current_model = 'missing-global-model'; + const manager = new AIManager(config); + let requestedUrl = ''; + let requestedBody: unknown; + global.fetch = async (input, init) => { + requestedUrl = String(input); + requestedBody = typeof init?.body === 'string' ? JSON.parse(init.body) as unknown : null; + return new Response( + JSON.stringify({ message: { content: 'Override worked' }, done: false }) + '\n' + + JSON.stringify({ done: true }) + '\n', + { status: 200, headers: { 'Content-Type': 'application/x-ndjson' } }, + ); + }; + + const result = await manager.standaloneAgentTurn({ + messages: [{ role: 'user', content: 'Run the automation' }], + allowedToolNames: new Set(), + overrideProvider: 'ollama', + overrideModel: 'automation-override-model', + }); + + expect(requestedUrl).toContain('localhost:11434'); + expect(requestedBody).toEqual(expect.objectContaining({ + model: 'automation-override-model', + })); + expect(result).toEqual(expect.objectContaining({ + content: 'Override worked', + provider: 'ollama', + model: 'automation-override-model', + })); + }); + /** * 注册可由标题生成路径调用的本地 Ollama 模型。 * 原因:测试需要覆盖真实的模型目标解析与流读取,但不能访问外部网络。 diff --git a/backend/tests/unit/automation-agent-runner.test.ts b/backend/tests/unit/automation-agent-runner.test.ts new file mode 100644 index 00000000..387a8b1c --- /dev/null +++ b/backend/tests/unit/automation-agent-runner.test.ts @@ -0,0 +1,224 @@ +import { AutomationAgentRunner } from '../../src/core/automation-agent-runner.js'; +import type { Automation } from '../../src/core/automation-types.js'; +import type { AIManager } from '../../src/ai/provider.js'; +import { jest } from '@jest/globals'; + +const automation: Automation = { + id: 'automation-test', + name: 'Daily summary', + prompt: 'Summarize the current data', + schedule: { kind: 'daily', hour: 9, minute: 0 }, + timezone: 'UTC', + enabled: true, + allowedTools: ['read_data_stats'], + providerOverride: null, + modelOverride: null, + reasoningOverride: null, + nextRunAt: null, + lastRunAt: null, + createdAt: 0, + updatedAt: 0, +}; + +describe('AutomationAgentRunner', () => { + it('feeds an allowed tool result back into a second Agent turn', async () => { + let turn = 0; + const manager = { + standaloneAgentTurn: jest.fn(async () => { + turn += 1; + return turn === 1 + ? { + content: '', + reasoning: 'Need stats', + toolCalls: [{ id: 'call-1', name: 'read_data_stats', params: {} }], + model: 'test-model', + provider: 'test-provider', + } + : { + content: 'Summary complete', + reasoning: '', + toolCalls: [], + model: 'test-model', + provider: 'test-provider', + }; + }), + }; + const tools = { + executeTool: jest.fn(() => ({ success: true, cards: 3 })), + }; + const runner = new AutomationAgentRunner(manager, tools); + + const result = await runner.run(automation); + + expect(result.output).toBe('Summary complete'); + expect(result.reasoning).toContain('Need stats'); + expect(result.toolCalls).toHaveLength(1); + expect(tools.executeTool).toHaveBeenCalledWith('read_data_stats', {}); + expect(manager.standaloneAgentTurn).toHaveBeenCalledTimes(2); + const secondTurnInput = manager.standaloneAgentTurn.mock.calls[1]?.[0]; + expect(secondTurnInput?.messages.at(-1)).toEqual({ + role: 'tool', + content: JSON.stringify({ success: true, cards: 3 }), + tool_call_id: 'call-1', + name: 'read_data_stats', + }); + }); + + it('passes the provider, model, and reasoning override as one target', async () => { + const manager = { + standaloneAgentTurn: jest.fn(async () => ({ + content: 'Provider-specific result', + reasoning: '', + toolCalls: [], + model: 'qwen-test', + provider: 'ollama', + })), + }; + const runner = new AutomationAgentRunner(manager, { executeTool: jest.fn() }); + + const result = await runner.run({ + ...automation, + providerOverride: 'ollama', + modelOverride: 'qwen-test', + reasoningOverride: true, + }); + + expect(manager.standaloneAgentTurn).toHaveBeenCalledWith(expect.objectContaining({ + overrideProvider: 'ollama', + overrideModel: 'qwen-test', + reasoning: true, + })); + expect(result).toEqual(expect.objectContaining({ + output: 'Provider-specific result', + model: 'qwen-test', + provider: 'ollama', + })); + }); + + it('returns a failed tool result to the model and preserves it for auditing', async () => { + let turn = 0; + const manager = { + standaloneAgentTurn: jest.fn(async () => { + turn += 1; + return turn === 1 + ? { + content: '', + reasoning: '', + toolCalls: [{ id: 'failed-call', name: 'read_data_stats', params: {} }], + model: 'test-model', + provider: 'test-provider', + } + : { + content: 'Handled the tool failure', + reasoning: '', + toolCalls: [], + model: 'test-model', + provider: 'test-provider', + }; + }), + }; + const runner = new AutomationAgentRunner(manager, { + executeTool: jest.fn(() => ({ success: false, error: 'database unavailable' })), + }); + + const result = await runner.run(automation); + + expect(result.output).toBe('Handled the tool failure'); + expect(result.toolCalls).toEqual([{ + name: 'read_data_stats', + params: {}, + success: false, + error: 'database unavailable', + }]); + const secondTurnInput = manager.standaloneAgentTurn.mock.calls[1]?.[0]; + expect(secondTurnInput?.messages.at(-1)?.content).toBe( + JSON.stringify({ success: false, error: 'database unavailable' }), + ); + }); + + it('rejects an automation containing an unknown tool before contacting the model', async () => { + const manager = { standaloneAgentTurn: jest.fn() }; + const runner = new AutomationAgentRunner(manager, { executeTool: jest.fn() }); + + await expect(runner.run({ ...automation, allowedTools: ['unknown_tool'] })) + .rejects.toThrow('未知工具'); + expect(manager.standaloneAgentTurn).not.toHaveBeenCalled(); + }); + + it('rejects a write tool call that is not in the automation whitelist', async () => { + const manager = { + standaloneAgentTurn: jest.fn(async () => ({ + content: '', + reasoning: '', + toolCalls: [{ id: 'write-call', name: 'create_card', params: { question: 'Q', answer: 'A' } }], + model: 'test-model', + provider: 'test-provider', + })), + }; + const executeTool = jest.fn(); + const runner = new AutomationAgentRunner(manager, { executeTool }); + + await expect(runner.run(automation)).rejects.toThrow('未授权工具'); + expect(executeTool).not.toHaveBeenCalled(); + }); + + it('stops after eight Agent turns without a final answer', async () => { + const manager = { + standaloneAgentTurn: jest.fn(async () => ({ + content: '', + reasoning: '', + toolCalls: [{ id: 'loop', name: 'read_data_stats', params: {} }], + model: 'test-model', + provider: 'test-provider', + })), + }; + const runner = new AutomationAgentRunner(manager, { + executeTool: jest.fn(() => ({ success: true })), + }); + + await expect(runner.run(automation)).rejects.toThrow('回合超过上限 8'); + expect(manager.standaloneAgentTurn).toHaveBeenCalledTimes(8); + }); + + it('stops before executing more than twenty tool calls', async () => { + const manager = { + standaloneAgentTurn: jest.fn(async () => ({ + content: '', + reasoning: '', + toolCalls: Array.from({ length: 21 }, (_, index) => ({ + id: `call-${index}`, + name: 'read_data_stats', + params: {}, + })), + model: 'test-model', + provider: 'test-provider', + })), + }; + const executeTool = jest.fn(() => ({ success: true })); + const runner = new AutomationAgentRunner(manager, { executeTool }); + + await expect(runner.run(automation)).rejects.toThrow('工具调用超过上限 20'); + expect(executeTool).toHaveBeenCalledTimes(20); + }); + + it('aborts a run after ten minutes', async () => { + jest.useFakeTimers(); + const manager = { + standaloneAgentTurn: jest.fn((input: Parameters[0]) => ( + new Promise((_resolve, reject) => { + input.signal?.addEventListener('abort', () => reject(new Error('aborted'))); + }) + )), + }; + const runner = new AutomationAgentRunner(manager, { executeTool: jest.fn() }); + + try { + const run = runner.run(automation); + const expectedRejection = expect(run).rejects.toThrow('运行超过 10 分钟'); + await jest.advanceTimersByTimeAsync(10 * 60 * 1000); + await expectedRejection; + } finally { + jest.useRealTimers(); + } + }); +}); diff --git a/backend/tests/unit/automation-schedule.test.ts b/backend/tests/unit/automation-schedule.test.ts new file mode 100644 index 00000000..82690be3 --- /dev/null +++ b/backend/tests/unit/automation-schedule.test.ts @@ -0,0 +1,72 @@ +import { calculateNextRun } from '../../src/core/automation-schedule.js'; + +describe('automation schedule calculation', () => { + it('uses the current hourly slot when its configured minute is still ahead', () => { + const after = new Date(2026, 7, 3, 10, 16, 30).getTime() / 1000; + const next = calculateNextRun({ kind: 'hourly', intervalHours: 2, minute: 30 }, after); + + expect(new Date(next * 1000)).toEqual(new Date(2026, 7, 3, 10, 30, 0)); + }); + + it('advances by the configured interval after or exactly on the hourly slot', () => { + const afterSlot = new Date(2026, 7, 3, 10, 31, 0).getTime() / 1000; + const exactlyOnSlot = new Date(2026, 7, 3, 10, 30, 0).getTime() / 1000; + + expect(new Date(calculateNextRun( + { kind: 'hourly', intervalHours: 2, minute: 30 }, + afterSlot, + ) * 1000)).toEqual(new Date(2026, 7, 3, 12, 30, 0)); + expect(new Date(calculateNextRun( + { kind: 'hourly', intervalHours: 2, minute: 30 }, + exactlyOnSlot, + ) * 1000)).toEqual(new Date(2026, 7, 3, 12, 30, 0)); + }); + + it('carries hourly intervals across midnight', () => { + const after = new Date(2026, 7, 3, 23, 31, 0).getTime() / 1000; + const next = calculateNextRun({ kind: 'hourly', intervalHours: 2, minute: 30 }, after); + + expect(new Date(next * 1000)).toEqual(new Date(2026, 7, 4, 1, 30, 0)); + }); + + it('keeps a daily slot today when it is ahead and moves exact or past slots to tomorrow', () => { + const before = new Date(2026, 7, 3, 8, 0, 0).getTime() / 1000; + const exact = new Date(2026, 7, 3, 9, 15, 0).getTime() / 1000; + const schedule = { kind: 'daily' as const, hour: 9, minute: 15 }; + + expect(new Date(calculateNextRun(schedule, before) * 1000)) + .toEqual(new Date(2026, 7, 3, 9, 15, 0)); + expect(new Date(calculateNextRun(schedule, exact) * 1000)) + .toEqual(new Date(2026, 7, 4, 9, 15, 0)); + }); + + it('selects the nearest configured weekday including today before its time', () => { + const mondayBefore = new Date(2026, 7, 3, 7, 0, 0).getTime() / 1000; + const mondayAfter = new Date(2026, 7, 3, 10, 0, 0).getTime() / 1000; + const schedule = { kind: 'weekly' as const, daysOfWeek: [1, 3, 5], hour: 8, minute: 0 }; + + expect(new Date(calculateNextRun(schedule, mondayBefore) * 1000)) + .toEqual(new Date(2026, 7, 3, 8, 0, 0)); + expect(new Date(calculateNextRun(schedule, mondayAfter) * 1000)) + .toEqual(new Date(2026, 7, 5, 8, 0, 0)); + }); + + it('wraps weekly schedules into the following week', () => { + const mondayAfter = new Date(2026, 7, 3, 10, 0, 0).getTime() / 1000; + const next = calculateNextRun( + { kind: 'weekly', daysOfWeek: [1], hour: 8, minute: 0 }, + mondayAfter, + ); + + expect(new Date(next * 1000)).toEqual(new Date(2026, 7, 10, 8, 0, 0)); + }); + + it('returns a finite seven-day fallback for corrupted weekly data', () => { + const after = new Date(2026, 7, 3, 10, 0, 0).getTime() / 1000; + + expect(calculateNextRun( + { kind: 'weekly', daysOfWeek: [], hour: 8, minute: 0 }, + after, + )).toBe(after + 7 * 24 * 60 * 60); + }); +}); \ No newline at end of file diff --git a/backend/tests/unit/automation-scheduler.test.ts b/backend/tests/unit/automation-scheduler.test.ts new file mode 100644 index 00000000..59ee5510 --- /dev/null +++ b/backend/tests/unit/automation-scheduler.test.ts @@ -0,0 +1,219 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { jest } from '@jest/globals'; +import type { Automation } from '../../src/core/automation-types.js'; + +describe('AutomationScheduler', () => { + const testDir = path.join(os.tmpdir(), 'papyrus-automation-scheduler-' + Date.now()); + let closeDb: typeof import('../../src/db/database.js').closeDb; + let getDb: typeof import('../../src/db/database.js').getDb; + let repository: typeof import('../../src/core/automations.js'); + let Scheduler: typeof import('../../src/core/automation-scheduler.js').AutomationScheduler; + + beforeAll(async () => { + fs.mkdirSync(testDir, { recursive: true }); + process.env.PAPYRUS_DATA_DIR = testDir; + const database = await import('../../src/db/database.js'); + const schedulerModule = await import('../../src/core/automation-scheduler.js'); + closeDb = database.closeDb; + getDb = database.getDb; + repository = await import('../../src/core/automations.js'); + Scheduler = schedulerModule.AutomationScheduler; + getDb(); + }); + + afterAll(() => { + closeDb(); + delete process.env.PAPYRUS_DATA_DIR; + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + beforeEach(() => { + getDb().exec('DELETE FROM automation_runs; DELETE FROM automations;'); + }); + + /** + * 创建使用真实仓储的调度器测试任务。 + * 原因:各用例只替换外部 Agent 边界,队列、状态机和持久化必须使用生产实现。 + * 未共享返回对象:每个任务需要独立 ID 才能验证全局串行行为。 + */ + function createAutomation(name: string): Automation { + return repository.createAutomation({ + name, + prompt: 'Read current statistics', + schedule: { kind: 'daily', hour: 9, minute: 0 }, + timezone: 'UTC', + enabled: true, + allowedTools: ['read_data_stats'], + providerOverride: 'ollama', + modelOverride: 'scheduler-model', + reasoningOverride: null, + }); + } + + /** + * 等待异步 Worker 把运行写入终态。 + * 原因:enqueueManual 设计为立即返回 202 语义,测试不能假设 void Promise 已完成。 + * 未使用固定长延时:逐微任务轮询让测试快速且减少慢机器波动。 + */ + async function waitForTerminal(runId: string): Promise> { + for (let attempt = 0; attempt < 100; attempt += 1) { + const run = repository.getAutomationRun(runId); + if (run && ['succeeded', 'failed'].includes(run.status)) return run; + await new Promise((resolve) => setImmediate(resolve)); + } + throw new Error('Scheduler run did not reach a terminal state'); + } + + it('persists a successful manual run through the real queue and database', async () => { + const manager = { + standaloneAgentTurn: jest.fn(async () => ({ + content: '', + reasoning: '', + toolCalls: [], + model: 'unused', + provider: 'unused', + })), + }; + const runner = { + run: jest.fn(async (automation: Automation) => ({ + output: 'Scheduler completed', + reasoning: 'Used the injected boundary', + toolCalls: [], + model: automation.modelOverride ?? '', + provider: automation.providerOverride ?? '', + })), + }; + const scheduler = new Scheduler(manager, undefined, runner); + const automation = createAutomation('Manual success'); + + const queued = scheduler.enqueueManual(automation.id); + if (!queued) throw new Error('Expected a queued manual run'); + const terminal = await waitForTerminal(queued.id); + + expect(runner.run).toHaveBeenCalledWith(expect.objectContaining({ id: automation.id })); + expect(terminal).toEqual(expect.objectContaining({ + status: 'succeeded', + output: 'Scheduler completed', + reasoning: 'Used the injected boundary', + model: 'scheduler-model', + provider: 'ollama', + })); + expect(repository.getAutomation(automation.id)?.lastRunAt).not.toBeNull(); + }); + + it('keeps different automations globally serial and rejects same-task overlap', async () => { + let releaseFirst: (() => void) | undefined; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + let active = 0; + let maximumActive = 0; + const runner = { + run: jest.fn(async (automation: Automation) => { + active += 1; + maximumActive = Math.max(maximumActive, active); + if (automation.name === 'First serial task') await firstGate; + active -= 1; + return { + output: automation.name, + reasoning: '', + toolCalls: [], + model: 'scheduler-model', + provider: 'ollama', + }; + }), + }; + const manager = { + standaloneAgentTurn: jest.fn(async () => ({ + content: '', + reasoning: '', + toolCalls: [], + model: 'unused', + provider: 'unused', + })), + }; + const scheduler = new Scheduler(manager, undefined, runner); + const first = createAutomation('First serial task'); + const second = createAutomation('Second serial task'); + + const firstRun = scheduler.enqueueManual(first.id); + if (!firstRun) throw new Error('Expected first run'); + expect(scheduler.enqueueManual(first.id)).toBeNull(); + const secondRun = scheduler.enqueueManual(second.id); + if (!secondRun) throw new Error('Expected second run'); + await new Promise((resolve) => setImmediate(resolve)); + + expect(repository.getAutomationRun(firstRun.id)?.status).toBe('running'); + expect(repository.getAutomationRun(secondRun.id)?.status).toBe('queued'); + releaseFirst?.(); + await Promise.all([waitForTerminal(firstRun.id), waitForTerminal(secondRun.id)]); + + expect(maximumActive).toBe(1); + expect(runner.run).toHaveBeenCalledTimes(2); + }); + + it('records runner failures instead of leaving work active', async () => { + const manager = { + standaloneAgentTurn: jest.fn(async () => ({ + content: '', + reasoning: '', + toolCalls: [], + model: 'unused', + provider: 'unused', + })), + }; + const runner = { + run: jest.fn(async () => { + throw new Error('provider offline'); + }), + }; + const scheduler = new Scheduler(manager, undefined, runner); + const automation = createAutomation('Failure state'); + + const queued = scheduler.enqueueManual(automation.id); + if (!queued) throw new Error('Expected failure run'); + const terminal = await waitForTerminal(queued.id); + + expect(terminal).toEqual(expect.objectContaining({ + status: 'failed', + error: 'provider offline', + model: 'scheduler-model', + provider: 'ollama', + })); + expect(repository.hasActiveAutomationRun(automation.id)).toBe(false); + }); + + it('recovers stale work and records one missed occurrence on startup', () => { + const manager = { + standaloneAgentTurn: jest.fn(async () => ({ + content: '', + reasoning: '', + toolCalls: [], + model: 'unused', + provider: 'unused', + })), + }; + const runner = { run: jest.fn(async () => { + throw new Error('Missed work must not be replayed'); + }) }; + const scheduler = new Scheduler(manager, undefined, runner); + const automation = createAutomation('Startup recovery'); + const stale = repository.createAutomationRun(automation.id, 'manual', null); + getDb().prepare('UPDATE automations SET next_run_at = ? WHERE id = ?') + .run(Date.now() / 1000 - 60, automation.id); + + scheduler.start(); + scheduler.stop(); + + expect(repository.getAutomationRun(stale.id)).toEqual(expect.objectContaining({ + status: 'failed', + error: 'Papyrus 在运行期间退出,任务未自动重试', + })); + const runs = repository.listAutomationRuns(automation.id); + expect(runs.filter((run) => run.status === 'missed')).toHaveLength(1); + expect(runner.run).not.toHaveBeenCalled(); + expect(repository.getAutomation(automation.id)?.nextRunAt).toBeGreaterThan(Date.now() / 1000); + }); +}); \ No newline at end of file diff --git a/backend/tests/unit/automations.test.ts b/backend/tests/unit/automations.test.ts new file mode 100644 index 00000000..2d9ff328 --- /dev/null +++ b/backend/tests/unit/automations.test.ts @@ -0,0 +1,218 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +describe('automation repository', () => { + const testDir = path.join(os.tmpdir(), `papyrus-automations-test-${Date.now()}`); + let closeDb: typeof import('../../src/db/database.js').closeDb; + let getDb: typeof import('../../src/db/database.js').getDb; + let repository: typeof import('../../src/core/automations.js'); + + beforeAll(async () => { + fs.mkdirSync(testDir, { recursive: true }); + process.env.PAPYRUS_DATA_DIR = testDir; + const database = await import('../../src/db/database.js'); + closeDb = database.closeDb; + getDb = database.getDb; + repository = await import('../../src/core/automations.js'); + getDb(); + }); + + afterAll(() => { + closeDb(); + delete process.env.PAPYRUS_DATA_DIR; + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + beforeEach(() => { + getDb().exec('DELETE FROM automation_runs; DELETE FROM automations;'); + }); + + const create = () => repository.createAutomation({ + name: 'Test automation', + prompt: 'Summarize due cards', + schedule: { kind: 'daily', hour: 9, minute: 0 }, + timezone: 'UTC', + enabled: true, + allowedTools: ['read_data_stats'], + providerOverride: null, + modelOverride: null, + reasoningOverride: null, + }); + + it('recalculates nextRunAt when paused and resumed', () => { + const created = create(); + expect(created.nextRunAt).not.toBeNull(); + + const paused = repository.updateAutomation(created.id, { enabled: false }); + expect(paused?.nextRunAt).toBeNull(); + + const resumed = repository.updateAutomation(created.id, { enabled: true }); + expect(resumed?.nextRunAt).not.toBeNull(); + }); + + it('records one missed run and advances the schedule', () => { + const created = create(); + const past = Date.now() / 1000 - 3600; + getDb().prepare('UPDATE automations SET next_run_at = ? WHERE id = ?').run(past, created.id); + const due = repository.getAutomation(created.id); + if (!due) throw new Error('Expected automation fixture'); + + const missed = repository.markAutomationMissed(due, Date.now() / 1000); + + expect(missed?.status).toBe('missed'); + expect(repository.listAutomationRuns(created.id)).toHaveLength(1); + expect(repository.getAutomation(created.id)?.nextRunAt).toBeGreaterThan(Date.now() / 1000); + }); + + it('atomically claims a queued run only once', () => { + const created = create(); + const run = repository.createAutomationRun(created.id, 'manual', null); + + expect(repository.claimAutomationRun(run.id)).toBe(true); + expect(repository.claimAutomationRun(run.id)).toBe(false); + expect(repository.hasActiveAutomationRun(created.id)).toBe(true); + }); + + it('round-trips every editable field and clears the provider/model pair together', () => { + const created = repository.createAutomation({ + name: 'Provider-specific automation', + prompt: 'Use the selected target', + schedule: { kind: 'weekly', daysOfWeek: [1, 5], hour: 18, minute: 45 }, + timezone: 'Asia/Shanghai', + enabled: true, + allowedTools: ['read_data_stats', 'search_cards'], + providerOverride: 'ollama', + modelOverride: 'qwen-test', + reasoningOverride: true, + }); + + expect(repository.getAutomation(created.id)).toEqual(expect.objectContaining({ + name: 'Provider-specific automation', + prompt: 'Use the selected target', + schedule: { kind: 'weekly', daysOfWeek: [1, 5], hour: 18, minute: 45 }, + timezone: 'Asia/Shanghai', + enabled: true, + allowedTools: ['read_data_stats', 'search_cards'], + providerOverride: 'ollama', + modelOverride: 'qwen-test', + reasoningOverride: true, + })); + + const updated = repository.updateAutomation(created.id, { + prompt: 'Use the global target', + providerOverride: null, + modelOverride: null, + reasoningOverride: false, + }); + expect(updated).toEqual(expect.objectContaining({ + prompt: 'Use the global target', + providerOverride: null, + modelOverride: null, + reasoningOverride: false, + })); + expect(updated?.schedule).toEqual(created.schedule); + }); + + it('only finishes claimed runs and persists auditable result metadata', () => { + const created = create(); + const run = repository.createAutomationRun(created.id, 'manual', null); + const result = { + runId: run.id, + status: 'succeeded' as const, + output: 'Three cards are due', + reasoning: 'Read current statistics', + toolCalls: [{ + name: 'read_data_stats', + params: {}, + success: true, + result: { cards: 3 }, + }], + error: null, + model: 'qwen-test', + provider: 'ollama', + }; + + expect(repository.finishAutomationRun(result)).toBe(false); + expect(repository.claimAutomationRun(run.id)).toBe(true); + expect(repository.finishAutomationRun(result)).toBe(true); + expect(repository.getAutomationRun(run.id)).toEqual(expect.objectContaining({ + status: 'succeeded', + output: 'Three cards are due', + reasoning: 'Read current statistics', + toolCalls: result.toolCalls, + error: null, + model: 'qwen-test', + provider: 'ollama', + })); + }); + + it('enqueues a due schedule once even when the same stale snapshot is retried', () => { + const created = create(); + const scheduledFor = Date.now() / 1000 - 5; + getDb().prepare('UPDATE automations SET next_run_at = ? WHERE id = ?') + .run(scheduledFor, created.id); + const due = repository.getAutomation(created.id); + if (!due) throw new Error('Expected due automation fixture'); + + const first = repository.enqueueScheduledAutomation(due, Date.now() / 1000); + const duplicate = repository.enqueueScheduledAutomation(due, Date.now() / 1000); + + expect(first).toEqual(expect.objectContaining({ + automationId: created.id, + trigger: 'scheduled', + status: 'queued', + scheduledFor, + })); + expect(duplicate).toBeNull(); + expect(repository.listAutomationRuns(created.id)).toHaveLength(1); + expect(repository.getAutomation(created.id)?.nextRunAt).toBeGreaterThan(Date.now() / 1000); + }); + + it('fails stale queued and running work without replaying it', () => { + const firstAutomation = create(); + const secondAutomation = repository.createAutomation({ + name: 'Second automation', + prompt: 'Read stats', + schedule: { kind: 'daily', hour: 12, minute: 0 }, + timezone: 'UTC', + enabled: true, + allowedTools: ['read_data_stats'], + providerOverride: null, + modelOverride: null, + reasoningOverride: null, + }); + const running = repository.createAutomationRun(firstAutomation.id, 'manual', null); + const queued = repository.createAutomationRun(secondAutomation.id, 'manual', null); + expect(repository.claimAutomationRun(running.id)).toBe(true); + + expect(repository.failStaleAutomationRuns()).toBe(2); + expect(repository.getAutomationRun(running.id)).toEqual(expect.objectContaining({ + status: 'failed', + error: 'Papyrus 在运行期间退出,任务未自动重试', + })); + expect(repository.getAutomationRun(queued.id)).toEqual(expect.objectContaining({ + status: 'failed', + error: 'Papyrus 在运行期间退出,任务未自动重试', + })); + }); + + it('deletes run history through the database foreign-key cascade', () => { + const created = create(); + const run = repository.createAutomationRun(created.id, 'manual', null); + + expect(repository.deleteAutomation(created.id)).toBe(true); + expect(repository.getAutomation(created.id)).toBeNull(); + expect(repository.getAutomationRun(run.id)).toBeNull(); + }); + + it('isolates a corrupted allowed-tools field instead of failing the full list', () => { + const created = create(); + getDb().prepare('UPDATE automations SET allowed_tools = ? WHERE id = ?') + .run('{broken json', created.id); + + expect(repository.getAutomation(created.id)?.allowedTools).toEqual([]); + expect(repository.listAutomations()).toHaveLength(1); + }); +}); + diff --git a/backend/tests/unit/cli-manager.test.ts b/backend/tests/unit/cli-manager.test.ts index d7d54ac5..c677ca4c 100644 --- a/backend/tests/unit/cli-manager.test.ts +++ b/backend/tests/unit/cli-manager.test.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import http from 'node:http'; import os from 'node:os'; import path from 'node:path'; +import { jest } from '@jest/globals'; import { CliManager } from '../../src/cli/cli-manager.js'; import { executeMcpTool, getMcpToolsCatalog } from '../../src/mcp/tools.js'; @@ -132,4 +133,154 @@ describe('CliManager', () => { expect(status.success).toBe(true); expect(status.installed).toBe(true); }); -}); + + it('dispatches bundled CLI commands through real Desktop API request contracts', async () => { + const originalFetch = global.fetch; + const originalApiUrl = process.env.PAPYRUS_API_URL; + const originalAuthToken = process.env.PAPYRUS_AUTH_TOKEN; + const requests: Array<{ url: string; method: string; headers: Headers; body: string }> = []; + const workDir = makeTempDir(); + const cardsFile = path.join(workDir, 'cards.txt'); + const dataFile = path.join(workDir, 'data.json'); + const extensionFile = path.join(workDir, 'extension.zip'); + fs.writeFileSync(cardsFile, 'Question === Answer', 'utf8'); + fs.writeFileSync(dataFile, JSON.stringify({ cards: [], notes: [] }), 'utf8'); + fs.writeFileSync(extensionFile, Buffer.from('zip-content')); + process.env.PAPYRUS_API_URL = 'http://127.0.0.1:43123/'; + process.env.PAPYRUS_AUTH_TOKEN = 'cli-contract-token'; + + global.fetch = jest.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url; + const method = init?.method ?? 'GET'; + const headers = new Headers(init?.headers); + const body = typeof init?.body === 'string' ? init.body : ''; + requests.push({ url, method, headers, body }); + if (url.endsWith('/cards/bad')) { + return new Response(JSON.stringify({ error: 'missing card' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); + } + return new Response(JSON.stringify({ success: true, url, method, body }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); + + try { + const { + executeCommand, + normalizeApiBase, + parseFlags, + stripFlags, + } = await import('../../src/cli/papyrus-cli.js'); + + expect(normalizeApiBase('http://127.0.0.1:9000///')).toBe('http://127.0.0.1:9000/api'); + expect(parseFlags(['--json', '--tags', 'one,two', '--params', '{"scope":"today"}'])).toEqual({ + json: true, + values: { tags: 'one,two' }, + params: { scope: 'today' }, + }); + expect(stripFlags(['card', 'list', '--json', '--tags', 'one'])).toEqual(['card', 'list']); + expect(() => parseFlags(['--params'])).toThrow('--params 需要提供 JSON 字符串'); + expect(() => parseFlags(['--params', '[]'])).toThrow('--params 必须是 JSON 对象'); + expect(() => parseFlags(['--name'])).toThrow('--name 需要提供值'); + + const status = await executeCommand(['status', '--json']); + expect(status).toEqual(expect.objectContaining({ + success: true, + cli: 'bundled', + apiBase: 'http://127.0.0.1:43123/api', + json: true, + })); + expect(requests[0]).toEqual(expect.objectContaining({ + url: 'http://127.0.0.1:43123/api/health', + method: 'GET', + })); + expect(requests[0]?.headers.get('X-Papyrus-Token')).toBe('cli-contract-token'); + + await executeCommand(['cards', 'list']); + await executeCommand(['card', 'show', 'card id']); + await executeCommand(['card', 'add', 'Question', 'Answer', '--tags', 'alpha,beta']); + await executeCommand(['card', 'edit', 'card-1', '--question', 'Updated', '--tags', 'alpha']); + await executeCommand(['card', 'delete', 'card-1']); + await executeCommand(['card', 'search', 'spaced query']); + await executeCommand(['card', 'import', cardsFile]); + await executeCommand(['card', 'export']); + await executeCommand(['card', 'due']); + await executeCommand(['review']); + await executeCommand(['stats']); + await executeCommand(['review', 'summary']); + await executeCommand(['review', 'rate', 'card-1', '--grade', '3']); + await executeCommand(['files', 'list']); + await executeCommand(['ext', 'list']); + await executeCommand(['ext', 'install', extensionFile]); + await executeCommand(['mcp', 'tools']); + await executeCommand(['mcp', 'call', 'read_data_stats', '--params', '{"scope":"today"}']); + await executeCommand(['data', 'backup']); + await executeCommand(['data', 'export']); + await executeCommand(['data', 'import', dataFile]); + await executeCommand(['data', 'stats']); + + expect(requests.map(request => `${request.method} ${new URL(request.url).pathname}`)).toEqual([ + 'GET /api/health', + 'GET /api/cards', + 'GET /api/cards/card%20id', + 'POST /api/cards', + 'PATCH /api/cards/card-1', + 'DELETE /api/cards/card-1', + 'GET /api/search', + 'POST /api/cards/import/txt', + 'GET /api/cards', + 'GET /api/review/next', + 'GET /api/review/next', + 'POST /api/mcp/call', + 'POST /api/mcp/call', + 'POST /api/review/card-1/rate', + 'GET /api/files', + 'GET /api/extensions', + 'POST /api/extensions/install-local', + 'GET /api/mcp/tools', + 'POST /api/mcp/call', + 'POST /api/backup', + 'GET /api/export', + 'POST /api/import', + 'POST /api/mcp/call', + ]); + expect(JSON.parse(requests[3]?.body ?? '{}')).toEqual({ + q: 'Question', + a: 'Answer', + tags: ['alpha', 'beta'], + }); + expect(JSON.parse(requests[18]?.body ?? '{}')).toEqual({ + tool: 'read_data_stats', + params: { scope: 'today' }, + }); + + await expect(executeCommand(['card', 'show', 'bad'])).rejects.toThrow('missing card'); + await expect(executeCommand(['review', 'rate', 'card-1', '--grade', '4'])) + .rejects.toThrow('--grade 必须是 1、2 或 3'); + await expect(executeCommand(['stop'])).rejects.toThrow('不支持'); + await expect(executeCommand(['unknown'])).rejects.toThrow('未知命令'); + expect(await executeCommand(['serve'])).toEqual(expect.objectContaining({ success: true })); + expect(await executeCommand(['docs'])).toEqual(expect.objectContaining({ + docs: 'http://127.0.0.1:43123/api/health', + })); + expect(await executeCommand(['config'])).toEqual(expect.objectContaining({ + config: expect.objectContaining({ apiUrl: 'http://127.0.0.1:43123/api' }), + })); + expect(await executeCommand(['quickstart'])).toEqual(expect.objectContaining({ + steps: expect.arrayContaining(['papyrus status']), + })); + } finally { + global.fetch = originalFetch; + if (originalApiUrl === undefined) delete process.env.PAPYRUS_API_URL; + else process.env.PAPYRUS_API_URL = originalApiUrl; + if (originalAuthToken === undefined) delete process.env.PAPYRUS_AUTH_TOKEN; + else process.env.PAPYRUS_AUTH_TOKEN = originalAuthToken; + } + });}); diff --git a/backend/tests/unit/database.test.ts b/backend/tests/unit/database.test.ts index fe415cb9..ba2bbdbd 100644 --- a/backend/tests/unit/database.test.ts +++ b/backend/tests/unit/database.test.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; +import { DatabaseSync } from 'node:sqlite'; import type { CardRecord, Note, FileRecord } from '../../src/core/types.js'; describe('Database', () => { @@ -152,6 +153,43 @@ describe('Database', () => { const d = getDb(); expect(d).toBeDefined(); }); + + it('should add provider_override to an existing automation table without losing rows', () => { + closeDb(); + if (fs.existsSync(dbPath)) fs.rmSync(dbPath); + const legacyDb = new DatabaseSync(dbPath); + legacyDb.exec([ + 'CREATE TABLE automations (', + 'id TEXT PRIMARY KEY, name TEXT NOT NULL, prompt TEXT NOT NULL,', + 'schedule_json TEXT NOT NULL, timezone TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1,', + "allowed_tools TEXT NOT NULL DEFAULT '[]', model_override TEXT, reasoning_override INTEGER,", + 'next_run_at REAL, last_run_at REAL, created_at REAL NOT NULL, updated_at REAL NOT NULL);', + 'INSERT INTO automations (', + 'id, name, prompt, schedule_json, timezone, enabled, allowed_tools,', + 'model_override, reasoning_override, created_at, updated_at', + ") VALUES ('legacy-automation', 'Legacy', 'Keep this row',", + "'{\"kind\":\"daily\",\"hour\":9,\"minute\":0}', 'UTC', 1, '[]', NULL, NULL, 1, 1);", + ].join('\n')); + legacyDb.close(); + + getDb(); + closeDb(); + const inspectedDb = new DatabaseSync(dbPath); + const columns = inspectedDb.prepare( + "SELECT name FROM pragma_table_info('automations')" + ).all().map((row) => row.name); + const preserved = inspectedDb.prepare( + 'SELECT name, prompt, provider_override FROM automations WHERE id = ?' + ).get('legacy-automation'); + inspectedDb.close(); + + expect(columns).toContain('provider_override'); + expect(preserved).toEqual(expect.objectContaining({ + name: 'Legacy', + prompt: 'Keep this row', + provider_override: null, + })); + }); }); describe('Cards', () => { diff --git a/e2e/automations.spec.ts b/e2e/automations.spec.ts new file mode 100644 index 00000000..ebbf6b03 --- /dev/null +++ b/e2e/automations.spec.ts @@ -0,0 +1,249 @@ +import { expect, test } from '@playwright/test'; + +const AUTH_TOKEN = process.env.PAPYRUS_AUTH_TOKEN || 'e2e-test-token-e2e-test-token-32chars'; +const MOCK_PROVIDER_URL = process.env.PAPYRUS_E2E_MOCK_PROVIDER_URL; +const AUTOMATION_PROVIDER_ID = 'e2e-automation-provider'; + +// 为自动化页面提供 Electron 认证桥接,并清理临时 E2E 数据库中的旧夹具。 +// 原因:Playwright retry 需要从可重复的空列表开始验证创建路径。 +// 未使用生产数据库:playwright.config 已把 PAPYRUS_DATA_DIR 指向进程级临时目录。 +test.beforeEach(async ({ page, request }) => { + const response = await request.get('/api/automations'); + if (response.ok()) { + const body = await response.json() as { automations?: Array<{ id: string }> }; + for (const automation of body.automations ?? []) { + await request.delete(`/api/automations/${automation.id}`); + } + } + await page.setViewportSize({ width: 1280, height: 840 }); + await page.addInitScript(({ token }) => { + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { + getAuthToken: () => Promise.resolve(token), + getPlatform: () => Promise.resolve('win32'), + getSystemAppearance: () => Promise.resolve({ darkMode: false, reduceTransparency: false }), + onMenuAction: () => () => undefined, + onSystemAppearanceChanged: () => () => undefined, + openExternal: () => Promise.resolve(), + }, + }); + }, { token: AUTH_TOKEN }); +}); + +// 覆盖入口位置、结构化计划创建、暂停和无 Provider 时的失败审核记录。 +// 原因:这些行为横跨 Sidebar、React 表单、Fastify、SQLite 与后台 Worker。 +// 未模拟 API:真实链路能同时验证认证、字段映射和轮询刷新。 +test('automation can be created, paused, run, and reviewed', async ({ page }) => { + test.setTimeout(60_000); + const frontendUrl = process.env.PAPYRUS_E2E_FRONTEND_URL; + if (!frontendUrl) throw new Error('PAPYRUS_E2E_FRONTEND_URL must be provided by Playwright config'); + + await page.goto(frontendUrl); + const filesButton = page.getByRole('button', { name: '文件库', exact: true }); + const automationsButton = page.getByRole('button', { name: '自动化', exact: true }); + await expect(filesButton).toBeVisible(); + await expect(automationsButton).toBeVisible(); + const filesBox = await filesButton.boundingBox(); + const automationBox = await automationsButton.boundingBox(); + expect(automationBox?.y).toBeGreaterThan(filesBox?.y ?? 0); + + await automationsButton.click(); + await expect(page.getByRole('heading', { name: '自动化', level: 1 })).toBeVisible(); + await page.waitForTimeout(650); + await page.getByRole('button', { name: '新建自动化' }).click(); + + const drawer = page.locator('.arco-drawer-wrapper:not(.arco-drawer-wrapper-hide)').filter({ hasText: '新建自动化' }); + await expect(drawer).toBeVisible(); + await drawer.locator('input[maxlength="100"]').fill('E2E 每日复习摘要'); + await drawer.locator('textarea').fill('汇总今天到期的卡片,并给出简短复习建议。'); + await drawer.getByRole('button', { name: '保存', exact: true }).click(); + + const card = page.locator('.automation-card').filter({ hasText: 'E2E 每日复习摘要' }); + await expect(card).toBeVisible(); + await expect(card).toContainText('每天'); + await card.getByRole('button', { name: '编辑', exact: true }).click(); + const editDrawer = page.locator('.arco-drawer-wrapper:not(.arco-drawer-wrapper-hide)').filter({ hasText: '编辑自动化' }); + await editDrawer.locator('input[maxlength="100"]').fill('E2E 每日复习审核'); + await editDrawer.getByRole('button', { name: '保存', exact: true }).click(); + const editedCard = page.locator('.automation-card').filter({ hasText: 'E2E 每日复习审核' }); + await expect(editedCard).toBeVisible(); + const enabledSwitch = editedCard.getByRole('switch'); + await expect(enabledSwitch).toBeChecked(); + await enabledSwitch.click(); + await expect(enabledSwitch).not.toBeChecked(); + + await editedCard.getByRole('button', { name: '立即运行' }).click(); + await expect(page.getByText('运行记录', { exact: true }).first()).toBeVisible(); + const runRow = page.locator('.automation-run-row').filter({ hasText: 'E2E 每日复习审核' }); + await expect(runRow).toBeVisible(); + await expect(runRow.getByText('失败', { exact: true })).toBeVisible({ timeout: 15_000 }); + await runRow.click(); + const runDrawer = page.locator('.arco-drawer-wrapper:not(.arco-drawer-wrapper-hide)').filter({ hasText: '运行详情' }); + await expect(runDrawer.getByText('失败', { exact: true })).toBeVisible(); + await expect(runDrawer.locator('.arco-alert-error')).toBeVisible(); + await expect(runDrawer.getByText('尚未配置 AI Provider,请先在设置中添加并启用一个提供商', { exact: true })).toBeVisible(); +}); + +test('automation completes a real tool loop through the local Provider and persists its audit trail', async ({ page, request }) => { + test.setTimeout(60_000); + const frontendUrl = process.env.PAPYRUS_E2E_FRONTEND_URL; + if (!frontendUrl || !MOCK_PROVIDER_URL) { + throw new Error('Playwright frontend and mock Provider URLs must be configured'); + } + + await request.delete('/api/providers/' + AUTOMATION_PROVIDER_ID); + const providerResponse = await request.post('/api/providers', { + data: { + id: AUTOMATION_PROVIDER_ID, + type: 'ollama', + name: 'E2E Automation Provider', + baseUrl: MOCK_PROVIDER_URL, + enabled: true, + isDefault: false, + apiKeys: [], + models: [{ + id: 'e2e-automation-model-row', + name: 'E2E Automation Model', + modelId: 'e2e-automation-model', + port: 'ollama', + capabilities: ['tools'], + enabled: true, + }], + }, + }); + expect(providerResponse.ok(), await providerResponse.text()).toBe(true); + const clearGlobalResponse = await request.post('/api/config/ai', { + data: { current_provider: '', current_model: '' }, + }); + expect(clearGlobalResponse.ok(), await clearGlobalResponse.text()).toBe(true); + const resetMockResponse = await request.post(MOCK_PROVIDER_URL + '/reset'); + expect(resetMockResponse.ok(), await resetMockResponse.text()).toBe(true); + + const automationResponse = await request.post('/api/automations', { + data: { + name: 'E2E Provider 成功链路', + prompt: '读取复习统计,并根据真实工具结果给出摘要。', + schedule: { kind: 'daily', hour: 9, minute: 0 }, + enabled: true, + allowedTools: ['read_data_stats'], + providerOverride: 'ollama', + modelOverride: 'e2e-automation-model', + reasoningOverride: null, + }, + }); + expect(automationResponse.ok(), await automationResponse.text()).toBe(true); + const automationBody: { + automation: { id: string; providerOverride: string | null; modelOverride: string | null }; + } = await automationResponse.json(); + expect(automationBody.automation).toEqual(expect.objectContaining({ + providerOverride: 'ollama', + modelOverride: 'e2e-automation-model', + })); + + await page.goto(frontendUrl); + await page.getByRole('button', { name: '自动化', exact: true }).click(); + const card = page.locator('.automation-card').filter({ hasText: 'E2E Provider 成功链路' }); + await expect(card).toContainText('ollama / e2e-automation-model'); + const runResponsePromise = page.waitForResponse((response) => ( + response.request().method() === 'POST' + && new URL(response.url()).pathname === `/api/automations/${automationBody.automation.id}/run` + )); + await card.getByRole('button', { name: '立即运行' }).click(); + const runResponse = await runResponsePromise; + if (!runResponse.ok()) { + throw new Error(`Run request failed with ${runResponse.status()}: ${await runResponse.text()}`); + } + expect(runResponse.ok()).toBe(true); + await expect.poll(async () => { + const response = await request.get(`/api/automations/${automationBody.automation.id}/runs?limit=1`); + if (!response.ok()) return `http-${response.status()}`; + const body: { runs: Array<{ status: string; error: string | null }> } = await response.json(); + const latestRun = body.runs[0]; + return latestRun ? `${latestRun.status}:${latestRun.error}` : 'missing'; + }, { timeout: 20_000 }).toBe('succeeded:null'); + await page.getByRole('tab', { name: '运行记录', exact: true }).click(); + await page.getByRole('button', { name: '刷新', exact: true }).click(); + + const runRow = page.locator('.automation-run-row').filter({ hasText: 'E2E Provider 成功链路' }); + await expect(runRow.getByText('成功', { exact: true })).toBeVisible({ timeout: 20_000 }); + await runRow.click(); + const runDrawer = page.locator('.arco-drawer-wrapper:not(.arco-drawer-wrapper-hide)') + .filter({ hasText: '运行详情' }); + await expect(runDrawer.getByText('E2E automation completed with verified tool data', { exact: true })) + .toBeVisible(); + await expect(runDrawer.getByText('read_data_stats', { exact: true })).toBeVisible(); + await expect(runDrawer).toContainText('e2e-automation-model'); + await expect(runDrawer).toContainText('ollama'); + + const recentResponse = await request.get('/api/automations/runs/recent?limit=1'); + expect(recentResponse.ok(), await recentResponse.text()).toBe(true); + const recentBody: { + runs: Array<{ + status: string; + output: string; + model: string; + provider: string; + toolCalls: Array<{ name: string; success: boolean }>; + }>; + } = await recentResponse.json(); + const persistedRun = recentBody.runs[0]; + if (!persistedRun) throw new Error('Expected persisted automation run'); + expect(persistedRun).toEqual(expect.objectContaining({ + status: 'succeeded', + output: 'E2E automation completed with verified tool data', + model: 'e2e-automation-model', + provider: 'ollama', + })); + expect(persistedRun.toolCalls).toEqual([ + expect.objectContaining({ name: 'read_data_stats', success: true }), + ]); + + const mockStateResponse = await request.get(MOCK_PROVIDER_URL + '/state'); + expect(mockStateResponse.ok(), await mockStateResponse.text()).toBe(true); + expect(await mockStateResponse.json()).toEqual({ + requestCount: 2, + toolTurnCount: 1, + finalTurnCount: 1, + }); +}); + +// 验证键盘入口、窄窗口布局、深色主题和减少动画偏好。 +// 原因:自动化是高频侧栏功能,必须在非鼠标与紧凑桌面布局中保持可用。 +// 未依赖截图像素:语义和计算样式断言在不同 Windows 缩放比例下更稳定。 +test('automation page honors accessibility and compact appearance settings', async ({ page, request }) => { + const frontendUrl = process.env.PAPYRUS_E2E_FRONTEND_URL; + if (!frontendUrl) throw new Error('PAPYRUS_E2E_FRONTEND_URL must be provided by Playwright config'); + const createResponse = await request.post('/api/automations', { + data: { + name: '紧凑布局测试', + prompt: '只读取复习统计。', + schedule: { kind: 'daily', hour: 9, minute: 0 }, + enabled: true, + }, + }); + expect(createResponse.ok()).toBe(true); + await page.emulateMedia({ colorScheme: 'dark', reducedMotion: 'reduce' }); + await page.setViewportSize({ width: 720, height: 650 }); + await page.goto(frontendUrl); + + const automationsButton = page.getByRole('button', { name: '自动化', exact: true }); + await automationsButton.focus(); + await page.keyboard.press('Enter'); + await expect(page.getByRole('heading', { name: '自动化', level: 1 })).toBeVisible(); + await expect(page.locator('body')).toHaveAttribute('arco-theme', 'dark'); + await expect(page.locator('.automations-grid')).toHaveCSS('grid-template-columns', /\d+(\.\d+)?px/); + const transitionSeconds = await page.locator('.automation-card').first().evaluate((element) => ( + Number.parseFloat(window.getComputedStyle(element).transitionDuration) + )); + expect(transitionSeconds).toBeLessThan(0.001); + + const newButton = page.getByRole('button', { name: '新建自动化' }); + await newButton.focus(); + await page.keyboard.down('Enter'); + await expect(page.locator('.arco-drawer-wrapper:not(.arco-drawer-wrapper-hide)').filter({ hasText: '新建自动化' })).toBeVisible(); + await page.keyboard.up('Enter'); + + await page.keyboard.press('Escape'); + await expect(page.locator('.automations-page-shell')).toBeVisible(); +}); diff --git a/e2e/fixtures/mock-ai-provider.mjs b/e2e/fixtures/mock-ai-provider.mjs new file mode 100644 index 00000000..3cbf0648 --- /dev/null +++ b/e2e/fixtures/mock-ai-provider.mjs @@ -0,0 +1,118 @@ +import http from 'node:http'; + +const port = Number(process.env.PAPYRUS_E2E_MOCK_PROVIDER_PORT); +if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error('PAPYRUS_E2E_MOCK_PROVIDER_PORT must be a valid port'); +} + +let requestCount = 0; +let toolTurnCount = 0; +let finalTurnCount = 0; + +/** + * 读取并解析单个 JSON 请求体。 + * 原因:假 Provider 必须验证 Papyrus 发出的真实模型消息,而不是盲目返回成功。 + * 未使用 Express:Node HTTP 足以提供三个确定端点,不增加测试依赖。 + */ +async function readJsonBody(request) { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + return JSON.parse(Buffer.concat(chunks).toString('utf8')); +} + +/** + * 返回 JSON 或 Ollama NDJSON 响应。 + * 原因:控制响应格式可让测试同时查询状态并驱动生产流解析器。 + * 未返回宽松文本:错误状态必须让调用方明确失败。 + */ +function send(response, status, body, contentType = 'application/json') { + response.writeHead(status, { 'Content-Type': contentType }); + response.end(body); +} + +const server = http.createServer(async (request, response) => { + const url = new URL(request.url ?? '/', 'http://127.0.0.1'); + + if (request.method === 'GET' && url.pathname === '/health') { + send(response, 200, JSON.stringify({ ok: true })); + return; + } + if (request.method === 'GET' && url.pathname === '/state') { + send(response, 200, JSON.stringify({ requestCount, toolTurnCount, finalTurnCount })); + return; + } + if (request.method === 'POST' && url.pathname === '/reset') { + requestCount = 0; + toolTurnCount = 0; + finalTurnCount = 0; + send(response, 200, JSON.stringify({ ok: true })); + return; + } + if (request.method !== 'POST' || url.pathname !== '/api/chat') { + send(response, 404, JSON.stringify({ error: 'not found' })); + return; + } + + try { + const body = await readJsonBody(request); + requestCount += 1; + if (body.model !== 'e2e-automation-model') { + send(response, 422, JSON.stringify({ error: 'unexpected model' })); + return; + } + + const toolNames = Array.isArray(body.tools) + ? body.tools.map((tool) => tool?.function?.name).filter((name) => typeof name === 'string') + : []; + if (!toolNames.includes('read_data_stats') || toolNames.includes('create_card')) { + send(response, 422, JSON.stringify({ error: 'unsafe or missing tool catalog' })); + return; + } + + const messages = Array.isArray(body.messages) ? body.messages : []; + const toolMessage = messages.find((message) => message?.role === 'tool'); + if (!toolMessage) { + toolTurnCount += 1; + const toolCall = { + id: 'e2e-read-stats', + type: 'function', + function: { name: 'read_data_stats', arguments: {} }, + }; + send( + response, + 200, + JSON.stringify({ message: { content: '', tool_calls: [toolCall] }, done: false }) + '\n' + + JSON.stringify({ done: true }) + '\n', + 'application/x-ndjson', + ); + return; + } + + const toolResult = JSON.parse(typeof toolMessage.content === 'string' ? toolMessage.content : '{}'); + if (toolResult.success === false || typeof toolResult !== 'object') { + send(response, 422, JSON.stringify({ error: 'missing successful tool result' })); + return; + } + + finalTurnCount += 1; + send( + response, + 200, + JSON.stringify({ + message: { content: 'E2E automation completed with verified tool data' }, + done: false, + }) + '\n' + JSON.stringify({ done: true }) + '\n', + 'application/x-ndjson', + ); + } catch (error) { + send(response, 400, JSON.stringify({ + error: error instanceof Error ? error.message : String(error), + })); + } +}); + +server.listen(port, '127.0.0.1'); + +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => server.close(() => process.exit(0))); +} \ No newline at end of file diff --git a/e2e/model-management.spec.ts b/e2e/model-management.spec.ts new file mode 100644 index 00000000..7bd0c695 --- /dev/null +++ b/e2e/model-management.spec.ts @@ -0,0 +1,243 @@ +import { expect, test } from '@playwright/test'; + +const AUTH_TOKEN = process.env.PAPYRUS_AUTH_TOKEN || 'e2e-test-token-e2e-test-token-32chars'; +const KEYED_PROVIDER_ID = 'e2e-model-modal-keyed-provider'; +const KEYLESS_PROVIDER_ID = 'e2e-model-modal-keyless-provider'; +const PRIMARY_KEY_ID = 'e2e-model-modal-primary-key'; +const SECONDARY_KEY_ID = 'e2e-model-modal-secondary-key'; + +// 为每次用例准备可重复创建的供应商数据,并模拟 Electron 向前端提供本地认证令牌。 +// 原因:固定 ID 配合测试前删除可以支持 Playwright retry,同时避免失败重试残留模型影响默认供应商顺序。 +// 未复用生产用户数据:E2E 配置已把数据库定向到临时目录,所有删除和创建都只作用于测试夹具。 +test.beforeEach(async ({ page, request }) => { + await request.delete(`/api/providers/${KEYED_PROVIDER_ID}`); + await request.delete(`/api/providers/${KEYLESS_PROVIDER_ID}`); + + await request.delete('/api/providers/e2e-automation-provider'); + + const keyedProviderResponse = await request.post('/api/providers', { + data: { + id: KEYED_PROVIDER_ID, + type: 'openai', + name: 'E2E OpenAI', + baseUrl: 'https://api.openai.com/v1', + enabled: true, + isDefault: true, + apiKeys: [ + { id: PRIMARY_KEY_ID, name: 'primary', key: 'e2e-primary-secret' }, + { id: SECONDARY_KEY_ID, name: 'secondary', key: 'e2e-secondary-secret' }, + ], + models: [], + }, + }); + expect(keyedProviderResponse.ok(), await keyedProviderResponse.text()).toBe(true); + + const keylessProviderResponse = await request.post('/api/providers', { + data: { + id: KEYLESS_PROVIDER_ID, + type: 'ollama', + name: 'E2E Keyless', + baseUrl: 'http://127.0.0.1:11434', + enabled: true, + isDefault: false, + apiKeys: [], + models: [], + }, + }); + expect(keylessProviderResponse.ok(), await keylessProviderResponse.text()).toBe(true); + + await page.setViewportSize({ width: 1280, height: 840 }); + await page.addInitScript(({ token }) => { + Object.defineProperty(window, 'electronAPI', { + configurable: true, + value: { + getAuthToken: () => Promise.resolve(token), + getPlatform: () => Promise.resolve('win32'), + getSystemAppearance: () => Promise.resolve({ + darkMode: false, + reduceTransparency: false, + }), + onMenuAction: () => () => undefined, + onSystemAppearanceChanged: () => () => undefined, + openExternal: () => Promise.resolve(), + }, + }); + }, { token: AUTH_TOKEN }); +}); + +// 覆盖模型弹窗首次打开、重复打开、无密钥供应商和编辑恢复的完整用户路径。 +// 原因:缺陷来自 Modal、Form 与异步 props 的组合生命周期,静态渲染无法验证实际交互状态。 +// 未拆成多个共享数据库用例:单一顺序场景能直接复现“继续添加模型”,并减少跨测试夹具干扰。 +test('model modal keeps provider and API key state consistent across repeated use', async ({ page }) => { + test.setTimeout(60_000); + const frontendUrl = process.env.PAPYRUS_E2E_FRONTEND_URL; + if (!frontendUrl) { + throw new Error('PAPYRUS_E2E_FRONTEND_URL must be provided by Playwright config'); + } + + // 统计浏览器实际发出的模型创建请求,验证必填校验在网络请求前截断提交。 + // 原因:只检查错误文案不能证明后台没有收到无效数据。 + // 未拦截或模拟请求:保留真实前后端链路,后续成功创建仍由响应断言验证。 + let modelCreateRequestCount = 0; + const modelCreateBodies: unknown[] = []; + page.on('request', request => { + if (request.method() === 'POST' && new URL(request.url()).pathname.endsWith('/models')) { + modelCreateRequestCount += 1; + const bodyText = request.postData(); + modelCreateBodies.push(bodyText ? JSON.parse(bodyText) : null); + } + }); + + await page.goto(frontendUrl); + await page.getByRole('button', { name: '设置', exact: true }).click(); + await expect(page.locator('.settings-page').locator('..')).not.toHaveAttribute('style', /opacity/); + const chatCategory = page.locator('.settings-category-card').filter({ hasText: '聊天' }); + await expect(chatCategory).toBeVisible(); + await chatCategory.click(); + await page.getByRole('button', { name: '模型管理', exact: true }).click(); + + await page.getByRole('button', { name: '添加模型', exact: true }).click(); + let modal = page.locator('.arco-modal').filter({ hasText: '添加模型' }); + await expect(modal).toBeVisible(); + + let providerField = modal.locator('.arco-form-item').filter({ hasText: '供应商' }); + let apiKeyField = modal.locator('.arco-form-item').filter({ hasText: 'API Key 方案' }); + await expect(providerField.locator('.arco-select-view')).toContainText('E2E OpenAI'); + await expect(apiKeyField.locator('.arco-select-view')).toContainText('primary'); + + await modal.getByRole('button', { name: '确定' }).click(); + await expect(modal.getByText('模型名称不能为空', { exact: true })).toBeVisible(); + await expect(modal.getByText('模型 ID 不能为空', { exact: true })).toBeVisible(); + await expect(modal).toBeVisible(); + expect(modelCreateRequestCount).toBe(0); + + await modal.getByPlaceholder('如:GPT-4o', { exact: true }).fill('E2E First Model'); + await modal.getByPlaceholder('实际的 API ID,如:gpt-4o', { exact: true }).fill('e2e-first-model'); + const toolsCheckbox = modal.getByRole('checkbox').first(); + await modal.locator('label.arco-checkbox').first().click(); + await expect(toolsCheckbox).toBeChecked(); + await apiKeyField.locator('.arco-select-view').click(); + await page.locator('.arco-select-option').filter({ hasText: /^secondary$/ }).click(); + + const firstCreateResponsePromise = page.waitForResponse(response => + new URL(response.url()).pathname === `/api/providers/${KEYED_PROVIDER_ID}/models` + && response.request().method() === 'POST' + ); + await modal.getByRole('button', { name: '确定' }).click(); + const firstCreateResponse = await firstCreateResponsePromise; + expect(firstCreateResponse.ok(), await firstCreateResponse.text()).toBe(true); + await expect(modal).toBeHidden(); + + const firstModelCard = page.locator('.arco-card').filter({ hasText: 'E2E First Model' }); + await expect(firstModelCard).toContainText('Key: secondary (已配置)'); + expect(modelCreateBodies[0]).toEqual(expect.objectContaining({ + name: 'E2E First Model', + modelId: 'e2e-first-model', + apiKeyId: SECONDARY_KEY_ID, + capabilities: ['tools'], + })); + + await page.getByRole('button', { name: '添加模型', exact: true }).click(); + modal = page.locator('.arco-modal').filter({ hasText: '添加模型' }); + providerField = modal.locator('.arco-form-item').filter({ hasText: '供应商' }); + apiKeyField = modal.locator('.arco-form-item').filter({ hasText: 'API Key 方案' }); + await expect(providerField.locator('.arco-select-view')).toContainText('E2E OpenAI'); + await expect(apiKeyField.locator('.arco-select-view')).toContainText('primary'); + + await modal.getByPlaceholder('如:GPT-4o', { exact: true }).fill('E2E Second Model'); + await modal.getByPlaceholder('实际的 API ID,如:gpt-4o', { exact: true }).fill('e2e-second-model'); + await apiKeyField.locator('.arco-select-view').click(); + await page.locator('.arco-select-option').filter({ hasText: /^secondary$/ }).click(); + + const secondCreateResponsePromise = page.waitForResponse(response => + new URL(response.url()).pathname === `/api/providers/${KEYED_PROVIDER_ID}/models` + && response.request().method() === 'POST' + ); + await modal.getByRole('button', { name: '确定' }).click(); + const secondCreateResponse = await secondCreateResponsePromise; + expect(secondCreateResponse.ok(), await secondCreateResponse.text()).toBe(true); + await expect(page.getByText('E2E Second Model', { exact: true })).toBeVisible(); + + await page.getByRole('button', { name: '添加模型', exact: true }).click(); + modal = page.locator('.arco-modal').filter({ hasText: '添加模型' }); + providerField = modal.locator('.arco-form-item').filter({ hasText: '供应商' }); + apiKeyField = modal.locator('.arco-form-item').filter({ hasText: 'API Key 方案' }); + await providerField.locator('.arco-select-view').click(); + await page.getByRole('option', { name: /E2E Keyless/ }).click(); + await expect(apiKeyField.locator('.arco-select-view')).not.toContainText(/primary|secondary/); + + await modal.getByPlaceholder('如:GPT-4o', { exact: true }).fill('E2E Keyless Model'); + await modal.getByPlaceholder('实际的 API ID,如:gpt-4o', { exact: true }).fill('e2e-keyless-model'); + const keylessCreateResponsePromise = page.waitForResponse(response => + new URL(response.url()).pathname === `/api/providers/${KEYLESS_PROVIDER_ID}/models` + && response.request().method() === 'POST' + ); + await modal.getByRole('button', { name: '确定' }).click(); + const keylessCreateResponse = await keylessCreateResponsePromise; + expect(keylessCreateResponse.ok(), await keylessCreateResponse.text()).toBe(true); + + const keylessModelCard = page.locator('.arco-card').filter({ hasText: 'E2E Keyless Model' }); + await expect(keylessModelCard).toContainText('Key: default (未配置)'); + expect(modelCreateBodies[2]).toEqual(expect.objectContaining({ + name: 'E2E Keyless Model', + modelId: 'e2e-keyless-model', + })); + expect(modelCreateBodies[2]).not.toEqual(expect.objectContaining({ + apiKeyId: expect.any(String), + })); + + await firstModelCard.getByTitle('编辑').click(); + const editModal = page.locator('.arco-modal').filter({ hasText: '编辑模型' }); + await expect(editModal.getByPlaceholder('如:GPT-4o', { exact: true })).toHaveValue('E2E First Model'); + await expect(editModal.getByPlaceholder('实际的 API ID,如:gpt-4o', { exact: true })).toHaveValue('e2e-first-model'); + const editApiKeyField = editModal.locator('.arco-form-item').filter({ hasText: 'API Key 方案' }); + await expect(editApiKeyField.locator('.arco-select-view')).toContainText('secondary'); + await expect(editModal.getByRole('checkbox').first()).toBeChecked(); + expect(modelCreateRequestCount).toBe(3); + await editModal.getByRole('button', { name: '取消' }).click(); + + await page.getByRole('button', { name: '添加模型', exact: true }).click(); + const resetModal = page.locator('.arco-modal').filter({ hasText: '添加模型' }); + await expect(resetModal.getByPlaceholder('如:GPT-4o', { exact: true })).toHaveValue(''); + await expect(resetModal.getByPlaceholder('实际的 API ID,如:gpt-4o', { exact: true })).toHaveValue(''); + const resetProviderField = resetModal.locator('.arco-form-item').filter({ hasText: '供应商' }); + const resetApiKeyField = resetModal.locator('.arco-form-item').filter({ hasText: 'API Key 方案' }); + await expect(resetProviderField.locator('.arco-select-view')).toContainText('E2E OpenAI'); + await expect(resetApiKeyField.locator('.arco-select-view')).toContainText('primary'); + await resetModal.getByRole('button', { name: '取消' }).click(); + + const providersResponse = await page.request.get('/api/providers'); + expect(providersResponse.ok(), await providersResponse.text()).toBe(true); + const providersBody: { + providers: Array<{ + id: string; + models: Array<{ + name: string; + modelId: string; + apiKeyId?: string; + capabilities: string[]; + }>; + }>; + } = await providersResponse.json(); + const keyedProvider = providersBody.providers.find((provider) => provider.id === KEYED_PROVIDER_ID); + const keylessProvider = providersBody.providers.find((provider) => provider.id === KEYLESS_PROVIDER_ID); + expect(keyedProvider?.models).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: 'E2E First Model', + modelId: 'e2e-first-model', + apiKeyId: SECONDARY_KEY_ID, + capabilities: ['tools'], + }), + expect.objectContaining({ + name: 'E2E Second Model', + modelId: 'e2e-second-model', + apiKeyId: SECONDARY_KEY_ID, + }), + ])); + expect(keylessProvider?.models).toEqual([ + expect.objectContaining({ + name: 'E2E Keyless Model', + modelId: 'e2e-keyless-model', + }), + ]); +}); diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 07272166..ddc51344 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -13,12 +13,16 @@ const PORT_BASE = Number.isInteger(configuredPortBase) && const BACKEND_URL = `http://127.0.0.1:${PORT_BASE}`; const FRONTEND_URL = `http://127.0.0.1:${PORT_BASE + 1}`; +const MOCK_PROVIDER_URL = 'http://127.0.0.1:' + (PORT_BASE + 3); + // Forward env vars so the backend uses a temp database instead of production data process.env.PAPYRUS_AUTH_TOKEN = AUTH_TOKEN; process.env.PAPYRUS_DATA_DIR = TEST_DATA_DIR; process.env.PAPYRUS_E2E_PORT_BASE = String(PORT_BASE); process.env.PAPYRUS_PORT = String(PORT_BASE); process.env.PAPYRUS_MCP_PORT = String(PORT_BASE + 2); +process.env.PAPYRUS_E2E_MOCK_PROVIDER_PORT = String(PORT_BASE + 3); +process.env.PAPYRUS_E2E_MOCK_PROVIDER_URL = MOCK_PROVIDER_URL; process.env.PAPYRUS_BACKEND_URL = BACKEND_URL; process.env.PAPYRUS_E2E_FRONTEND_URL = FRONTEND_URL; @@ -39,6 +43,12 @@ export default defineConfig({ }, webServer: [ + { + command: 'node fixtures/mock-ai-provider.mjs', + url: MOCK_PROVIDER_URL + '/health', + reuseExistingServer: false, + timeout: 30000, + }, { command: 'npx --prefix ../backend tsx --tsconfig ../backend/tsconfig.json ../backend/src/api/server.ts', url: `${BACKEND_URL}/api/health`, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9d87873c..f8645738 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "papyrus", - "version": "2.0.0-beta.14", + "version": "2.0.0-beta.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "papyrus", - "version": "2.0.0-beta.14", + "version": "2.0.0-beta.16", "dependencies": { "@arco-design/web-react": "^2.66.14", "dompurify": "^3.4.2", diff --git a/frontend/package.json b/frontend/package.json index 2047706a..275c0914 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "papyrus", "private": true, - "version": "2.0.0-beta.14", + "version": "2.0.0-beta.16", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c35e5fa4..2dcf7c0a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,6 +20,7 @@ import ScrollPage from './ScrollPage/ScrollPage'; import NotesPage from './NotesPage/NotesPage'; import FilesPage from './FilesPage/FilesPage'; import SettingsPage from './SettingsPage/SettingsPage'; +import AutomationsPage from './AutomationsPage/AutomationsPage'; import SectionNavigation from './components/SectionNavigation'; import { api, getAuthToken, type ChatPanelSide, type ChatSession, type SearchResult } from './api'; import { addRecentItem } from './utils/recentFiles'; @@ -27,7 +28,7 @@ import { clampChatWidth } from './utils/appLayout'; import { appPlatform } from './utils/platform'; import type { NativeMenuAction } from './types/electron'; -const PAGE_ORDER = ['start', 'scroll', 'notes', 'files', 'settings']; +const PAGE_ORDER = ['start', 'scroll', 'notes', 'files', 'automations', 'settings']; const CHAT_WIDTH_STORAGE_KEY = 'papyrus_chat_width'; const CHAT_DEFAULT_WIDTH = appPlatform === 'macos' ? 420 : 500; @@ -485,6 +486,7 @@ const App = () => { scroll: t('app.pageTitles.scroll'), notes: t('app.pageTitles.notes'), files: t('app.pageTitles.files'), + automations: t('app.pageTitles.automations'), settings: t('app.pageTitles.settings'), }; @@ -507,6 +509,7 @@ const App = () => { ), notes: setInitialNoteId(undefined)} />, files: setInitialFileId(undefined)} />, + automations: , settings: , }; diff --git a/frontend/src/AutomationsPage/AutomationsPage.css b/frontend/src/AutomationsPage/AutomationsPage.css new file mode 100644 index 00000000..7928ca94 --- /dev/null +++ b/frontend/src/AutomationsPage/AutomationsPage.css @@ -0,0 +1,257 @@ +.automations-page-shell { + min-width: 0; +} + +.automations-loading, +.automations-empty { + min-height: 260px; + display: flex; + align-items: center; + justify-content: center; +} + +.automations-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: 16px; +} + +.automation-card { + border-color: var(--color-border-hairline); + border-radius: var(--radius-lg); + background: var(--color-bg-1); + box-shadow: var(--shadow-1); + transition: border-color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard); +} + +.automation-card:hover { + border-color: var(--color-border-hover); + box-shadow: var(--shadow-2); +} + +.automation-card-header, +.automation-card-title-wrap, +.automation-card-actions, +.automation-run-row, +.automation-run-summary, +.automation-inline-setting, +.automation-drawer-footer { + display: flex; + align-items: center; +} + +.automation-card-header, +.automation-inline-setting, +.automation-run-row { + justify-content: space-between; +} + +.automation-card-title-wrap { + min-width: 0; + gap: 12px; +} + +.automation-card-icon { + width: 40px; + height: 40px; + border-radius: var(--radius-md); + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--color-primary); + background: var(--color-primary-light-1); + font-size: 20px; +} + +.automation-card-title { + margin: 0 0 4px !important; + font-size: var(--font-size-lg) !important; +} + +.automation-card-prompt { + min-height: 66px; + margin: 20px 0 !important; + color: var(--color-text-2); + line-height: 1.6; +} + +.automation-meta-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + padding: 16px; + border-radius: var(--radius-md); + background: var(--color-fill-1); +} + +.automation-meta-grid div { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.automation-meta-grid span, +.automation-inline-setting span, +.automation-run-main span { + color: var(--color-text-3); + font-size: var(--font-size-xs); +} + +.automation-meta-grid strong { + overflow: hidden; + color: var(--color-text-1); + font-size: var(--font-size-sm); + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} + +.automation-card-actions { + min-height: 44px; + margin-top: 16px; + gap: 8px; +} + +.automation-card-actions .arco-btn { + min-height: 36px; +} + +.automation-run-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.automation-run-row { + width: 100%; + min-height: 64px; + padding: 12px 16px; + border: 1px solid var(--color-border-hairline); + border-radius: var(--radius-md); + color: var(--color-text-1); + background: var(--color-bg-1); + cursor: pointer; + font: inherit; + text-align: left; + transition: background var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard); +} + +.automation-run-row:hover { + border-color: var(--color-border-hover); + background: var(--color-fill-1); +} + +.automation-run-row:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +.automation-run-main { + display: flex; + flex-direction: column; + gap: 4px; +} + +.automation-run-summary { + gap: 12px; + color: var(--color-text-2); + font-size: var(--font-size-sm); +} + +.automation-editor-form .arco-typography-heading-4 { + margin: 28px 0 12px; +} + +.automation-inline-setting { + min-height: 64px; + padding: 12px 16px; + border: 1px solid var(--color-border-hairline); + border-radius: var(--radius-md); +} + +.automation-inline-setting > div { + display: flex; + flex-direction: column; + gap: 4px; +} + +.automation-schedule-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.automation-schedule-row .arco-input-number { + width: 100%; +} + +.automation-tool-groups { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + margin-bottom: 16px; +} + +.automation-tool-groups > div { + min-width: 0; + padding: 16px; + border: 1px solid var(--color-border-hairline); + border-radius: var(--radius-md); + display: flex; + flex-direction: column; + gap: 12px; +} + +.automation-tool-groups .arco-checkbox { + align-items: flex-start; +} + +.automation-tool-label { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.automation-tool-label span { + color: var(--color-text-1); + font-family: var(--font-mono, monospace); +} + +.automation-tool-label small { + color: var(--color-text-3); + line-height: 1.4; + white-space: normal; +} + +.automation-drawer-footer { + justify-content: flex-end; + gap: 8px; +} + +.automation-run-details { + display: flex; + flex-direction: column; + gap: 20px; +} + +@media (max-width: 900px) { + .automations-grid, + .automation-tool-groups { + grid-template-columns: 1fr; + } + + .automation-run-summary > span:first-child { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .automation-card, + .automation-run-row { + transition: none; + } +} + diff --git a/frontend/src/AutomationsPage/AutomationsPage.tsx b/frontend/src/AutomationsPage/AutomationsPage.tsx new file mode 100644 index 00000000..83c544ff --- /dev/null +++ b/frontend/src/AutomationsPage/AutomationsPage.tsx @@ -0,0 +1,658 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { KeyboardEvent } from 'react'; +import { + Alert, + Button, + Card, + Checkbox, + Descriptions, + Drawer, + Empty, + Form, + Input, + InputNumber, + Message, + Modal, + Select, + Spin, + Switch, + Tabs, + Tag, + Typography, +} from '@arco-design/web-react'; +import { + IconCalendarClock, + IconDelete, + IconEdit, + IconPlayArrow, + IconPlus, + IconRefresh, +} from '@arco-design/web-react/icon'; +import { useTranslation } from 'react-i18next'; +import { api } from '../api'; +import type { + Automation, + AutomationInput, + AutomationRun, + AutomationRunStatus, + AutomationSchedule, + ToolCatalogItem, +} from '../api'; +import { PageLayout, ReasoningChain, ToolCallCard } from '../components'; +import { MarkdownView } from '../components/MarkdownView'; +import { useModelSelector } from '../hooks/useModelSelector'; +import './AutomationsPage.css'; + +const INHERIT_MODEL = '__inherit__'; + +interface EditorState { + name: string; + prompt: string; + enabled: boolean; + schedule: AutomationSchedule; + allowedTools: string[]; + providerOverride: string | null; + modelOverride: string | null; + reasoningMode: 'inherit' | 'on' | 'off'; +} + +/** + * 创建自动化编辑器默认值。 + * 原因:函数返回新对象可避免多次打开抽屉时共享可变数组。 + * 未将默认计划放入模块常量:daysOfWeek 和 allowedTools 需要独立实例。 + */ +function createDefaultEditor(readToolNames: string[]): EditorState { + const now = new Date(); + return { + name: '', + prompt: '', + enabled: true, + schedule: { kind: 'daily', hour: now.getHours(), minute: 0 }, + allowedTools: [...readToolNames], + providerOverride: null, + modelOverride: null, + reasoningMode: 'inherit', + }; +} + +/** + * 将公开自动化配置映射到受控表单状态。 + * 原因:三态推理设置需要与 API 的 nullable boolean 分开表达。 + * 未直接修改 Automation:编辑取消时必须保留服务器快照。 + */ +function editorFromAutomation(automation: Automation): EditorState { + return { + name: automation.name, + prompt: automation.prompt, + enabled: automation.enabled, + schedule: automation.schedule, + allowedTools: [...automation.allowedTools], + providerOverride: automation.providerOverride, + modelOverride: automation.modelOverride, + reasoningMode: automation.reasoningOverride === null + ? 'inherit' + : automation.reasoningOverride ? 'on' : 'off', + }; +} + +/** + * 根据运行状态返回语义颜色。 + * 原因:Tag 同时包含文字和颜色,满足非颜色单一表达要求。 + * 未写硬编码色值:Arco 语义色可自动适配主题。 + */ +function runStatusColor(status: AutomationRunStatus): 'blue' | 'green' | 'red' | 'orange' | 'gray' { + if (status === 'running') return 'blue'; + if (status === 'succeeded') return 'green'; + if (status === 'failed') return 'red'; + if (status === 'missed') return 'orange'; + return 'gray'; +} + +const AutomationsPage = () => { + const { t, i18n } = useTranslation(); + const { models, loading: modelsLoading } = useModelSelector(); + const [automations, setAutomations] = useState([]); + const [runs, setRuns] = useState([]); + const [tools, setTools] = useState([]); + const [loading, setLoading] = useState(true); + const [activeTab, setActiveTab] = useState('automations'); + const [editorVisible, setEditorVisible] = useState(false); + const [editingId, setEditingId] = useState(null); + const [saving, setSaving] = useState(false); + const [runningIds, setRunningIds] = useState>(new Set()); + const [selectedRun, setSelectedRun] = useState(null); + const [editor, setEditor] = useState(() => createDefaultEditor([])); + + const readTools = useMemo(() => tools.filter((tool) => tool.side_effect === 'read'), [tools]); + const writeTools = useMemo(() => tools.filter((tool) => tool.side_effect === 'write'), [tools]); + const readToolNames = useMemo(() => readTools.map((tool) => tool.name), [readTools]); + + // 先选择 Provider,再只展示其模型,并用稳定模型主键作为 Select 值。 + // 原因:分离选择可避免不同 Provider 的同名模型串用,也规避嵌套 OptGroup 在 React 19 下的 portal 重挂载。 + // 未把 providerType 与 modelId 拼成字符串:模型记录 ID 已稳定唯一,避免额外转义协议。 + const providerOptions = useMemo( + () => [...new Map(models.map((model) => [model.providerType, model.providerName])).entries()], + [models], + ); + const providerModels = useMemo( + () => models.filter((model) => model.providerType === editor.providerOverride), + [editor.providerOverride, models], + ); + const selectedOverrideModelId = useMemo( + () => models.find((model) => ( + model.providerType === editor.providerOverride + && model.modelId === editor.modelOverride + ))?.id ?? INHERIT_MODEL, + [editor.modelOverride, editor.providerOverride, models], + ); + const hasWritePermission = useMemo( + () => writeTools.some((tool) => editor.allowedTools.includes(tool.name)), + [editor.allowedTools, writeTools], + ); + + /** + * 刷新自动化、运行记录和首次所需工具目录。 + * 原因:单次并发请求减少页面进入后的布局跳动。 + * 未让轮询反复加载工具目录:目录随应用版本变化而非运行状态变化。 + */ + const loadData = useCallback(async (showError: boolean, includeTools = false) => { + try { + const requests = [api.listAutomations(), api.listRecentAutomationRuns(100)] as const; + const [automationResponse, runResponse] = await Promise.all(requests); + if (automationResponse.success) setAutomations(automationResponse.automations); + if (runResponse.success) setRuns(runResponse.runs); + if (includeTools) { + const toolResponse = await api.getToolCatalog(); + if (toolResponse.success) setTools(toolResponse.tools); + } + } catch (error) { + if (showError) Message.error(error instanceof Error ? error.message : t('automations.loadFailed')); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { + void loadData(true, true); + const interval = window.setInterval(() => void loadData(false), 3000); + return () => window.clearInterval(interval); + }, [loadData]); + + const formatTimestamp = (timestamp: number | null): string => { + if (timestamp === null) return t('automations.notAvailable'); + return new Intl.DateTimeFormat(i18n.language, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(timestamp * 1000)); + }; + + const describeSchedule = (schedule: AutomationSchedule): string => { + const pad = (value: number) => String(value).padStart(2, '0'); + if (schedule.kind === 'hourly') { + return t('automations.scheduleHourly', { interval: schedule.intervalHours, minute: pad(schedule.minute) }); + } + if (schedule.kind === 'daily') { + return t('automations.scheduleDaily', { time: `${pad(schedule.hour)}:${pad(schedule.minute)}` }); + } + const dayLabels = schedule.daysOfWeek.map((day) => t(`automations.weekdays.${day}`)).join(t('automations.daySeparator')); + return t('automations.scheduleWeekly', { + days: dayLabels, + time: `${pad(schedule.hour)}:${pad(schedule.minute)}`, + }); + }; + + const openCreate = () => { + setEditingId(null); + setEditor(createDefaultEditor(readToolNames)); + setEditorVisible(true); + }; + + /** + * 显式处理主操作按钮的 Enter 与 Space 激活。 + * 原因:部分 Electron/辅助输入注入只派发键盘事件,不会补发原生 button click。 + * 未替换原生 Button:继续保留浏览器语义、焦点顺序和鼠标/触控行为。 + */ + const openCreateFromKeyboard = (event: KeyboardEvent) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + openCreate(); + }; + + const openEdit = (automation: Automation) => { + setEditingId(automation.id); + setEditor(editorFromAutomation(automation)); + setEditorVisible(true); + }; + + const toggleTool = (toolName: string, checked: boolean) => { + setEditor((current) => ({ + ...current, + allowedTools: checked + ? [...new Set([...current.allowedTools, toolName])] + : current.allowedTools.filter((name) => name !== toolName), + })); + }; + + const saveAutomation = async () => { + if (!editor.name.trim() || !editor.prompt.trim()) { + Message.error(t('automations.requiredFields')); + return; + } + if (editor.schedule.kind === 'weekly' && editor.schedule.daysOfWeek.length === 0) { + Message.error(t('automations.weekdayRequired')); + return; + } + const input: AutomationInput = { + name: editor.name.trim(), + prompt: editor.prompt.trim(), + enabled: editor.enabled, + schedule: editor.schedule, + allowedTools: editor.allowedTools, + providerOverride: editor.providerOverride, + modelOverride: editor.modelOverride, + reasoningOverride: editor.reasoningMode === 'inherit' ? null : editor.reasoningMode === 'on', + }; + setSaving(true); + try { + if (editingId) { + await api.updateAutomation(editingId, input); + Message.success(t('automations.updated')); + } else { + await api.createAutomation(input); + Message.success(t('automations.created')); + } + setEditorVisible(false); + await loadData(false); + } catch (error) { + Message.error(error instanceof Error ? error.message : t('automations.saveFailed')); + } finally { + setSaving(false); + } + }; + + const toggleAutomation = async (automation: Automation, enabled: boolean) => { + try { + await api.updateAutomation(automation.id, { enabled }); + setAutomations((current) => current.map((item) => ( + item.id === automation.id ? { ...item, enabled } : item + ))); + await loadData(false); + } catch (error) { + Message.error(error instanceof Error ? error.message : t('automations.updateFailed')); + } + }; + + const runAutomation = async (automation: Automation) => { + setRunningIds((current) => new Set(current).add(automation.id)); + try { + await api.runAutomation(automation.id); + Message.success(t('automations.runQueued')); + setActiveTab('runs'); + await loadData(false); + } catch (error) { + Message.error(error instanceof Error ? error.message : t('automations.runFailed')); + } finally { + setRunningIds((current) => { + const next = new Set(current); + next.delete(automation.id); + return next; + }); + } + }; + + const confirmDelete = (automation: Automation) => { + Modal.confirm({ + title: t('automations.deleteTitle'), + content: t('automations.deleteConfirm', { name: automation.name }), + okButtonProps: { status: 'danger' }, + onOk: async () => { + await api.deleteAutomation(automation.id); + Message.success(t('automations.deleted')); + await loadData(false); + }, + }); + }; + + const updateScheduleKind = (kind: AutomationSchedule['kind']) => { + setEditor((current) => ({ + ...current, + schedule: kind === 'hourly' + ? { kind: 'hourly', intervalHours: 1, minute: 0 } + : kind === 'daily' + ? { kind: 'daily', hour: 9, minute: 0 } + : { kind: 'weekly', daysOfWeek: [1], hour: 9, minute: 0 }, + })); + }; + + const activeCount = automations.filter((automation) => automation.enabled).length; + const failedCount = runs.filter((run) => run.status === 'failed').length; + + const renderAutomationCards = () => { + if (automations.length === 0) { + return ; + } + return ( +
+ {automations.map((automation) => ( + +
+
+ +
+ {automation.name} + {describeSchedule(automation.schedule)} +
+
+ void toggleAutomation(automation, checked)} + aria-label={t('automations.toggleLabel', { name: automation.name })} + /> +
+ + {automation.prompt} + +
+
{t('automations.nextRun')}{formatTimestamp(automation.nextRunAt)}
+
{t('automations.lastRun')}{formatTimestamp(automation.lastRunAt)}
+
+ {t('automations.model')} + + {automation.providerOverride && automation.modelOverride + ? automation.providerOverride + ' / ' + automation.modelOverride + : t('automations.inheritGlobal')} + +
+
{t('automations.permissions')}{t('automations.toolCount', { count: automation.allowedTools.length })}
+
+
+ + +
+
+ ))} +
+ ); + }; + + const renderRuns = () => { + if (runs.length === 0) { + return ; + } + return ( +
+ {runs.map((run) => { + const automation = automations.find((item) => item.id === run.automationId); + return ( + + ); + })} +
+ ); + }; + + const editorFooter = ( +
+ + +
+ ); + + return ( + + + + + )} + > +
+ + + + + {loading ?
: ( + activeTab === 'automations' ? renderAutomationCards() : renderRuns() + )} +
+ + setEditorVisible(false)} + footer={editorFooter} + unmountOnExit + > +
+ + setEditor((current) => ({ ...current, name }))} /> + + + setEditor((current) => ({ ...current, prompt }))} + autoSize={{ minRows: 5, maxRows: 12 }} + maxLength={20_000} + showWordLimit + /> + +
+
{t('automations.enabled')}{t('automations.enabledHelp')}
+ setEditor((current) => ({ ...current, enabled }))} /> +
+ + {t('automations.scheduleSection')} + + + + {editor.schedule.kind === 'hourly' && ( +
+ + setEditor((current) => ({ + ...current, + schedule: { kind: 'hourly', intervalHours, minute: current.schedule.kind === 'hourly' ? current.schedule.minute : 0 }, + }))} /> + + + setEditor((current) => ({ + ...current, + schedule: { kind: 'hourly', intervalHours: current.schedule.kind === 'hourly' ? current.schedule.intervalHours : 1, minute }, + }))} /> + +
+ )} + {(editor.schedule.kind === 'daily' || editor.schedule.kind === 'weekly') && ( +
+ + setEditor((current) => ({ + ...current, + schedule: current.schedule.kind === 'weekly' + ? { ...current.schedule, hour } + : { kind: 'daily', hour, minute: current.schedule.kind === 'daily' ? current.schedule.minute : 0 }, + }))} /> + + + setEditor((current) => ({ + ...current, + schedule: current.schedule.kind === 'weekly' + ? { ...current.schedule, minute } + : { kind: 'daily', hour: current.schedule.kind === 'daily' ? current.schedule.hour : 9, minute }, + }))} /> + +
+ )} + {editor.schedule.kind === 'weekly' && ( + + setEditor((current) => ({ + ...current, + schedule: current.schedule.kind === 'weekly' + ? { ...current.schedule, daysOfWeek: days.filter((day): day is number => typeof day === 'number') } + : current.schedule, + }))} + options={[0, 1, 2, 3, 4, 5, 6].map((day) => ({ label: t(`automations.weekdays.${day}`), value: day }))} + /> + + )} + + + {t('automations.modelSection')} + + + + + + + + + + + {t('automations.permissionsSection')} + {t('automations.permissionsHelp')} +
+
+ {t('automations.readTools')} + {readTools.map((tool) => ( + toggleTool(tool.name, checked)}> + {tool.name}{tool.description} + + ))} +
+
+ {t('automations.writeTools')} + {writeTools.map((tool) => ( + toggleTool(tool.name, checked)}> + {tool.name}{tool.description} + + ))} +
+
+ {hasWritePermission && } + +
+ + setSelectedRun(null)} + footer={null} + > + {selectedRun && ( +
+ {t(`automations.statuses.${selectedRun.status}`)} }, + { label: t('automations.trigger'), value: t(`automations.triggers.${selectedRun.trigger}`) }, + { label: t('automations.startedAt'), value: formatTimestamp(selectedRun.startedAt) }, + { label: t('automations.finishedAt'), value: formatTimestamp(selectedRun.finishedAt) }, + { label: t('automations.model'), value: selectedRun.model || t('automations.inheritGlobal') }, + { label: t('automations.provider'), value: selectedRun.provider || t('automations.notAvailable') }, + ]} + /> + {selectedRun.error && } + {selectedRun.output && ( +
{t('automations.output')}
+ )} + {selectedRun.reasoning && } + {selectedRun.toolCalls.length > 0 && ( +
+ {t('automations.toolCalls')} + {selectedRun.toolCalls.map((call, index) => ( + + ))} +
+ )} +
+ )} +
+
+ ); +}; + +export default AutomationsPage; diff --git a/frontend/src/SettingsPage/views/ChatView/components/ModelModal.tsx b/frontend/src/SettingsPage/views/ChatView/components/ModelModal.tsx index c5a4d0d1..5f4ac848 100644 --- a/frontend/src/SettingsPage/views/ChatView/components/ModelModal.tsx +++ b/frontend/src/SettingsPage/views/ChatView/components/ModelModal.tsx @@ -10,6 +10,20 @@ const { Title, Text } = Typography; const FormItem = Form.Item; const Option = Select.Option; +// 定义模型弹窗在编辑过程中的可选表单字段及能力开关。 +// 原因:字段在用户输入和校验前可能为 undefined,类型必须反映真实运行时状态。 +// 未把字段声明为全部必填:API Key 对 Ollama 等本地供应商是可选的,必填约束由表单规则处理。 +interface ModelFormValues { + providerId?: string; + name?: string; + modelId?: string; + port?: string; + apiKeyId?: string; + cap_tools?: boolean; + cap_vision?: boolean; + cap_reasoning?: boolean; +} + interface ModelModalProps { visible: boolean; onClose: () => void; @@ -20,130 +34,154 @@ interface ModelModalProps { t: (key: string) => string; } +// 根据供应商类型选择模型请求协议的初始值。 +// 原因:供应商类型通常与协议一致,但自定义或旧数据可能不在当前选项中。 +// 未直接返回 provider.type:Select 无法展示未知值,因此未知类型安全回退到 OpenAI 协议。 +function getProviderPort(provider: Provider): string { + return PORT_OPTIONS.some(option => option.value === provider.type) + ? provider.type + : 'openai'; +} + const ModelModal = ({ visible, onClose, onModelSaved, providers, editingModel, selectedProviderId, t }: ModelModalProps) => { - const [modelForm] = Form.useForm(); - const [modelFormProviderId, setModelFormProviderId] = useState(selectedProviderId || ''); + const [modelForm] = Form.useForm(); const [saveModelLoading, setSaveModelLoading] = useState(false); + const watchedProviderId = Form.useWatch< + ModelFormValues, + ModelFormValues[keyof ModelFormValues], + keyof ModelFormValues, + 'providerId' + >('providerId', modelForm); + const enabledProviders = providers.filter(provider => provider.enabled); + const apiKeyProvider = enabledProviders.find(provider => provider.id === watchedProviderId); - const handleSaveModel = () => { + // 关闭弹窗并清空字段、校验错误和 touched 状态。 + // 原因:同一弹窗实例会被重复使用,每次打开都必须从显式初始化值开始。 + // 未依赖 Modal 卸载重建:父组件始终挂载弹窗,单纯 unmountOnExit 无法重置外部 form 实例。 + const closeAndReset = () => { + onClose(); + modelForm.resetFields(); + }; + + // 校验并保存新增或编辑后的模型配置。 + // 原因:先完成同步校验,再进入请求 loading,确保无效字段得到可见反馈且不会静默抛错。 + // 未继续使用 Promise 链:分离校验和请求错误后,用户输入错误不会被误报为网络保存失败。 + const handleSaveModel = async (): Promise => { if (saveModelLoading) return; - modelForm.validate().then((values: { - name: string; - modelId: string; - port: string; - apiKeyId?: string; - providerId?: string; - cap_tools?: boolean; - cap_vision?: boolean; - cap_reasoning?: boolean; - }) => { - const targetProviderId = values.providerId || ''; - const trimmedModelId = values.modelId.trim(); - - const targetProvider = providers.find(p => p.id === targetProviderId); - if (!targetProvider) { - window.dispatchEvent(new CustomEvent('papyrus_ai_config_changed')); - return; - } - if (!trimmedModelId) { - window.dispatchEvent(new CustomEvent('papyrus_ai_config_changed')); + let values: ModelFormValues; + try { + values = await modelForm.validate(); + } catch { + return; + } + + const targetProviderId = values.providerId ?? ''; + const targetProvider = enabledProviders.find(provider => provider.id === targetProviderId); + if (!targetProvider) { + Message.error(t('chatView.providerNotFound')); + return; + } + + const trimmedName = (values.name ?? '').trim(); + if (!trimmedName) { + Message.error(t('chatView.modelNameEmpty')); + return; + } + + const trimmedModelId = (values.modelId ?? '').trim(); + if (!trimmedModelId) { + Message.error(t('chatView.modelIdEmpty')); + return; + } + + const capabilities: string[] = []; + if (values.cap_tools) capabilities.push('tools'); + if (values.cap_vision) capabilities.push('vision'); + if (values.cap_reasoning) capabilities.push('reasoning'); + + const modelData = { + id: editingModel ? editingModel.id : crypto.randomUUID(), + name: trimmedName, + modelId: trimmedModelId, + port: values.port ?? getProviderPort(targetProvider), + capabilities, + apiKeyId: values.apiKeyId, + enabled: true, + }; + + setSaveModelLoading(true); + try { + const data = editingModel + ? await api.updateModel(targetProviderId, editingModel.id, modelData) + : await api.addModel(targetProviderId, modelData); + + if (!data.success) { + Message.error(data.error || t('chatView.saveFailed')); return; } - const capabilities: string[] = []; - if (values.cap_tools) capabilities.push('tools'); - if (values.cap_vision) capabilities.push('vision'); - if (values.cap_reasoning) capabilities.push('reasoning'); - - const modelData = { - id: editingModel ? editingModel.id : crypto.randomUUID(), - name: values.name.trim(), - modelId: trimmedModelId, - port: values.port, - capabilities, - apiKeyId: values.apiKeyId, - enabled: true, - }; - - const closeModal = () => { - onClose(); - modelForm.resetFields(); - setModelFormProviderId(''); - }; - - setSaveModelLoading(true); - if (editingModel) { - api.updateModel(targetProviderId, editingModel.id, modelData) - .then(data => { - if (data.success) { - window.dispatchEvent(new CustomEvent('papyrus_ai_config_changed')); - onModelSaved(); - closeModal(); - } else { - Message.error(data.error || t('chatView.saveFailed')); - window.dispatchEvent(new CustomEvent('papyrus_ai_config_changed')); - } - }) - .catch(err => { - console.error('Failed to update model:', err); - Message.error(t('chatView.saveFailed')); - window.dispatchEvent(new CustomEvent('papyrus_ai_config_changed')); - }) - .finally(() => { - setSaveModelLoading(false); - }); - } else { - api.addModel(targetProviderId, modelData) - .then(data => { - if (data.success) { - window.dispatchEvent(new CustomEvent('papyrus_ai_config_changed')); - onModelSaved(); - closeModal(); - } else { - Message.error(data.error || t('chatView.saveFailed')); - window.dispatchEvent(new CustomEvent('papyrus_ai_config_changed')); - } - }) - .catch(err => { - console.error('Failed to add model:', err); - const msg = err instanceof Error ? err.message : String(err); - Message.error(msg || t('chatView.saveFailed')); - window.dispatchEvent(new CustomEvent('papyrus_ai_config_changed')); - }) - .finally(() => { - setSaveModelLoading(false); - }); - } - }).catch((err: unknown) => { - console.error('Model form validation failed:', err); + Message.success(t(editingModel ? 'chatView.modelUpdated' : 'chatView.modelAdded')); window.dispatchEvent(new CustomEvent('papyrus_ai_config_changed')); - }); + onModelSaved(); + closeAndReset(); + } catch (error: unknown) { + console.error(editingModel ? 'Failed to update model:' : 'Failed to add model:', error); + const message = error instanceof Error && error.message + ? error.message + : t('chatView.saveFailed'); + Message.error(message); + } finally { + setSaveModelLoading(false); + } }; + // 取消编辑并复用统一的关闭重置流程。 + // 原因:取消和保存成功后的清理行为必须一致,避免下次打开残留 touched 或错误状态。 + // 未在父组件单独清理字段:表单实例属于弹窗,清理职责留在组件内部更可靠。 const handleCancel = () => { - onClose(); - modelForm.resetFields(); - setModelFormProviderId(''); + closeAndReset(); }; + // 切换供应商时同步协议并选择该供应商的第一个密钥。 + // 原因:API Key 外键属于供应商,保留上一个供应商的密钥会导致后端外键校验失败。 + // 未强制要求密钥存在:无密钥供应商应把 apiKeyId 清空后继续允许保存。 const handleProviderChange = (value: string) => { - setModelFormProviderId(value); - const provider = providers.find(p => p.id === value); - if (provider && provider.apiKeys.length > 0) { - modelForm.setFieldValue('apiKeyId', provider.apiKeys[0].id); - } + const provider = enabledProviders.find(item => item.id === value); + modelForm.setFieldsValue({ + port: provider ? getProviderPort(provider) : 'openai', + apiKeyId: provider?.apiKeys[0]?.id, + }); }; + // 每次弹窗打开时根据当前 props 建立完整、互相一致的表单快照。 + // 原因:Arco Form 的 initialValue 仅在字段首次挂载时生效,无法响应随后传入的供应商或编辑模型。 + // 未保留额外 React 状态:Form.useWatch 已能驱动密钥选项,重复状态容易再次产生首开与重开不一致。 useEffect(() => { - if (visible && editingModel) { - modelForm.setFieldsValue({ - apiKeyId: editingModel.apiKeyId, - }); - } - }, [visible, editingModel, modelForm]); + if (!visible) return; + + const targetProvider = enabledProviders.find(provider => provider.id === selectedProviderId) + ?? enabledProviders[0]; + modelForm.resetFields(); + if (!targetProvider) return; - const enabledProviders = providers.filter(p => p.enabled); + const editingApiKeyId = editingModel?.apiKeyId; + const validEditingApiKeyId = editingApiKeyId + && targetProvider.apiKeys.some(key => key.id === editingApiKeyId) + ? editingApiKeyId + : undefined; + + modelForm.setFieldsValue({ + providerId: targetProvider.id, + name: editingModel?.name ?? '', + modelId: editingModel?.modelId ?? '', + port: editingModel?.port || getProviderPort(targetProvider), + apiKeyId: editingModel ? validEditingApiKeyId : targetProvider.apiKeys[0]?.id, + cap_tools: editingModel?.capabilities.includes('tools') ?? false, + cap_vision: editingModel?.capabilities.includes('vision') ?? false, + cap_reasoning: editingModel?.capabilities.includes('reasoning') ?? false, + }); + }, [visible, editingModel, selectedProviderId, providers, modelForm]); return (
- {t('chatView.provider')}} field="providerId" initialValue={selectedProviderId || '1'}> + {t('chatView.provider')}} + field="providerId" + rules={[{ required: true, message: t('chatView.noProviderSelected') }]} + > - {t('chatView.modelId')}} field="modelId"> + {t('chatView.modelId')}} + field="modelId" + rules={[{ required: true, match: /\S/, message: t('chatView.modelIdEmpty') }]} + > - {t('chatView.port')}} field="port" initialValue={enabledProviders[0]?.type || 'openai'}> + {t('chatView.port')}} field="port"> {t('chatView.apiKeyScheme')}} field="apiKeyId"> - + {apiKeyProvider?.apiKeys.map(key => ( + + ))} {t('chatView.modelCapabilities')}} style={{ marginBottom: 0 }}> @@ -208,11 +254,12 @@ const ModelModal = ({ visible, onClose, onModelSaved, providers, editingModel, s style={{ marginBottom: 0 }} triggerPropName="checked" > -
- - - {t(cap.labelKey)} -
+ + + + {t(cap.labelKey)} + +
); })} diff --git a/frontend/src/Sidebar.tsx b/frontend/src/Sidebar.tsx index e5e2e8f5..220e4910 100644 --- a/frontend/src/Sidebar.tsx +++ b/frontend/src/Sidebar.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Tooltip } from '@arco-design/web-react'; -import { IconPlus, IconFolder, IconMindMapping, IconSettings, IconLock, IconUnlock, IconMoon, IconSun, IconRobot } from '@arco-design/web-react/icon'; +import { IconPlus, IconFolder, IconMindMapping, IconSettings, IconLock, IconUnlock, IconMoon, IconSun, IconRobot, IconCalendarClock } from '@arco-design/web-react/icon'; import IconPapyrus from './icons/IconPapyrus'; import IconScroll from './icons/IconScroll'; import { SidebarChatHistory } from './components/SidebarChatHistory'; @@ -78,6 +78,7 @@ const Sidebar = ({ { key: 'scroll', icon: IconScroll, label: t('sidebar.scroll') }, { key: 'notes', icon: IconMindMapping, label: t('sidebar.notes') }, { key: 'files', icon: IconFolder, label: t('sidebar.files') }, + { key: 'automations', icon: IconCalendarClock, label: t('sidebar.automations') }, ]; const [locked, setLocked] = useState(false); diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 59583ff4..22a58846 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -379,6 +379,75 @@ export type UiSettings = SidebarSettings & { dateFormat: UiDateFormat; }; +// ========== Automation Types ========== +export type AutomationSchedule = + | { kind: 'hourly'; intervalHours: number; minute: number } + | { kind: 'daily'; hour: number; minute: number } + | { kind: 'weekly'; daysOfWeek: number[]; hour: number; minute: number }; + +export type AutomationRunStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'missed'; +export type AutomationRunTrigger = 'manual' | 'scheduled' | 'missed'; + +export interface AutomationToolCall { + name: string; + params: Record; + success: boolean; + result?: Record; + error?: string; +} + +export interface Automation { + id: string; + name: string; + prompt: string; + schedule: AutomationSchedule; + timezone: string; + enabled: boolean; + allowedTools: string[]; + providerOverride: string | null; + modelOverride: string | null; + reasoningOverride: boolean | null; + nextRunAt: number | null; + lastRunAt: number | null; + createdAt: number; + updatedAt: number; +} + +export interface AutomationRun { + id: string; + automationId: string; + trigger: AutomationRunTrigger; + status: AutomationRunStatus; + scheduledFor: number | null; + output: string; + reasoning: string; + toolCalls: AutomationToolCall[]; + error: string | null; + model: string; + provider: string; + startedAt: number | null; + finishedAt: number | null; + createdAt: number; +} + +export interface AutomationInput { + name: string; + prompt: string; + schedule: AutomationSchedule; + enabled: boolean; + allowedTools: string[]; + providerOverride: string | null; + modelOverride: string | null; + reasoningOverride: boolean | null; +} + +export interface ToolCatalogItem { + name: string; + category: string; + side_effect: 'read' | 'write'; + description: string; +} + // ========== Update Types ========== export type VersionInfo = { current_version: string; @@ -823,6 +892,34 @@ export const api = { body: JSON.stringify(settings), }), + // Automations + listAutomations: () => + request<{ success: boolean; automations: Automation[] }>('/automations'), + getAutomation: (id: string) => + request<{ success: boolean; automation: Automation }>(`/automations/${id}`), + createAutomation: (input: AutomationInput) => + request<{ success: boolean; automation: Automation }>('/automations', { + method: 'POST', + body: JSON.stringify(input), + }), + updateAutomation: (id: string, input: Partial) => + request<{ success: boolean; automation: Automation }>(`/automations/${id}`, { + method: 'PATCH', + body: JSON.stringify(input), + }), + deleteAutomation: (id: string) => + request<{ success: boolean }>(`/automations/${id}`, { method: 'DELETE' }), + runAutomation: (id: string) => + request<{ success: boolean; run: AutomationRun }>(`/automations/${id}/run`, { method: 'POST' }), + listAutomationRuns: (id: string, limit = 100) => + request<{ success: boolean; runs: AutomationRun[] }>(`/automations/${id}/runs?limit=${limit}`), + listRecentAutomationRuns: (limit = 100) => + request<{ success: boolean; runs: AutomationRun[] }>(`/automations/runs/recent?limit=${limit}`), + getAutomationRun: (runId: string) => + request<{ success: boolean; run: AutomationRun }>(`/automations/runs/${runId}`), + getToolCatalog: () => + request<{ success: boolean; tools: ToolCatalogItem[] }>('/tools/catalog'), + // Files listFiles: () => request('/files'), createFolder: (name: string, parentId?: string) => diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json index cb901beb..554deae1 100644 --- a/frontend/src/locales/en-US.json +++ b/frontend/src/locales/en-US.json @@ -5,6 +5,7 @@ "notes": "Notes", "charts": "Data", "files": "Files", + "automations": "Automations", "extensions": "Extensions", "sidebar": "Sidebar", "shrink": "Shrink Sidebar", @@ -209,6 +210,7 @@ "saveFailed": "Save failed", "noProviderSelected": "Please enable at least one provider", "providerNotFound": "Selected provider not found, please refresh and try again", + "modelNameEmpty": "Model name cannot be empty", "modelIdEmpty": "Model ID cannot be empty", "modelAlreadyExists": "This model already exists in current provider", "defaultProviderSet": "Default provider set", @@ -636,6 +638,7 @@ "notes": "Notes", "charts": "Stats", "files": "Files", + "automations": "Automations", "extensions": "Extensions", "settings": "Settings" } @@ -998,6 +1001,92 @@ "studyProgress": "Study Progress", "studied": "Studied" }, + "automations": { + "title": "Automations", + "refresh": "Refresh", + "newAutomation": "New automation", + "editAutomation": "Edit automation", + "automationTab": "Automations", + "runsTab": "Run history", + "total": "Total", + "active": "Active", + "failedRuns": "Failed runs", + "empty": "No automations yet. Create a recurring Agent task.", + "noRuns": "No runs yet", + "loadFailed": "Failed to load automations", + "saveFailed": "Failed to save automation", + "updateFailed": "Failed to update automation", + "created": "Automation created", + "updated": "Automation updated", + "deleted": "Automation deleted", + "runQueued": "Automation queued", + "runFailed": "Automation run failed", + "requiredFields": "Enter a name and Agent instructions", + "weekdayRequired": "Select at least one weekday", + "name": "Name", + "instructions": "Agent instructions", + "instructionsHelp": "Describe the work to perform and the expected output for every run.", + "enabled": "Enable schedule", + "enabledHelp": "Paused automations can still be run manually", + "scheduleSection": "Schedule", + "frequency": "Frequency", + "hourly": "Hourly", + "daily": "Daily", + "weekly": "Weekly", + "intervalHours": "Interval in hours", + "hour": "Hour", + "minute": "Minute", + "weekdaysLabel": "Weekdays", + "localTimezoneHelp": "Uses local timezone {{timezone}}. Runs are skipped while Papyrus is closed or the computer is asleep.", + "modelSection": "Model and reasoning", + "model": "Model", + "modelHelp": "Inherit follows the current model in Chat settings.", + "inheritGlobal": "Inherit global settings", + "reasoning": "Reasoning", + "reasoningOn": "On", + "reasoningOff": "Off", + "permissionsSection": "Tool permissions", + "permissionsHelp": "Only selected tools are exposed to the Agent. Read tools are enabled by default; write tools require explicit permission.", + "readTools": "Read tools", + "writeTools": "Write tools", + "writeWarning": "Unattended write access is enabled. Scheduled Agents can modify cards, notes, files, or other data. Review the instructions and permissions carefully.", + "nextRun": "Next run", + "lastRun": "Last run", + "permissions": "Permissions", + "toolCount": "{{count}} tools", + "runNow": "Run now", + "toggleLabel": "Toggle automation {{name}}", + "deleteLabel": "Delete automation {{name}}", + "deleteTitle": "Delete automation", + "deleteConfirm": "Delete “{{name}}” and all of its run history? This cannot be undone.", + "deletedAutomation": "Deleted automation", + "runDetails": "Run details", + "status": "Status", + "trigger": "Trigger", + "startedAt": "Started", + "finishedAt": "Finished", + "provider": "Provider", + "output": "Output", + "toolCalls": "Tool calls", + "notAvailable": "Not available", + "scheduleHourly": "Every {{interval}} hour(s) at minute {{minute}}", + "scheduleDaily": "Daily at {{time}}", + "scheduleWeekly": "Weekly on {{days}} at {{time}}", + "daySeparator": ", ", + "weekdays": { "0": "Sun", "1": "Mon", "2": "Tue", "3": "Wed", "4": "Thu", "5": "Fri", "6": "Sat" }, + "statuses": { + "queued": "Queued", + "running": "Running", + "succeeded": "Succeeded", + "failed": "Failed", + "missed": "Missed" + }, + "triggers": { + "manual": "Manual", + "scheduled": "Scheduled", + "missed": "Missed while offline" + } + }, "weekdays": { "mon": "Mon", "tue": "Tue", diff --git a/frontend/src/locales/ja-JP.json b/frontend/src/locales/ja-JP.json index 6d2b843e..bb922422 100644 --- a/frontend/src/locales/ja-JP.json +++ b/frontend/src/locales/ja-JP.json @@ -5,6 +5,7 @@ "notes": "ノート", "charts": "データ", "files": "ファイル", + "automations": "自動化", "extensions": "拡張機能", "sidebar": "サイドバー", "shrink": "サイドバーを縮小", @@ -209,6 +210,7 @@ "saveFailed": "保存に失敗しました", "noProviderSelected": "少なくとも1つのプロバイダーを有効にしてください", "providerNotFound": "選択したプロバイダーが見つかりません。ページを更新して再試行してください", + "modelNameEmpty": "モデル名は空にできません", "modelIdEmpty": "モデルIDは空にできません", "modelAlreadyExists": "このモデルは現在のプロバイダーに既に存在します", "defaultProviderSet": "デフォルトプロバイダーを設定しました", @@ -636,6 +638,7 @@ "notes": "ノート", "charts": "統計", "files": "ファイル", + "automations": "自動化", "extensions": "拡張機能", "settings": "設定" } @@ -998,6 +1001,82 @@ "studyProgress": "学習進捗", "studied": "学習" }, + "automations": { + "title": "自動化", + "refresh": "更新", + "newAutomation": "自動化を作成", + "editAutomation": "自動化を編集", + "automationTab": "自動化", + "runsTab": "実行履歴", + "total": "合計", + "active": "有効", + "failedRuns": "失敗した実行", + "empty": "自動化はまだありません。定期実行する Agent タスクを作成してください。", + "noRuns": "実行履歴はありません", + "loadFailed": "自動化を読み込めませんでした", + "saveFailed": "自動化を保存できませんでした", + "updateFailed": "自動化を更新できませんでした", + "created": "自動化を作成しました", + "updated": "自動化を更新しました", + "deleted": "自動化を削除しました", + "runQueued": "自動化を実行キューに追加しました", + "runFailed": "自動化の実行に失敗しました", + "requiredFields": "名前と Agent の指示を入力してください", + "weekdayRequired": "曜日を1つ以上選択してください", + "name": "名前", + "instructions": "Agent の指示", + "instructionsHelp": "各実行で行う作業と期待する出力を具体的に記述します。", + "enabled": "スケジュールを有効化", + "enabledHelp": "一時停止中でも手動実行できます", + "scheduleSection": "スケジュール", + "frequency": "頻度", + "hourly": "時間ごと", + "daily": "毎日", + "weekly": "毎週", + "intervalHours": "時間間隔", + "hour": "時", + "minute": "分", + "weekdaysLabel": "曜日", + "localTimezoneHelp": "ローカルタイムゾーン {{timezone}} を使用します。Papyrus が終了中またはスリープ中の実行はスキップされます。", + "modelSection": "モデルと推論", + "model": "モデル", + "modelHelp": "グローバル設定を継承すると、チャット設定の現在のモデルを使用します。", + "inheritGlobal": "グローバル設定を継承", + "reasoning": "推論", + "reasoningOn": "オン", + "reasoningOff": "オフ", + "permissionsSection": "ツール権限", + "permissionsHelp": "選択したツールだけを Agent に公開します。読み取りツールは既定で有効、書き込みツールは個別の許可が必要です。", + "readTools": "読み取りツール", + "writeTools": "書き込みツール", + "writeWarning": "無人の書き込みアクセスが有効です。スケジュールされた Agent はカード、ノート、ファイルなどを直接変更できます。指示と権限を確認してください。", + "nextRun": "次回実行", + "lastRun": "前回実行", + "permissions": "権限", + "toolCount": "{{count}} 個のツール", + "runNow": "今すぐ実行", + "toggleLabel": "自動化 {{name}} を切り替え", + "deleteLabel": "自動化 {{name}} を削除", + "deleteTitle": "自動化を削除", + "deleteConfirm": "「{{name}}」とすべての実行履歴を削除しますか?元に戻せません。", + "deletedAutomation": "削除済みの自動化", + "runDetails": "実行の詳細", + "status": "状態", + "trigger": "トリガー", + "startedAt": "開始", + "finishedAt": "完了", + "provider": "プロバイダー", + "output": "出力", + "toolCalls": "ツール呼び出し", + "notAvailable": "なし", + "scheduleHourly": "{{interval}} 時間ごとの {{minute}} 分", + "scheduleDaily": "毎日 {{time}}", + "scheduleWeekly": "毎週 {{days}} {{time}}", + "daySeparator": "、", + "weekdays": { "0": "日", "1": "月", "2": "火", "3": "水", "4": "木", "5": "金", "6": "土" }, + "statuses": { "queued": "待機中", "running": "実行中", "succeeded": "成功", "failed": "失敗", "missed": "スキップ" }, + "triggers": { "manual": "手動", "scheduled": "スケジュール", "missed": "オフライン中にスキップ" } + }, "weekdays": { "mon": "月", "tue": "火", diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index e8f45187..405d5f01 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -5,6 +5,7 @@ "notes": "结构笔记", "charts": "数据", "files": "文件库", + "automations": "自动化", "extensions": "扩展管理", "sidebar": "侧边栏", "shrink": "缩小侧边栏", @@ -209,6 +210,7 @@ "saveFailed": "保存失败", "noProviderSelected": "请先启用至少一个供应商", "providerNotFound": "所选供应商不存在,请刷新页面后重试", + "modelNameEmpty": "模型名称不能为空", "modelIdEmpty": "模型 ID 不能为空", "modelAlreadyExists": "该模型已存在于当前供应商", "defaultProviderSet": "默认供应商已设置", @@ -636,6 +638,7 @@ "notes": "笔记", "charts": "统计", "files": "文件", + "automations": "自动化", "extensions": "扩展", "settings": "设置" } @@ -998,6 +1001,92 @@ "studyProgress": "学习进度", "studied": "学习" }, + "automations": { + "title": "自动化", + "refresh": "刷新", + "newAutomation": "新建自动化", + "editAutomation": "编辑自动化", + "automationTab": "自动化", + "runsTab": "运行记录", + "total": "总数", + "active": "已启用", + "failedRuns": "失败运行", + "empty": "暂无自动化,创建一个定期执行的 Agent 任务", + "noRuns": "暂无运行记录", + "loadFailed": "自动化加载失败", + "saveFailed": "自动化保存失败", + "updateFailed": "自动化更新失败", + "created": "自动化已创建", + "updated": "自动化已更新", + "deleted": "自动化已删除", + "runQueued": "自动化已加入运行队列", + "runFailed": "自动化运行失败", + "requiredFields": "请填写名称和 Agent 指令", + "weekdayRequired": "每周计划至少选择一天", + "name": "名称", + "instructions": "Agent 指令", + "instructionsHelp": "清楚描述每次运行要完成的工作和期望输出。", + "enabled": "启用计划", + "enabledHelp": "暂停后仍可手动立即运行", + "scheduleSection": "计划", + "frequency": "频率", + "hourly": "每小时", + "daily": "每日", + "weekly": "每周", + "intervalHours": "间隔小时数", + "hour": "小时", + "minute": "分钟", + "weekdaysLabel": "星期", + "localTimezoneHelp": "使用本机时区 {{timezone}}。Papyrus 关闭或电脑休眠时不会运行,错过的计划将被跳过。", + "modelSection": "模型与推理", + "model": "模型", + "modelHelp": "继承全局时会跟随聊天设置中的当前模型。", + "inheritGlobal": "继承全局设置", + "reasoning": "推理", + "reasoningOn": "开启", + "reasoningOff": "关闭", + "permissionsSection": "工具权限", + "permissionsHelp": "只向 Agent 暴露勾选的工具。只读工具默认启用,写工具需逐项授权。", + "readTools": "只读工具", + "writeTools": "写入工具", + "writeWarning": "已授权无人值守写操作。Agent 到点运行时可直接修改卡片、笔记、文件或其他数据,请仔细检查指令和权限。", + "nextRun": "下次运行", + "lastRun": "上次运行", + "permissions": "权限", + "toolCount": "{{count}} 个工具", + "runNow": "立即运行", + "toggleLabel": "切换自动化 {{name}}", + "deleteLabel": "删除自动化 {{name}}", + "deleteTitle": "删除自动化", + "deleteConfirm": "确定删除“{{name}}”及其全部运行记录吗?此操作不可恢复。", + "deletedAutomation": "已删除的自动化", + "runDetails": "运行详情", + "status": "状态", + "trigger": "触发方式", + "startedAt": "开始时间", + "finishedAt": "完成时间", + "provider": "提供商", + "output": "输出", + "toolCalls": "工具调用", + "notAvailable": "暂无", + "scheduleHourly": "每 {{interval}} 小时,第 {{minute}} 分运行", + "scheduleDaily": "每天 {{time}}", + "scheduleWeekly": "每周{{days}} {{time}}", + "daySeparator": "、", + "weekdays": { "0": "日", "1": "一", "2": "二", "3": "三", "4": "四", "5": "五", "6": "六" }, + "statuses": { + "queued": "排队中", + "running": "运行中", + "succeeded": "成功", + "failed": "失败", + "missed": "已错过" + }, + "triggers": { + "manual": "手动", + "scheduled": "计划", + "missed": "离线错过" + } + }, "weekdays": { "mon": "周一", "tue": "周二", diff --git a/frontend/src/locales/zh-TW.json b/frontend/src/locales/zh-TW.json index e8bc6326..277803c3 100644 --- a/frontend/src/locales/zh-TW.json +++ b/frontend/src/locales/zh-TW.json @@ -5,6 +5,7 @@ "notes": "結構筆記", "charts": "數據", "files": "文件庫", + "automations": "自動化", "extensions": "擴展管理", "sidebar": "側邊欄", "shrink": "縮小側邊欄", @@ -209,6 +210,7 @@ "saveFailed": "保存失敗", "noProviderSelected": "請先啟用至少一個供應商", "providerNotFound": "所選供應商不存在,請刷新頁面後重試", + "modelNameEmpty": "模型名稱不能為空", "modelIdEmpty": "模型 ID 不能為空", "modelAlreadyExists": "該模型已存在於當前供應商", "defaultProviderSet": "默認供應商已設置", @@ -636,6 +638,7 @@ "notes": "筆記", "charts": "統計", "files": "文件", + "automations": "自動化", "extensions": "擴展", "settings": "設置" } @@ -998,6 +1001,82 @@ "studyProgress": "學習進度", "studied": "學習" }, + "automations": { + "title": "自動化", + "refresh": "重新整理", + "newAutomation": "新增自動化", + "editAutomation": "編輯自動化", + "automationTab": "自動化", + "runsTab": "執行記錄", + "total": "總數", + "active": "已啟用", + "failedRuns": "失敗執行", + "empty": "尚無自動化,建立一個定期執行的 Agent 任務", + "noRuns": "尚無執行記錄", + "loadFailed": "自動化載入失敗", + "saveFailed": "自動化儲存失敗", + "updateFailed": "自動化更新失敗", + "created": "自動化已建立", + "updated": "自動化已更新", + "deleted": "自動化已刪除", + "runQueued": "自動化已加入執行佇列", + "runFailed": "自動化執行失敗", + "requiredFields": "請填寫名稱與 Agent 指令", + "weekdayRequired": "每週計畫至少選擇一天", + "name": "名稱", + "instructions": "Agent 指令", + "instructionsHelp": "清楚描述每次執行要完成的工作與預期輸出。", + "enabled": "啟用計畫", + "enabledHelp": "暫停後仍可手動立即執行", + "scheduleSection": "計畫", + "frequency": "頻率", + "hourly": "每小時", + "daily": "每日", + "weekly": "每週", + "intervalHours": "間隔小時數", + "hour": "小時", + "minute": "分鐘", + "weekdaysLabel": "星期", + "localTimezoneHelp": "使用本機時區 {{timezone}}。Papyrus 關閉或電腦休眠時不會執行,錯過的計畫將被略過。", + "modelSection": "模型與推理", + "model": "模型", + "modelHelp": "繼承全域設定時會跟隨聊天設定中的目前模型。", + "inheritGlobal": "繼承全域設定", + "reasoning": "推理", + "reasoningOn": "開啟", + "reasoningOff": "關閉", + "permissionsSection": "工具權限", + "permissionsHelp": "只向 Agent 顯示勾選的工具。唯讀工具預設啟用,寫入工具需逐項授權。", + "readTools": "唯讀工具", + "writeTools": "寫入工具", + "writeWarning": "已授權無人值守寫入。Agent 定時執行時可直接修改卡片、筆記、文件或其他資料,請仔細檢查指令與權限。", + "nextRun": "下次執行", + "lastRun": "上次執行", + "permissions": "權限", + "toolCount": "{{count}} 個工具", + "runNow": "立即執行", + "toggleLabel": "切換自動化 {{name}}", + "deleteLabel": "刪除自動化 {{name}}", + "deleteTitle": "刪除自動化", + "deleteConfirm": "確定刪除「{{name}}」及其全部執行記錄嗎?此操作無法復原。", + "deletedAutomation": "已刪除的自動化", + "runDetails": "執行詳情", + "status": "狀態", + "trigger": "觸發方式", + "startedAt": "開始時間", + "finishedAt": "完成時間", + "provider": "供應商", + "output": "輸出", + "toolCalls": "工具呼叫", + "notAvailable": "暫無", + "scheduleHourly": "每 {{interval}} 小時,第 {{minute}} 分執行", + "scheduleDaily": "每天 {{time}}", + "scheduleWeekly": "每週{{days}} {{time}}", + "daySeparator": "、", + "weekdays": { "0": "日", "1": "一", "2": "二", "3": "三", "4": "四", "5": "五", "6": "六" }, + "statuses": { "queued": "排隊中", "running": "執行中", "succeeded": "成功", "failed": "失敗", "missed": "已錯過" }, + "triggers": { "manual": "手動", "scheduled": "計畫", "missed": "離線錯過" } + }, "weekdays": { "mon": "週一", "tue": "週二", diff --git a/package-lock.json b/package-lock.json index b9e8e947..c564a210 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "papyrus", - "version": "2.0.0-beta.14", + "version": "2.0.0-beta.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "papyrus", - "version": "2.0.0-beta.14", + "version": "2.0.0-beta.16", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 82a0db79..a4c02712 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { "name": "papyrus", - "version": "2.0.0-beta.14", + "version": "2.0.0-beta.16", "description": "Papyrus Desktop - A modern note-taking and learning application", "main": "electron/main.js", - "author": "PapyrusOR", + "author": "LiYuanStudio", "license": "MIT", "private": false, "scripts": { diff --git a/root-package.json b/root-package.json index 14aa3b4e..1feaa303 100644 --- a/root-package.json +++ b/root-package.json @@ -1,9 +1,9 @@ { "name": "papyrus", - "version": "2.0.0-beta.11", + "version": "2.0.0-beta.16", "description": "Papyrus Desktop - A modern note-taking and learning application", "main": "electron/main.js", - "author": "PapyrusOR", + "author": "LiYuanStudio", "license": "MIT", "private": false, "scripts": {