feat(ai): complete cross-platform custom prompt behavior

This commit is contained in:
tianma-if committed 2026-08-12 16:06:28 +08:00
1 parent e440da9b2f
commit 55fd32686d
38 files changed
+1967 -406

No files matched your search

+173 -2
View File
@@ -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,
});
});
});
+230 -94
View File
@@ -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<AiPromptTemplateRow | null> =>
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<AiPromptTemplateRow>();
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<AiPromptTemplateRow>();
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<AiPromptTemplateRow>();
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 });
},
);
};
+25 -11
View File
@@ -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<string>(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 };
};
+115
View File
@@ -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<AiPromptTemplateRow>();
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<AiPromptTemplateRow>();
return rows.results.map((row) => mapAiPromptTemplateRow(row, locale));
};
export const resolveWorkspaceActionInstruction = async (
db: DatabaseAdapter,
workspaceId: string,
action: AiAction,
locale?: string | null,
): Promise<string | undefined> => {
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<AiPromptTemplateRow>();
const instruction = row
? mapAiPromptTemplateRow(row, locale).instruction
: localizeAiPromptSeed(promptSeed, locale).instruction;
return instruction.trim() || undefined;
};
+63
View File
@@ -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(
+41 -5
View File
@@ -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<AppEnv>, 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,
});
+3 -21
View File
@@ -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<Exclude<AiAction, "translate" | "chang
const AI_PROMPT_METADATA_INSTRUCTION =
"Call the submitNoteResult tool exactly once. Treat the user-prompt field labels as metadata. Put only the requested Markdown result in the contentMarkdown field. Never include 'User instruction:', 'Target language:', 'Tone:', 'Note title:', or 'Note content:' in that field, and never repeat the note title unless it is part of the note content.";
/** Load the workspace copy of a default prompt, falling back to the shared seed text. */
export const resolveWorkspaceActionInstruction = async (
db: DatabaseAdapter,
workspaceId: string,
action: AiAction,
): Promise<string | undefined> => {
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,
@@ -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 {
@@ -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<AiStreamEvent, Error> {
var request = URLRequest(url: makeURL(path: "/api/v1/ai/generate"))
request.httpMethod = "POST"
@@ -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 = ""
+130 -15
View File
@@ -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<void>;
onPickImage: () => Promise<void>;
@@ -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<AiPromptTemplate>).id === "string"
&& typeof (prompt as Partial<AiPromptTemplate>).name === "string"
&& typeof (prompt as Partial<AiPromptTemplate>).action === "string");
} catch {
return [];
}
}, [aiPromptsJson]);
const startedAtRef = useRef(performance.now());
const changeTimerRef = useRef<number | null>(null);
const imageUploadInFlightRef = useRef(false);
@@ -431,6 +460,44 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) {
const [aiUndoFingerprint, setAiUndoFingerprint] = useState<string | null>(null);
const aiUndoTimerRef = useRef<number | null>(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}
</div>
@@ -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<AiAction, string> = {
@@ -1110,8 +1192,36 @@ const MobileSelectionAiPanel = ({
direct: english ? "Direct" : "直接",
};
const update = (next: Partial<MobileAiPanelState>) => 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 (
<section
@@ -1138,13 +1248,18 @@ const MobileSelectionAiPanel = ({
<span>{english ? "AI action" : "AI 操作"}</span>
<select
disabled={panel.generating}
onChange={(event) => update({ action: event.target.value as AiAction, output: "", error: null })}
value={panel.action}
onChange={(event) => selectPromptOrAction(event.target.value)}
value={selectedValue}
>
{AI_SELECTED_TEXT_ACTIONS.map((action) => <option key={action} value={action}>{actionLabels[action]}</option>)}
{prompts.length > 0
? <>
{prompts.map((prompt) => <option key={prompt.id} value={`${AI_PROMPT_OPTION_PREFIX}${prompt.id}`}>{prompt.name}</option>)}
<option value="custom">{actionLabels.custom}</option>
</>
: AI_SELECTED_TEXT_ACTIONS.map((action) => <option key={action} value={action}>{actionLabels[action]}</option>)}
</select>
</label>
{panel.action === "translate" ? (
{panel.parameterKind === "target-language" ? (
<label>
<span>{english ? "Target language" : "目标语言"}</span>
<select disabled={panel.generating} onChange={(event) => update({ targetLanguage: event.target.value as AiTargetLanguage, output: "", error: null })} value={panel.targetLanguage}>
@@ -1152,7 +1267,7 @@ const MobileSelectionAiPanel = ({
</select>
</label>
) : null}
{panel.action === "change-tone" ? (
{panel.parameterKind === "tone" ? (
<label>
<span>{english ? "Tone" : "语气"}</span>
<select disabled={panel.generating} onChange={(event) => update({ tone: event.target.value as AiTone, output: "", error: null })} value={panel.tone}>
@@ -1160,7 +1275,7 @@ const MobileSelectionAiPanel = ({
</select>
</label>
) : null}
{panel.action === "custom" ? (
{!panel.promptId && panel.action === "custom" ? (
<label>
<span>{english ? "Tell AI what to do" : "告诉 AI 你想怎么处理"}</span>
<textarea
@@ -1206,7 +1321,7 @@ const MobileSelectionAiPanel = ({
</div>
<footer className="edgeever-ai-panel-footer">
<div>
<button disabled={!panel.output || panel.generating} onClick={() => onApply("append")} type="button">
<button disabled={appendDisabled} onClick={() => onApply("append")} type="button">
{english ? "Insert after" : "插入到选区后"}
</button>
<button disabled={replaceDisabled} onClick={() => onApply("replace")} type="button">
@@ -1,10 +1,15 @@
import { useEffect, useRef, useState } from "react";
import { ApiRequestError } from "@edgeever/client";
import { useQuery } from "@tanstack/react-query";
import {
AI_TARGET_LANGUAGES,
AI_TONES,
AI_WHOLE_NOTE_ACTIONS,
getDefaultAiTargetLanguage,
promptAllowsAppend,
promptAllowsReplace,
promptNeedsTargetLanguage,
promptNeedsTone,
type AiAction,
type AiTargetLanguage,
type AiTone,
@@ -26,6 +31,7 @@ type TargetLanguage = AiTargetLanguage;
const actions = AI_WHOLE_NOTE_ACTIONS;
const tones = AI_TONES;
const targetLanguages = AI_TARGET_LANGUAGES;
const PROMPT_PREFIX = "prompt:";
export const MobileAiAssistantModal = ({
memo,
@@ -44,6 +50,7 @@ export const MobileAiAssistantModal = ({
const dark = resolvedTheme === "dark";
const tr = (zh: string, en: string) => resolvedLocale === "en-US" ? en : zh;
const [action, setAction] = useState<AssistantAction>("summarize");
const [selectedPromptId, setSelectedPromptId] = useState<string | null>(null);
const [targetLanguage, setTargetLanguage] = useState<TargetLanguage>(() => getDefaultAiTargetLanguage(resolvedLocale));
const [tone, setTone] = useState<Tone>("professional");
const [customInstruction, setCustomInstruction] = useState("");
@@ -54,6 +61,18 @@ export const MobileAiAssistantModal = ({
const [applying, setApplying] = useState(false);
const [picker, setPicker] = useState<"action" | "language" | "tone" | null>(null);
const controllerRef = useRef<AbortController | null>(null);
const promptsQuery = useQuery({
queryKey: ["ai-prompts", resolvedLocale],
queryFn: async () => (await client!.listAiPrompts(resolvedLocale)).prompts,
enabled: visible && Boolean(client),
retry: false,
});
const prompts = promptsQuery.data ?? [];
const selectedPrompt = prompts.find((prompt) => prompt.id === selectedPromptId) ?? null;
const parameterKind = selectedPrompt?.parameterKind
?? (action === "translate" ? "target-language" : action === "change-tone" ? "tone" : "none");
const resultMode = selectedPrompt?.resultMode
?? (["summarize", "extract-key-points", "extract-todos", "continue-writing"].includes(action) ? "append" : "both");
const labels: Record<AssistantAction, string> = {
summarize: tr("总结", "Summarize"),
@@ -95,6 +114,7 @@ export const MobileAiAssistantModal = ({
if (visible) {
controllerRef.current?.abort();
setAction("summarize");
setSelectedPromptId(null);
setTargetLanguage(getDefaultAiTargetLanguage(resolvedLocale));
setTone("professional");
setCustomInstruction("");
@@ -113,6 +133,13 @@ export const MobileAiAssistantModal = ({
setTargetLanguage(getDefaultAiTargetLanguage(resolvedLocale));
}, [resolvedLocale]);
useEffect(() => {
if (!visible || selectedPromptId || prompts.length === 0) return;
const preferred = prompts.find((prompt) => prompt.seedKey === "summarize") ?? prompts[0];
setSelectedPromptId(preferred.id);
setAction(preferred.action);
}, [prompts, selectedPromptId, visible]);
const buildRequest = (source: string, refinement?: string) => {
const base = {
title: memo.title?.trim() ?? "",
@@ -121,15 +148,17 @@ export const MobileAiAssistantModal = ({
if (refinement?.trim()) return { ...base, action: "custom" as const, instruction: refinement.trim() };
return {
...base,
action,
...(action === "translate" ? { targetLanguage } : {}),
...(action === "change-tone" ? { tone } : {}),
...(action === "custom" ? { instruction: customInstruction.trim() } : {}),
action: selectedPrompt?.action ?? action,
locale: resolvedLocale,
...(selectedPrompt ? { promptId: selectedPrompt.id } : {}),
...(promptNeedsTargetLanguage(parameterKind) ? { targetLanguage } : {}),
...(promptNeedsTone(parameterKind) ? { tone } : {}),
...(!selectedPrompt && action === "custom" ? { instruction: customInstruction.trim() } : {}),
};
};
const generate = async (source = memo.contentMarkdown, refinement?: string) => {
if (!client || (action === "custom" && !customInstruction.trim() && !refinement?.trim())) return;
if (!client || (!selectedPrompt && action === "custom" && !customInstruction.trim() && !refinement?.trim())) return;
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
@@ -187,7 +216,7 @@ export const MobileAiAssistantModal = ({
const border = dark ? "#33453d" : "#dbe4df";
const foreground = dark ? "#e2e8f0" : "#0f172a";
const muted = dark ? "#94a3b8" : "#64748b";
const actionDisabled = applying || (action === "custom" && !customInstruction.trim());
const actionDisabled = applying || (!selectedPrompt && action === "custom" && !customInstruction.trim());
const selectField = (label: string, value: string, onPress: () => void) => (
<View style={styles.field}>
@@ -200,7 +229,16 @@ export const MobileAiAssistantModal = ({
);
const pickerOptions = picker === "action"
? actions.map((value) => ({ value, label: labels[value], active: action === value }))
? (prompts.length
? [
...prompts.map((prompt) => ({
value: `${PROMPT_PREFIX}${prompt.id}`,
label: prompt.name,
active: selectedPromptId === prompt.id,
})),
{ value: "custom", label: labels.custom, active: !selectedPromptId && action === "custom" },
]
: actions.map((value) => ({ value, label: labels[value], active: !selectedPromptId && action === value })))
: picker === "language"
? targetLanguages.map((value) => ({ value, label: languageLabels[value], active: targetLanguage === value }))
: tones.map((value) => ({ value, label: toneLabels[value], active: tone === value }));
@@ -208,7 +246,10 @@ export const MobileAiAssistantModal = ({
const choosePickerOption = (value: string) => {
if (picker === "action") {
controllerRef.current?.abort();
setAction(value as AssistantAction);
const promptId = value.startsWith(PROMPT_PREFIX) ? value.slice(PROMPT_PREFIX.length) : null;
const prompt = promptId ? prompts.find((item) => item.id === promptId) : null;
setSelectedPromptId(prompt?.id ?? null);
setAction(prompt?.action ?? (value as AssistantAction));
setOutput("");
setError(null);
} else if (picker === "language") {
@@ -235,10 +276,10 @@ export const MobileAiAssistantModal = ({
<Text style={[styles.description, { color: muted }]}>
{tr("选择 AI 要做的事。输出只会作为草稿,确认后才会修改笔记。", "Choose what AI should do. Output remains a draft until you apply it.")}
</Text>
{selectField(tr("AI 操作", "AI action"), labels[action], () => setPicker("action"))}
{action === "translate" ? selectField(tr("目标语言", "Target language"), languageLabels[targetLanguage], () => setPicker("language")) : null}
{action === "change-tone" ? selectField(tr("语气", "Tone"), toneLabels[tone], () => setPicker("tone")) : null}
{action === "custom" ? (
{selectField(tr("AI 操作", "AI action"), selectedPrompt?.name ?? labels[action], () => setPicker("action"))}
{promptNeedsTargetLanguage(parameterKind) ? selectField(tr("目标语言", "Target language"), languageLabels[targetLanguage], () => setPicker("language")) : null}
{promptNeedsTone(parameterKind) ? selectField(tr("语气", "Tone"), toneLabels[tone], () => setPicker("tone")) : null}
{!selectedPrompt && action === "custom" ? (
<View style={styles.field}>
<Text style={[styles.fieldLabel, { color: foreground }]}>{tr("告诉 AI 你想怎么处理", "Tell AI what to do")}</Text>
<TextInput
@@ -289,12 +330,16 @@ export const MobileAiAssistantModal = ({
<Copy color={foreground} size={16} />
<Text style={[styles.secondaryText, { color: foreground }]}>{tr("复制", "Copy")}</Text>
</Pressable>
<Pressable disabled={!output || generating || applying} onPress={() => void apply("append")} style={[styles.secondaryButton, { borderColor: border }, (!output || generating || applying) && styles.disabled]}>
<Text style={[styles.secondaryText, { color: foreground }]}>{tr("追加", "Append")}</Text>
</Pressable>
<Pressable disabled={!output || generating || applying} onPress={() => void apply("replace")} style={[styles.secondaryButton, { borderColor: border }, (!output || generating || applying) && styles.disabled]}>
<Text style={[styles.secondaryText, { color: foreground }]}>{tr("替换", "Replace")}</Text>
</Pressable>
{promptAllowsAppend(resultMode) ? (
<Pressable disabled={!output || generating || applying} onPress={() => void apply("append")} style={[styles.secondaryButton, { borderColor: border }, (!output || generating || applying) && styles.disabled]}>
<Text style={[styles.secondaryText, { color: foreground }]}>{tr("追加", "Append")}</Text>
</Pressable>
) : null}
{promptAllowsReplace(resultMode) ? (
<Pressable disabled={!output || generating || applying} onPress={() => void apply("replace")} style={[styles.secondaryButton, { borderColor: border }, (!output || generating || applying) && styles.disabled]}>
<Text style={[styles.secondaryText, { color: foreground }]}>{tr("替换", "Replace")}</Text>
</Pressable>
) : null}
</View>
{generating ? (
<Pressable onPress={() => controllerRef.current?.abort()} style={styles.primaryButton}>
+15 -2
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, type RefObject } from "react";
import { useCallback, useEffect, useMemo, useRef, type RefObject } from "react";
import { ApiRequestError } from "@edgeever/client";
import { useQuery } from "@tanstack/react-query";
import type { LocalTiptapEditorRef } from "../components/LocalTiptapEditor";
import { buildMobileAiStreamBridgePayload, parseMobileSelectionAiRequest } from "../lib/mobile-ai-selection";
import { safeDomCall } from "../lib/safe-dom-call";
@@ -19,6 +20,16 @@ export const useMobileSelectionAi = ({
titleRef: RefObject<string>;
}) => {
const activeRequestRef = useRef<{ requestId: string; controller: AbortController } | null>(null);
const promptsQuery = useQuery({
queryKey: ["ai-prompts", resolvedLocale],
queryFn: async () => (await client!.listAiPrompts(resolvedLocale)).prompts,
enabled: Boolean(client),
retry: false,
});
const aiPromptsJson = useMemo(
() => JSON.stringify(promptsQuery.data ?? []),
[promptsQuery.data],
);
useEffect(() => () => {
activeRequestRef.current?.controller.abort();
@@ -36,6 +47,8 @@ export const useMobileSelectionAi = ({
void client.streamAiGeneration({
action: request.action,
promptId: request.promptId,
locale: request.locale ?? resolvedLocale,
title: titleRef.current.trim(),
contentMarkdown: request.contentMarkdown,
targetLanguage: request.targetLanguage,
@@ -75,5 +88,5 @@ export const useMobileSelectionAi = ({
activeRequestRef.current = null;
}, []);
return { cancelSelectionAi, requestSelectionAi };
return { aiPromptsJson, cancelSelectionAi, requestSelectionAi };
};
@@ -19,6 +19,8 @@ describe("mobile AI selection bridge", () => {
expect(parseMobileSelectionAiRequest(JSON.stringify({
requestId: "request-1",
action: "change-tone",
promptId: undefined,
locale: undefined,
contentMarkdown: "Selected text",
tone: "friendly",
}))).toEqual({
@@ -29,6 +31,20 @@ describe("mobile AI selection bridge", () => {
tone: "friendly",
instruction: undefined,
});
expect(parseMobileSelectionAiRequest(JSON.stringify({
requestId: "request-saved-prompt",
action: "custom",
promptId: "aiprompt_saved",
locale: "en-US",
contentMarkdown: "Selected text",
}))).toMatchObject({
requestId: "request-saved-prompt",
action: "custom",
promptId: "aiprompt_saved",
locale: "en-US",
contentMarkdown: "Selected text",
});
});
test("rejects malformed, incomplete, and unsupported requests", () => {
@@ -9,6 +9,8 @@ import {
export type MobileSelectionAiRequest = {
requestId: string;
action: AiAction;
promptId?: string;
locale?: string;
contentMarkdown: string;
targetLanguage?: AiTargetLanguage;
tone?: AiTone;
@@ -44,6 +46,8 @@ export const parseMobileSelectionAiRequest = (requestJson: string): MobileSelect
return {
requestId: raw.requestId,
action: parsed.data.action,
promptId: parsed.data.promptId,
locale: parsed.data.locale,
contentMarkdown: parsed.data.contentMarkdown,
targetLanguage: parsed.data.targetLanguage,
tone: parsed.data.tone,
+6 -4
View File
@@ -1815,7 +1815,7 @@ const CreateMemoModal = ({
targetNotebookIdRef.current = targetNotebookId;
imageOperationRef.current = imageOperation;
const { cancelSelectionAi, requestSelectionAi } = useMobileSelectionAi({
const { aiPromptsJson, cancelSelectionAi, requestSelectionAi } = useMobileSelectionAi({
client,
editorRef,
resolvedLocale,
@@ -2199,6 +2199,7 @@ const CreateMemoModal = ({
const editorElement = useMemo(() => draftLoaded && baseUrl ? (
<LocalTiptapEditor
autoFocus
aiPromptsJson={aiPromptsJson}
baseUrl={baseUrl}
content={contentJsonRef.current}
dom={{
@@ -2232,7 +2233,7 @@ const CreateMemoModal = ({
locale={resolvedLocale}
theme={resolvedTheme}
/>
) : null, [baseUrl, cancelSelectionAi, draftLoaded, loadEditorResource, requestSelectionAi, resolvedLocale, resolvedTheme, scheduleBodyKeyboard, selectResource]);
) : null, [aiPromptsJson, baseUrl, cancelSelectionAi, draftLoaded, loadEditorResource, requestSelectionAi, resolvedLocale, resolvedTheme, scheduleBodyKeyboard, selectResource]);
return (
<SafeAreaView edges={["top", "left", "right", "bottom"]} style={styles.createMemoSafeArea}>
@@ -2643,7 +2644,7 @@ const RichEditorModal = ({
};
const flushEditor = () => flushMobileEditor(editorRef, flushResolverRef);
const { cancelSelectionAi, requestSelectionAi } = useMobileSelectionAi({
const { aiPromptsJson, cancelSelectionAi, requestSelectionAi } = useMobileSelectionAi({
client,
editorRef,
resolvedLocale,
@@ -2735,6 +2736,7 @@ const RichEditorModal = ({
() => memo && baseUrl ? (
<LocalTiptapEditor
autoFocus
aiPromptsJson={aiPromptsJson}
baseUrl={baseUrl}
content={contentJsonRef.current}
dom={{
@@ -2774,7 +2776,7 @@ const RichEditorModal = ({
theme={resolvedTheme}
/>
) : null,
[baseUrl, cancelSelectionAi, loadEditorResource, memo?.id, requestSelectionAi, resolvedLocale, resolvedTheme, selectResource]
[aiPromptsJson, baseUrl, cancelSelectionAi, loadEditorResource, memo?.id, requestSelectionAi, resolvedLocale, resolvedTheme, selectResource]
);
useEffect(() => {
+104 -19
View File
@@ -1,6 +1,11 @@
import { useState, type FormEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AiPromptTemplate } from "@edgeever/shared";
import type {
AiPromptParameterKind,
AiPromptResultMode,
AiPromptTemplate,
AiPromptTemplateUpdateInput,
} from "@edgeever/shared";
import {
ChevronLeft,
Eye,
@@ -24,6 +29,14 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { api, ApiRequestError } from "@/lib/api";
import { WORKSPACE_PAGE_TITLE_CLASSNAME } from "@/lib/workspace-ui";
import { formatDateTime } from "@/lib/utils";
@@ -32,10 +45,12 @@ const emptyForm = () => ({
name: "",
description: "",
instruction: "",
parameterKind: "none" as AiPromptParameterKind,
resultMode: "both" as AiPromptResultMode,
});
export const AiPromptsPane = ({ onClose }: { onClose: () => void }) => {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
const [editing, setEditing] = useState<AiPromptTemplate | null>(null);
const [creating, setCreating] = useState(false);
@@ -46,8 +61,8 @@ export const AiPromptsPane = ({ onClose }: { onClose: () => void }) => {
const [restoreFeedback, setRestoreFeedback] = useState<string | null>(null);
const promptsQuery = useQuery({
queryKey: ["ai-prompts"],
queryFn: async () => (await api.listAiPrompts()).prompts,
queryKey: ["ai-prompts", i18n.resolvedLanguage],
queryFn: async () => (await api.listAiPrompts(i18n.resolvedLanguage)).prompts,
});
const prompts = promptsQuery.data ?? [];
@@ -66,6 +81,8 @@ export const AiPromptsPane = ({ onClose }: { onClose: () => void }) => {
name: prompt.name,
description: prompt.description ?? "",
instruction: prompt.instruction,
parameterKind: prompt.parameterKind,
resultMode: prompt.resultMode,
});
};
@@ -82,16 +99,20 @@ export const AiPromptsPane = ({ onClose }: { onClose: () => void }) => {
const instruction = form.instruction.trim();
const description = form.description.trim();
if (editing) {
return api.updateAiPrompt(editing.id, {
name,
description: description || null,
instruction,
});
const payload: AiPromptTemplateUpdateInput = {};
if (name !== editing.name) payload.name = name;
if ((description || null) !== editing.description) payload.description = description || null;
if (instruction !== editing.instruction) payload.instruction = instruction;
if (form.parameterKind !== editing.parameterKind) payload.parameterKind = form.parameterKind;
if (form.resultMode !== editing.resultMode) payload.resultMode = form.resultMode;
return api.updateAiPrompt(editing.id, payload);
}
return api.createAiPrompt({
name,
description: description || undefined,
instruction,
parameterKind: form.parameterKind,
resultMode: form.resultMode,
});
},
onSuccess: async () => {
@@ -106,10 +127,11 @@ export const AiPromptsPane = ({ onClose }: { onClose: () => void }) => {
setDeleteTarget(null);
await refresh();
},
onError: () => setDeleteTarget(null),
});
const restoreMutation = useMutation({
mutationFn: () => api.restoreDefaultAiPrompts(),
mutationFn: () => api.restoreDefaultAiPrompts(i18n.resolvedLanguage),
onSuccess: async (result) => {
setRestoreConfirmOpen(false);
setRestoreFeedback(
@@ -117,9 +139,10 @@ export const AiPromptsPane = ({ onClose }: { onClose: () => void }) => {
? t("aiPrompts.restoreDefaultsDone", { count: result.restoredCount })
: t("aiPrompts.restoreDefaultsNone"),
);
queryClient.setQueryData(["ai-prompts"], result.prompts);
queryClient.setQueryData(["ai-prompts", i18n.resolvedLanguage], result.prompts);
await refresh();
},
onError: () => setRestoreConfirmOpen(false),
});
const errorMessage = (error: unknown) => {
@@ -132,17 +155,31 @@ export const AiPromptsPane = ({ onClose }: { onClose: () => void }) => {
const saveError = saveMutation.error ? errorMessage(saveMutation.error) : null;
const listError = promptsQuery.error ? errorMessage(promptsQuery.error) : null;
const deleteError = deleteMutation.error ? errorMessage(deleteMutation.error) : null;
const restoreError = restoreMutation.error ? errorMessage(restoreMutation.error) : null;
const canSubmit = form.name.trim().length > 0 && form.instruction.trim().length > 0;
const hasChanges = !editing
|| form.name.trim() !== editing.name
|| (form.description.trim() || null) !== editing.description
|| form.instruction.trim() !== editing.instruction
|| form.parameterKind !== editing.parameterKind
|| form.resultMode !== editing.resultMode;
const editorOpen = creating || Boolean(editing);
return (
<div className="flex h-full min-h-0 min-w-0 flex-col bg-slate-50/60">
<header className="flex h-[calc(3.75rem+env(safe-area-inset-top))] shrink-0 items-end border-b border-slate-200/80 bg-white px-6 pb-3 pt-[env(safe-area-inset-top)] shadow-2xs lg:h-16 lg:items-center lg:pb-0 lg:pt-0">
<div className="flex min-w-0 items-center gap-3">
<Button size="icon" variant="ghost" title={t("common.back")} aria-label={t("common.back")} onClick={onClose}>
<ChevronLeft className="h-5 w-5 text-slate-500" />
</Button>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" variant="ghost" aria-label={t("common.back")} onClick={onClose}>
<ChevronLeft className="h-5 w-5 text-slate-500" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.back")}</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="min-w-0">
<h1 className={`flex items-center gap-2 text-slate-900 ${WORKSPACE_PAGE_TITLE_CLASSNAME}`}>
<Sparkles className="h-4.5 w-4.5 text-emerald-600" />
@@ -205,7 +242,7 @@ export const AiPromptsPane = ({ onClose }: { onClose: () => void }) => {
className="rounded-xl border border-emerald-200 bg-white p-5 shadow-xs"
onSubmit={(event: FormEvent) => {
event.preventDefault();
if (canSubmit) saveMutation.mutate();
if (canSubmit && hasChanges) saveMutation.mutate();
}}
>
<div className="mb-4 flex items-center justify-between border-b border-slate-100 pb-3">
@@ -213,7 +250,7 @@ export const AiPromptsPane = ({ onClose }: { onClose: () => void }) => {
<Pencil className="h-4 w-4 text-emerald-600" />
{editing ? t("aiPrompts.edit") : t("aiPrompts.add")}
</h3>
<Button size="icon" variant="ghost" className="h-7 w-7 text-slate-400 hover:text-slate-600" type="button" onClick={cancelEditor}>
<Button aria-label={t("common.close")} size="icon" variant="ghost" className="h-7 w-7 text-slate-400 hover:text-slate-600" type="button" onClick={cancelEditor}>
<X className="h-4 w-4" />
</Button>
</div>
@@ -228,6 +265,42 @@ export const AiPromptsPane = ({ onClose }: { onClose: () => void }) => {
required
/>
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label className="grid gap-1.5 text-xs font-medium text-slate-700">
{t("aiPrompts.parameterKind")}
<Select
value={form.parameterKind}
onValueChange={(value) => setForm((current) => ({
...current,
parameterKind: value as AiPromptParameterKind,
}))}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="none">{t("aiPrompts.parameterKinds.none")}</SelectItem>
<SelectItem value="target-language">{t("aiPrompts.parameterKinds.target-language")}</SelectItem>
<SelectItem value="tone">{t("aiPrompts.parameterKinds.tone")}</SelectItem>
</SelectContent>
</Select>
</label>
<label className="grid gap-1.5 text-xs font-medium text-slate-700">
{t("aiPrompts.resultMode")}
<Select
value={form.resultMode}
onValueChange={(value) => setForm((current) => ({
...current,
resultMode: value as AiPromptResultMode,
}))}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="both">{t("aiPrompts.resultModes.both")}</SelectItem>
<SelectItem value="append">{t("aiPrompts.resultModes.append")}</SelectItem>
<SelectItem value="replace">{t("aiPrompts.resultModes.replace")}</SelectItem>
</SelectContent>
</Select>
</label>
</div>
<label className="grid gap-1.5 text-xs font-medium text-slate-700">
{t("aiPrompts.descriptionLabel")}
<Input
@@ -253,7 +326,7 @@ export const AiPromptsPane = ({ onClose }: { onClose: () => void }) => {
<Button type="button" size="sm" variant="outline" onClick={cancelEditor}>
{t("common.cancel")}
</Button>
<Button type="submit" size="sm" variant="solid" disabled={!canSubmit || saveMutation.isPending}>
<Button type="submit" size="sm" variant="solid" disabled={!canSubmit || !hasChanges || saveMutation.isPending}>
{saveMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
{editing ? t("aiPrompts.save") : t("aiPrompts.create")}
</Button>
@@ -275,7 +348,14 @@ export const AiPromptsPane = ({ onClose }: { onClose: () => void }) => {
className="group flex flex-col justify-between rounded-xl border border-slate-200 bg-white p-4 transition-all duration-200 hover:border-emerald-300 hover:shadow-md"
>
<div className="min-w-0">
<h3 className="truncate text-sm font-semibold text-slate-900">{prompt.name}</h3>
<div className="flex min-w-0 items-center gap-2">
<h3 className="truncate text-sm font-semibold text-slate-900">{prompt.name}</h3>
{prompt.origin === "default" ? (
<span className="shrink-0 rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium text-slate-500">
{t("aiPrompts.defaultBadge")}
</span>
) : null}
</div>
<p className="mt-2 line-clamp-3 text-xs leading-5 text-slate-500">
{prompt.description || prompt.instruction}
</p>
@@ -307,7 +387,10 @@ export const AiPromptsPane = ({ onClose }: { onClose: () => void }) => {
size="sm"
variant="ghost"
className="h-8 gap-1 text-xs text-rose-600 hover:text-rose-700"
onClick={() => setDeleteTarget(prompt)}
onClick={() => {
deleteMutation.reset();
setDeleteTarget(prompt);
}}
>
<Trash2 className="h-3.5 w-3.5" />
{t("aiPrompts.delete")}
@@ -343,6 +426,7 @@ export const AiPromptsPane = ({ onClose }: { onClose: () => void }) => {
</div>
)}
{listError ? <p className="text-xs font-medium text-rose-600" role="alert">{listError}</p> : null}
{deleteError ? <p className="text-xs font-medium text-rose-600" role="alert">{deleteError}</p> : null}
</div>
</main>
@@ -369,6 +453,7 @@ export const AiPromptsPane = ({ onClose }: { onClose: () => void }) => {
{t("aiPrompts.edit")}
</Button>
<Button type="button" variant="ghost" size="sm" className="text-rose-600" onClick={() => {
deleteMutation.reset();
setDeleteTarget(preview);
setPreview(null);
}}>
@@ -23,14 +23,14 @@ import {
} from "@/components/ui/select";
import { api, ApiRequestError } from "@/lib/api";
import {
actionNeedsTargetLanguage,
actionNeedsTone,
aiTones,
buildAiAssistantRequest,
canReplaceAiSource,
getDefaultAiAction,
getDefaultTargetLanguage,
parseDefaultAiPromptKey,
promptAllowsAppend,
promptAllowsReplace,
promptNeedsTargetLanguage,
promptNeedsTone,
targetLanguages,
type AiAssistantAction,
type AiTone,
@@ -89,8 +89,8 @@ export const AiAssistantDialog = ({
const lastRequestRef = useRef<Parameters<typeof api.streamAiGeneration>[0] | null>(null);
const promptsQuery = useQuery({
queryKey: ["ai-prompts"],
queryFn: async () => (await api.listAiPrompts()).prompts,
queryKey: ["ai-prompts", i18n.resolvedLanguage],
queryFn: async () => (await api.listAiPrompts(i18n.resolvedLanguage)).prompts,
enabled: open,
retry: false,
});
@@ -99,15 +99,13 @@ export const AiAssistantDialog = ({
() => prompts.find((prompt) => prompt.id === selectedPromptId) ?? null,
[prompts, selectedPromptId],
);
const effectiveActionKey = selectedPromptId
? (parseDefaultAiPromptKey(selectedPromptId) ?? "custom")
: action;
const effectiveActionKey = selectedPrompt?.action ?? action;
const effectiveParameterKind = selectedPrompt?.parameterKind ?? "none";
const effectiveResultMode = selectedPrompt?.resultMode ?? "both";
const selectValue = selectedPromptId
? promptSelectValue(selectedPromptId)
: action === "custom"
? FREEFORM_VALUE
: FREEFORM_VALUE;
: FREEFORM_VALUE;
useEffect(() => () => controllerRef.current?.abort(), []);
useEffect(() => {
@@ -137,13 +135,12 @@ export const AiAssistantDialog = ({
// Once prompts load, pick the library entry that matches the default action (same text as the library).
useEffect(() => {
if (!open || initializedForOpen || promptsQuery.isLoading) return;
const preferred = prompts.find((prompt) => parseDefaultAiPromptKey(prompt.id) === defaultAction)
const preferred = prompts.find((prompt) => prompt.seedKey === defaultAction)
?? prompts[0]
?? null;
if (preferred) {
const key = parseDefaultAiPromptKey(preferred.id);
setSelectedPromptId(preferred.id);
setAction(key ?? "custom");
setAction(preferred.action);
setCustomInstruction(preferred.instruction);
} else {
setAction("custom");
@@ -153,6 +150,15 @@ export const AiAssistantDialog = ({
setInitializedForOpen(true);
}, [defaultAction, initializedForOpen, open, prompts, promptsQuery.isLoading]);
useEffect(() => {
if (!open || !initializedForOpen || promptsQuery.isLoading || !selectedPromptId || selectedPrompt) return;
setSelectedPromptId(null);
setAction("custom");
setCustomInstruction("");
setOutput("");
setError(t("aiAssistant.promptMissing"));
}, [initializedForOpen, open, promptsQuery.isLoading, selectedPrompt, selectedPromptId, t]);
const clearResult = () => {
setOutput("");
setError(null);
@@ -171,9 +177,8 @@ export const AiAssistantDialog = ({
const promptId = parsePromptSelectValue(value);
if (!promptId) return;
const prompt = prompts.find((item) => item.id === promptId);
const key = parseDefaultAiPromptKey(promptId);
setSelectedPromptId(promptId);
setAction(key ?? "custom");
setAction(prompt?.action ?? "custom");
setCustomInstruction(prompt?.instruction ?? "");
clearResult();
};
@@ -215,6 +220,9 @@ export const AiAssistantDialog = ({
action: effectiveActionKey,
contentMarkdown: sourceMarkdown,
customInstruction,
locale: i18n.resolvedLanguage,
parameterKind: selectedPrompt ? effectiveParameterKind : undefined,
promptId: selectedPrompt?.id,
targetLanguage,
title,
tone,
@@ -249,11 +257,13 @@ export const AiAssistantDialog = ({
name: saveName.trim(),
description: saveDescription.trim() || undefined,
instruction: customInstruction.trim(),
parameterKind: "none",
resultMode: "both",
}),
onSuccess: async (response) => {
await queryClient.invalidateQueries({ queryKey: ["ai-prompts"] });
setSelectedPromptId(response.prompt.id);
setAction(parseDefaultAiPromptKey(response.prompt.id) ?? "custom");
setAction(response.prompt.action);
setCustomInstruction(response.prompt.instruction);
setSaveDialogOpen(false);
setSaveName("");
@@ -275,8 +285,8 @@ export const AiAssistantDialog = ({
const generateDisabled = isGenerating
|| (isFreeformCustom && !customInstruction.trim())
|| (!isFreeformCustom && !selectedPromptId)
|| (actionNeedsTargetLanguage(effectiveActionKey) && !targetLanguage)
|| (actionNeedsTone(effectiveActionKey) && !tone);
|| (promptNeedsTargetLanguage(effectiveParameterKind) && !targetLanguage)
|| (promptNeedsTone(effectiveParameterKind) && !tone);
return (
<>
@@ -316,9 +326,9 @@ export const AiAssistantDialog = ({
</button>
) : null}
</div>
<div className="flex min-w-0 items-center gap-2">
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
<Select value={selectValue} onValueChange={handleActionChange}>
<SelectTrigger aria-label={t("aiAssistant.actionLabel")} className="h-10 w-[14rem] shrink-0 sm:w-60">
<SelectTrigger aria-label={t("aiAssistant.actionLabel")} className="h-10 w-full min-w-0 sm:w-60 sm:shrink-0">
<SelectValue placeholder={t("aiAssistant.actionLabel")} />
</SelectTrigger>
<SelectContent>
@@ -337,27 +347,29 @@ export const AiAssistantDialog = ({
</SelectGroup>
</SelectContent>
</Select>
<Button
type="button"
variant={!selectedPromptId && action === "custom" ? "solid" : "outline"}
className="h-8 shrink-0 gap-1 px-2 text-xs font-normal text-slate-600"
onClick={() => handleActionChange(FREEFORM_VALUE)}
>
<PenLine className="h-3.5 w-3.5" />
{t("aiAssistant.useCustom")}
</Button>
{isGenerating ? (
<Button type="button" variant="solid" className="h-10 w-[7.5rem] shrink-0 gap-1.5 px-0 text-sm font-semibold" onClick={() => controllerRef.current?.abort()}>
<Square className="h-3.5 w-3.5" />{t("aiAssistant.stop")}
<div className="flex min-w-0 gap-2">
<Button
type="button"
variant={!selectedPromptId && action === "custom" ? "solid" : "outline"}
className="h-10 min-w-0 flex-1 gap-1 px-2 text-xs font-normal text-slate-600 sm:h-8 sm:flex-none sm:shrink-0"
onClick={() => handleActionChange(FREEFORM_VALUE)}
>
<PenLine className="h-3.5 w-3.5" />
{t("aiAssistant.useCustom")}
</Button>
) : (
<Button type="button" variant="solid" className="h-10 w-[7.5rem] shrink-0 gap-1.5 px-0 text-sm font-semibold" disabled={generateDisabled} onClick={() => void generate()}>
<Sparkles className="h-4 w-4" />{t("aiAssistant.generate")}
</Button>
)}
{isGenerating ? (
<Button type="button" variant="solid" className="h-10 min-w-[7.5rem] flex-1 shrink-0 gap-1.5 px-3 text-sm font-semibold sm:w-[7.5rem] sm:flex-none sm:px-0" onClick={() => controllerRef.current?.abort()}>
<Square className="h-3.5 w-3.5" />{t("aiAssistant.stop")}
</Button>
) : (
<Button type="button" variant="solid" className="h-10 min-w-[7.5rem] flex-1 shrink-0 gap-1.5 px-3 text-sm font-semibold sm:w-[7.5rem] sm:flex-none sm:px-0" disabled={generateDisabled} onClick={() => void generate()}>
<Sparkles className="h-4 w-4" />{t("aiAssistant.generate")}
</Button>
)}
</div>
</div>
</div>
{actionNeedsTargetLanguage(effectiveActionKey) ? (
{promptNeedsTargetLanguage(effectiveParameterKind) ? (
<div className="grid gap-1.5">
<span className="text-sm font-medium text-slate-700">{t("aiAssistant.targetLanguage")}</span>
<Select value={targetLanguage} onValueChange={(value) => {
@@ -375,7 +387,7 @@ export const AiAssistantDialog = ({
</Select>
</div>
) : null}
{actionNeedsTone(effectiveActionKey) ? (
{promptNeedsTone(effectiveParameterKind) ? (
<div className="grid gap-1.5">
<span className="text-sm font-medium text-slate-700">{t("aiAssistant.tone")}</span>
<Select value={tone} onValueChange={(value) => {
@@ -475,12 +487,14 @@ export const AiAssistantDialog = ({
<Button type="button" variant="outline" disabled={isGenerating} onClick={() => void retry()}><RefreshCw className="h-4 w-4" />{t("aiAssistant.retry")}</Button>
</div>
<div className="flex flex-wrap gap-2">
{canReplaceAiSource(effectiveActionKey) ? (
{promptAllowsReplace(effectiveResultMode) ? (
<Button type="button" variant={hasSelection ? "solid" : "outline"} disabled={isGenerating} onClick={() => onApply(output, "replace")}>
{t(hasSelection ? "aiAssistant.replaceSelection" : "aiAssistant.replace")}
</Button>
) : null}
<Button type="button" variant={hasSelection && canReplaceAiSource(effectiveActionKey) ? "outline" : "solid"} disabled={isGenerating} onClick={() => onApply(output, "append")}>{t("aiAssistant.append")}</Button>
{promptAllowsAppend(effectiveResultMode) ? (
<Button type="button" variant={hasSelection && promptAllowsReplace(effectiveResultMode) ? "outline" : "solid"} disabled={isGenerating} onClick={() => onApply(output, "append")}>{t("aiAssistant.append")}</Button>
) : null}
</div>
</DialogFooter>
) : null}
@@ -9,11 +9,11 @@ import { api } from "@/lib/api";
import { cn } from "@/lib/utils";
export const AiPromptsCard = ({ onOpenLibrary }: { onOpenLibrary: () => void }) => {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const [expanded, setExpanded] = useState(true);
const promptsQuery = useQuery({
queryKey: ["ai-prompts"],
queryFn: async () => (await api.listAiPrompts()).prompts,
queryKey: ["ai-prompts", i18n.resolvedLanguage],
queryFn: async () => (await api.listAiPrompts(i18n.resolvedLanguage)).prompts,
retry: false,
});
const count = promptsQuery.data?.length ?? 0;
@@ -79,7 +79,7 @@ export const DataExportCard = ({ refreshWorkspaceAfterImport }: DataExportCardPr
setErrorMessage(null);
try {
const blob = await createEdgeEverZip(
{ listNotebooks: api.listNotebooks, getPage: api.getJsonBackupPage, getResourceBlob: api.getResourceBlob },
{ listNotebooks: api.listNotebooks, listPrompts: api.listAiPrompts, getPage: api.getJsonBackupPage, getResourceBlob: api.getResourceBlob },
{ edgeeverVersion: __EDGEEVER_APP_VERSION__, buildId: __EDGEEVER_BUILD_ID__ },
setProgress
);
@@ -125,6 +125,7 @@ export const DataExportCard = ({ refreshWorkspaceAfterImport }: DataExportCardPr
{
restoreNotebooks: api.restoreJsonNotebooks,
restoreMemos: api.restoreJsonMemos,
restorePrompts: api.restoreJsonAiPrompts,
restoreResource: api.restoreJsonResource,
},
refreshWorkspaceAfterImport,
@@ -32,7 +32,7 @@ export const WebDavAutoBackup = () => {
saveWebDavBackupSchedule({ ...schedule, lastAttemptAt: attemptAt });
try {
const archive = await createEdgeEverZip(
{ listNotebooks: api.listNotebooks, getPage: api.getJsonBackupPage, getResourceBlob: api.getResourceBlob },
{ listNotebooks: api.listNotebooks, listPrompts: api.listAiPrompts, getPage: api.getJsonBackupPage, getResourceBlob: api.getResourceBlob },
{ edgeeverVersion: __EDGEEVER_APP_VERSION__, buildId: __EDGEEVER_BUILD_ID__ }
);
await uploadWebDavBackup(config, password, archive);
@@ -80,7 +80,7 @@ export const WebDavBackupCard = () => {
try {
const normalized = persistSettings();
const archive = await createEdgeEverZip(
{ listNotebooks: api.listNotebooks, getPage: api.getJsonBackupPage, getResourceBlob: api.getResourceBlob },
{ listNotebooks: api.listNotebooks, listPrompts: api.listAiPrompts, getPage: api.getJsonBackupPage, getResourceBlob: api.getResourceBlob },
{ edgeeverVersion: __EDGEEVER_APP_VERSION__, buildId: __EDGEEVER_BUILD_ID__ },
setProgress
);
+21
View File
@@ -43,6 +43,27 @@ describe("AI assistant interaction model", () => {
});
});
test("sends saved prompt identity and explicit parameters without duplicating its instruction", () => {
expect(buildAiAssistantRequest({
action: "custom",
contentMarkdown: "Hello",
customInstruction: "A stale client-side copy that must not be trusted.",
locale: "en-US",
parameterKind: "target-language",
promptId: "aiprompt_translate_for_clients",
targetLanguage: "zh-CN",
title: "Draft",
tone: "professional",
})).toEqual({
action: "custom",
promptId: "aiprompt_translate_for_clients",
locale: "en-US",
title: "Draft",
contentMarkdown: "Hello",
targetLanguage: "zh-CN",
});
});
test("keeps extractive output additive while allowing rewritten content to replace its source", () => {
expect(canReplaceAiSource("summarize")).toBe(false);
expect(canReplaceAiSource("continue-writing")).toBe(false);
+30 -4
View File
@@ -9,7 +9,13 @@ import {
getDefaultAiAction,
getDefaultAiTargetLanguage,
parseDefaultAiPromptKey,
promptAllowsAppend,
promptAllowsReplace,
promptNeedsTargetLanguage,
promptNeedsTone,
type AiAction,
type AiPromptParameterKind,
type AiPromptResultMode,
type AiTargetLanguage,
type AiTone,
} from "@edgeever/shared";
@@ -30,12 +36,19 @@ export {
canReplaceAiSource,
getDefaultAiAction,
parseDefaultAiPromptKey,
promptAllowsAppend,
promptAllowsReplace,
promptNeedsTargetLanguage,
promptNeedsTone,
};
export const buildAiAssistantRequest = ({
action,
contentMarkdown,
customInstruction,
locale,
parameterKind,
promptId,
targetLanguage,
title,
tone,
@@ -43,6 +56,9 @@ export const buildAiAssistantRequest = ({
action: AiAssistantAction;
contentMarkdown: string;
customInstruction: string;
locale?: string;
parameterKind?: AiPromptParameterKind;
promptId?: string | null;
targetLanguage: TargetLanguage;
title: string;
tone: AiTone;
@@ -50,18 +66,28 @@ export const buildAiAssistantRequest = ({
action: AiAction;
title: string;
contentMarkdown: string;
promptId?: string;
locale?: string;
targetLanguage?: AiTargetLanguage;
tone?: AiTone;
instruction?: string;
} => {
const instruction = customInstruction.trim();
const needsTargetLanguage = parameterKind
? promptNeedsTargetLanguage(parameterKind)
: actionNeedsTargetLanguage(action);
const needsTone = parameterKind ? promptNeedsTone(parameterKind) : actionNeedsTone(action);
return {
action,
...(promptId ? { promptId } : {}),
...(locale ? { locale } : {}),
title,
contentMarkdown,
// Always send the visible instruction so library text and freeform share one path.
...(instruction ? { instruction } : {}),
...(actionNeedsTargetLanguage(action) ? { targetLanguage } : {}),
...(actionNeedsTone(action) ? { tone } : {}),
// Saved prompts are resolved by id on the server; only freeform actions send client text.
...(!promptId && instruction ? { instruction } : {}),
...(needsTargetLanguage ? { targetLanguage } : {}),
...(needsTone ? { tone } : {}),
};
};
export type { AiPromptParameterKind, AiPromptResultMode };
+32 -7
View File
@@ -6,6 +6,7 @@ import type {
CreatedApiToken,
JsonBackupMemo,
JsonBackupNotebook,
JsonBackupAiPrompt,
JsonBackupRevision,
MemoDetail,
MemoTemplate,
@@ -22,7 +23,11 @@ import type {
AiDiscoveredModel,
AiProvider,
AiPromptTemplate,
AiPromptTemplateCreateInput,
AiPromptTemplateUpdateInput,
AiAction,
AiTargetLanguage,
AiTone,
AiStreamEvent,
PublicMemoShare,
TagSummary,
@@ -573,9 +578,12 @@ export const api = {
body: JSON.stringify({ modelConfigId }),
}),
listAiPrompts: () => request<{ prompts: AiPromptTemplate[] }>("/api/v1/ai/prompts"),
listAiPrompts: (locale?: string) => {
const search = locale ? `?locale=${encodeURIComponent(locale)}` : "";
return request<{ prompts: AiPromptTemplate[] }>(`/api/v1/ai/prompts${search}`);
},
createAiPrompt: (payload: { name: string; description?: string; instruction: string }) =>
createAiPrompt: (payload: AiPromptTemplateCreateInput) =>
request<{ prompt: AiPromptTemplate }>("/api/v1/ai/prompts", {
method: "POST",
body: JSON.stringify(payload),
@@ -583,7 +591,7 @@ export const api = {
updateAiPrompt: (
promptId: string,
payload: { name?: string; description?: string | null; instruction?: string },
payload: AiPromptTemplateUpdateInput,
) =>
request<{ prompt: AiPromptTemplate }>(`/api/v1/ai/prompts/${encodeURIComponent(promptId)}`, {
method: "PATCH",
@@ -595,14 +603,25 @@ export const api = {
method: "DELETE",
}),
restoreDefaultAiPrompts: () =>
request<{ prompts: AiPromptTemplate[]; restoredCount: number }>("/api/v1/ai/prompts/restore-defaults", {
restoreDefaultAiPrompts: (locale?: string) => {
const search = locale ? `?locale=${encodeURIComponent(locale)}` : "";
return request<{ prompts: AiPromptTemplate[]; restoredCount: number }>(`/api/v1/ai/prompts/restore-defaults${search}`, {
method: "POST",
body: JSON.stringify({}),
}),
});
},
streamAiGeneration: async (
payload: { action: AiAction; title: string; contentMarkdown: string; targetLanguage?: string; instruction?: string },
payload: {
action: AiAction;
promptId?: string;
locale?: string;
title: string;
contentMarkdown: string;
targetLanguage?: AiTargetLanguage;
tone?: AiTone;
instruction?: string;
},
options: { signal?: AbortSignal; onEvent: (event: AiStreamEvent) => void },
) => {
const headers = new Headers({ "Content-Type": "application/json" });
@@ -845,6 +864,12 @@ export const api = {
body: JSON.stringify({ memos }),
}),
restoreJsonAiPrompts: (prompts: JsonBackupAiPrompt[]) =>
request<{ ok: true }>("/api/v1/restores/json/ai-prompts", {
method: "POST",
body: JSON.stringify({ prompts }),
}),
restoreJsonResource: (resourceId: string, metadata: JsonBackupMemo["resources"][number], file: Blob) => {
const form = new FormData();
form.append("metadata", JSON.stringify(metadata));
+33 -4
View File
@@ -2,9 +2,11 @@ import {
EDGEEVER_ZIP_FORMAT,
EDGEEVER_ZIP_FORMAT_VERSION,
JsonBackupManifestSchema,
JsonBackupAiPromptSchema,
JsonBackupMemoSchema,
JsonBackupNotebookSchema,
type JsonBackupManifest,
type JsonBackupAiPrompt,
type JsonBackupMemo,
type JsonBackupNotebook,
type JsonBackupResource,
@@ -61,18 +63,21 @@ type JsonBackupSource = {
listNotebooks: () => Promise<{ notebooks: Notebook[] }>;
getPage: (offset: number, limit: number) => Promise<JsonBackupPage>;
getResourceBlob: (resourceUrl: string) => Promise<Blob>;
listPrompts: () => Promise<{ prompts: JsonBackupAiPrompt[] }>;
};
type JsonRestoreTarget = {
restoreNotebooks: (notebooks: JsonBackupNotebook[]) => Promise<unknown>;
restoreMemos: (memos: JsonBackupMemo[]) => Promise<unknown>;
restoreResource: (resourceId: string, metadata: JsonBackupResource, file: Blob) => Promise<unknown>;
restorePrompts: (prompts: JsonBackupAiPrompt[]) => Promise<unknown>;
};
export type ParsedEdgeEverZip = {
manifest: JsonBackupManifest;
notebooks: JsonBackupNotebook[];
memos: JsonBackupMemo[];
prompts: JsonBackupAiPrompt[];
files: Record<string, Uint8Array>;
};
@@ -103,7 +108,10 @@ export const createEdgeEverZip = async (
version: { edgeeverVersion: string; buildId: string },
onProgress?: (progress: EdgeEverZipProgress) => void
) => {
const { notebooks } = await source.listNotebooks();
const [{ notebooks }, { prompts }] = await Promise.all([
source.listNotebooks(),
source.listPrompts(),
]);
const notebookPaths = buildNotebookExportPaths(notebooks);
const memoNamesByNotebook = new Map<string, Set<string>>();
const chunks: ArrayBuffer[] = [];
@@ -124,6 +132,7 @@ export const createEdgeEverZip = async (
try {
const backupNotebooks = notebooks.map(toBackupNotebook);
addJsonFile(zip, "notebooks.json", backupNotebooks);
addJsonFile(zip, "prompts.json", prompts);
let offset = 0;
let completed = 0;
let total = 0;
@@ -221,6 +230,7 @@ export const createEdgeEverZip = async (
memos: total,
revisions: revisionCount,
resources: resourceCount,
prompts: prompts.length,
},
};
addJsonFile(zip, "manifest.json", manifest);
@@ -278,7 +288,7 @@ export const parseEdgeEverZip = async (blob: Blob): Promise<ParsedEdgeEverZip> =
if (manifestRecord.format !== EDGEEVER_ZIP_FORMAT) {
throw new EdgeEverZipImportError("unsupportedFormat");
}
if (manifestRecord.formatVersion !== EDGEEVER_ZIP_FORMAT_VERSION) {
if (manifestRecord.formatVersion !== 1 && manifestRecord.formatVersion !== EDGEEVER_ZIP_FORMAT_VERSION) {
throw new EdgeEverZipImportError("unsupportedVersion");
}
const manifestResult = JsonBackupManifestSchema.safeParse(manifestValue);
@@ -295,6 +305,14 @@ export const parseEdgeEverZip = async (blob: Blob): Promise<ParsedEdgeEverZip> =
throw new EdgeEverZipImportError("invalidData", notebooksResult.error);
}
const notebooks = sortNotebooksForRestore(notebooksResult.data);
const promptsValue = manifest.formatVersion >= 2
? parseJsonFile(files, "prompts.json")
: [];
const promptsResult = JsonBackupAiPromptSchema.array().safeParse(promptsValue);
if (!promptsResult.success) {
throw new EdgeEverZipImportError("invalidData", promptsResult.error);
}
const prompts = promptsResult.data as JsonBackupAiPrompt[];
const memoPaths = Object.keys(files).filter((path) => /^memos\/[^/]+\.json$/.test(path)).sort();
const memos: JsonBackupMemo[] = [];
for (const path of memoPaths) {
@@ -310,6 +328,7 @@ export const parseEdgeEverZip = async (blob: Blob): Promise<ParsedEdgeEverZip> =
manifest.counts.notebooks !== notebooks.length
|| manifest.counts.memos !== memos.length
|| manifest.counts.memos !== markdownCount
|| (manifest.counts.prompts ?? 0) !== prompts.length
) {
throw new EdgeEverZipImportError("incompleteData");
}
@@ -326,7 +345,7 @@ export const parseEdgeEverZip = async (blob: Blob): Promise<ParsedEdgeEverZip> =
throw new EdgeEverZipImportError("incompleteResources");
}
return { manifest, notebooks, memos, files };
return { manifest, notebooks, memos, prompts, files };
};
export const restoreEdgeEverZip = async (
@@ -334,10 +353,20 @@ export const restoreEdgeEverZip = async (
target: JsonRestoreTarget,
onProgress?: (progress: EdgeEverZipProgress) => void
) => {
const total = backup.notebooks.length + backup.memos.length + backup.manifest.counts.resources;
const total = backup.notebooks.length
+ backup.memos.length
+ backup.prompts.length
+ backup.manifest.counts.resources;
let completed = 0;
onProgress?.({ completed, total });
for (let index = 0; index < backup.prompts.length; index += 100) {
const batch = backup.prompts.slice(index, index + 100);
await target.restorePrompts(batch);
completed += batch.length;
onProgress?.({ completed, total });
}
for (let index = 0; index < backup.notebooks.length; index += 100) {
const batch = backup.notebooks.slice(index, index + 100);
await target.restoreNotebooks(batch);
+128 -3
View File
@@ -201,10 +201,14 @@
"schema": {
"type": "object",
"properties": {
"action": { "type": "string", "enum": ["summarize", "extract-key-points", "extract-todos", "rewrite-proofread", "translate"] },
"action": { "type": "string", "enum": ["summarize", "extract-key-points", "extract-todos", "rewrite-proofread", "translate", "improve-writing", "fix-spelling-grammar", "make-shorter", "make-longer", "simplify-language", "change-tone", "continue-writing", "custom"] },
"promptId": { "type": "string", "minLength": 1, "maxLength": 200, "description": "Saved prompt to resolve inside the authenticated workspace" },
"locale": { "type": "string", "maxLength": 35 },
"title": { "type": "string", "maxLength": 160 },
"contentMarkdown": { "type": "string", "maxLength": 300000 },
"targetLanguage": { "type": "string", "maxLength": 80, "description": "Required when action is translate" }
"targetLanguage": { "type": "string", "enum": ["en", "zh-CN", "zh-TW", "ja", "ko", "es", "fr", "de", "pt"], "description": "Required when the action or selected prompt requests a target language" },
"tone": { "type": "string", "enum": ["professional", "friendly", "casual", "direct"] },
"instruction": { "type": "string", "minLength": 1, "maxLength": 2000, "description": "Freeform instruction; ignored when promptId is supplied" }
},
"required": ["action", "title", "contentMarkdown"]
}
@@ -216,9 +220,83 @@
"description": "Server-sent events containing start, text-delta, finish, or error events",
"content": { "text/event-stream": { "schema": { "type": "string" } } }
},
"400": { "$ref": "#/components/responses/Error" },
"401": { "$ref": "#/components/responses/Error" },
"403": { "$ref": "#/components/responses/Error" },
"409": { "$ref": "#/components/responses/Error" }
"404": { "$ref": "#/components/responses/Error" }
}
}
},
"/api/v1/ai/prompts": {
"get": {
"summary": "List the current workspace prompt library",
"parameters": [{ "name": "locale", "in": "query", "schema": { "type": "string" } }],
"responses": {
"200": {
"description": "Localized prompt templates",
"content": { "application/json": { "schema": { "type": "object", "properties": { "prompts": { "type": "array", "items": { "$ref": "#/components/schemas/AiPromptTemplate" } } }, "required": ["prompts"] } } }
},
"401": { "$ref": "#/components/responses/Error" },
"403": { "$ref": "#/components/responses/Error" }
}
},
"post": {
"summary": "Create a reusable prompt",
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AiPromptCreateInput" } } } },
"responses": {
"201": { "description": "Prompt created", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AiPromptResponse" } } } },
"400": { "$ref": "#/components/responses/Error" },
"401": { "$ref": "#/components/responses/Error" },
"403": { "$ref": "#/components/responses/Error" }
}
}
},
"/api/v1/ai/prompts/{promptId}": {
"parameters": [{ "name": "promptId", "in": "path", "required": true, "schema": { "type": "string" } }],
"patch": {
"summary": "Update prompt text or execution behavior",
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AiPromptUpdateInput" } } } },
"responses": {
"200": { "description": "Prompt updated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AiPromptResponse" } } } },
"400": { "$ref": "#/components/responses/Error" },
"401": { "$ref": "#/components/responses/Error" },
"403": { "$ref": "#/components/responses/Error" },
"404": { "$ref": "#/components/responses/Error" }
}
},
"delete": {
"summary": "Delete a prompt",
"responses": {
"200": { "$ref": "#/components/responses/Ok" },
"401": { "$ref": "#/components/responses/Error" },
"403": { "$ref": "#/components/responses/Error" },
"404": { "$ref": "#/components/responses/Error" }
}
}
},
"/api/v1/ai/prompts/restore-defaults": {
"post": {
"summary": "Restore missing factory prompts without overwriting edits",
"parameters": [{ "name": "locale", "in": "query", "schema": { "type": "string" } }],
"responses": {
"200": { "description": "Updated prompt list and restored count" },
"401": { "$ref": "#/components/responses/Error" },
"403": { "$ref": "#/components/responses/Error" }
}
}
},
"/api/v1/restores/json/ai-prompts": {
"post": {
"summary": "Restore prompt templates from an EdgeEver ZIP backup",
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "type": "object", "properties": { "prompts": { "type": "array", "maxItems": 100, "items": { "$ref": "#/components/schemas/AiPromptTemplate" } } }, "required": ["prompts"] } } }
},
"responses": {
"200": { "$ref": "#/components/responses/Ok" },
"400": { "$ref": "#/components/responses/Error" },
"401": { "$ref": "#/components/responses/Error" },
"403": { "$ref": "#/components/responses/Error" }
}
}
},
@@ -1956,6 +2034,53 @@
},
"required": ["modelId"]
},
"AiPromptTemplate": {
"type": "object",
"properties": {
"id": { "type": "string" },
"origin": { "type": "string", "enum": ["default", "custom"] },
"seedKey": { "type": ["string", "null"] },
"action": { "type": "string", "enum": ["summarize", "extract-key-points", "extract-todos", "rewrite-proofread", "translate", "improve-writing", "fix-spelling-grammar", "make-shorter", "make-longer", "simplify-language", "change-tone", "continue-writing", "custom"] },
"parameterKind": { "type": "string", "enum": ["none", "target-language", "tone"] },
"resultMode": { "type": "string", "enum": ["append", "replace", "both"] },
"nameCustomized": { "type": "boolean" },
"descriptionCustomized": { "type": "boolean" },
"instructionCustomized": { "type": "boolean" },
"name": { "type": "string", "minLength": 1, "maxLength": 80 },
"description": { "type": ["string", "null"], "maxLength": 200 },
"instruction": { "type": "string", "minLength": 1, "maxLength": 2000 },
"createdAt": { "type": "string", "format": "date-time" },
"updatedAt": { "type": "string", "format": "date-time" }
},
"required": ["id", "origin", "seedKey", "action", "parameterKind", "resultMode", "nameCustomized", "descriptionCustomized", "instructionCustomized", "name", "description", "instruction", "createdAt", "updatedAt"]
},
"AiPromptCreateInput": {
"type": "object",
"properties": {
"name": { "type": "string", "minLength": 1, "maxLength": 80 },
"description": { "type": "string", "maxLength": 200 },
"instruction": { "type": "string", "minLength": 1, "maxLength": 2000 },
"parameterKind": { "type": "string", "enum": ["none", "target-language", "tone"], "default": "none" },
"resultMode": { "type": "string", "enum": ["append", "replace", "both"], "default": "both" }
},
"required": ["name", "instruction"]
},
"AiPromptUpdateInput": {
"type": "object",
"minProperties": 1,
"properties": {
"name": { "type": "string", "minLength": 1, "maxLength": 80 },
"description": { "type": ["string", "null"], "maxLength": 200 },
"instruction": { "type": "string", "minLength": 1, "maxLength": 2000 },
"parameterKind": { "type": "string", "enum": ["none", "target-language", "tone"] },
"resultMode": { "type": "string", "enum": ["append", "replace", "both"] }
}
},
"AiPromptResponse": {
"type": "object",
"properties": { "prompt": { "$ref": "#/components/schemas/AiPromptTemplate" } },
"required": ["prompt"]
},
"LoginInput": {
"type": "object",
"properties": {
@@ -0,0 +1,90 @@
PRAGMA foreign_keys = ON;
-- Prompt execution behavior is explicit so editable prompt text is never interpreted from its id.
-- Per-field customization flags let untouched factory prompts be localized at read time.
ALTER TABLE ai_prompt_templates ADD COLUMN seed_key TEXT;
ALTER TABLE ai_prompt_templates ADD COLUMN action TEXT NOT NULL DEFAULT 'custom'
CHECK (action IN (
'summarize', 'extract-key-points', 'extract-todos', 'rewrite-proofread',
'translate', 'improve-writing', 'fix-spelling-grammar', 'make-shorter',
'make-longer', 'simplify-language', 'change-tone', 'continue-writing', 'custom'
));
ALTER TABLE ai_prompt_templates ADD COLUMN parameter_kind TEXT NOT NULL DEFAULT 'none'
CHECK (parameter_kind IN ('none', 'target-language', 'tone'));
ALTER TABLE ai_prompt_templates ADD COLUMN result_mode TEXT NOT NULL DEFAULT 'both'
CHECK (result_mode IN ('append', 'replace', 'both'));
ALTER TABLE ai_prompt_templates ADD COLUMN name_customized INTEGER NOT NULL DEFAULT 1
CHECK (name_customized IN (0, 1));
ALTER TABLE ai_prompt_templates ADD COLUMN description_customized INTEGER NOT NULL DEFAULT 1
CHECK (description_customized IN (0, 1));
ALTER TABLE ai_prompt_templates ADD COLUMN instruction_customized INTEGER NOT NULL DEFAULT 1
CHECK (instruction_customized IN (0, 1));
UPDATE ai_prompt_templates
SET
seed_key = substr(id, length(workspace_id) + 11),
action = substr(id, length(workspace_id) + 11),
parameter_kind = CASE substr(id, length(workspace_id) + 11)
WHEN 'translate' THEN 'target-language'
WHEN 'change-tone' THEN 'tone'
ELSE 'none'
END,
result_mode = CASE substr(id, length(workspace_id) + 11)
WHEN 'summarize' THEN 'append'
WHEN 'extract-key-points' THEN 'append'
WHEN 'extract-todos' THEN 'append'
WHEN 'continue-writing' THEN 'append'
ELSE 'both'
END,
name_customized = CASE WHEN
(substr(id, length(workspace_id) + 11) = 'summarize' AND name = '总结') OR
(substr(id, length(workspace_id) + 11) = 'extract-key-points' AND name = '提炼要点') OR
(substr(id, length(workspace_id) + 11) = 'extract-todos' AND name = '提取待办') OR
(substr(id, length(workspace_id) + 11) = 'rewrite-proofread' AND name = '改写与校对') OR
(substr(id, length(workspace_id) + 11) = 'translate' AND name = '翻译') OR
(substr(id, length(workspace_id) + 11) = 'improve-writing' AND name = '改进表达') OR
(substr(id, length(workspace_id) + 11) = 'fix-spelling-grammar' AND name = '修正错别字与语法') OR
(substr(id, length(workspace_id) + 11) = 'make-shorter' AND name = '缩短内容') OR
(substr(id, length(workspace_id) + 11) = 'make-longer' AND name = '扩写内容') OR
(substr(id, length(workspace_id) + 11) = 'simplify-language' AND name = '简化表达') OR
(substr(id, length(workspace_id) + 11) = 'change-tone' AND name = '改变语气') OR
(substr(id, length(workspace_id) + 11) = 'continue-writing' AND name = '继续写作')
THEN 0 ELSE 1 END,
description_customized = CASE WHEN
(substr(id, length(workspace_id) + 11) = 'summarize' AND description = '压缩全文,提炼主题、结论与可执行结果') OR
(substr(id, length(workspace_id) + 11) = 'extract-key-points' AND description = '提取最重要观点,输出简洁要点列表') OR
(substr(id, length(workspace_id) + 11) = 'extract-todos' AND description = '识别可执行任务,生成任务清单') OR
(substr(id, length(workspace_id) + 11) = 'rewrite-proofread' AND description = '润色全文并校对语法、标点与结构') OR
(substr(id, length(workspace_id) + 11) = 'translate' AND description = '翻译为指定目标语言,保留结构与格式') OR
(substr(id, length(workspace_id) + 11) = 'improve-writing' AND description = '提升表达清晰度与流畅度') OR
(substr(id, length(workspace_id) + 11) = 'fix-spelling-grammar' AND description = '只修正错别字、语法与标点') OR
(substr(id, length(workspace_id) + 11) = 'make-shorter' AND description = '删减冗余,保留关键事实') OR
(substr(id, length(workspace_id) + 11) = 'make-longer' AND description = '在不编造事实的前提下扩写说明') OR
(substr(id, length(workspace_id) + 11) = 'simplify-language' AND description = '用更通俗易懂的语言改写') OR
(substr(id, length(workspace_id) + 11) = 'change-tone' AND description = '按指定语气重写,不改变含义') OR
(substr(id, length(workspace_id) + 11) = 'continue-writing' AND description = '从笔记末尾自然续写')
THEN 0 ELSE 1 END,
instruction_customized = CASE WHEN
(substr(id, length(workspace_id) + 11) = 'summarize' AND instruction = '对笔记做真正的精简总结,不要逐句改写或复述原文。提炼中心主题、主要观点、关键结论与可执行结果。去掉重复、修辞、举例和次要细节,除非它们对理解结论必不可少。较长笔记用 3–7 条简洁 Markdown 要点;短笔记用 1–3 句即可。不要大段照搬原文,也不要添加原文没有的信息。保持笔记原语言,只返回总结内容。') OR
(substr(id, length(workspace_id) + 11) = 'extract-key-points' AND instruction = '提取笔记中最重要的要点,用简洁的 Markdown 列表输出。保持原语言,不要添加原文没有的信息。') OR
(substr(id, length(workspace_id) + 11) = 'extract-todos' AND instruction = '从笔记中提取明确或隐含的待办事项,用 Markdown 任务列表(- [ ])输出。保持原语言,不要编造任务。若没有可执行事项,用原文语言简短说明。') OR
(substr(id, length(workspace_id) + 11) = 'rewrite-proofread' AND instruction = '改写并校对整篇笔记。修正拼写、语法、标点、清晰度与结构,不改变原意。保持原语言与 Markdown 格式,只返回修订后的完整内容。') OR
(substr(id, length(workspace_id) + 11) = 'translate' AND instruction = '将完整笔记翻译成用户指定的目标语言。保留原意、Markdown 结构、链接与代码块。只返回译文,不要评论。') OR
(substr(id, length(workspace_id) + 11) = 'improve-writing' AND instruction = '改进文字的清晰度、流畅度与用词,不改变原意。保持原语言与有用的 Markdown 格式,只返回改进后的内容。') OR
(substr(id, length(workspace_id) + 11) = 'fix-spelling-grammar' AND instruction = '只修正拼写、语法与标点。不要改变语气、结构或含义。保持原语言与 Markdown 格式。只返回修正后的内容。') OR
(substr(id, length(workspace_id) + 11) = 'make-shorter' AND instruction = '把内容改写得更简洁。去掉重复与废话,保留每一个重要事实。保持原语言与有用的 Markdown 格式,只返回缩短后的内容。') OR
(substr(id, length(workspace_id) + 11) = 'make-longer' AND instruction = '扩写内容,补充有用的说明与更顺畅的过渡,但不要编造事实。保持原语言与有用的 Markdown 格式,只返回扩写后的内容。') OR
(substr(id, length(workspace_id) + 11) = 'simplify-language' AND instruction = '用清晰、平实、更好懂的语言改写内容。保持原意、原语言与有用的 Markdown 格式,只返回简化后的内容。') OR
(substr(id, length(workspace_id) + 11) = 'change-tone' AND instruction = '按用户指定的语气重写内容,不改变原意。保持原语言与有用的 Markdown 格式。只返回改写后的内容。') OR
(substr(id, length(workspace_id) + 11) = 'continue-writing' AND instruction = '从笔记结束处自然续写。只返回新增续写内容,不要重复原文。保持原语言与 Markdown 风格。')
THEN 0 ELSE 1 END
WHERE id = workspace_id || '_aiprompt_' || substr(id, length(workspace_id) + 11)
AND substr(id, length(workspace_id) + 11) IN (
'summarize', 'extract-key-points', 'extract-todos', 'rewrite-proofread',
'translate', 'improve-writing', 'fix-spelling-grammar', 'make-shorter',
'make-longer', 'simplify-language', 'change-tone', 'continue-writing'
);
CREATE UNIQUE INDEX idx_ai_prompt_templates_workspace_seed
ON ai_prompt_templates(workspace_id, seed_key)
WHERE seed_key IS NOT NULL;
+22 -6
View File
@@ -7,6 +7,7 @@ import type {
CreatedApiToken,
JsonBackupMemo,
JsonBackupNotebook,
JsonBackupAiPrompt,
JsonBackupRevision,
MemoDetail,
MemoEditSession,
@@ -28,6 +29,8 @@ import type {
AiDiscoveredModel,
AiProvider,
AiPromptTemplate,
AiPromptTemplateCreateInput,
AiPromptTemplateUpdateInput,
AiStreamEvent,
} from "@edgeever/shared";
@@ -289,9 +292,12 @@ export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => {
body: JSON.stringify({ modelConfigId }),
}),
listAiPrompts: () => request<{ prompts: AiPromptTemplate[] }>("/api/v1/ai/prompts"),
listAiPrompts: (locale?: string) => {
const search = locale ? `?locale=${encodeURIComponent(locale)}` : "";
return request<{ prompts: AiPromptTemplate[] }>(`/api/v1/ai/prompts${search}`);
},
createAiPrompt: (payload: { name: string; description?: string; instruction: string }) =>
createAiPrompt: (payload: AiPromptTemplateCreateInput) =>
request<{ prompt: AiPromptTemplate }>("/api/v1/ai/prompts", {
method: "POST",
body: JSON.stringify(payload),
@@ -299,7 +305,7 @@ export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => {
updateAiPrompt: (
promptId: string,
payload: { name?: string; description?: string | null; instruction?: string },
payload: AiPromptTemplateUpdateInput,
) =>
request<{ prompt: AiPromptTemplate }>(`/api/v1/ai/prompts/${encodeURIComponent(promptId)}`, {
method: "PATCH",
@@ -311,15 +317,19 @@ export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => {
method: "DELETE",
}),
restoreDefaultAiPrompts: () =>
request<{ prompts: AiPromptTemplate[]; restoredCount: number }>("/api/v1/ai/prompts/restore-defaults", {
restoreDefaultAiPrompts: (locale?: string) => {
const search = locale ? `?locale=${encodeURIComponent(locale)}` : "";
return request<{ prompts: AiPromptTemplate[]; restoredCount: number }>(`/api/v1/ai/prompts/restore-defaults${search}`, {
method: "POST",
body: JSON.stringify({}),
}),
});
},
streamAiGeneration: async (
payload: {
action: AiAction;
promptId?: string;
locale?: string;
title: string;
contentMarkdown: string;
targetLanguage?: AiTargetLanguage;
@@ -590,6 +600,12 @@ export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => {
body: JSON.stringify({ memos }),
}),
restoreJsonAiPrompts: (prompts: JsonBackupAiPrompt[]) =>
request<{ ok: true }>("/api/v1/restores/json/ai-prompts", {
method: "POST",
body: JSON.stringify({ prompts }),
}),
restoreJsonResource: (resourceId: string, metadata: JsonBackupMemo["resources"][number], file: Blob) => {
const form = new FormData();
form.append("metadata", JSON.stringify(metadata));
+18
View File
@@ -22,6 +22,12 @@ export type AiTone = (typeof AI_TONES)[number];
export const AI_TARGET_LANGUAGES = ["en", "zh-CN", "zh-TW", "ja", "ko", "es", "fr", "de", "pt"] as const;
export type AiTargetLanguage = (typeof AI_TARGET_LANGUAGES)[number];
export const AI_PROMPT_PARAMETER_KINDS = ["none", "target-language", "tone"] as const;
export type AiPromptParameterKind = (typeof AI_PROMPT_PARAMETER_KINDS)[number];
export const AI_PROMPT_RESULT_MODES = ["append", "replace", "both"] as const;
export type AiPromptResultMode = (typeof AI_PROMPT_RESULT_MODES)[number];
export const AI_SELECTED_TEXT_ACTIONS: readonly AiAction[] = [
"improve-writing",
"fix-spelling-grammar",
@@ -73,3 +79,15 @@ export const actionNeedsTargetLanguage = (action: AiAction | string | null | und
export const actionNeedsTone = (action: AiAction | string | null | undefined) =>
action === "change-tone";
export const promptNeedsTargetLanguage = (parameterKind: AiPromptParameterKind) =>
parameterKind === "target-language";
export const promptNeedsTone = (parameterKind: AiPromptParameterKind) =>
parameterKind === "tone";
export const promptAllowsAppend = (resultMode: AiPromptResultMode) =>
resultMode === "append" || resultMode === "both";
export const promptAllowsReplace = (resultMode: AiPromptResultMode) =>
resultMode === "replace" || resultMode === "both";
+213 -92
View File
@@ -1,112 +1,233 @@
import type { AiAction } from "./ai-assistant";
import type {
AiAction,
AiPromptParameterKind,
AiPromptResultMode,
} from "./ai-assistant";
/** Default prompt library entries seeded per workspace. All are normal user-editable prompts. */
export type AiPromptSeed = {
/** Stable suffix appended to workspace id so re-seed is idempotent. Matches AiAction when not custom. */
key: Exclude<AiAction, "custom">;
export type AiPromptSeedKey = Exclude<AiAction, "custom">;
export type AiPromptSeedLocale = "zh-CN" | "en-US";
export type AiPromptSeedTranslation = {
name: string;
description: string;
instruction: string;
};
/** Factory prompt metadata. The top-level text is the Simplified Chinese fallback for legacy callers. */
export type AiPromptSeed = AiPromptSeedTranslation & {
key: AiPromptSeedKey;
action: AiPromptSeedKey;
parameterKind: AiPromptParameterKind;
resultMode: AiPromptResultMode;
translations: Record<AiPromptSeedLocale, AiPromptSeedTranslation>;
};
const seed = (
metadata: Omit<AiPromptSeed, keyof AiPromptSeedTranslation | "translations">,
zhCN: AiPromptSeedTranslation,
enUS: AiPromptSeedTranslation,
): AiPromptSeed => ({
...metadata,
...zhCN,
translations: { "zh-CN": zhCN, "en-US": enUS },
});
export const normalizeAiPromptSeedLocale = (locale: string | null | undefined): AiPromptSeedLocale =>
locale?.toLowerCase().startsWith("en") ? "en-US" : "zh-CN";
export const localizeAiPromptSeed = (
promptSeed: AiPromptSeed,
locale: string | null | undefined,
): AiPromptSeedTranslation => promptSeed.translations[normalizeAiPromptSeedLocale(locale)];
/** Deterministic id for a seeded default prompt in a workspace. */
export const defaultAiPromptId = (workspaceId: string, seedKey: string) =>
`${workspaceId}_aiprompt_${seedKey}`;
/** Parse seed key from a deterministic default prompt id, if any. */
export const parseDefaultAiPromptKey = (promptId: string): Exclude<AiAction, "custom"> | null => {
/** Parse legacy deterministic ids. New code should use the persisted seedKey field instead. */
export const parseDefaultAiPromptKey = (promptId: string): AiPromptSeedKey | null => {
const match = /_aiprompt_([a-z0-9-]+)$/i.exec(promptId);
if (!match) return null;
const key = match[1] as Exclude<AiAction, "custom">;
return DEFAULT_AI_PROMPT_SEEDS.some((seed) => seed.key === key) ? key : null;
const key = match[1] as AiPromptSeedKey;
return DEFAULT_AI_PROMPT_SEEDS.some((item) => item.key === key) ? key : null;
};
/**
* Single source of truth for default note-processing prompts.
* Used by: workspace seed, restore-defaults, and AI generate (when resolving built-in actions).
* Text is user-visible and fully customizable after seeding.
* Single application catalog for default prompt behavior and localized copy.
* Persisted rows only contain user overrides; untouched defaults are materialized from this catalog.
*/
export const DEFAULT_AI_PROMPT_SEEDS: readonly AiPromptSeed[] = [
{
key: "summarize",
name: "总结",
description: "压缩全文,提炼主题、结论与可执行结果",
instruction: [
"对笔记做真正的精简总结,不要逐句改写、同义复述或回声式重写。",
"识别中心主题、主要主张、关键结论与可执行结果。",
"省略重复、修辞、举例、引语和次要细节,除非它们对理解关键结论必不可少。",
"较长笔记目标约为原文 20–30% 篇幅,用 3–7 条简洁 Markdown 要点;短笔记用 1–3 句即可。",
"不要大段照搬原文,也不要添加原文没有的信息。",
"保持笔记原语言,只返回 Markdown 总结。",
].join(""),
},
{
key: "extract-key-points",
name: "提炼要点",
description: "提取最重要观点,输出简洁要点列表",
instruction: "提取笔记中最重要的要点,用简洁的 Markdown 列表输出。保持原语言,不要添加原文没有的信息。",
},
{
key: "extract-todos",
name: "提取待办",
description: "识别可执行任务,生成任务清单",
instruction: "从笔记中提取明确或隐含的可执行任务,用 Markdown 任务列表(- [ ])输出。保持原语言,不要编造任务。若没有可执行事项,用原文语言简短说明。",
},
{
key: "rewrite-proofread",
name: "改写与校对",
description: "润色全文并校对语法、标点与结构",
instruction: "改写并校对完整笔记。修正拼写、语法、标点、清晰度与结构,不改变原意。保持原语言与 Markdown 格式。只返回完整修订稿。",
},
{
key: "translate",
name: "翻译",
description: "翻译为指定目标语言,保留结构与格式",
instruction: "将完整笔记翻译成用户指定的目标语言。保留原意、Markdown 结构、链接与代码块。只返回译文,不要评论。",
},
{
key: "improve-writing",
name: "改进表达",
description: "提升表达清晰度与流畅度",
instruction: "改进文字的清晰度、流畅度与用词,不改变原意。保持原语言与有用的 Markdown 格式。只返回改进后的内容。",
},
{
key: "fix-spelling-grammar",
name: "修正错别字与语法",
description: "只修正错别字、语法与标点",
instruction: "只修正拼写、语法与标点。不要改变语气、结构或含义。保持原语言与 Markdown 格式。只返回修正后的内容。",
},
{
key: "make-shorter",
name: "缩短内容",
description: "删减冗余,保留关键事实",
instruction: "把内容改写得更简洁。去掉重复与废话,保留每一个重要事实。保持原语言与有用的 Markdown 格式。只返回缩短后的内容。",
},
{
key: "make-longer",
name: "扩写内容",
description: "在不编造事实的前提下扩写说明",
instruction: "扩写内容,补充有用的说明与更顺畅的过渡,但不要编造事实。保持原语言与有用的 Markdown 格式。只返回扩写后的内容。",
},
{
key: "simplify-language",
name: "简化表达",
description: "用更通俗易懂的语言改写",
instruction: "用清晰、平实、更好懂的语言改写内容。保持原意、原语言与有用的 Markdown 格式。只返回简化后的内容。",
},
{
key: "change-tone",
name: "改变语气",
description: "按指定语气重写,不改变含义",
instruction: "按用户指定的语气重写内容,不改变原意。保持原语言与有用的 Markdown 格式。只返回改写后的内容。",
},
{
key: "continue-writing",
name: "继续写作",
description: "从笔记末尾自然续写",
instruction: "从笔记结束处自然续写。只返回新增续写内容,不要重复原文。保持原语言与 Markdown 风格。",
},
seed(
{ key: "summarize", action: "summarize", parameterKind: "none", resultMode: "append" },
{
name: "总结",
description: "压缩全文,提炼主题、结论与可执行结果",
instruction: [
"对笔记做真正的精简总结,不要逐句改写、同义复述或回声式重写。",
"识别中心主题、主要主张、关键结论与可执行结果。",
"省略重复、修辞、举例、引语和次要细节,除非它们对理解关键结论必不可少。",
"较长笔记目标约为原文 20–30% 篇幅,用 3–7 条简洁 Markdown 要点;短笔记用 1–3 句即可。",
"不要大段照搬原文,也不要添加原文没有的信息。",
"保持笔记原语言,只返回 Markdown 总结。",
].join(""),
},
{
name: "Summarize",
description: "Condense the note into its topic, conclusions, and actionable outcomes",
instruction: [
"Create a genuinely condensed summary of the note rather than rewriting, paraphrasing line by line, or echoing it. ",
"Identify the central topic, main claims, essential conclusions, and actionable outcomes. ",
"Omit repetition, rhetorical phrasing, examples, quotations, and minor details unless necessary to understand a key conclusion. ",
"For a substantial note, target roughly 20–30% of the source length and use 3–7 concise Markdown bullet points; for a short note, use 1–3 concise sentences. ",
"Do not reproduce long passages verbatim or add facts that are not present in the source. ",
"Preserve the note's language and return only the summary in Markdown.",
].join(""),
},
),
seed(
{ key: "extract-key-points", action: "extract-key-points", parameterKind: "none", resultMode: "append" },
{
name: "提炼要点",
description: "提取最重要观点,输出简洁要点列表",
instruction: "提取笔记中最重要的要点,用简洁的 Markdown 列表输出。保持原语言,不要添加原文没有的信息。",
},
{
name: "Key points",
description: "Extract the most important ideas as a concise list",
instruction: "Extract the note's most important points as a concise Markdown bullet list. Preserve its language and do not add information that is not present in the note.",
},
),
seed(
{ key: "extract-todos", action: "extract-todos", parameterKind: "none", resultMode: "append" },
{
name: "提取待办",
description: "识别可执行任务,生成任务清单",
instruction: "从笔记中提取明确或隐含的可执行任务,用 Markdown 任务列表(- [ ])输出。保持原语言,不要编造任务。若没有可执行事项,用原文语言简短说明。",
},
{
name: "Extract tasks",
description: "Identify actionable work and produce a task list",
instruction: "Extract explicit or implied actionable tasks from the note as a Markdown task list using '- [ ]'. Preserve its language and do not invent tasks. If there are no actionable tasks, say so briefly in the note's language.",
},
),
seed(
{ key: "rewrite-proofread", action: "rewrite-proofread", parameterKind: "none", resultMode: "both" },
{
name: "改写与校对",
description: "润色全文并校对语法、标点与结构",
instruction: "改写并校对完整笔记。修正拼写、语法、标点、清晰度与结构,不改变原意。保持原语言与 Markdown 格式。只返回完整修订稿。",
},
{
name: "Rewrite & proofread",
description: "Polish the complete note and correct language and structure",
instruction: "Rewrite and proofread the complete note. Correct spelling, grammar, punctuation, clarity, and structure without changing its meaning. Preserve its language and Markdown formatting. Return the complete revised note only.",
},
),
seed(
{ key: "translate", action: "translate", parameterKind: "target-language", resultMode: "both" },
{
name: "翻译",
description: "翻译为指定目标语言,保留结构与格式",
instruction: "将完整笔记翻译成用户指定的目标语言。保留原意、Markdown 结构、链接与代码块。只返回译文,不要评论。",
},
{
name: "Translate",
description: "Translate into a selected language while preserving formatting",
instruction: "Translate the complete note into the target language specified by the user. Preserve its meaning, Markdown structure, links, and code blocks. Return only the translated note without commentary.",
},
),
seed(
{ key: "improve-writing", action: "improve-writing", parameterKind: "none", resultMode: "both" },
{
name: "改进表达",
description: "提升表达清晰度与流畅度",
instruction: "改进文字的清晰度、流畅度与用词,不改变原意。保持原语言与有用的 Markdown 格式。只返回改进后的内容。",
},
{
name: "Improve writing",
description: "Improve clarity, flow, and word choice",
instruction: "Improve the writing for clarity, flow, and word choice without changing its meaning. Preserve its language and useful Markdown formatting. Return only the improved content.",
},
),
seed(
{ key: "fix-spelling-grammar", action: "fix-spelling-grammar", parameterKind: "none", resultMode: "both" },
{
name: "修正错别字与语法",
description: "只修正错别字、语法与标点",
instruction: "只修正拼写、语法与标点。不要改变语气、结构或含义。保持原语言与 Markdown 格式。只返回修正后的内容。",
},
{
name: "Fix spelling & grammar",
description: "Correct spelling, grammar, and punctuation only",
instruction: "Correct spelling, grammar, and punctuation only. Do not change the voice, structure, or meaning. Preserve its language and Markdown formatting. Return only the corrected content.",
},
),
seed(
{ key: "make-shorter", action: "make-shorter", parameterKind: "none", resultMode: "both" },
{
name: "缩短内容",
description: "删减冗余,保留关键事实",
instruction: "把内容改写得更简洁。去掉重复与废话,保留每一个重要事实。保持原语言与有用的 Markdown 格式。只返回缩短后的内容。",
},
{
name: "Make shorter",
description: "Remove repetition while preserving important facts",
instruction: "Rewrite the content more concisely. Remove repetition and filler while preserving every important fact. Preserve its language and useful Markdown formatting. Return only the shortened content.",
},
),
seed(
{ key: "make-longer", action: "make-longer", parameterKind: "none", resultMode: "both" },
{
name: "扩写内容",
description: "在不编造事实的前提下扩写说明",
instruction: "扩写内容,补充有用的说明与更顺畅的过渡,但不要编造事实。保持原语言与有用的 Markdown 格式。只返回扩写后的内容。",
},
{
name: "Make longer",
description: "Expand the explanation without inventing facts",
instruction: "Expand the content with useful explanation and smoother transitions, but do not invent facts. Preserve its language and useful Markdown formatting. Return only the expanded content.",
},
),
seed(
{ key: "simplify-language", action: "simplify-language", parameterKind: "none", resultMode: "both" },
{
name: "简化表达",
description: "用更通俗易懂的语言改写",
instruction: "用清晰、平实、更好懂的语言改写内容。保持原意、原语言与有用的 Markdown 格式。只返回简化后的内容。",
},
{
name: "Simplify language",
description: "Rewrite in clearer, easier-to-understand language",
instruction: "Rewrite the content in clear, plain language that is easier to understand. Preserve its meaning, language, and useful Markdown formatting. Return only the simplified content.",
},
),
seed(
{ key: "change-tone", action: "change-tone", parameterKind: "tone", resultMode: "both" },
{
name: "改变语气",
description: "按指定语气重写,不改变含义",
instruction: "按用户指定的语气重写内容,不改变原意。保持原语言与有用的 Markdown 格式。只返回改写后的内容。",
},
{
name: "Change tone",
description: "Rewrite in a selected tone without changing the meaning",
instruction: "Rewrite the content in the tone specified by the user without changing its meaning. Preserve its language and useful Markdown formatting. Return only the rewritten content.",
},
),
seed(
{ key: "continue-writing", action: "continue-writing", parameterKind: "none", resultMode: "append" },
{
name: "继续写作",
description: "从笔记末尾自然续写",
instruction: "从笔记结束处自然续写。只返回新增续写内容,不要重复原文。保持原语言与 Markdown 风格。",
},
{
name: "Continue writing",
description: "Continue naturally from the end of the note",
instruction: "Continue writing naturally from where the note ends. Return only the new continuation, not the original content. Preserve its language and Markdown style.",
},
),
];
export const getDefaultAiPromptSeed = (key: string) =>
DEFAULT_AI_PROMPT_SEEDS.find((seed) => seed.key === key) ?? null;
DEFAULT_AI_PROMPT_SEEDS.find((item) => item.key === key) ?? null;
+59 -4
View File
@@ -1,13 +1,18 @@
import { z } from "zod";
import type { TiptapDoc } from "./content";
import type { MemoDetail, Notebook, Resource } from "./types";
import {
AI_ACTIONS,
AI_PROMPT_PARAMETER_KINDS,
AI_PROMPT_RESULT_MODES,
} from "./ai-assistant";
import type { AiPromptTemplate, MemoDetail, Notebook, Resource } from "./types";
export const EDGEEVER_ZIP_FORMAT = "edgeever-zip";
export const EDGEEVER_ZIP_FORMAT_VERSION = 1;
export const EDGEEVER_ZIP_FORMAT_VERSION = 2;
export type JsonBackupManifest = {
format: typeof EDGEEVER_ZIP_FORMAT;
formatVersion: typeof EDGEEVER_ZIP_FORMAT_VERSION;
formatVersion: 1 | typeof EDGEEVER_ZIP_FORMAT_VERSION;
schemaVersion: number;
edgeeverVersion: string;
buildId: string;
@@ -18,6 +23,7 @@ export type JsonBackupManifest = {
memos: number;
revisions: number;
resources: number;
prompts?: number;
};
};
@@ -47,11 +53,13 @@ export type JsonBackupMemo = {
resources: JsonBackupResource[];
};
export type JsonBackupAiPrompt = AiPromptTemplate;
const DateTimeSchema = z.string().datetime();
export const JsonBackupManifestSchema = z.object({
format: z.literal(EDGEEVER_ZIP_FORMAT),
formatVersion: z.literal(EDGEEVER_ZIP_FORMAT_VERSION),
formatVersion: z.union([z.literal(1), z.literal(EDGEEVER_ZIP_FORMAT_VERSION)]),
schemaVersion: z.number().int().positive(),
edgeeverVersion: z.string().trim().min(1),
buildId: z.string().trim().min(1),
@@ -62,6 +70,7 @@ export const JsonBackupManifestSchema = z.object({
memos: z.number().int().min(0),
revisions: z.number().int().min(0),
resources: z.number().int().min(0),
prompts: z.number().int().min(0).optional(),
}),
});
@@ -141,4 +150,50 @@ export const RestoreJsonMemosSchema = z.object({
memos: z.array(JsonBackupMemoSchema).min(1).max(10),
});
export const JsonBackupAiPromptSchema = z.object({
id: z.string().trim().min(1).max(200),
origin: z.enum(["default", "custom"]),
seedKey: z.enum(AI_ACTIONS.filter((action) => action !== "custom") as [
Exclude<(typeof AI_ACTIONS)[number], "custom">,
...Exclude<(typeof AI_ACTIONS)[number], "custom">[],
]).nullable(),
action: z.enum(AI_ACTIONS),
parameterKind: z.enum(AI_PROMPT_PARAMETER_KINDS),
resultMode: z.enum(AI_PROMPT_RESULT_MODES),
nameCustomized: z.boolean(),
descriptionCustomized: z.boolean(),
instructionCustomized: z.boolean(),
name: z.string().trim().min(1).max(80),
description: z.string().max(200).nullable(),
instruction: z.string().trim().min(1).max(2_000),
createdAt: DateTimeSchema,
updatedAt: DateTimeSchema,
}).superRefine((prompt, context) => {
if (prompt.origin === "default" && !prompt.seedKey) {
context.addIssue({
code: "custom",
path: ["seedKey"],
message: "A default prompt must include a seed key.",
});
}
if (prompt.origin === "default" && prompt.seedKey && prompt.action !== prompt.seedKey) {
context.addIssue({
code: "custom",
path: ["action"],
message: "A default prompt action must match its seed key.",
});
}
if (prompt.origin === "custom" && (prompt.seedKey !== null || prompt.action !== "custom")) {
context.addIssue({
code: "custom",
path: ["origin"],
message: "A custom prompt cannot use factory prompt identity.",
});
}
});
export const RestoreJsonAiPromptsSchema = z.object({
prompts: z.array(JsonBackupAiPromptSchema).max(100),
});
export const JsonBackupResourceMetadataSchema = JsonBackupMemoSchema.shape.resources.element;
+14
View File
@@ -361,6 +361,19 @@ export const enUS = {
descriptionPlaceholder: "When to use this prompt",
instruction: "Prompt text",
instructionPlaceholder: "For example: Extract this week's progress, risks, and next priorities as a Markdown list.",
parameterKind: "Extra input",
parameterKinds: {
none: "None",
"target-language": "Target language",
tone: "Tone",
},
resultMode: "Result actions",
resultModes: {
both: "Append or replace",
append: "Append only",
replace: "Replace only",
},
defaultBadge: "Starter",
saved: "Prompt saved.",
deleted: "Prompt deleted.",
failed: "The prompt operation failed.",
@@ -424,6 +437,7 @@ export const enUS = {
promptNamePlaceholder: "For example: Weekly digest",
promptDescription: "Description (optional)",
promptSaved: "Prompt saved.",
promptMissing: "The selected prompt no longer exists. Choose another prompt.",
promptUpdated: "Prompt updated.",
promptSaveFailed: "Failed to save the prompt.",
generate: "Generate",
+14
View File
@@ -361,6 +361,19 @@ export const zhCN = {
descriptionPlaceholder: "简要说明何时使用这条提示词",
instruction: "提示词内容",
instructionPlaceholder: "例如:提炼本周进展、风险与下周优先级,输出 Markdown 列表。",
parameterKind: "附加输入",
parameterKinds: {
none: "无",
"target-language": "目标语言",
tone: "语气",
},
resultMode: "结果操作",
resultModes: {
both: "可追加或替换",
append: "仅追加",
replace: "仅替换",
},
defaultBadge: "预置",
saved: "提示词已保存。",
deleted: "提示词已删除。",
failed: "提示词操作失败。",
@@ -424,6 +437,7 @@ export const zhCN = {
promptNamePlaceholder: "例如:周报提炼",
promptDescription: "描述(可选)",
promptSaved: "提示词已保存。",
promptMissing: "所选提示词已不存在,请重新选择。",
promptUpdated: "提示词已更新。",
promptSaveFailed: "保存提示词失败。",
generate: "生成",
+18 -6
View File
@@ -1,5 +1,11 @@
import { z } from "zod";
import { AI_ACTIONS, AI_TARGET_LANGUAGES, AI_TONES } from "./ai-assistant";
import {
AI_ACTIONS,
AI_PROMPT_PARAMETER_KINDS,
AI_PROMPT_RESULT_MODES,
AI_TARGET_LANGUAGES,
AI_TONES,
} from "./ai-assistant";
export const NotebookCreateSchema = z.object({
name: z.string().trim().min(1).max(80),
@@ -205,19 +211,21 @@ export const AiDefaultModelUpdateSchema = z.object({
export const AiGenerateSchema = z.object({
action: z.enum(AI_ACTIONS),
promptId: z.string().trim().min(1).max(200).optional(),
locale: z.string().trim().min(2).max(35).optional(),
title: z.string().trim().max(160).default(""),
contentMarkdown: z.string().max(300_000),
targetLanguage: z.enum(AI_TARGET_LANGUAGES).optional(),
tone: z.enum(AI_TONES).optional(),
instruction: z.string().trim().min(1).max(2_000).optional(),
}).superRefine((input, context) => {
if (input.action === "translate" && !input.targetLanguage) {
if (!input.promptId && input.action === "translate" && !input.targetLanguage) {
context.addIssue({ code: "custom", path: ["targetLanguage"], message: "A target language is required for translation." });
}
if (input.action === "change-tone" && !input.tone) {
if (!input.promptId && input.action === "change-tone" && !input.tone) {
context.addIssue({ code: "custom", path: ["tone"], message: "A tone is required when changing tone." });
}
if (input.action === "custom" && !input.instruction) {
if (!input.promptId && input.action === "custom" && !input.instruction) {
context.addIssue({ code: "custom", path: ["instruction"], message: "An instruction is required for a custom action." });
}
if (!input.title && !input.contentMarkdown.trim()) {
@@ -229,13 +237,17 @@ export const AiPromptTemplateCreateSchema = z.object({
name: z.string().trim().min(1).max(80),
description: z.string().trim().max(200).optional(),
instruction: z.string().trim().min(1).max(2_000),
parameterKind: z.enum(AI_PROMPT_PARAMETER_KINDS).default("none"),
resultMode: z.enum(AI_PROMPT_RESULT_MODES).default("both"),
});
export const AiPromptTemplateUpdateSchema = z.object({
name: z.string().trim().min(1).max(80).optional(),
description: z.string().trim().max(200).nullable().optional(),
instruction: z.string().trim().min(1).max(2_000).optional(),
}).refine((input) => input.name !== undefined || input.description !== undefined || input.instruction !== undefined, {
parameterKind: z.enum(AI_PROMPT_PARAMETER_KINDS).optional(),
resultMode: z.enum(AI_PROMPT_RESULT_MODES).optional(),
}).refine((input) => Object.values(input).some((value) => value !== undefined), {
message: "At least one field is required.",
});
@@ -264,5 +276,5 @@ export type AiProviderConnectionTestInput = z.infer<typeof AiProviderConnectionT
export type AiModelConfigCreateInput = z.infer<typeof AiModelConfigCreateSchema>;
export type AiDefaultModelUpdateInput = z.infer<typeof AiDefaultModelUpdateSchema>;
export type AiGenerateInput = z.infer<typeof AiGenerateSchema>;
export type AiPromptTemplateCreateInput = z.infer<typeof AiPromptTemplateCreateSchema>;
export type AiPromptTemplateCreateInput = z.input<typeof AiPromptTemplateCreateSchema>;
export type AiPromptTemplateUpdateInput = z.infer<typeof AiPromptTemplateUpdateSchema>;
+9
View File
@@ -1,4 +1,5 @@
import type { TiptapDoc } from "./content";
import type { AiAction, AiPromptParameterKind, AiPromptResultMode } from "./ai-assistant";
export type Notebook = {
id: string;
@@ -144,6 +145,14 @@ export type AiSettings = {
export type AiPromptTemplate = {
id: string;
origin: "default" | "custom";
seedKey: Exclude<AiAction, "custom"> | null;
action: AiAction;
parameterKind: AiPromptParameterKind;
resultMode: AiPromptResultMode;
nameCustomized: boolean;
descriptionCustomized: boolean;
instructionCustomized: boolean;
name: string;
description: string | null;
instruction: string;
+49 -6
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import type { JsonBackupMemo, JsonBackupNotebook, MemoDetail, Notebook, Resource } from "@edgeever/shared";
import type { AiPromptTemplate, JsonBackupMemo, JsonBackupNotebook, MemoDetail, Notebook, Resource } from "@edgeever/shared";
import { strFromU8, strToU8, zipSync } from "fflate";
import {
createEdgeEverZip,
@@ -60,6 +60,23 @@ const resource: Resource = {
url: "/api/v1/resources/res_backup/blob",
};
const prompt: AiPromptTemplate = {
id: "aiprompt_backup",
origin: "custom",
seedKey: null,
action: "custom",
parameterKind: "tone",
resultMode: "replace",
nameCustomized: true,
descriptionCustomized: true,
instructionCustomized: true,
name: "Polish",
description: "Polish selected text",
instruction: "Polish this text.",
createdAt: memo.createdAt,
updatedAt: memo.updatedAt,
};
describe("EdgeEver ZIP", () => {
test("reports actionable archive validation errors", async () => {
await expect(parseEdgeEverZip(new Blob(["not a zip"]))).rejects.toEqual(
@@ -93,6 +110,7 @@ describe("EdgeEver ZIP", () => {
const blob = await createEdgeEverZip(
{
listNotebooks: async () => ({ notebooks: [notebook("nb_child", "Child", "nb_root"), notebook("nb_root", "Root")] }),
listPrompts: async () => ({ prompts: [prompt] }),
getPage: async () => ({
memos: [memo],
resources: [resource],
@@ -119,8 +137,9 @@ describe("EdgeEver ZIP", () => {
const backup = await parseEdgeEverZip(blob);
expect(backup.manifest.format).toBe("edgeever-zip");
expect(backup.manifest.formatVersion).toBe(1);
expect(backup.manifest.counts).toEqual({ notebooks: 2, memos: 1, revisions: 1, resources: 1 });
expect(backup.manifest.formatVersion).toBe(2);
expect(backup.manifest.counts).toEqual({ notebooks: 2, memos: 1, revisions: 1, resources: 1, prompts: 1 });
expect(backup.prompts).toEqual([prompt]);
expect(backup.notebooks.map((item) => item.id)).toEqual(["nb_root", "nb_child"]);
expect(backup.memos[0].memo.contentMarkdown).toBe(memo.contentMarkdown);
expect(Array.from(backup.files[backup.memos[0].resources[0].archivePath])).toEqual([1, 2, 3]);
@@ -130,6 +149,26 @@ describe("EdgeEver ZIP", () => {
expect(backup.memos[0].resources[0].archivePath).toBe("notes/Root/Child/Backup note.assets/asset.png");
});
test("keeps version 1 archives importable without prompt data", async () => {
const legacyManifest = {
format: "edgeever-zip",
formatVersion: 1,
schemaVersion: 1,
edgeeverVersion: "1.13.3",
buildId: "legacy-build",
exportedAt: "2026-08-09T00:00:00.000Z",
includesTrash: false,
counts: { notebooks: 0, memos: 0, revisions: 0, resources: 0 },
};
const backup = await parseEdgeEverZip(new Blob([zipSync({
"manifest.json": strToU8(JSON.stringify(legacyManifest)),
"notebooks.json": strToU8("[]"),
})]));
expect(backup.manifest.formatVersion).toBe(1);
expect(backup.prompts).toEqual([]);
});
test("restores notebooks before memos and binary resources", async () => {
const calls: string[] = [];
const { url: _url, ...resourceMetadata } = resource;
@@ -141,13 +180,13 @@ describe("EdgeEver ZIP", () => {
const backup = {
manifest: {
format: "edgeever-zip" as const,
formatVersion: 1 as const,
formatVersion: 2 as const,
schemaVersion: 1,
edgeeverVersion: "0.1.13",
buildId: "test-build",
exportedAt: "2026-07-14T00:00:00.000Z",
includesTrash: false as const,
counts: { notebooks: 1, memos: 1, revisions: 0, resources: 1 },
counts: { notebooks: 1, memos: 1, revisions: 0, resources: 1, prompts: 1 },
},
notebooks: [{
id: "nb_child",
@@ -161,18 +200,20 @@ describe("EdgeEver ZIP", () => {
updatedAt: memo.updatedAt,
}] satisfies JsonBackupNotebook[],
memos: [backupMemo],
prompts: [prompt],
files: { "resources/res_backup/asset.png": new Uint8Array([1, 2, 3]) },
};
await restoreEdgeEverZip(backup, {
restoreNotebooks: async () => { calls.push("notebooks"); },
restoreMemos: async () => { calls.push("memos"); },
restorePrompts: async () => { calls.push("prompts"); },
restoreResource: async (_id, _metadata, file) => {
calls.push(`resource:${file.size}`);
},
});
expect(calls).toEqual(["notebooks", "memos", "resource:3"]);
expect(calls).toEqual(["prompts", "notebooks", "memos", "resource:3"]);
});
test("refreshes the workspace after all imported data is restored", async () => {
@@ -200,6 +241,7 @@ describe("EdgeEver ZIP", () => {
updatedAt: memo.updatedAt,
}] satisfies JsonBackupNotebook[],
memos: [{ memo, revisions: [], resources: [] }] satisfies JsonBackupMemo[],
prompts: [],
files: {},
};
@@ -208,6 +250,7 @@ describe("EdgeEver ZIP", () => {
{
restoreNotebooks: async () => { calls.push("notebooks"); },
restoreMemos: async () => { calls.push("memos"); },
restorePrompts: async () => { calls.push("prompts"); },
restoreResource: async () => { calls.push("resource"); },
},
async () => { calls.push("refresh"); },
+7
View File
@@ -4,6 +4,7 @@ import { readFileSync } from "node:fs";
const readSource = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
const androidAssistantSource = readSource("../apps/mobile/src/components/MobileAiAssistantModal.tsx");
const androidEditorSource = readSource("../apps/mobile/src/components/LocalTiptapEditor.tsx");
const androidDetailSource = readSource("../apps/mobile/src/screens/WorkspaceMemoDetail.tsx");
const androidWorkspaceSource = readSource("../apps/mobile/src/screens/WorkspaceScreen.tsx");
const androidSessionSource = readSource("../apps/mobile/src/lib/session.tsx");
@@ -35,9 +36,15 @@ describe("native mobile AI note assistant", () => {
});
test("streams AI output from the shared workspace configuration on both clients", () => {
expect(androidAssistantSource).toContain("client!.listAiPrompts(resolvedLocale)");
expect(androidAssistantSource).toContain("client.streamAiGeneration(");
expect(androidAssistantSource).toContain("promptId: selectedPrompt.id");
expect(androidWorkspaceSource).toContain("aiPromptsJson={aiPromptsJson}");
expect(androidEditorSource).toContain("...(promptId ? { promptId } : {})");
expect(androidSessionSource).toContain("fetch: expoFetch as typeof fetch");
expect(iosAssistantSource).toContain("env.session.client.listAiPrompts(locale: locale)");
expect(iosAssistantSource).toContain("client.streamAiGeneration(input)");
expect(iosAssistantSource).toContain("promptId: selectedPrompt?.id");
expect(iosApiSource).toContain('makeURL(path: "/api/v1/ai/generate")');
expect(iosApiSource).toContain("for try await line in bytes.lines");
});