feat(ai): complete cross-platform custom prompt behavior
This commit is contained in:
1 parent
e440da9b2f
commit
55fd32686d
38 files changed
+1967
-406
No files matched your search
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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));
|
||||
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "生成",
|
||||
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
|
||||
@@ -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"); },
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user