From 55fd32686dec4c383addeced99ecf0e8fbf4fd7c Mon Sep 17 00:00:00 2001 From: tianma-if Date: Wed, 12 Aug 2026 16:06:28 +0800 Subject: [PATCH] feat(ai): complete cross-platform custom prompt behavior --- apps/api/src/ai-prompt-routes.test.mjs | 175 +++++++++- apps/api/src/ai-prompt-routes.ts | 324 +++++++++++++----- apps/api/src/ai-prompt-seed.ts | 36 +- apps/api/src/ai-prompt-service.ts | 115 +++++++ apps/api/src/ai-routes.test.mjs | 63 ++++ apps/api/src/ai-routes.ts | 46 ++- apps/api/src/ai-service.ts | 24 +- apps/ios/EdgeEver/Data/Models/Models.swift | 35 ++ .../ios/EdgeEver/Data/Network/APIClient.swift | 8 + .../Features/Workspace/AiAssistantSheet.swift | 149 ++++++-- .../src/components/LocalTiptapEditor.tsx | 145 +++++++- .../src/components/MobileAiAssistantModal.tsx | 81 ++++- apps/mobile/src/hooks/useMobileSelectionAi.ts | 17 +- .../src/lib/mobile-ai-selection.test.ts | 16 + apps/mobile/src/lib/mobile-ai-selection.ts | 4 + apps/mobile/src/screens/WorkspaceScreen.tsx | 10 +- apps/web/src/components/AiPromptsPane.tsx | 123 ++++++- .../components/dialogs/AiAssistantDialog.tsx | 100 +++--- .../src/components/settings/AiPromptsCard.tsx | 6 +- .../components/settings/DataExportCard.tsx | 3 +- .../components/settings/WebDavAutoBackup.tsx | 2 +- .../components/settings/WebDavBackupCard.tsx | 2 +- apps/web/src/lib/ai-assistant.test.mjs | 21 ++ apps/web/src/lib/ai-assistant.ts | 34 +- apps/web/src/lib/api.ts | 39 ++- apps/web/src/lib/json-backup.ts | 37 +- docs/openapi.json | 131 ++++++- ...29_ai_prompt_behavior_and_localization.sql | 90 +++++ packages/client/src/index.ts | 28 +- packages/shared/src/ai-assistant.ts | 18 + packages/shared/src/ai-prompt-seeds.ts | 305 ++++++++++++----- packages/shared/src/backup.ts | 63 +++- packages/shared/src/i18n/en-US.ts | 14 + packages/shared/src/i18n/zh-CN.ts | 14 + packages/shared/src/schemas.ts | 24 +- packages/shared/src/types.ts | 9 + tests/json-backup.test.ts | 55 ++- tests/mobile-ai-assistant.test.ts | 7 + 38 files changed, 1967 insertions(+), 406 deletions(-) create mode 100644 apps/api/src/ai-prompt-service.ts create mode 100644 migrations/0029_ai_prompt_behavior_and_localization.sql diff --git a/apps/api/src/ai-prompt-routes.test.mjs b/apps/api/src/ai-prompt-routes.test.mjs index fcf3c3cf..e4f0a9b5 100644 --- a/apps/api/src/ai-prompt-routes.test.mjs +++ b/apps/api/src/ai-prompt-routes.test.mjs @@ -78,6 +78,35 @@ const createDatabaseEnvironment = () => { }; }; +const createUpgradedDatabaseEnvironment = () => { + const sqlite = new Database(":memory:"); + const migrations = globSync("migrations/*.sql").sort(); + for (const migration of migrations.filter((path) => path < "migrations/0027")) { + sqlite.exec(readFileSync(migration, "utf8")); + } + sqlite.query("INSERT INTO workspaces (id, name, is_personal) VALUES (?, ?, 1)") + .run("ws_member", "Member workspace"); + for (const migration of migrations.filter((path) => path >= "migrations/0027" && path < "migrations/0029")) { + sqlite.exec(readFileSync(migration, "utf8")); + } + sqlite.query( + `UPDATE ai_prompt_templates + SET instruction = ?, updated_at = ? + WHERE id = ?`, + ).run( + "Only this instruction was customized before the metadata migration.", + "2026-08-12T01:00:00.000Z", + defaultAiPromptId("ws_member", "summarize"), + ); + sqlite.exec(readFileSync("migrations/0029_ai_prompt_behavior_and_localization.sql", "utf8")); + return { + sqlite, + environment: { + storage: { db: new SqliteD1Database(sqlite), resources: {} }, + }, + }; +}; + const createApp = ({ currentAuth = auth, demoMode = false } = {}) => { const app = new Hono(); app.use("/api/v1/*", async (context, next) => { @@ -93,7 +122,10 @@ describe("AI prompt template routes", () => { expect(AiPromptTemplateCreateSchema.safeParse({ name: "Weekly digest", instruction: "Summarize progress and risks.", - }).success).toBe(true); + })).toMatchObject({ + success: true, + data: { parameterKind: "none", resultMode: "both" }, + }); expect(AiPromptTemplateCreateSchema.safeParse({ name: "", instruction: "Body", @@ -124,6 +156,14 @@ describe("AI prompt template routes", () => { expect(created.status).toBe(201); const createdBody = await created.json(); expect(createdBody.prompt).toMatchObject({ + origin: "custom", + seedKey: null, + action: "custom", + parameterKind: "none", + resultMode: "both", + nameCustomized: true, + descriptionCustomized: true, + instructionCustomized: true, name: "会议待办", description: "提取行动项", instruction: "提取明确待办,输出 Markdown 任务列表。", @@ -221,7 +261,7 @@ describe("AI prompt template routes", () => { const workspaceId = auth.workspaceId; const first = await app.request( - "/api/v1/ai/prompts/restore-defaults", + "/api/v1/ai/prompts/restore-defaults?locale=en-US", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }, environment, ); @@ -229,6 +269,16 @@ describe("AI prompt template routes", () => { const firstBody = await first.json(); expect(firstBody.restoredCount).toBe(DEFAULT_AI_PROMPT_SEEDS.length); expect(firstBody.prompts).toHaveLength(DEFAULT_AI_PROMPT_SEEDS.length); + expect(firstBody.prompts.find((prompt) => prompt.seedKey === "summarize")).toMatchObject({ + origin: "default", + action: "summarize", + parameterKind: "none", + resultMode: "append", + nameCustomized: false, + descriptionCustomized: false, + instructionCustomized: false, + name: "Summarize", + }); const summarizeId = defaultAiPromptId(workspaceId, "summarize"); await app.request( @@ -264,4 +314,125 @@ describe("AI prompt template routes", () => { ); expect(await third.json()).toMatchObject({ restoredCount: 0 }); }); + + test("localizes untouched factory fields while preserving field-level edits after upgrade", async () => { + const { environment } = createUpgradedDatabaseEnvironment(); + const app = createApp(); + + const english = await app.request("/api/v1/ai/prompts?locale=en-US", {}, environment); + expect(english.status).toBe(200); + const englishSummary = (await english.json()).prompts.find( + (prompt) => prompt.seedKey === "summarize", + ); + expect(englishSummary).toMatchObject({ + origin: "default", + action: "summarize", + resultMode: "append", + name: "Summarize", + description: "Condense the note into its topic, conclusions, and actionable outcomes", + instruction: "Only this instruction was customized before the metadata migration.", + nameCustomized: false, + descriptionCustomized: false, + instructionCustomized: true, + }); + + const chinese = await app.request("/api/v1/ai/prompts?locale=zh-CN", {}, environment); + const chineseSummary = (await chinese.json()).prompts.find( + (prompt) => prompt.seedKey === "summarize", + ); + expect(chineseSummary).toMatchObject({ + name: "总结", + description: "压缩全文,提炼主题、结论与可执行结果", + instruction: "Only this instruction was customized before the metadata migration.", + }); + }); + + test("restores prompt behavior from backup without overwriting factory identity", async () => { + const { environment } = createDatabaseEnvironment(); + const app = createApp(); + await app.request( + "/api/v1/ai/prompts/restore-defaults?locale=en-US", + { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }, + environment, + ); + + const now = "2026-08-12T01:00:00.000Z"; + const response = await app.request( + "/api/v1/restores/json/ai-prompts", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + prompts: [{ + id: defaultAiPromptId(auth.workspaceId, "summarize"), + origin: "custom", + seedKey: null, + action: "custom", + parameterKind: "tone", + resultMode: "replace", + nameCustomized: true, + descriptionCustomized: true, + instructionCustomized: true, + name: "Collision-safe custom prompt", + description: null, + instruction: "Rewrite in the requested tone.", + createdAt: now, + updatedAt: now, + }], + }), + }, + environment, + ); + expect(response.status).toBe(200); + + const listed = await app.request("/api/v1/ai/prompts?locale=en-US", {}, environment); + const prompts = (await listed.json()).prompts; + expect(prompts).toHaveLength(DEFAULT_AI_PROMPT_SEEDS.length + 1); + expect(prompts.find((prompt) => prompt.seedKey === "summarize")).toMatchObject({ + origin: "default", + name: "Summarize", + }); + expect(prompts.find((prompt) => prompt.name === "Collision-safe custom prompt")).toMatchObject({ + origin: "custom", + seedKey: null, + parameterKind: "tone", + resultMode: "replace", + }); + }); + + test("keeps untouched factory copy localizable when restoring a backup made in another locale", async () => { + const { environment } = createDatabaseEnvironment(); + const app = createApp(); + const restored = await app.request( + "/api/v1/ai/prompts/restore-defaults?locale=en-US", + { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }, + environment, + ); + const englishSummary = (await restored.json()).prompts.find( + (prompt) => prompt.seedKey === "summarize", + ); + + const response = await app.request( + "/api/v1/restores/json/ai-prompts", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompts: [englishSummary] }), + }, + environment, + ); + expect(response.status).toBe(200); + + const chinese = await app.request("/api/v1/ai/prompts?locale=zh-CN", {}, environment); + const chineseSummary = (await chinese.json()).prompts.find( + (prompt) => prompt.seedKey === "summarize", + ); + expect(chineseSummary).toMatchObject({ + name: "总结", + description: "压缩全文,提炼主题、结论与可执行结果", + nameCustomized: false, + descriptionCustomized: false, + instructionCustomized: false, + }); + }); }); diff --git a/apps/api/src/ai-prompt-routes.ts b/apps/api/src/ai-prompt-routes.ts index e9afe404..eaca6f01 100644 --- a/apps/api/src/ai-prompt-routes.ts +++ b/apps/api/src/ai-prompt-routes.ts @@ -1,48 +1,31 @@ import { AiPromptTemplateCreateSchema, AiPromptTemplateUpdateSchema, - type AiPromptTemplate, + RestoreJsonAiPromptsSchema, + type JsonBackupAiPrompt, } from "@edgeever/shared"; import { zValidator } from "@hono/zod-validator"; import type { Hono } from "hono"; import type { AppContext, AppEnv, Bindings } from "./api-context"; +import { + getAiPromptTemplateRow, + listAiPromptTemplates, + mapAiPromptTemplateRow, +} from "./ai-prompt-service"; import { restoreMissingDefaultAiPrompts } from "./ai-prompt-seed"; -import { audit } from "./audit"; +import { audit, auditStatement } from "./audit"; import { createId, isoNow } from "./entity-utils"; import { forbidden, notFound } from "./http-errors"; import { getAuditActor, getWorkspaceId, requireUser } from "./request-auth"; -export type AiPromptTemplateRow = { - id: string; - name: string; - description: string | null; - instruction: string; - created_at: string; - updated_at: string; -}; - type AiPromptRouteDependencies = { isDemoMode: (environment: Bindings) => boolean; }; -const mapAiPromptTemplateRow = (row: AiPromptTemplateRow): AiPromptTemplate => ({ - id: row.id, - name: row.name, - description: row.description, - instruction: row.instruction, - createdAt: row.created_at, - updatedAt: row.updated_at, -}); - -const getAiPromptTemplateRow = async ( - context: AppContext, - id: string, -): Promise => - context.env.storage.db.prepare( - `SELECT id, name, description, instruction, created_at, updated_at - FROM ai_prompt_templates - WHERE id = ? AND workspace_id = ?`, - ).bind(id, getWorkspaceId(context)).first(); +const getRequestLocale = (context: AppContext) => + context.req.query("locale")?.trim() + || context.req.header("Accept-Language")?.split(",")[0]?.trim() + || undefined; const denyMutation = (context: AppContext, dependencies: AiPromptRouteDependencies) => { const denied = requireUser(context); @@ -61,14 +44,12 @@ export const registerAiPromptRoutes = ( const denied = requireUser(context); if (denied) return denied; - const rows = await context.env.storage.db.prepare( - `SELECT id, name, description, instruction, created_at, updated_at - FROM ai_prompt_templates - WHERE workspace_id = ? - ORDER BY updated_at DESC, name ASC`, - ).bind(getWorkspaceId(context)).all(); - - return context.json({ prompts: rows.results.map(mapAiPromptTemplateRow) }); + const prompts = await listAiPromptTemplates( + context.env.storage.db, + getWorkspaceId(context), + getRequestLocale(context), + ); + return context.json({ prompts }); }); app.post( @@ -86,24 +67,39 @@ export const registerAiPromptRoutes = ( const description = input.description?.trim() || null; const instruction = input.instruction.trim(); - await context.env.storage.db.prepare( - `INSERT INTO ai_prompt_templates ( - id, workspace_id, name, description, instruction, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?)`, - ).bind(id, workspaceId, name, description, instruction, now, now).run(); - - const row = await getAiPromptTemplateRow(context, id); const actor = getAuditActor(context); - await audit( - context.env.storage.db, - actor.actorType, - actor.actorId, - "ai_prompt.create", - "ai_prompt", - id, - {}, - ); - return context.json({ prompt: mapAiPromptTemplateRow(row!) }, 201); + await context.env.storage.db.batch([ + context.env.storage.db.prepare( + `INSERT INTO ai_prompt_templates ( + id, workspace_id, seed_key, action, parameter_kind, result_mode, + name, description, instruction, + name_customized, description_customized, instruction_customized, + created_at, updated_at + ) VALUES (?, ?, NULL, 'custom', ?, ?, ?, ?, ?, 1, 1, 1, ?, ?)`, + ).bind( + id, + workspaceId, + input.parameterKind, + input.resultMode, + name, + description, + instruction, + now, + now, + ), + auditStatement( + context.env.storage.db, + actor.actorType, + actor.actorId, + "ai_prompt.create", + "ai_prompt", + id, + {}, + ), + ]); + + const row = await getAiPromptTemplateRow(context.env.storage.db, workspaceId, id); + return context.json({ prompt: mapAiPromptTemplateRow(row!, getRequestLocale(context)) }, 201); }, ); @@ -116,7 +112,8 @@ export const registerAiPromptRoutes = ( const id = context.req.param("id"); const input = context.req.valid("json"); - const current = await getAiPromptTemplateRow(context, id); + const workspaceId = getWorkspaceId(context); + const current = await getAiPromptTemplateRow(context.env.storage.db, workspaceId, id); if (!current) return notFound(context, "Prompt not found"); const name = input.name?.trim() ?? current.name; @@ -125,25 +122,42 @@ export const registerAiPromptRoutes = ( : current.description; const instruction = input.instruction?.trim() ?? current.instruction; const now = isoNow(); - - await context.env.storage.db.prepare( - `UPDATE ai_prompt_templates - SET name = ?, description = ?, instruction = ?, updated_at = ? - WHERE id = ? AND workspace_id = ?`, - ).bind(name, description, instruction, now, id, getWorkspaceId(context)).run(); - - const row = await getAiPromptTemplateRow(context, id); const actor = getAuditActor(context); - await audit( - context.env.storage.db, - actor.actorType, - actor.actorId, - "ai_prompt.update", - "ai_prompt", - id, - {}, - ); - return context.json({ prompt: mapAiPromptTemplateRow(row!) }); + + await context.env.storage.db.batch([ + context.env.storage.db.prepare( + `UPDATE ai_prompt_templates + SET name = ?, description = ?, instruction = ?, + parameter_kind = ?, result_mode = ?, + name_customized = ?, description_customized = ?, instruction_customized = ?, + updated_at = ? + WHERE id = ? AND workspace_id = ?`, + ).bind( + name, + description, + instruction, + input.parameterKind ?? current.parameter_kind, + input.resultMode ?? current.result_mode, + input.name !== undefined ? 1 : current.name_customized, + input.description !== undefined ? 1 : current.description_customized, + input.instruction !== undefined ? 1 : current.instruction_customized, + now, + id, + workspaceId, + ), + auditStatement( + context.env.storage.db, + actor.actorType, + actor.actorId, + "ai_prompt.update", + "ai_prompt", + id, + {}, + ), + ]); + + const row = await getAiPromptTemplateRow(context.env.storage.db, workspaceId, id); + return context.json({ prompt: mapAiPromptTemplateRow(row!, getRequestLocale(context)) }); }, ); @@ -152,23 +166,25 @@ export const registerAiPromptRoutes = ( if (denied) return denied; const id = context.req.param("id"); - const current = await getAiPromptTemplateRow(context, id); + const workspaceId = getWorkspaceId(context); + const current = await getAiPromptTemplateRow(context.env.storage.db, workspaceId, id); if (!current) return notFound(context, "Prompt not found"); - await context.env.storage.db.prepare( - `DELETE FROM ai_prompt_templates WHERE id = ? AND workspace_id = ?`, - ).bind(id, getWorkspaceId(context)).run(); - const actor = getAuditActor(context); - await audit( - context.env.storage.db, - actor.actorType, - actor.actorId, - "ai_prompt.delete", - "ai_prompt", - id, - {}, - ); + await context.env.storage.db.batch([ + context.env.storage.db.prepare( + `DELETE FROM ai_prompt_templates WHERE id = ? AND workspace_id = ?`, + ).bind(id, workspaceId), + auditStatement( + context.env.storage.db, + actor.actorType, + actor.actorId, + "ai_prompt.delete", + "ai_prompt", + id, + {}, + ), + ]); return context.json({ ok: true }); }); @@ -183,12 +199,11 @@ export const registerAiPromptRoutes = ( workspaceId, ); - const rows = await context.env.storage.db.prepare( - `SELECT id, name, description, instruction, created_at, updated_at - FROM ai_prompt_templates - WHERE workspace_id = ? - ORDER BY updated_at DESC, name ASC`, - ).bind(workspaceId).all(); + const prompts = await listAiPromptTemplates( + context.env.storage.db, + workspaceId, + getRequestLocale(context), + ); if (restoredCount > 0) { const actor = getAuditActor(context); @@ -204,8 +219,129 @@ export const registerAiPromptRoutes = ( } return context.json({ - prompts: rows.results.map(mapAiPromptTemplateRow), + prompts, restoredCount, }); }); + + app.post( + "/api/v1/restores/json/ai-prompts", + zValidator("json", RestoreJsonAiPromptsSchema), + async (context) => { + const denied = denyMutation(context, dependencies); + if (denied) return denied; + + const workspaceId = getWorkspaceId(context); + const input = context.req.valid("json"); + await restoreMissingDefaultAiPrompts(context.env.storage.db, workspaceId); + const statements = []; + const factoryRows = await context.env.storage.db.prepare( + `SELECT seed_key, name, description, instruction + FROM ai_prompt_templates + WHERE workspace_id = ? AND seed_key IS NOT NULL`, + ).bind(workspaceId).all<{ + seed_key: string; + name: string; + description: string | null; + instruction: string; + }>(); + const factoryRowsBySeed = new Map( + factoryRows.results.map((row) => [row.seed_key, row]), + ); + const customPromptIds = (input.prompts as JsonBackupAiPrompt[]) + .filter((prompt) => prompt.origin === "custom") + .map((prompt) => prompt.id); + const existingRows = customPromptIds.length > 0 + ? await context.env.storage.db.prepare( + `SELECT id, workspace_id, seed_key FROM ai_prompt_templates + WHERE id IN (${customPromptIds.map(() => "?").join(", ")})`, + ).bind(...customPromptIds).all<{ + id: string; + workspace_id: string; + seed_key: string | null; + }>() + : { results: [] }; + const existingRowsById = new Map(existingRows.results.map((row) => [row.id, row])); + + for (const prompt of input.prompts as JsonBackupAiPrompt[]) { + if (prompt.origin === "default" && prompt.seedKey) { + const catalogPrompt = factoryRowsBySeed.get(prompt.seedKey); + statements.push(context.env.storage.db.prepare( + `UPDATE ai_prompt_templates + SET action = ?, parameter_kind = ?, result_mode = ?, + name = ?, description = ?, instruction = ?, + name_customized = ?, description_customized = ?, instruction_customized = ?, + created_at = ?, updated_at = ? + WHERE workspace_id = ? AND seed_key = ?`, + ).bind( + prompt.action, + prompt.parameterKind, + prompt.resultMode, + prompt.nameCustomized ? prompt.name : catalogPrompt?.name ?? prompt.name, + prompt.descriptionCustomized + ? prompt.description + : catalogPrompt?.description ?? prompt.description, + prompt.instructionCustomized + ? prompt.instruction + : catalogPrompt?.instruction ?? prompt.instruction, + prompt.nameCustomized ? 1 : 0, + prompt.descriptionCustomized ? 1 : 0, + prompt.instructionCustomized ? 1 : 0, + prompt.createdAt, + prompt.updatedAt, + workspaceId, + prompt.seedKey, + )); + continue; + } + + const existing = existingRowsById.get(prompt.id); + const id = existing && (existing.workspace_id !== workspaceId || existing.seed_key) + ? createId("aiprompt") + : prompt.id; + statements.push(context.env.storage.db.prepare( + `INSERT INTO ai_prompt_templates ( + id, workspace_id, seed_key, action, parameter_kind, result_mode, + name, description, instruction, + name_customized, description_customized, instruction_customized, + created_at, updated_at + ) VALUES (?, ?, NULL, 'custom', ?, ?, ?, ?, ?, 1, 1, 1, ?, ?) + ON CONFLICT(id) DO UPDATE SET + parameter_kind = excluded.parameter_kind, + result_mode = excluded.result_mode, + name = excluded.name, + description = excluded.description, + instruction = excluded.instruction, + name_customized = 1, + description_customized = 1, + instruction_customized = 1, + updated_at = excluded.updated_at + WHERE ai_prompt_templates.workspace_id = excluded.workspace_id`, + ).bind( + id, + workspaceId, + prompt.parameterKind, + prompt.resultMode, + prompt.name, + prompt.description, + prompt.instruction, + prompt.createdAt, + prompt.updatedAt, + )); + } + + const actor = getAuditActor(context); + statements.push(auditStatement( + context.env.storage.db, + actor.actorType, + actor.actorId, + "ai_prompt.restore_backup", + "workspace", + workspaceId, + { count: input.prompts.length }, + )); + await context.env.storage.db.batch(statements); + return context.json({ ok: true }); + }, + ); }; diff --git a/apps/api/src/ai-prompt-seed.ts b/apps/api/src/ai-prompt-seed.ts index 28d3242b..7648f5da 100644 --- a/apps/api/src/ai-prompt-seed.ts +++ b/apps/api/src/ai-prompt-seed.ts @@ -4,7 +4,7 @@ import { isoNow } from "./entity-utils"; /** * Insert any missing factory-default prompts for a workspace. - * Uses deterministic ids + INSERT OR IGNORE so: + * Uses explicit seed keys plus deterministic ids so: * - deleted defaults are re-created * - user-edited defaults (same id) are left alone * - user-created prompts (random ids) are never touched @@ -14,12 +14,13 @@ export const restoreMissingDefaultAiPrompts = async ( workspaceId: string, ): Promise<{ restoredCount: number; restoredIds: string[] }> => { const existing = await db.prepare( - `SELECT id FROM ai_prompt_templates WHERE workspace_id = ? AND id LIKE ?`, - ).bind(workspaceId, `${workspaceId}_aiprompt_%`).all<{ id: string }>(); + `SELECT id, seed_key FROM ai_prompt_templates + WHERE workspace_id = ? AND seed_key IS NOT NULL`, + ).bind(workspaceId).all<{ id: string; seed_key: string }>(); - const existingIds = new Set((existing.results ?? []).map((row) => row.id)); + const existingSeedKeys = new Set((existing.results ?? []).map((row) => row.seed_key)); const missing = DEFAULT_AI_PROMPT_SEEDS.filter( - (seed) => !existingIds.has(defaultAiPromptId(workspaceId, seed.key)), + (seed) => !existingSeedKeys.has(seed.key), ); if (missing.length === 0) { @@ -27,25 +28,38 @@ export const restoreMissingDefaultAiPrompts = async ( } const now = isoNow(); - const restoredIds: string[] = []; for (const seed of missing) { - const id = defaultAiPromptId(workspaceId, seed.key); await db.prepare( `INSERT OR IGNORE INTO ai_prompt_templates ( - id, workspace_id, name, description, instruction, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + id, workspace_id, seed_key, action, parameter_kind, result_mode, + name, description, instruction, + name_customized, description_customized, instruction_customized, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?)`, ).bind( - id, + defaultAiPromptId(workspaceId, seed.key), workspaceId, + seed.key, + seed.action, + seed.parameterKind, + seed.resultMode, seed.name, seed.description, seed.instruction, now, now, ).run(); - restoredIds.push(id); } + const restored = await db.prepare( + `SELECT id, seed_key FROM ai_prompt_templates + WHERE workspace_id = ? AND seed_key IS NOT NULL`, + ).bind(workspaceId).all<{ id: string; seed_key: string }>(); + const missingSeedKeys = new Set(missing.map((seed) => seed.key)); + const restoredIds = (restored.results ?? []) + .filter((row) => missingSeedKeys.has(row.seed_key)) + .map((row) => row.id); + return { restoredCount: restoredIds.length, restoredIds }; }; diff --git a/apps/api/src/ai-prompt-service.ts b/apps/api/src/ai-prompt-service.ts new file mode 100644 index 00000000..b24567c3 --- /dev/null +++ b/apps/api/src/ai-prompt-service.ts @@ -0,0 +1,115 @@ +import { + getDefaultAiPromptSeed, + localizeAiPromptSeed, + type AiAction, + type AiPromptParameterKind, + type AiPromptResultMode, + type AiPromptTemplate, +} from "@edgeever/shared"; +import type { DatabaseAdapter } from "./storage-contract"; + +export type AiPromptTemplateRow = { + id: string; + workspace_id: string; + seed_key: string | null; + action: AiAction; + parameter_kind: AiPromptParameterKind; + result_mode: AiPromptResultMode; + name: string; + description: string | null; + instruction: string; + name_customized: number; + description_customized: number; + instruction_customized: number; + created_at: string; + updated_at: string; +}; + +export const AI_PROMPT_SELECT_COLUMNS = `id, workspace_id, seed_key, action, + parameter_kind, result_mode, name, description, instruction, + name_customized, description_customized, instruction_customized, created_at, updated_at`; + +export const mapAiPromptTemplateRow = ( + row: AiPromptTemplateRow, + locale?: string | null, +): AiPromptTemplate => { + const promptSeed = row.seed_key ? getDefaultAiPromptSeed(row.seed_key) : null; + const localized = promptSeed ? localizeAiPromptSeed(promptSeed, locale) : null; + + return { + id: row.id, + origin: promptSeed ? "default" : "custom", + seedKey: promptSeed?.key ?? null, + action: promptSeed?.action ?? row.action, + parameterKind: row.parameter_kind, + resultMode: row.result_mode, + nameCustomized: Boolean(row.name_customized), + descriptionCustomized: Boolean(row.description_customized), + instructionCustomized: Boolean(row.instruction_customized), + name: localized && !row.name_customized ? localized.name : row.name, + description: localized && !row.description_customized + ? localized.description + : row.description, + instruction: localized && !row.instruction_customized + ? localized.instruction + : row.instruction, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +}; + +export const getAiPromptTemplateRow = ( + db: DatabaseAdapter, + workspaceId: string, + id: string, +) => db.prepare( + `SELECT ${AI_PROMPT_SELECT_COLUMNS} + FROM ai_prompt_templates + WHERE id = ? AND workspace_id = ?`, +).bind(id, workspaceId).first(); + +export const getAiPromptTemplate = async ( + db: DatabaseAdapter, + workspaceId: string, + id: string, + locale?: string | null, +) => { + const row = await getAiPromptTemplateRow(db, workspaceId, id); + return row ? mapAiPromptTemplateRow(row, locale) : null; +}; + +export const listAiPromptTemplates = async ( + db: DatabaseAdapter, + workspaceId: string, + locale?: string | null, +) => { + const rows = await db.prepare( + `SELECT ${AI_PROMPT_SELECT_COLUMNS} + FROM ai_prompt_templates + WHERE workspace_id = ? + ORDER BY updated_at DESC, name ASC`, + ).bind(workspaceId).all(); + return rows.results.map((row) => mapAiPromptTemplateRow(row, locale)); +}; + +export const resolveWorkspaceActionInstruction = async ( + db: DatabaseAdapter, + workspaceId: string, + action: AiAction, + locale?: string | null, +): Promise => { + if (action === "custom") return undefined; + const promptSeed = getDefaultAiPromptSeed(action); + if (!promptSeed) return undefined; + + const row = await db.prepare( + `SELECT ${AI_PROMPT_SELECT_COLUMNS} + FROM ai_prompt_templates + WHERE workspace_id = ? AND seed_key = ?`, + ).bind(workspaceId, promptSeed.key).first(); + + const instruction = row + ? mapAiPromptTemplateRow(row, locale).instruction + : localizeAiPromptSeed(promptSeed, locale).instruction; + return instruction.trim() || undefined; +}; diff --git a/apps/api/src/ai-routes.test.mjs b/apps/api/src/ai-routes.test.mjs index 8621d40a..17f5dfcf 100644 --- a/apps/api/src/ai-routes.test.mjs +++ b/apps/api/src/ai-routes.test.mjs @@ -117,6 +117,21 @@ describe("AI route contracts", () => { } }); + test("defers prompt-specific action and parameter validation to the saved prompt", () => { + expect(AiGenerateSchema.safeParse({ + action: "custom", + promptId: "aiprompt_saved", + title: "Note", + contentMarkdown: "Body", + }).success).toBe(true); + expect(AiGenerateSchema.safeParse({ + action: "translate", + promptId: "aiprompt_server_resolves_behavior", + title: "Note", + contentMarkdown: "Body", + }).success).toBe(true); + }); + test("does not allow API tokens to manage personal AI credentials", async () => { const app = createApp({ currentAuth: { ...auth, kind: "agent", actorType: "agent" } }); const response = await app.request("/api/v1/ai/settings", {}, environment); @@ -181,6 +196,54 @@ describe("AI route contracts", () => { expect(response.status).toBe(400); }); + test("resolves saved prompt behavior inside the authenticated workspace", async () => { + const app = createApp(); + const { sqlite, environment: databaseEnvironment } = createDatabaseEnvironment(); + const now = "2026-08-12T01:00:00.000Z"; + sqlite.query( + `INSERT INTO ai_prompt_templates ( + id, workspace_id, seed_key, action, parameter_kind, result_mode, + name, description, instruction, + name_customized, description_customized, instruction_customized, + created_at, updated_at + ) VALUES (?, ?, NULL, 'custom', 'tone', 'replace', ?, NULL, ?, 1, 1, 1, ?, ?)`, + ).run("aiprompt_tone", auth.workspaceId, "Tone prompt", "Use the requested tone.", now, now); + + const missingParameter = await app.request( + "/api/v1/ai/generate", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + action: "summarize", + promptId: "aiprompt_tone", + title: "Note", + contentMarkdown: "Body", + }), + }, + databaseEnvironment, + ); + expect(missingParameter.status).toBe(400); + expect(await missingParameter.json()).toMatchObject({ error: { code: "ai_tone_required" } }); + + const missingPrompt = await app.request( + "/api/v1/ai/generate", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + action: "custom", + promptId: "aiprompt_other_workspace", + title: "Note", + contentMarkdown: "Body", + }), + }, + databaseEnvironment, + ); + expect(missingPrompt.status).toBe(404); + expect(await missingPrompt.json()).toMatchObject({ error: { code: "ai_prompt_not_found" } }); + }); + test("bounds custom editing instructions", async () => { const app = createApp(); const response = await app.request( diff --git a/apps/api/src/ai-routes.ts b/apps/api/src/ai-routes.ts index 8e46404d..178932e8 100644 --- a/apps/api/src/ai-routes.ts +++ b/apps/api/src/ai-routes.ts @@ -5,12 +5,15 @@ import { AiProviderConfigCreateSchema, AiProviderConfigUpdateSchema, AiProviderConnectionTestSchema, + promptNeedsTargetLanguage, + promptNeedsTone, } from "@edgeever/shared"; import { zValidator } from "@hono/zod-validator"; import type { Hono } from "hono"; import type { AppContext, AppEnv, Bindings } from "./api-context"; import { AppError } from "./app-error"; import { auditStatement } from "./audit"; +import { getAiPromptTemplate, resolveWorkspaceActionInstruction } from "./ai-prompt-service"; import { decryptAiCredential, discoverAiModels, @@ -22,7 +25,6 @@ import { normalizeAiBaseUrl, parseAiGenerationResult, resolvePrimaryAiCredentialEncryptionKey, - resolveWorkspaceActionInstruction, streamAiGeneration, testAiModel, } from "./ai-service"; @@ -447,18 +449,52 @@ export const registerAiRoutes = (app: Hono, dependencies: AiRouteDepende try { const input = context.req.valid("json"); const workspaceId = getWorkspaceId(context); + const selectedPrompt = input.promptId + ? await getAiPromptTemplate( + context.env.storage.db, + workspaceId, + input.promptId, + input.locale, + ) + : null; + if (input.promptId && !selectedPrompt) { + throw new AppError("ai_prompt_not_found", "The selected prompt no longer exists.", 404); + } + + const action = selectedPrompt?.action ?? input.action; + const needsTargetLanguage = selectedPrompt + ? promptNeedsTargetLanguage(selectedPrompt.parameterKind) + : action === "translate"; + const needsTone = selectedPrompt + ? promptNeedsTone(selectedPrompt.parameterKind) + : action === "change-tone"; + if (needsTargetLanguage && !input.targetLanguage) { + throw new AppError("ai_target_language_required", "Choose a target language for this prompt.", 400); + } + if (needsTone && !input.tone) { + throw new AppError("ai_tone_required", "Choose a tone for this prompt.", 400); + } + + const resolvedInstruction = selectedPrompt?.instruction + || input.instruction?.trim() + || await resolveWorkspaceActionInstruction( + context.env.storage.db, + workspaceId, + action, + input.locale, + ) + || undefined; const model = await loadDefaultAiModel( context.env.storage.db, workspaceId, context.env, ); - // Always prefer the user-visible prompt library text (workspace DB seed or freeform). - const resolvedInstruction = input.instruction?.trim() - || await resolveWorkspaceActionInstruction(context.env.storage.db, workspaceId, input.action) - || undefined; const result = streamAiGeneration({ ...input, + action, instruction: resolvedInstruction, + targetLanguage: needsTargetLanguage ? input.targetLanguage : undefined, + tone: needsTone ? input.tone : undefined, model, abortSignal: context.req.raw.signal, }); diff --git a/apps/api/src/ai-service.ts b/apps/api/src/ai-service.ts index 05e8eaf7..5a9602fb 100644 --- a/apps/api/src/ai-service.ts +++ b/apps/api/src/ai-service.ts @@ -11,7 +11,7 @@ import type { AiTargetLanguage, AiTone, } from "@edgeever/shared"; -import { defaultAiPromptId, getDefaultAiPromptSeed } from "@edgeever/shared"; +import { getDefaultAiPromptSeed } from "@edgeever/shared"; import { generateText, streamText, tool } from "ai"; import { z } from "zod"; import { AppError } from "./app-error"; @@ -348,24 +348,6 @@ export const aiActionInstructions: Record => { - if (action === "custom") return undefined; - const seed = getDefaultAiPromptSeed(action); - if (!seed) return undefined; - - const row = await db.prepare( - `SELECT instruction FROM ai_prompt_templates WHERE id = ? AND workspace_id = ?`, - ).bind(defaultAiPromptId(workspaceId, seed.key), workspaceId).first<{ instruction: string }>(); - - const instruction = row?.instruction?.trim() || seed.instruction; - return instruction || undefined; -}; - const aiGenerationResultSchema = z.object({ contentMarkdown: z.string().describe( "Only the requested Markdown result, without prompt labels, the note title, commentary, or surrounding code fences.", @@ -432,8 +414,8 @@ export const streamAiGeneration = (input: { prompt: buildAiGenerationPrompt({ title: input.title, contentMarkdown: input.contentMarkdown, - targetLanguage: input.action === "translate" ? input.targetLanguage : undefined, - tone: input.action === "change-tone" ? input.tone : undefined, + targetLanguage: input.targetLanguage, + tone: input.tone, instruction: input.instruction, }), maxOutputTokens: 4096, diff --git a/apps/ios/EdgeEver/Data/Models/Models.swift b/apps/ios/EdgeEver/Data/Models/Models.swift index 400946f5..e374d7b0 100644 --- a/apps/ios/EdgeEver/Data/Models/Models.swift +++ b/apps/ios/EdgeEver/Data/Models/Models.swift @@ -147,6 +147,8 @@ enum AiAction: String, Codable, CaseIterable, Sendable, Identifiable { struct AiGenerateInput: Encodable, Sendable { var action: AiAction + var promptId: String? = nil + var locale: String? = nil var title: String var contentMarkdown: String var targetLanguage: String? @@ -154,6 +156,35 @@ struct AiGenerateInput: Encodable, Sendable { var instruction: String? = nil } +enum AiPromptParameterKind: String, Codable, Sendable { + case none + case targetLanguage = "target-language" + case tone +} + +enum AiPromptResultMode: String, Codable, Sendable { + case append + case replace + case both +} + +struct AiPromptTemplate: Codable, Equatable, Sendable, Identifiable { + var id: String + var origin: String + var seedKey: String? + var action: AiAction + var parameterKind: AiPromptParameterKind + var resultMode: AiPromptResultMode + var nameCustomized: Bool + var descriptionCustomized: Bool + var instructionCustomized: Bool + var name: String + var description: String? + var instruction: String + var createdAt: String + var updatedAt: String +} + struct AiStreamEvent: Decodable, Sendable { var type: String var text: String? @@ -299,6 +330,10 @@ struct TagsResponse: Codable, Sendable { var tags: [TagSummary] } +struct AiPromptsResponse: Codable, Sendable { + var prompts: [AiPromptTemplate] +} + // MARK: - Outbox / drafts domain enum MemoSortMode: String, CaseIterable, Sendable { diff --git a/apps/ios/EdgeEver/Data/Network/APIClient.swift b/apps/ios/EdgeEver/Data/Network/APIClient.swift index 55d0590e..3f712e6f 100644 --- a/apps/ios/EdgeEver/Data/Network/APIClient.swift +++ b/apps/ios/EdgeEver/Data/Network/APIClient.swift @@ -187,6 +187,14 @@ actor APIClient { // MARK: - AI note processing + func listAiPrompts(locale: String) async throws -> [AiPromptTemplate] { + let response: AiPromptsResponse = try await request( + path: "/api/v1/ai/prompts", + query: [.init(name: "locale", value: locale)] + ) + return response.prompts + } + func streamAiGeneration(_ input: AiGenerateInput) -> AsyncThrowingStream { var request = URLRequest(url: makeURL(path: "/api/v1/ai/generate")) request.httpMethod = "POST" diff --git a/apps/ios/EdgeEver/Features/Workspace/AiAssistantSheet.swift b/apps/ios/EdgeEver/Features/Workspace/AiAssistantSheet.swift index ebefedee..c6618036 100644 --- a/apps/ios/EdgeEver/Features/Workspace/AiAssistantSheet.swift +++ b/apps/ios/EdgeEver/Features/Workspace/AiAssistantSheet.swift @@ -57,6 +57,8 @@ struct AiAssistantSheet: View { } @State private var action: AiAction = .summarize + @State private var prompts: [AiPromptTemplate] = [] + @State private var selectedPromptID: String? @State private var targetLanguage: AssistantTargetLanguage = .english @State private var tone: AssistantTone = .professional @State private var customInstruction = "" @@ -82,15 +84,15 @@ struct AiAssistantSheet: View { actionPicker - if action == .translate { + if needsTargetLanguage { languagePicker } - if action == .changeTone { + if needsTone { tonePicker } - if action == .custom { + if selectedPrompt == nil, action == .custom { VStack(alignment: .leading, spacing: 7) { Text(env.preferences.t("告诉 AI 你想怎么处理", en: "Tell AI what to do")) .font(.system(size: 13, weight: .bold)) @@ -201,21 +203,49 @@ struct AiAssistantSheet: View { didSetLanguageDefault = true } .onDisappear { streamTask?.cancel() } + .task { await loadPrompts() } } private var actionPicker: some View { - pickerField(env.preferences.t("AI 操作", en: "AI action"), selection: actionTitle(action)) { - ForEach(availableActions) { item in + pickerField( + env.preferences.t("AI 操作", en: "AI action"), + selection: selectedPrompt?.name ?? actionTitle(action) + ) { + if prompts.isEmpty { + ForEach(availableActions) { item in + Button { + selectAction(item) + } label: { + if selectedPromptID == nil, action == item { + Label(actionTitle(item), systemImage: "checkmark") + } else { + Text(actionTitle(item)) + } + } + } + } else { + ForEach(prompts) { prompt in + Button { + streamTask?.cancel() + selectedPromptID = prompt.id + action = prompt.action + output = "" + error = nil + } label: { + if selectedPromptID == prompt.id { + Label(prompt.name, systemImage: "checkmark") + } else { + Text(prompt.name) + } + } + } Button { - streamTask?.cancel() - action = item - output = "" - error = nil + selectAction(.custom) } label: { - if action == item { - Label(actionTitle(item), systemImage: "checkmark") + if selectedPromptID == nil, action == .custom { + Label(actionTitle(.custom), systemImage: "checkmark") } else { - Text(actionTitle(item)) + Text(actionTitle(.custom)) } } } @@ -295,22 +325,25 @@ struct AiAssistantSheet: View { secondaryButton(env.preferences.t("复制", en: "Copy"), systemImage: "doc.on.doc") { UIPasteboard.general.string = output } - secondaryButton( - isSelection - ? env.preferences.t("插入到选区后", en: "Insert after") - : env.preferences.t("追加", en: "Append"), - systemImage: "text.append" - ) { - apply(.append) + if canAppendSource { + secondaryButton( + isSelection + ? env.preferences.t("插入到选区后", en: "Insert after") + : env.preferences.t("追加", en: "Append"), + systemImage: "text.append" + ) { + apply(.append) + } } - secondaryButton( - isSelection - ? env.preferences.t("替换选中内容", en: "Replace selection") - : env.preferences.t("替换", en: "Replace"), - systemImage: "arrow.triangle.2.circlepath", - disabled: !canReplaceSource - ) { - apply(.replace) + if canReplaceSource { + secondaryButton( + isSelection + ? env.preferences.t("替换选中内容", en: "Replace selection") + : env.preferences.t("替换", en: "Replace"), + systemImage: "arrow.triangle.2.circlepath" + ) { + apply(.replace) + } } } if isGenerating { @@ -338,7 +371,7 @@ struct AiAssistantSheet: View { } .buttonStyle(.borderedProminent) .tint(AppTheme.accent) - .disabled(isApplying || (action == .custom && customInstruction.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)) + .disabled(isApplying || (selectedPrompt == nil && action == .custom && customInstruction.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)) } } .padding(.horizontal, 12) @@ -437,18 +470,40 @@ struct AiAssistantSheet: View { } private var canReplaceSource: Bool { + if let selectedPrompt { + return selectedPrompt.resultMode == .replace || selectedPrompt.resultMode == .both + } switch action { case .summarize, .extractKeyPoints, .extractTodos, .continueWriting: - false + return false default: - true + return true } } + private var canAppendSource: Bool { + guard let selectedPrompt else { return true } + return selectedPrompt.resultMode == .append || selectedPrompt.resultMode == .both + } + + private var selectedPrompt: AiPromptTemplate? { + guard let selectedPromptID else { return nil } + return prompts.first { $0.id == selectedPromptID } + } + + private var needsTargetLanguage: Bool { + selectedPrompt?.parameterKind == .targetLanguage || (selectedPrompt == nil && action == .translate) + } + + private var needsTone: Bool { + selectedPrompt?.parameterKind == .tone || (selectedPrompt == nil && action == .changeTone) + } + private func request(for source: String, refinement: String? = nil) -> AiGenerateInput { if let refinement, !refinement.isEmpty { return AiGenerateInput( action: .custom, + locale: env.preferences.isEnglish ? "en-US" : "zh-CN", title: title, contentMarkdown: source, targetLanguage: nil, @@ -457,17 +512,43 @@ struct AiAssistantSheet: View { } return AiGenerateInput( - action: action, + action: selectedPrompt?.action ?? action, + promptId: selectedPrompt?.id, + locale: env.preferences.isEnglish ? "en-US" : "zh-CN", title: title, contentMarkdown: source, - targetLanguage: action == .translate ? targetLanguage.rawValue : nil, - tone: action == .changeTone ? tone.rawValue : nil, - instruction: action == .custom + targetLanguage: needsTargetLanguage ? targetLanguage.rawValue : nil, + tone: needsTone ? tone.rawValue : nil, + instruction: selectedPrompt == nil && action == .custom ? customInstruction.trimmingCharacters(in: .whitespacesAndNewlines) : nil ) } + private func selectAction(_ nextAction: AiAction) { + streamTask?.cancel() + selectedPromptID = nil + action = nextAction + output = "" + error = nil + } + + @MainActor + private func loadPrompts() async { + do { + let locale = env.preferences.isEnglish ? "en-US" : "zh-CN" + let loaded = try await env.session.client.listAiPrompts(locale: locale) + prompts = loaded + let preferredAction: AiAction = isSelection ? .improveWriting : .summarize + if let preferred = loaded.first(where: { $0.seedKey == preferredAction.rawValue }) ?? loaded.first { + selectedPromptID = preferred.id + action = preferred.action + } + } catch { + // Keep the built-in action list as an offline/older-server fallback. + } + } + private func generate(source: String? = nil, refinement: String? = nil) { streamTask?.cancel() output = "" diff --git a/apps/mobile/src/components/LocalTiptapEditor.tsx b/apps/mobile/src/components/LocalTiptapEditor.tsx index 934d2c7a..1c1caaca 100644 --- a/apps/mobile/src/components/LocalTiptapEditor.tsx +++ b/apps/mobile/src/components/LocalTiptapEditor.tsx @@ -27,6 +27,9 @@ import { getImageReferrerPolicy, getResourceIdFromUrl, type AiAction, + type AiPromptParameterKind, + type AiPromptResultMode, + type AiPromptTemplate, type AiStreamEvent, type AiTargetLanguage, type AiTone, @@ -95,6 +98,7 @@ type LocalTiptapEditorSharedProps = { /** Editable note body with toolbar (create / rich edit). */ type LocalTiptapEditorModeProps = LocalTiptapEditorSharedProps & { mode?: "editor"; + aiPromptsJson?: string; autoFocus?: boolean; onChange: (content: EditorDoc) => Promise; onPickImage: () => Promise; @@ -127,6 +131,13 @@ const CHANGE_IDLE_MS = 500; const TRANSIENT_IMAGE_UPLOAD_META = "edgeeverImageUploadPlaceholder"; const ignoreSearchResult = async () => undefined; const ignoreAiRequest = async () => undefined; +const AI_PROMPT_OPTION_PREFIX = "prompt:"; + +const fallbackPromptParameterKind = (action: AiAction): AiPromptParameterKind => + action === "translate" ? "target-language" : action === "change-tone" ? "tone" : "none"; + +const fallbackPromptResultMode = (action: AiAction): AiPromptResultMode => + canReplaceAiSource(action) ? "both" : "append"; type MobileAiSelection = { from: number; @@ -139,6 +150,9 @@ type MobileAiSelection = { type MobileAiPanelState = { selection: MobileAiSelection; action: AiAction; + promptId: string | null; + parameterKind: AiPromptParameterKind; + resultMode: AiPromptResultMode; targetLanguage: AiTargetLanguage; tone: AiTone; customInstruction: string; @@ -412,6 +426,21 @@ const inlineMermaidSvgStyles = (svg: string) => { function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) { const isViewer = props.mode === "viewer"; const autoFocus = props.mode === "viewer" ? false : Boolean(props.autoFocus); + const aiPromptsJson = props.mode === "viewer" ? "[]" : (props.aiPromptsJson ?? "[]"); + const aiPrompts = useMemo(() => { + try { + const parsed = JSON.parse(aiPromptsJson) as unknown; + if (!Array.isArray(parsed)) return []; + return parsed.filter((prompt): prompt is AiPromptTemplate => + Boolean(prompt) + && typeof prompt === "object" + && typeof (prompt as Partial).id === "string" + && typeof (prompt as Partial).name === "string" + && typeof (prompt as Partial).action === "string"); + } catch { + return []; + } + }, [aiPromptsJson]); const startedAtRef = useRef(performance.now()); const changeTimerRef = useRef(null); const imageUploadInFlightRef = useRef(false); @@ -431,6 +460,44 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) { const [aiUndoFingerprint, setAiUndoFingerprint] = useState(null); const aiUndoTimerRef = useRef(null); + useEffect(() => { + setAiPanel((current) => { + if (!current) return current; + if (current.promptId) { + const selected = aiPrompts.find((prompt) => prompt.id === current.promptId); + if (!selected) { + return { + ...current, + action: "custom", + promptId: null, + parameterKind: "none", + resultMode: "both", + output: "", + error: props.locale === "en-US" + ? "The selected prompt no longer exists. Choose another prompt." + : "所选提示词已不存在,请重新选择。", + }; + } + return { + ...current, + action: selected.action, + parameterKind: selected.parameterKind, + resultMode: selected.resultMode, + }; + } + if (current.action === "custom" || aiPrompts.length === 0) return current; + const preferred = aiPrompts.find((prompt) => prompt.seedKey === current.action) + ?? aiPrompts[0]; + return preferred ? { + ...current, + action: preferred.action, + promptId: preferred.id, + parameterKind: preferred.parameterKind, + resultMode: preferred.resultMode, + } : current; + }); + }, [aiPrompts, props.locale]); + onChangeRef.current = props.mode === "viewer" ? undefined : props.onChange; onLoadResourceRef.current = props.onLoadResource; onResourcePressRef.current = props.onResourcePress; @@ -693,6 +760,11 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) { } as EditorDoc, props.baseUrl); const markdown = docToMarkdown(selectionDoc).trim(); if (!markdown) return false; + const preferredAction = wholeNote ? "summarize" : "improve-writing"; + const preferredPrompt = aiPrompts.find((prompt) => prompt.seedKey === preferredAction) + ?? aiPrompts[0] + ?? null; + const initialAction = preferredPrompt?.action ?? preferredAction; setAiPanel({ selection: { from, @@ -701,7 +773,10 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) { markdown, documentFingerprint: getAiDocumentFingerprint(getPersistableEditorDoc(editor.getJSON() as EditorDoc, props.baseUrl)), }, - action: "improve-writing", + action: initialAction, + promptId: preferredPrompt?.id ?? null, + parameterKind: preferredPrompt?.parameterKind ?? fallbackPromptParameterKind(initialAction), + resultMode: preferredPrompt?.resultMode ?? fallbackPromptResultMode(initialAction), targetLanguage: getDefaultAiTargetLanguage(props.locale), tone: "professional", customInstruction: "", @@ -713,7 +788,7 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) { }); editor.commands.blur(); return true; - }, [editor, isViewer, props.baseUrl, props.locale]); + }, [aiPrompts, editor, isViewer, props.baseUrl, props.locale]); const closeAiPanel = useCallback(() => { if (!aiPanel || !editor) return; @@ -736,6 +811,7 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) { const requestId = globalThis.crypto?.randomUUID?.() ?? `ai-${Date.now()}-${Math.random().toString(16).slice(2)}`; const source = instruction ? aiPanel.output : aiPanel.selection.markdown; const action = instruction ? "custom" : aiPanel.action; + const promptId = instruction ? null : aiPanel.promptId; setAiPanel((current) => current ? { ...current, output: "", @@ -747,10 +823,12 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) { void onAiRequestRef.current(JSON.stringify({ requestId, action, + locale: props.locale, + ...(promptId ? { promptId } : {}), contentMarkdown: source, - ...(action === "translate" ? { targetLanguage: aiPanel.targetLanguage } : {}), - ...(action === "change-tone" ? { tone: aiPanel.tone } : {}), - ...(action === "custom" ? { instruction: instruction ?? aiPanel.customInstruction.trim() } : {}), + ...(aiPanel.parameterKind === "target-language" && !instruction ? { targetLanguage: aiPanel.targetLanguage } : {}), + ...(aiPanel.parameterKind === "tone" && !instruction ? { tone: aiPanel.tone } : {}), + ...(!promptId && action === "custom" ? { instruction: instruction ?? aiPanel.customInstruction.trim() } : {}), })).catch((requestError) => { setAiPanel((current) => current?.requestId === requestId ? { ...current, @@ -802,7 +880,8 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) { } : current); return; } - if (mode === "replace" && !canReplaceAiSource(aiPanel.action)) return; + if (mode === "append" && aiPanel.resultMode === "replace") return; + if (mode === "replace" && aiPanel.resultMode === "append") return; const parsed = resolveImageSources(markdownToDoc(aiPanel.output), props.baseUrl); const content = parsed.content ?? []; const range = mode === "append" @@ -1037,6 +1116,7 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) { onRefine={(instruction) => runAiSelectionRequest(instruction)} onStop={stopAiSelectionRequest} panel={aiPanel} + prompts={aiPrompts} /> ) : null} @@ -1066,6 +1146,7 @@ const MobileSelectionAiPanel = ({ onRefine, onStop, panel, + prompts, }: { locale: "zh-CN" | "en-US"; onApply: (mode: "append" | "replace") => void; @@ -1075,6 +1156,7 @@ const MobileSelectionAiPanel = ({ onRefine: (instruction: string) => void; onStop: () => void; panel: MobileAiPanelState; + prompts: AiPromptTemplate[]; }) => { const english = locale === "en-US"; const actionLabels: Record = { @@ -1110,8 +1192,36 @@ const MobileSelectionAiPanel = ({ direct: english ? "Direct" : "直接", }; const update = (next: Partial) => onChange((current) => current ? { ...current, ...next } : current); - const generateDisabled = panel.generating || (panel.action === "custom" && !panel.customInstruction.trim()); - const replaceDisabled = panel.generating || !panel.output || !canReplaceAiSource(panel.action); + const generateDisabled = panel.generating || (!panel.promptId && panel.action === "custom" && !panel.customInstruction.trim()); + const appendDisabled = panel.generating || !panel.output || panel.resultMode === "replace"; + const replaceDisabled = panel.generating || !panel.output || panel.resultMode === "append"; + const selectedValue = panel.promptId ? `${AI_PROMPT_OPTION_PREFIX}${panel.promptId}` : panel.action; + + const selectPromptOrAction = (value: string) => { + if (value.startsWith(AI_PROMPT_OPTION_PREFIX)) { + const promptId = value.slice(AI_PROMPT_OPTION_PREFIX.length); + const prompt = prompts.find((item) => item.id === promptId); + if (!prompt) return; + update({ + action: prompt.action, + promptId: prompt.id, + parameterKind: prompt.parameterKind, + resultMode: prompt.resultMode, + output: "", + error: null, + }); + return; + } + const action = value as AiAction; + update({ + action, + promptId: null, + parameterKind: fallbackPromptParameterKind(action), + resultMode: fallbackPromptResultMode(action), + output: "", + error: null, + }); + }; return (
{english ? "AI action" : "AI 操作"} - {panel.action === "translate" ? ( + {panel.parameterKind === "target-language" ? ( ) : null} - {panel.action === "change-tone" ? ( + {panel.parameterKind === "tone" ? ( ) : null} - {panel.action === "custom" ? ( + {!panel.promptId && panel.action === "custom" ? (