feat: add BYO AI note processing

This commit is contained in:
tianma-if committed 2026-08-10 02:00:29 +08:00
1 parent e07b5c3217
commit b272e8fc9d
27 files changed
+1379 -11

No files matched your search

+8 -1
View File
@@ -36,6 +36,7 @@ The public demo resets every day at 3:00 AM (China Standard Time) and restores s
- **Open Data, No Vendor Lock-in**: Built on standard SQLite with complete REST API, MCP, and CLI access. Your knowledge is stored transparently and accessible anytime without being locked to a single app.
- **Lossless ZIP Backup & Portability**: Export your complete library as a clean archive containing Markdown, Front Matter, nested folders, relative attachment links, and version histories for instant restoration anywhere.
- **Native AI Agent Synergy**: Deep integration with Model Context Protocol (MCP) allows AI tools like Claude Code, Codex, and Antigravity to read, organize, and summarize your notes, or sync seamlessly with Notion and Feishu Bitable.
- **Bring Your Own AI Model**: Connect an OpenAI-compatible, Anthropic, or Gemini cloud endpoint with your own Base URL and API key, then summarize notes, extract key points or tasks, rewrite and proofread, or translate from a reviewable streaming draft.
- **Unlimited Multi-Device Sync**: No commercial device caps or paywalls. Enjoy seamless synchronization across PC, tablet, and mobile via web, PWA, or browser.
- **Classic Three-Pane Layout & Focus Mode**: Clean navigation featuring notebook trees, note lists, and an expansive editor, with a desktop focus mode to eliminate distractions.
- **Unlimited Nested Notebooks**: Organize your knowledge with arbitrary folder depth.
@@ -186,6 +187,12 @@ Create an API token in **Profile** -> **MCP settings**, then give the token or f
With MCP, EdgeEver can also connect to tools such as Notion databases and Feishu Bitable, turning scattered ideas, information, and materials from everyday notes into structured data that is easier to organize, search, and manage.
## Bring Your Own AI Model
Open **Profile → AI Integrations** to connect an OpenAI-compatible, Anthropic Messages, or Google Gemini cloud endpoint with your own Base URL, API key, and model ID. The first AI release focuses on note processing: summarizing, extracting key points, extracting tasks, rewriting and proofreading, and translating. Results stream into a reviewable draft before you copy, append, or replace content.
AI requests are sent by the EdgeEver server rather than directly by the browser or native client. Model credentials are isolated by personal workspace and encrypted before being stored. Standard deployments automatically derive an AI-specific encryption key from the existing instance authentication secret, so no additional deployment variable is required. The same AI business code runs in Cloudflare Workers and the planned Docker/Bun runtime.
## Image Compression
Image compression happens in the Web client before upload and is controlled by the **Compress note images** setting. When enabled, PNG, JPEG, WebP, and AVIF files are converted to WebP when beneficial, with the longest edge limited to `2560px`. If compression does not reduce size, the original file is kept.
@@ -196,7 +203,7 @@ EdgeEver avoids Worker-side image processing to reduce compute and image-process
The instance owner can open **Settings → Advanced → OSS object storage** to send new images and attachments to an S3-compatible service such as Alibaba Cloud OSS, Tencent COS, AWS S3, MinIO, or R2. Existing resources stay in their original store, so changing the default does not migrate or break historical attachments.
Before saving third-party credentials on a Cloudflare deployment, configure the `EDGE_EVER_STORAGE_ENCRYPTION_KEY` Worker Secret with a random value of at least 32 characters. EdgeEver uses this instance-level key to encrypt the access secret stored in D1. Keep the key stable and backed up; it is required to read resources that use the external store.
Before saving third-party object-storage credentials on a Cloudflare deployment, configure the `EDGE_EVER_STORAGE_ENCRYPTION_KEY` Worker Secret with a random value of at least 32 characters. EdgeEver uses this instance-level key to encrypt the access secret stored in D1. Keep the key stable and backed up; changing it makes previously saved object-storage credentials unusable.
## Migration
+9 -1
View File
@@ -36,6 +36,7 @@ EdgeEver 是一款现代化的开源笔记工作区。它为你找回经典印
- **数据开放,不设围墙**:基于标准 SQLite 存储,提供 REST API、MCP 与 CLI 接口。数据随时可读可导,不再担心被任何特定平台绑定。
- **无损 ZIP 打包与无缝迁移**:一键打包导出包含 Markdown、Front Matter、嵌套目录及附件的完整档案,同时保留历史版本与结构化数据,方便在不同实例间完整还原。
- **原生 AI Agent 智脑联动**:内置 MCP(Model Context Protocol)协议,支持 Claude Code、Codex、Antigravity 等 AI 助手直接读取与整理笔记,也可与 Notion Database、飞书多维表格轻松打通。
- **接入自己的 AI 模型**:使用自己的 Base URL 和 API Key 连接 OpenAI 兼容、Anthropic 或 Gemini 云端服务,对当前笔记进行总结、提炼要点、提取待办、改写与校对或翻译,结果确认后再应用。
- **多端无缝同步,无设备限制**:自托管数据无商业限制,摆脱免费账号仅限 2 台设备的束缚,在 PC、平板与手机上随心同步。
- **经典三栏布局与专注模式**:笔记本树、笔记列表与编辑区一目了然;桌面端一键开启专注模式,让思绪尽情铺满屏幕。
- **无限层级笔记本**:轻松构建清晰的多级目录结构。
@@ -197,6 +198,13 @@ https://你的域名/api/openapi.json
> 放飞你的思路,这种情况下是有很多灵活玩法:
比如让AI Agent归纳你随机记录的灵感创意、针对你的笔记做精准的人物画像、构建自己的知识图谱、自动为笔记打标签)
借助 MCP,EdgeEver 还可以与 Notion Database、飞书多维表格等工具联动,把日常笔记中零散的灵感、信息和素材沉淀到结构化数据库中,方便后续整理、检索与管理。
## 接入自己的 AI 模型
进入**个人中心 → AI 集成**,可以使用自己的 Base URL、API Key 和模型 ID 连接 OpenAI 兼容协议、Anthropic Messages 或 Google Gemini 云端服务。第一版 AI 功能聚焦笔记内容处理:总结、提炼要点、提取待办、改写与校对以及翻译。模型结果会先作为流式草稿展示,确认后才能复制、追加或替换正文。
AI 请求统一由 EdgeEver 服务端发出,不会由浏览器或原生客户端直接携带模型密钥。模型凭据按个人工作区隔离并加密保存;标准部署会自动从已有的实例认证 Secret 派生 AI 专用加密密钥,不需要增加任何部署变量。Cloudflare Worker 与未来的 Docker/Bun 运行时共用同一套 AI 业务代码。
## 图片压缩规则
图片压缩仅在 Web 端上传前执行,由设置页的“压缩笔记内图片”开关控制。启用后,浏览器会把 PNG、JPEG、WebP、AVIF 尝试压缩为 WebP,并将最长边限制在 `2560px` 以内;如果压缩结果不比原图小,则保留原图。
@@ -207,7 +215,7 @@ Cloudflare Worker 侧执行图片处理会消耗计算/图片处理额度,因
实例 Owner 可以进入**设置 → 高级设置 → OSS 对象存储**,让后续上传的图片和附件写入兼容 S3 API 的阿里云 OSS、腾讯云 COS、AWS S3、MinIO 或 R2。已有资源继续保留在原存储中,因此切换默认存储不会迁移历史附件,也不会让历史附件失效。
在 Cloudflare 部署中保存第三方凭据前,需要先配置一个至少 32 个字符的随机 `EDGE_EVER_STORAGE_ENCRYPTION_KEY` Worker Secret。EdgeEver 会用这个实例级密钥加密保存在 D1 中的访问密钥。请保持该密钥稳定并妥善备份;读取外部存储中的资源时仍需要它。
在 Cloudflare 部署中保存第三方对象存储凭据前,需要先配置一个至少 32 个字符的随机 `EDGE_EVER_STORAGE_ENCRYPTION_KEY` Worker Secret。EdgeEver 会用这个实例级密钥加密保存在 D1 中的对象存储 Secret。请保持该密钥稳定并妥善备份;更换密钥会导致之前保存的对象存储凭据无法继续使用。
## 导入与迁移 (Migration)
+110
View File
@@ -0,0 +1,110 @@
import { describe, expect, test } from "bun:test";
import { Hono } from "hono";
import { registerAiRoutes } from "./ai-routes.ts";
const auth = {
kind: "user",
actorType: "user",
actorId: "usr_member",
username: "member",
displayName: "Member",
scopes: [],
workspaceId: "ws_member",
role: "member",
};
const environment = {
storage: {
db: {
prepare: () => { throw new Error("Unexpected database access"); },
batch: async () => [],
},
resources: {},
},
EDGE_EVER_STORAGE_ENCRYPTION_KEY: "x".repeat(32),
};
const createApp = ({ currentAuth = auth, demoMode = false } = {}) => {
const app = new Hono();
app.use("/api/v1/*", async (context, next) => {
context.set("auth", currentAuth);
await next();
});
registerAiRoutes(app, { isDemoMode: () => demoMode });
return app;
};
const validSettings = {
provider: "openai-compatible",
displayName: "Cloud model",
baseUrl: "https://models.example.com/v1",
apiKey: "secret",
modelId: "model-a",
isEnabled: true,
};
describe("AI route contracts", () => {
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);
expect(response.status).toBe(403);
expect(await response.json()).toMatchObject({ error: { code: "forbidden" } });
});
test("keeps demo AI settings immutable", async () => {
const app = createApp({ demoMode: true });
const response = await app.request(
"/api/v1/ai/settings",
{
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(validSettings),
},
environment,
);
expect(response.status).toBe(403);
expect(await response.json()).toMatchObject({ error: { code: "forbidden" } });
});
test("rejects credentials embedded in a Base URL", async () => {
const app = createApp();
const response = await app.request(
"/api/v1/ai/settings",
{
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...validSettings, baseUrl: "https://user:pass@models.example.com/v1" }),
},
environment,
);
expect(response.status).toBe(400);
});
test("rejects actions outside the first note-processing release", async () => {
const app = createApp();
const response = await app.request(
"/api/v1/ai/generate",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ action: "continue", title: "Note", contentMarkdown: "Body" }),
},
environment,
);
expect(response.status).toBe(400);
});
test("requires a target language for translation", async () => {
const app = createApp();
const response = await app.request(
"/api/v1/ai/generate",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ action: "translate", title: "Note", contentMarkdown: "Body" }),
},
environment,
);
expect(response.status).toBe(400);
});
});
+193
View File
@@ -0,0 +1,193 @@
import {
AiConnectionTestSchema,
AiGenerateSchema,
AiModelSettingsUpdateSchema,
} 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 {
getAiModelConfig,
decryptAiCredential,
loadActiveAiModel,
mapAiModelSettings,
normalizeAiBaseUrl,
resolvePrimaryAiCredentialEncryptionKey,
streamAiGeneration,
testAiModel,
} from "./ai-service";
import { isoNow } from "./entity-utils";
import { apiError, forbidden } from "./http-errors";
import { getWorkspaceId, requireUser } from "./request-auth";
import { encryptSecret } from "./secret-encryption";
type AiRouteDependencies = {
isDemoMode: (environment: Bindings) => boolean;
};
const getSubmittedApiKey = async (context: AppContext, submittedApiKey: string | undefined) => {
if (submittedApiKey) return submittedApiKey;
const row = await getAiModelConfig(context.env.storage.db, getWorkspaceId(context));
if (!row?.api_key_encrypted) {
throw new AppError("ai_api_key_required", "API Key is required.", 400);
}
return decryptAiCredential(row.api_key_encrypted, context.env);
};
const providerErrorMessage = (error: unknown) => {
if (error instanceof AppError) return error.message;
if (!(error instanceof Error)) return "The AI provider request failed.";
return error.message.replace(/Bearer\s+\S+/gi, "Bearer [redacted]").slice(0, 1000);
};
export const registerAiRoutes = (app: Hono<AppEnv>, dependencies: AiRouteDependencies) => {
app.get("/api/v1/ai/settings", async (context) => {
const denied = requireUser(context);
if (denied) return denied;
const row = await getAiModelConfig(context.env.storage.db, getWorkspaceId(context));
return context.json({
settings: row
? mapAiModelSettings(row, Boolean(resolvePrimaryAiCredentialEncryptionKey(context.env)))
: null,
encryptionConfigured: Boolean(resolvePrimaryAiCredentialEncryptionKey(context.env)),
});
});
app.post(
"/api/v1/ai/settings/test",
zValidator("json", AiConnectionTestSchema),
async (context) => {
const denied = requireUser(context);
if (denied) return denied;
const input = context.req.valid("json");
try {
const result = await testAiModel({
provider: input.provider,
baseUrl: input.baseUrl,
apiKey: await getSubmittedApiKey(context, input.apiKey),
modelId: input.modelId,
});
return context.json({ ok: true, response: result.text.trim() });
} catch (error) {
return apiError(context, "ai_connection_failed", providerErrorMessage(error), 400);
}
},
);
app.put(
"/api/v1/ai/settings",
zValidator("json", AiModelSettingsUpdateSchema),
async (context) => {
const denied = requireUser(context);
if (denied) return denied;
if (dependencies.isDemoMode(context.env)) {
return forbidden(context, "AI settings cannot be changed in demo mode.");
}
const encryptionKey = resolvePrimaryAiCredentialEncryptionKey(context.env);
if (!encryptionKey) {
return apiError(
context,
"ai_encryption_key_missing",
"AI credential encryption requires instance authentication or an optional EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY.",
400,
);
}
const input = context.req.valid("json");
const apiKey = await getSubmittedApiKey(context, input.apiKey);
const workspaceId = getWorkspaceId(context);
const now = isoNow();
const configId = `ai_${workspaceId}`;
await context.env.storage.db.batch([
context.env.storage.db.prepare(
`INSERT INTO ai_model_configs (
id, workspace_id, provider, display_name, base_url, api_key_encrypted,
model_id, is_enabled, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(workspace_id) DO UPDATE SET
provider = excluded.provider, display_name = excluded.display_name,
base_url = excluded.base_url, api_key_encrypted = excluded.api_key_encrypted,
model_id = excluded.model_id, is_enabled = excluded.is_enabled,
updated_at = excluded.updated_at`,
).bind(
configId,
workspaceId,
input.provider,
input.displayName,
normalizeAiBaseUrl(input.baseUrl),
await encryptSecret(apiKey, encryptionKey),
input.modelId,
input.isEnabled ? 1 : 0,
now,
now,
),
auditStatement(
context.env.storage.db,
"user",
context.get("auth").actorId,
"workspace.ai_model.update",
"ai_model_config",
configId,
{ provider: input.provider, modelId: input.modelId, isEnabled: input.isEnabled },
),
]);
const row = await getAiModelConfig(context.env.storage.db, workspaceId);
return context.json({
settings: row ? mapAiModelSettings(row, true) : null,
encryptionConfigured: true,
});
},
);
app.post(
"/api/v1/ai/generate",
zValidator("json", AiGenerateSchema),
async (context) => {
const denied = requireUser(context);
if (denied) return denied;
try {
const input = context.req.valid("json");
const model = await loadActiveAiModel(
context.env.storage.db,
getWorkspaceId(context),
context.env,
);
const result = streamAiGeneration({ ...input, model, abortSignal: context.req.raw.signal });
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
const send = (event: unknown) => controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
send({ type: "start" });
try {
for await (const text of result.textStream) {
send({ type: "text-delta", text });
}
const [usage, finishReason] = await Promise.all([result.usage, result.finishReason]);
send({
type: "finish",
finishReason,
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
});
} catch (error) {
send({ type: "error", code: "ai_generation_failed", message: providerErrorMessage(error) });
} finally {
controller.close();
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
},
});
} catch (error) {
if (error instanceof AppError) return apiError(context, error.code, error.message, error.status);
return apiError(context, "ai_generation_failed", providerErrorMessage(error), 400);
}
},
);
};
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, test } from "bun:test";
import {
aiActionInstructions,
mapAiModelSettings,
normalizeAiBaseUrl,
decryptAiCredential,
resolveAiCredentialEncryptionKeys,
resolveCredentialEncryptionKey,
} from "./ai-service.ts";
import { encryptSecret } from "./secret-encryption.ts";
describe("AI model service", () => {
test("limits the first release to note-processing instructions", () => {
expect(Object.keys(aiActionInstructions).sort()).toEqual([
"extract-key-points",
"extract-todos",
"rewrite-proofread",
"summarize",
]);
expect(aiActionInstructions["extract-todos"]).toContain("- [ ]");
});
test("normalizes only trailing separators from a custom Base URL", () => {
expect(normalizeAiBaseUrl(" https://models.example.com/openai/v1/// ")).toBe(
"https://models.example.com/openai/v1",
);
});
test("derives an AI-specific key from the existing authentication secret", () => {
expect(resolveCredentialEncryptionKey(" instance-password ")).toBe("instance-password");
expect(resolveAiCredentialEncryptionKeys({ EDGE_EVER_AUTH_PASSWORD: "instance-password" })[0])
.toBe("edgeever:ai-credentials:v1:instance-password");
});
test("decrypts credentials saved with the legacy object-storage key", async () => {
const encrypted = await encryptSecret("provider-key", "legacy-storage-key");
await expect(decryptAiCredential(encrypted, {
EDGE_EVER_AUTH_PASSWORD: "current-auth-secret",
EDGE_EVER_STORAGE_ENCRYPTION_KEY: "legacy-storage-key",
})).resolves.toBe("provider-key");
});
test("never exposes the encrypted API key in settings", () => {
const settings = mapAiModelSettings({
id: "ai_ws_personal",
workspace_id: "ws_personal",
provider: "openai-compatible",
display_name: "My model",
base_url: "https://models.example.com/v1",
api_key_encrypted: "v1.secret.ciphertext",
model_id: "model-a",
is_enabled: 1,
}, true);
expect(settings).toEqual({
provider: "openai-compatible",
displayName: "My model",
baseUrl: "https://models.example.com/v1",
modelId: "model-a",
isEnabled: true,
hasApiKey: true,
encryptionConfigured: true,
});
expect(JSON.stringify(settings)).not.toContain("ciphertext");
});
});
+178
View File
@@ -0,0 +1,178 @@
import { createAnthropic } from "@ai-sdk/anthropic";
import { createGoogle } from "@ai-sdk/google";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import type { AiAction, AiModelSettings, AiProvider } from "@edgeever/shared";
import { generateText, streamText } from "ai";
import { AppError } from "./app-error";
import { decryptSecret } from "./secret-encryption";
import type { DatabaseAdapter } from "./storage-contract";
export type AiModelConfigRow = {
id: string;
workspace_id: string;
provider: AiProvider;
display_name: string;
base_url: string;
api_key_encrypted: string;
model_id: string;
is_enabled: number;
};
const selectConfigSql = `SELECT id, workspace_id, provider, display_name, base_url,
api_key_encrypted, model_id, is_enabled FROM ai_model_configs`;
export const resolveCredentialEncryptionKey = (value: string | undefined) => {
const key = value?.trim();
return key || undefined;
};
export type AiCredentialEnvironment = {
EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY?: string;
EDGE_EVER_STORAGE_ENCRYPTION_KEY?: string;
EDGE_EVER_AUTH_PASSWORD?: string;
EDGE_EVER_AUTH_PASSWORD_HASH?: string;
};
const uniqueKeys = (values: Array<string | undefined>) => Array.from(new Set(values.filter(Boolean) as string[]));
const deriveAiCredentialKey = (value: string | undefined) => value
? `edgeever:ai-credentials:v1:${value}`
: undefined;
/**
* Authentication is already required by a normal EdgeEver deployment, so its
* stable deployment secret is the zero-configuration credential-encryption
* root. A dedicated key is an optional advanced override. The legacy storage
* key remains in the decryption ring for AI credentials saved before v1.15.
*/
export const resolveAiCredentialEncryptionKeys = (environment: AiCredentialEnvironment) => uniqueKeys([
deriveAiCredentialKey(resolveCredentialEncryptionKey(environment.EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY)),
deriveAiCredentialKey(resolveCredentialEncryptionKey(environment.EDGE_EVER_AUTH_PASSWORD)),
deriveAiCredentialKey(resolveCredentialEncryptionKey(environment.EDGE_EVER_AUTH_PASSWORD_HASH)),
resolveCredentialEncryptionKey(environment.EDGE_EVER_STORAGE_ENCRYPTION_KEY),
]);
export const resolvePrimaryAiCredentialEncryptionKey = (environment: AiCredentialEnvironment) =>
resolveAiCredentialEncryptionKeys(environment)[0];
export const decryptAiCredential = async (
encryptedValue: string,
environment: AiCredentialEnvironment,
) => {
for (const key of resolveAiCredentialEncryptionKeys(environment)) {
try {
return await decryptSecret(encryptedValue, key);
} catch {
// Try the next key so credentials encrypted by the legacy OSS key remain usable.
}
}
throw new AppError(
"ai_credentials_unavailable",
"The saved AI credential cannot be decrypted. Restore the deployment authentication secret or credential encryption key.",
503,
);
};
export const getAiModelConfig = (db: DatabaseAdapter, workspaceId: string) =>
db.prepare(`${selectConfigSql} WHERE workspace_id = ? LIMIT 1`).bind(workspaceId).first<AiModelConfigRow>();
export const mapAiModelSettings = (
row: AiModelConfigRow,
encryptionConfigured: boolean,
): AiModelSettings => ({
provider: row.provider,
displayName: row.display_name,
baseUrl: row.base_url,
modelId: row.model_id,
isEnabled: Boolean(row.is_enabled),
hasApiKey: Boolean(row.api_key_encrypted),
encryptionConfigured,
});
export const normalizeAiBaseUrl = (value: string) => value.trim().replace(/\/+$/, "");
export const createAiModel = (config: {
provider: AiProvider;
baseUrl: string;
apiKey: string;
modelId: string;
}) => {
const baseURL = normalizeAiBaseUrl(config.baseUrl);
switch (config.provider) {
case "anthropic":
return createAnthropic({ baseURL, apiKey: config.apiKey })(config.modelId);
case "google":
return createGoogle({ baseURL, apiKey: config.apiKey })(config.modelId);
default:
return createOpenAICompatible({
name: "edgeever-openai-compatible",
baseURL,
apiKey: config.apiKey,
includeUsage: true,
})(config.modelId);
}
};
export const loadActiveAiModel = async (
db: DatabaseAdapter,
workspaceId: string,
environment: AiCredentialEnvironment,
) => {
const row = await getAiModelConfig(db, workspaceId);
if (!row || !row.is_enabled) {
throw new AppError("ai_not_configured", "Configure and enable an AI model first.", 409);
}
if (!resolvePrimaryAiCredentialEncryptionKey(environment)) {
throw new AppError(
"ai_encryption_key_missing",
"AI credential encryption is unavailable because instance authentication is not configured.",
503,
);
}
return createAiModel({
provider: row.provider,
baseUrl: row.base_url,
apiKey: await decryptAiCredential(row.api_key_encrypted, environment),
modelId: row.model_id,
});
};
export const testAiModel = async (config: {
provider: AiProvider;
baseUrl: string;
apiKey: string;
modelId: string;
}) => generateText({
model: createAiModel(config),
system: "You are responding to an API connectivity check. Follow the user instruction exactly.",
prompt: "Reply with only: OK",
maxOutputTokens: 16,
abortSignal: AbortSignal.timeout(20_000),
});
export const aiActionInstructions: Record<Exclude<AiAction, "translate">, string> = {
summarize: "Summarize the note clearly and concisely. Preserve its language. Return Markdown only.",
"extract-key-points": "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.",
"extract-todos": "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.",
"rewrite-proofread": "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.",
};
export const streamAiGeneration = (input: {
model: ReturnType<typeof createAiModel>;
action: AiAction;
title: string;
contentMarkdown: string;
targetLanguage?: string;
abortSignal?: AbortSignal;
}) => streamText({
model: input.model,
system: input.action === "translate"
? "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."
: aiActionInstructions[input.action],
prompt: [
input.action === "translate" ? `Target language:\n${input.targetLanguage}` : undefined,
`Note title:\n${input.title || "Untitled"}`,
`Note content:\n${input.contentMarkdown}`,
].filter(Boolean).join("\n\n"),
maxOutputTokens: 4096,
abortSignal: input.abortSignal,
});
+1
View File
@@ -16,6 +16,7 @@ export type Bindings = CloudflareStorageBindings & {
EDGE_EVER_AUTH_LOGIN_IP_COOLDOWN_SECONDS?: string;
EDGE_EVER_R2_BUCKET_NAME?: string;
EDGE_EVER_STORAGE_ENCRYPTION_KEY?: string;
EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY?: string;
EDGE_EVER_DEMO_MODE?: string;
EDGE_EVER_LOCAL_DEMO_SEED?: string;
EDGE_EVER_ALLOW_UNAUTHENTICATED?: string;
+4
View File
@@ -113,6 +113,7 @@ import { registerTemplateRoutes } from "./template-routes";
import { registerAuthRoutes, type UserRow } from "./auth-routes";
import { registerApiTokenRoutes, type ApiTokenRow } from "./api-token-routes";
import { registerObjectStorageRoutes } from "./object-storage-routes";
import { registerAiRoutes } from "./ai-routes";
import { registerResourceRoutes } from "./resource-routes";
import { registerSyncRoutes } from "./sync-routes";
import { registerMemoRoutes } from "./memo-routes";
@@ -323,6 +324,9 @@ app.use("/api/v1/*", async (c, next) => {
registerObjectStorageRoutes(app, {
isDemoMode: (...args) => isDemoMode(...args),
});
registerAiRoutes(app, {
isDemoMode: (...args) => isDemoMode(...args),
});
registerApiTokenRoutes(app, {
sha256: (...args) => sha256(...args),
+52
View File
@@ -79,6 +79,7 @@ import { sanitizeAndScopeCss } from "@/lib/css-sandbox";
import { RevisionHistoryDialog } from "./dialogs/RevisionHistoryDialog";
import { ExternalLinkDialog } from "./dialogs/ExternalLinkDialog";
import { memoShareQueryKey, ShareMemoDialog } from "./dialogs/ShareMemoDialog";
import { AiAssistantDialog } from "./dialogs/AiAssistantDialog";
import { api } from "@/lib/api";
import { isDesktopResourceRuntime, stageDesktopResource, toDesktopResourceUrl } from "@/lib/desktop-resources";
import { cn, formatDateTime, parseTagsText } from "@/lib/utils";
@@ -675,6 +676,7 @@ const RichEditorPane = ({
const [imageUploadState, setImageUploadState] = useState<"idle" | "compressing" | "uploading" | "error">("idle");
const [historyOpen, setHistoryOpen] = useState(false);
const [shareOpen, setShareOpen] = useState(false);
const [aiAssistantOpen, setAiAssistantOpen] = useState(false);
const [systemInfoOpen, setSystemInfoOpen] = useState(false);
const [updateAvailable, setUpdateAvailable] = useState(false);
const [mobileNotebookSheetOpen, setMobileNotebookSheetOpen] = useState(false);
@@ -1784,6 +1786,32 @@ const RichEditorPane = ({
markDirtyStatus();
}, [markDirtyStatus]);
const getCurrentMarkdownForAi = useCallback(() => {
if (useMobilePlainTextEditor) return getMobilePlainTextValue();
if (useMarkdownSourceEditor) return markdownSource;
return isEditorReady(editor)
? docToMarkdown(editor.getJSON() as TiptapDoc)
: memoRef.current?.contentMarkdown ?? "";
}, [editor, getMobilePlainTextValue, markdownSource, useMarkdownSourceEditor, useMobilePlainTextEditor]);
const applyAiDraft = useCallback((draft: string, mode: "append" | "replace") => {
const current = getCurrentMarkdownForAi();
const next = mode === "append" && current.trim()
? `${current.replace(/\s+$/, "")}\n\n${draft}`
: draft;
if (useMobilePlainTextEditor) {
setMobilePlainText(next);
setMobilePlainTextElementValue(mobileTextAreaRef.current, next);
persistCurrentDraft(title, tagsText, next);
} else if (useMarkdownSourceEditor) {
setMarkdownSource(next);
} else if (isEditorReady(editor)) {
editor.commands.setContent(markdownToDoc(next));
}
markDirty();
setAiAssistantOpen(false);
}, [editor, getCurrentMarkdownForAi, markDirty, persistCurrentDraft, tagsText, title, useMarkdownSourceEditor, useMobilePlainTextEditor]);
const getCurrentContentJson = useCallback((): TiptapDoc | null => {
if (useMobilePlainTextEditor) {
return markdownToDoc(getMobilePlainTextValue());
@@ -3366,6 +3394,13 @@ const RichEditorPane = ({
<Search className="h-5 w-5" strokeWidth={2.25} />
</Button>
</IconTooltip>
{!readOnly && (
<IconTooltip label={t("aiAssistant.open")}>
<Button className="hidden h-8 w-8 text-emerald-600 transition-colors hover:bg-emerald-50 hover:text-emerald-800 focus-visible:ring-2 focus-visible:ring-emerald-300 sm:inline-flex" size="icon" variant="ghost" aria-label={t("aiAssistant.open")} onClick={() => setAiAssistantOpen(true)}>
<Sparkles className="h-5 w-5" strokeWidth={2.25} />
</Button>
</IconTooltip>
)}
<TooltipProvider delayDuration={0} skipDelayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
@@ -3438,6 +3473,15 @@ const RichEditorPane = ({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44 bg-white border border-slate-200 rounded-md py-1 shadow-md">
{!readOnly && (
<DropdownMenuItem
className="flex h-9 w-full items-center gap-2 px-3 text-left text-sm text-emerald-700 hover:bg-emerald-50 cursor-pointer outline-none"
onClick={() => setAiAssistantOpen(true)}
>
<Sparkles className="h-4 w-4 text-emerald-600" />
{t("aiAssistant.title")}
</DropdownMenuItem>
)}
<DropdownMenuItem
className="flex h-9 w-full items-center gap-2 px-3 text-left text-sm text-slate-700 hover:bg-slate-50 cursor-pointer outline-none"
onClick={() => openNoteSearch()}
@@ -4115,6 +4159,14 @@ const RichEditorPane = ({
<SystemInfoDialog open={systemInfoOpen} onOpenChange={setSystemInfoOpen} />
<AiAssistantDialog
open={aiAssistantOpen}
title={title}
contentMarkdown={getCurrentMarkdownForAi()}
onOpenChange={setAiAssistantOpen}
onApply={applyAiDraft}
/>
<ShareMemoDialog memoId={memo.id} open={shareOpen} onOpenChange={setShareOpen} />
{mobileNotebookSheetOpen && (
+2
View File
@@ -31,6 +31,7 @@ import { PasswordCard } from "./settings/PasswordCard";
import { SessionCard } from "./settings/SessionCard";
import { UserManagementCard } from "./settings/UserManagementCard";
import { ObjectStorageCard } from "./settings/ObjectStorageCard";
import { AiModelCard } from "./settings/AiModelCard";
import { ThemeToggle } from "./ThemeToggle";
import type { AuthUser } from "@edgeever/shared";
import { contentEnterMotion } from "@/lib/motion";
@@ -220,6 +221,7 @@ export const SettingsPane = ({
case "ai":
return (
<SettingsGroup>
<AiModelCard demoMode={demoMode} />
<AdvancedPlayCard />
<McpConfigCard />
</SettingsGroup>
@@ -0,0 +1,126 @@
import { useEffect, useRef, useState } from "react";
import type { AiAction } from "@edgeever/shared";
import { Check, Copy, Loader2, Sparkles, Square } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { api, ApiRequestError } from "@/lib/api";
import { copyTextToClipboard } from "@/lib/clipboard";
import { cn } from "@/lib/utils";
export const AiAssistantDialog = ({
open,
title,
contentMarkdown,
onOpenChange,
onApply,
}: {
open: boolean;
title: string;
contentMarkdown: string;
onOpenChange: (open: boolean) => void;
onApply: (text: string, mode: "append" | "replace") => void;
}) => {
const { t } = useTranslation();
const [action, setAction] = useState<AiAction>("summarize");
const [targetLanguage, setTargetLanguage] = useState("");
const [output, setOutput] = useState("");
const [error, setError] = useState<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const [copied, setCopied] = useState(false);
const controllerRef = useRef<AbortController | null>(null);
useEffect(() => () => controllerRef.current?.abort(), []);
useEffect(() => {
if (!open) controllerRef.current?.abort();
}, [open]);
const generate = async () => {
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
setOutput("");
setError(null);
setCopied(false);
setIsGenerating(true);
try {
await api.streamAiGeneration(
{ action, title, contentMarkdown, ...(action === "translate" ? { targetLanguage } : {}) },
{
signal: controller.signal,
onEvent: (event) => {
if (event.type === "text-delta") setOutput((current) => current + event.text);
if (event.type === "error") setError(event.message);
},
},
);
} catch (caught) {
if (caught instanceof DOMException && caught.name === "AbortError") return;
setError(caught instanceof ApiRequestError && caught.code === "ai_not_configured"
? t("aiAssistant.configure")
: caught instanceof Error ? caught.message : t("aiModel.failed"));
} finally {
if (controllerRef.current === controller) {
controllerRef.current = null;
setIsGenerating(false);
}
}
};
const copy = async () => {
if (await copyTextToClipboard(output)) {
setCopied(true);
window.setTimeout(() => setCopied(false), 1600);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90dvh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2"><Sparkles className="h-5 w-5 text-emerald-600" />{t("aiAssistant.title")}</DialogTitle>
<DialogDescription>{t("aiAssistant.description")}</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-5">
{(["summarize", "extract-key-points", "extract-todos", "rewrite-proofread", "translate"] as AiAction[]).map((item) => (
<Button key={item} type="button" variant={action === item ? "solid" : "outline"} onClick={() => setAction(item)}>{t(`aiAssistant.actions.${item}`)}</Button>
))}
</div>
{action === "translate" ? (
<label className="grid gap-1.5 text-sm font-medium text-slate-700">
{t("aiAssistant.targetLanguage")}
<input className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 text-sm outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-500/15" value={targetLanguage} onChange={(event) => setTargetLanguage(event.target.value)} placeholder={t("aiAssistant.targetLanguagePlaceholder")} maxLength={80} />
</label>
) : null}
<div className="grid gap-1.5">
<span className="text-sm font-medium text-slate-700">{t("aiAssistant.result")}</span>
<div className={cn("min-h-48 whitespace-pre-wrap rounded-lg border bg-slate-50 p-4 text-sm leading-6 text-slate-800", error ? "border-rose-200" : "border-slate-200")}>
{output || <span className="text-slate-400">{t("aiAssistant.resultPlaceholder")}</span>}
</div>
{error ? <p className="text-xs font-medium text-rose-600" role="alert">{error}</p> : null}
</div>
</div>
<DialogFooter className="flex-wrap sm:justify-between">
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" disabled={!output || isGenerating} onClick={() => void copy()}>{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}{t(copied ? "aiAssistant.copied" : "aiAssistant.copy")}</Button>
<Button type="button" variant="outline" disabled={!output || isGenerating} onClick={() => onApply(output, "append")}>{t("aiAssistant.append")}</Button>
<Button type="button" variant="outline" disabled={!output || isGenerating} onClick={() => onApply(output, "replace")}>{t("aiAssistant.replace")}</Button>
</div>
{isGenerating ? (
<Button type="button" variant="solid" onClick={() => controllerRef.current?.abort()}><Square className="h-3.5 w-3.5" />{t("aiAssistant.stop")}</Button>
) : (
<Button type="button" variant="solid" disabled={action === "translate" && !targetLanguage.trim()} onClick={() => void generate()}><Loader2 className="hidden h-4 w-4" />{t("aiAssistant.generate")}</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,136 @@
import { useEffect, useState, type FormEvent, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AiProvider } from "@edgeever/shared";
import { CheckCircle2, Loader2, Sparkles, TriangleAlert } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { ApiRequestError, api } from "@/lib/api";
const providerDefaults: Record<AiProvider, { displayName: string; baseUrl: string; modelId: string }> = {
"openai-compatible": { displayName: "OpenAI-compatible", baseUrl: "https://api.openai.com/v1", modelId: "gpt-4.1-mini" },
anthropic: { displayName: "Anthropic", baseUrl: "https://api.anthropic.com/v1", modelId: "claude-sonnet-4-5" },
google: { displayName: "Google Gemini", baseUrl: "https://generativelanguage.googleapis.com/v1beta", modelId: "gemini-2.5-flash" },
};
export const AiModelCard = ({ demoMode }: { demoMode: boolean }) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const settingsQuery = useQuery({ queryKey: ["ai-settings"], queryFn: api.getAiSettings });
const [provider, setProvider] = useState<AiProvider>("openai-compatible");
const [displayName, setDisplayName] = useState(providerDefaults["openai-compatible"].displayName);
const [baseUrl, setBaseUrl] = useState(providerDefaults["openai-compatible"].baseUrl);
const [modelId, setModelId] = useState(providerDefaults["openai-compatible"].modelId);
const [apiKey, setApiKey] = useState("");
const [isEnabled, setIsEnabled] = useState(true);
useEffect(() => {
const settings = settingsQuery.data?.settings;
if (!settings) return;
setProvider(settings.provider);
setDisplayName(settings.displayName);
setBaseUrl(settings.baseUrl);
setModelId(settings.modelId);
setIsEnabled(settings.isEnabled);
}, [settingsQuery.data]);
const payload = () => ({
provider,
displayName,
baseUrl,
modelId,
...(apiKey ? { apiKey } : {}),
});
const testMutation = useMutation({ mutationFn: () => api.testAiConnection(payload()) });
const saveMutation = useMutation({
mutationFn: () => api.updateAiSettings({ ...payload(), isEnabled }),
onSuccess: async () => {
setApiKey("");
await queryClient.invalidateQueries({ queryKey: ["ai-settings"] });
},
});
const handleProviderChange = (next: AiProvider) => {
const previousDefaults = providerDefaults[provider];
const nextDefaults = providerDefaults[next];
setProvider(next);
if (!displayName || displayName === previousDefaults.displayName) setDisplayName(nextDefaults.displayName);
if (!baseUrl || baseUrl === previousDefaults.baseUrl) setBaseUrl(nextDefaults.baseUrl);
if (!modelId || modelId === previousDefaults.modelId) setModelId(nextDefaults.modelId);
testMutation.reset();
saveMutation.reset();
};
const errorMessage = (error: unknown) => {
if (error instanceof ApiRequestError && error.code === "ai_encryption_key_missing") {
return t("aiModel.encryptionKeyMissing");
}
return error instanceof Error ? error.message : t("aiModel.failed");
};
const encryptionConfigured = settingsQuery.data?.encryptionConfigured ?? false;
const hasSavedKey = settingsQuery.data?.settings?.hasApiKey ?? false;
const submit = (event: FormEvent) => {
event.preventDefault();
saveMutation.mutate();
};
return (
<Card className="w-full min-w-0 overflow-hidden shadow-none">
<CardHeader className="p-4 sm:p-5">
<CardTitle className="flex items-center gap-2 text-sm"><Sparkles className="h-4 w-4 text-emerald-700" />{t("aiModel.title")}</CardTitle>
<CardDescription>{t("aiModel.description")}</CardDescription>
</CardHeader>
<CardContent className="p-4 pt-0 sm:px-5 sm:pb-5">
{settingsQuery.isLoading ? (
<p className="flex items-center gap-2 text-sm text-slate-500"><Loader2 className="h-4 w-4 animate-spin" />{t("common.loading")}</p>
) : (
<form className="grid gap-4" onSubmit={submit}>
{!encryptionConfigured ? (
<p className="flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs leading-5 text-amber-800">
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" />{t("aiModel.encryptionKeyMissing")}
</p>
) : null}
<div className="grid gap-4 sm:grid-cols-2">
<Field label={t("aiModel.provider")}>
<Select value={provider} onValueChange={(value) => handleProviderChange(value as AiProvider)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="openai-compatible">{t("aiModel.providers.openai-compatible")}</SelectItem>
<SelectItem value="anthropic">{t("aiModel.providers.anthropic")}</SelectItem>
<SelectItem value="google">{t("aiModel.providers.google")}</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label={t("aiModel.displayName")}><Input value={displayName} onChange={(event) => setDisplayName(event.target.value)} required maxLength={80} /></Field>
<Field label={t("aiModel.baseUrl")}><Input value={baseUrl} onChange={(event) => setBaseUrl(event.target.value)} required inputMode="url" /></Field>
<Field label={t("aiModel.modelId")}><Input value={modelId} onChange={(event) => setModelId(event.target.value)} required /></Field>
<Field label={t("aiModel.apiKey")} hint={hasSavedKey ? t("aiModel.apiKeySavedHint") : undefined}>
<Input type="password" value={apiKey} onChange={(event) => setApiKey(event.target.value)} required={!hasSavedKey} autoComplete="new-password" placeholder={hasSavedKey ? "••••••••••••" : ""} />
</Field>
<label className="flex items-center justify-between gap-3 rounded-lg border border-slate-200 bg-white px-3 py-2.5 text-sm font-medium text-slate-700">
<span><span className="block">{t("aiModel.enabled")}</span><span className="mt-0.5 block text-xs font-normal text-slate-500">{t("aiModel.enabledHint")}</span></span>
<Switch checked={isEnabled} onCheckedChange={setIsEnabled} />
</label>
</div>
{testMutation.isSuccess ? <p className="flex items-center gap-1.5 text-xs font-medium text-emerald-700"><CheckCircle2 className="h-4 w-4" />{t("aiModel.testSucceeded")}</p> : null}
{saveMutation.isSuccess ? <p className="flex items-center gap-1.5 text-xs font-medium text-emerald-700"><CheckCircle2 className="h-4 w-4" />{t("aiModel.saved")}</p> : null}
{testMutation.isError ? <p className="text-xs font-medium text-rose-600" role="alert">{errorMessage(testMutation.error)}</p> : null}
{saveMutation.isError ? <p className="text-xs font-medium text-rose-600" role="alert">{errorMessage(saveMutation.error)}</p> : null}
<div className="flex flex-wrap justify-end gap-2">
<Button type="button" variant="outline" disabled={testMutation.isPending || saveMutation.isPending || (!apiKey && !hasSavedKey)} onClick={() => testMutation.mutate()}>{testMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}{t("aiModel.test")}</Button>
<Button type="submit" disabled={demoMode || saveMutation.isPending || !encryptionConfigured || (!apiKey && !hasSavedKey)}>{saveMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}{t("common.save")}</Button>
</div>
</form>
)}
</CardContent>
</Card>
);
};
const Field = ({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) => (
<label className="grid gap-1.5 text-sm font-medium text-slate-700">{label}{children}{hint ? <span className="text-xs font-normal leading-4 text-slate-500">{hint}</span> : null}</label>
);
+71
View File
@@ -18,6 +18,10 @@ import type {
ResourceListItem,
ResourceStorageSummary,
ObjectStorageSettings,
AiModelSettings,
AiProvider,
AiAction,
AiStreamEvent,
PublicMemoShare,
TagSummary,
TiptapDoc,
@@ -63,6 +67,19 @@ type ObjectStorageSettingsResponse = {
settings: ObjectStorageSettings;
externalSettings?: ObjectStorageSettings | null;
};
type AiSettingsResponse = {
settings: AiModelSettings | null;
encryptionConfigured: boolean;
};
export type AiSettingsPayload = {
provider: AiProvider;
displayName: string;
baseUrl: string;
apiKey?: string;
modelId: string;
isEnabled: boolean;
};
const WEB_DEVICE_ID_STORAGE_KEY = "edgeever.web.device-id";
export const DESKTOP_API_BASE_URL_STORAGE_KEY = "edgeever.desktop.api-base-url";
@@ -429,6 +446,60 @@ export const api = {
body: JSON.stringify(payload),
}),
getAiSettings: () => request<AiSettingsResponse>("/api/v1/ai/settings"),
testAiConnection: (payload: Omit<AiSettingsPayload, "isEnabled">) =>
request<{ ok: true; response: string }>("/api/v1/ai/settings/test", {
method: "POST",
body: JSON.stringify(payload),
}),
updateAiSettings: (payload: AiSettingsPayload) =>
request<AiSettingsResponse>("/api/v1/ai/settings", {
method: "PUT",
body: JSON.stringify(payload),
}),
streamAiGeneration: async (
payload: { action: AiAction; title: string; contentMarkdown: string; targetLanguage?: string },
options: { signal?: AbortSignal; onEvent: (event: AiStreamEvent) => void },
) => {
const headers = new Headers({ "Content-Type": "application/json" });
const sessionToken = typeof window !== "undefined" && window.edgeeverDesktop?.isAvailable
? getDesktopSessionToken()
: undefined;
if (sessionToken) headers.set("Authorization", `Bearer ${sessionToken}`);
const response = await fetch(`${getConfiguredDesktopApiBaseUrl()}/api/v1/ai/generate`, {
method: "POST",
credentials: "include",
headers,
body: JSON.stringify(payload),
signal: options.signal,
});
if (!response.ok) {
const body = await response.json().catch(() => null) as { error?: { code?: string; message?: string } } | null;
throw new ApiRequestError(body?.error?.message || response.statusText, response.status, body?.error?.code);
}
if (!response.body) throw new ApiRequestError("Streaming response is unavailable", 502, "ai_stream_unavailable");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
buffer += decoder.decode(value, { stream: !done });
const frames = buffer.split("\n\n");
buffer = frames.pop() ?? "";
for (const frame of frames) {
const data = frame.split("\n").find((line) => line.startsWith("data: "))?.slice(6);
if (data) options.onEvent(JSON.parse(data) as AiStreamEvent);
}
if (done) break;
}
const trailingData = buffer.split("\n").find((line) => line.startsWith("data: "))?.slice(6);
if (trailingData) options.onEvent(JSON.parse(trailingData) as AiStreamEvent);
},
updateNotebook: (notebookId: string, payload: { name?: string; parentId?: string | null; sortOrder?: number }) =>
request<NotebookResponse>(`/api/v1/notebooks/${notebookId}`, {
method: "PATCH",
+30 -2
View File
@@ -5,6 +5,9 @@
"": {
"name": "edgeever",
"dependencies": {
"@ai-sdk/anthropic": "^4.0.36",
"@ai-sdk/google": "^4.0.39",
"@ai-sdk/openai-compatible": "^3.0.27",
"@aws-sdk/client-s3": "^3.1095.0",
"@edgeever/shared": "workspace:*",
"@hono/zod-validator": "latest",
@@ -25,6 +28,7 @@
"@tiptap/extension-table": "3.27.1",
"@tiptap/react": "latest",
"@tiptap/starter-kit": "latest",
"ai": "^7.0.58",
"aws4fetch": "^1.0.20",
"class-variance-authority": "latest",
"clsx": "latest",
@@ -79,7 +83,7 @@
},
"apps/desktop": {
"name": "@edgeever/desktop",
"version": "1.13.3",
"version": "1.14.2",
"dependencies": {
"electron-updater": "^6.6.2",
},
@@ -90,7 +94,7 @@
},
"apps/extension": {
"name": "@edgeever/extension",
"version": "0.1.3",
"version": "0.1.4",
"dependencies": {
"@mozilla/readability": "^0.6.0",
"turndown": "^7.2.4",
@@ -225,6 +229,18 @@
"packages": {
"@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@4.0.36", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wg5jfray0X4+qkrr73GZ7U7K2JNGx+S+rNY9LorVlXhkhTqnvtNLYWRA1WxSxlCDCw3yjRCJdL9kyc+b7naAog=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.25", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LIAO6kAG8fpXQb9L0iwPk1FIbXftvqnyC56v5NEAzeWTeL8fUsy/Hx86VPBTWEDFdwbVprjWifJOAqS6AOj3mA=="],
"@ai-sdk/google": ["@ai-sdk/google@4.0.39", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+mRx7UBZn9PkJ4J6YXowaRZKMZYa290cknVqqOw/roZaDg186IUOLn9JHNQkvgaj91/MLW1AZNESy1ZD2yXDCg=="],
"@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-icGSoBDbFKZotUwehg7h/JjtD7arweeKfeDcsT9UsPY+YdwiT94JciD59bqAOAdNA3wlS+klW2vsaVLxTUUhbA=="],
"@ai-sdk/provider": ["@ai-sdk/provider@4.0.7", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6or44XprPzKbr8zkmzosowSE0pxkvJcoojBL+mCZvPUt3kvXp3XSNqeVun9golb1acEfSo6yaEBRT18h2VU+1Q=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.25", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-xscPPHCSjCHWrdhai25sbHCJeKNLW/3D1uSpUZa4cEtTKXA8OnPQ3+Rfu1SmM5Ea/Mf8Dfn3cllw9zeMzo/zFA=="],
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="],
@@ -1151,6 +1167,8 @@
"@speed-highlight/core": ["@speed-highlight/core@1.2.17", "", {}, "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="],
"@tailwindcss/node": ["@tailwindcss/node@4.3.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "5.21.6", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.2" } }, "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg=="],
@@ -1419,6 +1437,8 @@
"@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="],
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.3", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg=="],
"@volar/kit": ["@volar/kit@2.4.28", "", { "dependencies": { "@volar/language-service": "2.4.28", "@volar/typescript": "2.4.28", "typesafe-path": "^0.2.2", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "typescript": "*" } }, "sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg=="],
@@ -1437,6 +1457,8 @@
"@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="],
"@workflow/serde": ["@workflow/serde@4.1.0", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="],
"abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="],
@@ -1461,6 +1483,8 @@
"agent-cli-detector": ["agent-cli-detector@0.1.2", "", { "bin": { "agent-cli-detector": "dist/cli.js" } }, "sha512-qdZ/9JFORtTKJNhT/IczMeEfEUbUU0K5umYeiIQHX+AjHs+Y9SXVzSgaYlpZeyNMrvuh2HpZiOTpvS57iPfBkQ=="],
"ai": ["ai@7.0.58", "", { "dependencies": { "@ai-sdk/gateway": "4.0.46", "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.25" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-GfgO90CQQ0yYuoxJAUOeQ6tviyYw1BUIDygSZ1q3Ce6kSc93tYmB5eltKY/NxC0YOouAax7JvDqVnYxvIAr04Q=="],
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
"ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="],
@@ -2087,6 +2111,8 @@
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
"eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
"expo": ["expo@57.0.7", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "^57.0.9", "@expo/config": "~57.0.5", "@expo/config-plugins": "~57.0.5", "@expo/devtools": "~57.0.1", "@expo/dom-webview": "~57.0.1", "@expo/fingerprint": "^0.20.5", "@expo/local-build-cache-provider": "^57.0.4", "@expo/log-box": "^57.0.1", "@expo/metro": "~56.0.0", "@expo/metro-config": "~57.0.6", "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~57.0.3", "expo-asset": "~57.0.6", "expo-constants": "~57.0.6", "expo-file-system": "~57.0.1", "expo-font": "~57.0.1", "expo-keep-awake": "~57.0.1", "expo-modules-autolinking": "~57.0.8", "expo-modules-core": "~57.0.6", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.2" }, "peerDependencies": { "@expo/metro-runtime": "*", "react": "*", "react-dom": "*", "react-native": "*", "react-native-web": "*", "react-native-webview": "*" }, "optionalPeers": ["@expo/metro-runtime", "react-dom", "react-native-web", "react-native-webview"], "bin": { "expo": "bin/cli", "fingerprint": "bin/fingerprint", "expo-modules-autolinking": "bin/autolinking" } }, "sha512-PJdE0EjoX878OqClmsigVKdT0jCNOAQDRLcvFkeBakxaVyEDhjcQ8baKq2YysYH2g0YNB1rEIpHUiKtluO918A=="],
"expo-asset": ["expo-asset@57.0.6", "", { "dependencies": { "@expo/image-utils": "^0.11.3", "expo-constants": "~57.0.6" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-n3Yb1VxcP+BMRTyC4R1x2It4+m5EDkNXiVCHGWbnIREQUUkMs2Yeul7D5qfFWAYtIn2Z3hbGMndwU6Az1FPSEg=="],
@@ -2551,6 +2577,8 @@
"json-merge-patch": ["json-merge-patch@1.0.2", "", { "dependencies": { "fast-deep-equal": "^3.1.3" } }, "sha512-M6Vp2GN9L7cfuMXiWOmHj9bEFbeC250iVtcKQbqVgEsDVYnIsrNsbU+h/Y/PkbBQCtEa4Bez+Ebv0zfbC8ObLg=="],
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
+1 -1
View File
@@ -66,7 +66,7 @@ To configure an S3-compatible object store from **Settings → Advanced**, add a
bunx wrangler secret put EDGE_EVER_STORAGE_ENCRYPTION_KEY
```
Use a random value of at least 32 characters and keep a secure backup. EdgeEver encrypts the external Secret Access Key before storing it in D1. Losing or changing this encryption key makes previously saved external credentials unusable. After adding the secret, redeploy or restart the Worker, then use **Test connection** before saving the OSS configuration.
Use a random value of at least 32 characters and keep a secure backup. EdgeEver encrypts the external Secret Access Key before storing it in D1. Losing or changing this encryption key makes previously saved external credentials unusable. After adding the secret, redeploy or restart the Worker, then use **Test connection** before saving the OSS configuration. Personal AI model credentials use the existing instance authentication secret automatically and do not require this variable.
## Recovery
+1 -1
View File
@@ -66,7 +66,7 @@ bun run deploy:manual
bunx wrangler secret put EDGE_EVER_STORAGE_ENCRYPTION_KEY
```
请使用至少 32 个字符的随机值并安全备份。EdgeEver 会先加密外部 Secret Access Key,再将其保存到 D1。丢失或更换这个加密密钥会导致之前保存的外部凭据无法使用。添加 Secret 后重新部署或重启 Worker,然后先使用“测试连接”,再保存 OSS 配置。
请使用至少 32 个字符的随机值并安全备份。EdgeEver 会先加密外部 Secret Access Key,再将其保存到 D1。丢失或更换这个加密密钥会导致之前保存的外部凭据无法使用。添加 Secret 后重新部署或重启 Worker,然后先使用“测试连接”,再保存 OSS 配置。个人 AI 模型凭据会自动使用已有的实例认证 Secret,不需要配置这个变量。
## 故障恢复
+108
View File
@@ -67,6 +67,89 @@
}
}
},
"/api/v1/ai/settings": {
"get": {
"summary": "Read the current workspace AI model settings",
"responses": {
"200": {
"description": "AI model settings with the API key omitted",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"settings": { "anyOf": [{ "$ref": "#/components/schemas/AiModelSettings" }, { "type": "null" }] },
"encryptionConfigured": { "type": "boolean" }
},
"required": ["settings", "encryptionConfigured"]
}
}
}
},
"401": { "$ref": "#/components/responses/Error" },
"403": { "$ref": "#/components/responses/Error" }
}
},
"put": {
"summary": "Save the current workspace AI model settings",
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/AiModelSettingsInput" } } }
},
"responses": {
"200": { "description": "AI model settings saved" },
"400": { "$ref": "#/components/responses/Error" },
"401": { "$ref": "#/components/responses/Error" },
"403": { "$ref": "#/components/responses/Error" }
}
}
},
"/api/v1/ai/settings/test": {
"post": {
"summary": "Test an AI model endpoint without exposing its credential",
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/AiModelSettingsInput" } } }
},
"responses": {
"200": { "description": "The model endpoint responded successfully" },
"400": { "$ref": "#/components/responses/Error" },
"401": { "$ref": "#/components/responses/Error" },
"403": { "$ref": "#/components/responses/Error" }
}
}
},
"/api/v1/ai/generate": {
"post": {
"summary": "Stream a note AI action using the current workspace model",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"action": { "type": "string", "enum": ["summarize", "extract-key-points", "extract-todos", "rewrite-proofread", "translate"] },
"title": { "type": "string", "maxLength": 160 },
"contentMarkdown": { "type": "string", "maxLength": 300000 },
"targetLanguage": { "type": "string", "maxLength": 80, "description": "Required when action is translate" }
},
"required": ["action", "title", "contentMarkdown"]
}
}
}
},
"responses": {
"200": {
"description": "Server-sent events containing start, text-delta, finish, or error events",
"content": { "text/event-stream": { "schema": { "type": "string" } } }
},
"401": { "$ref": "#/components/responses/Error" },
"403": { "$ref": "#/components/responses/Error" },
"409": { "$ref": "#/components/responses/Error" }
}
}
},
"/api/v1/auth/session": {
"get": {
"summary": "Read interactive session",
@@ -1737,6 +1820,31 @@
] }
]
},
"AiModelSettings": {
"type": "object",
"properties": {
"provider": { "type": "string", "enum": ["openai-compatible", "anthropic", "google"] },
"displayName": { "type": "string" },
"baseUrl": { "type": "string", "format": "uri" },
"modelId": { "type": "string" },
"isEnabled": { "type": "boolean" },
"hasApiKey": { "type": "boolean" },
"encryptionConfigured": { "type": "boolean" }
},
"required": ["provider", "displayName", "baseUrl", "modelId", "isEnabled", "hasApiKey", "encryptionConfigured"]
},
"AiModelSettingsInput": {
"type": "object",
"properties": {
"provider": { "type": "string", "enum": ["openai-compatible", "anthropic", "google"] },
"displayName": { "type": "string", "minLength": 1, "maxLength": 80 },
"baseUrl": { "type": "string", "format": "uri", "maxLength": 500 },
"apiKey": { "type": "string", "minLength": 1, "maxLength": 4096 },
"modelId": { "type": "string", "minLength": 1, "maxLength": 200 },
"isEnabled": { "type": "boolean", "default": true }
},
"required": ["provider", "displayName", "baseUrl", "modelId"]
},
"LoginInput": {
"type": "object",
"properties": {
+8 -4
View File
@@ -53,10 +53,12 @@ ambiguous.
- Keep `/api/*`, `/mcp`, `/api/openapi.json`, and `/api/health` unchanged.
- Keep the current migration files append-only; do not fork the schema for
Docker.
- Keep root secrets such as `EDGE_EVER_STORAGE_ENCRYPTION_KEY` in environment
variables or Docker secrets, never in the image or database. Credentials
entered through the instance settings may be stored only as AES-GCM
ciphertext protected by that deployment-level key.
- Keep root secrets in environment variables or Docker secrets, never in the
image or database. Object-storage secrets use `EDGE_EVER_STORAGE_ENCRYPTION_KEY`.
Personal AI model API keys automatically use an AI-specific key derived from
the existing instance authentication secret; an optional
`EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY` can override it for advanced key rotation.
Stored credentials must remain AES-GCM ciphertext.
- Make `/data` the only required persistent application path so NAS users can
back up one volume.
- Support `EDGE_EVER_AUTH_USERNAME`, `EDGE_EVER_AUTH_PASSWORD`, and session
@@ -81,6 +83,8 @@ EDGE_EVER_AUTH_PASSWORD='<strong-password>' bun run start:self-hosted
Set `EDGE_EVER_DATA_DIR` to the directory that should be persisted by Docker
or a NAS volume. This runtime is not yet a supported release artifact.
Long-running streaming responses use a 120-second idle timeout by default. Set
`EDGE_EVER_IDLE_TIMEOUT_SECONDS` to a value from 10 to 255 to override it.
The same runtime can use an S3-compatible object store:
+3 -1
View File
@@ -45,7 +45,7 @@ EdgeEver 容器
- 保持 `/api/*`、`/mcp`、`/api/openapi.json` 和 `/api/health` 不变。
- 继续追加 `migrations/*.sql`,禁止为 Docker 分叉数据库结构。
- `EDGE_EVER_STORAGE_ENCRYPTION_KEY` 等根密钥必须通过环境变量或 Docker secrets 注入,不能写入镜像或数据库;从实例设置录入的凭据只能以该部署级密钥保护的 AES-GCM 密文形式保存。
- 根密钥必须通过环境变量或 Docker secrets 注入,不能写入镜像或数据库。对象存储 Secret 使用 `EDGE_EVER_STORAGE_ENCRYPTION_KEY`;个人 AI 模型 API Key 默认从已有实例认证 Secret 派生 AI 专用密钥,高级密钥轮换场景可选用 `EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY` 覆盖。所有凭据在数据库中只能保存为 AES-GCM 密文。
- 将 `/data` 作为唯一必需的应用持久化路径,方便 NAS 用户备份一个卷。
- 容器入口需要支持 `EDGE_EVER_AUTH_USERNAME`、`EDGE_EVER_AUTH_PASSWORD` 和
会话配置,同时不能把 Cloudflare 专有配置当作前置条件。
@@ -65,6 +65,8 @@ EDGE_EVER_AUTH_PASSWORD='<强密码>' bun run start:self-hosted
可通过 `EDGE_EVER_DATA_DIR` 指定需要由 Docker 或 NAS 卷持久化的目录。该入口
目前仍不是正式支持的发行物。
长时间流式响应默认使用 120 秒空闲超时。可将
`EDGE_EVER_IDLE_TIMEOUT_SECONDS` 设置为 10 到 255 之间的值进行覆盖。
同一个入口也可以使用 S3 兼容对象存储:
+21
View File
@@ -0,0 +1,21 @@
PRAGMA foreign_keys = ON;
CREATE TABLE ai_model_configs (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
provider TEXT NOT NULL CHECK (provider IN ('openai-compatible', 'anthropic', 'google')),
display_name TEXT NOT NULL CHECK (length(trim(display_name)) > 0),
base_url TEXT NOT NULL CHECK (length(trim(base_url)) > 0),
api_key_encrypted TEXT NOT NULL,
model_id TEXT NOT NULL CHECK (length(trim(model_id)) > 0),
is_enabled INTEGER NOT NULL DEFAULT 1 CHECK (is_enabled IN (0, 1)),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
FOREIGN KEY (workspace_id) REFERENCES workspaces(id)
ON UPDATE CASCADE
ON DELETE CASCADE,
UNIQUE (workspace_id)
);
CREATE INDEX idx_ai_model_configs_workspace
ON ai_model_configs(workspace_id, is_enabled);
+7
View File
@@ -94,10 +94,16 @@
},
"EDGE_EVER_STORAGE_ENCRYPTION_KEY": {
"description": "可选。启用高级设置中的第三方 OSS 时必填,用于加密保存在 D1 中的对象存储访问密钥。"
},
"EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY": {
"description": "可选的高级配置。覆盖从实例认证 Secret 自动派生的 AI 凭据加密密钥,用于独立密钥轮换。"
}
}
},
"dependencies": {
"@ai-sdk/anthropic": "^4.0.36",
"@ai-sdk/google": "^4.0.39",
"@ai-sdk/openai-compatible": "^3.0.27",
"@aws-sdk/client-s3": "^3.1095.0",
"@edgeever/shared": "workspace:*",
"@hono/zod-validator": "latest",
@@ -118,6 +124,7 @@
"@tiptap/extension-table": "3.27.1",
"@tiptap/react": "latest",
"@tiptap/starter-kit": "latest",
"ai": "^7.0.58",
"aws4fetch": "^1.0.20",
"class-variance-authority": "latest",
"clsx": "latest",
+69
View File
@@ -21,6 +21,10 @@ import type {
PublicMemoShare,
TagSummary,
TiptapDoc,
AiAction,
AiModelSettings,
AiProvider,
AiStreamEvent,
} from "@edgeever/shared";
export type EdgeEverClientOptions = {
@@ -112,6 +116,20 @@ export type JsonBackupPage = MarkdownExportPage & {
revisions: JsonBackupRevision[];
};
export type AiSettingsResponse = {
settings: AiModelSettings | null;
encryptionConfigured: boolean;
};
export type AiSettingsPayload = {
provider: AiProvider;
displayName: string;
baseUrl: string;
apiKey?: string;
modelId: string;
isEnabled: boolean;
};
export type MobileSyncBootstrapPage = {
notebooks: Notebook[];
memos: MemoDetail[];
@@ -216,6 +234,57 @@ export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => {
body: JSON.stringify(payload),
}),
getAiSettings: () => request<AiSettingsResponse>("/api/v1/ai/settings"),
testAiConnection: (payload: Omit<AiSettingsPayload, "isEnabled">) =>
request<{ ok: true; response: string }>("/api/v1/ai/settings/test", {
method: "POST",
body: JSON.stringify(payload),
}),
updateAiSettings: (payload: AiSettingsPayload) =>
request<AiSettingsResponse>("/api/v1/ai/settings", {
method: "PUT",
body: JSON.stringify(payload),
}),
streamAiGeneration: async (
payload: { action: AiAction; title: string; contentMarkdown: string; targetLanguage?: string },
streamOptions: { signal?: AbortSignal; onEvent: (event: AiStreamEvent) => void },
) => {
const headers = new Headers({ "Content-Type": "application/json" });
if (options.token) headers.set("Authorization", `Bearer ${options.token}`);
const response = await fetchImpl(`${baseUrl}/api/v1/ai/generate`, {
method: "POST",
credentials: "include",
headers,
body: JSON.stringify(payload),
signal: streamOptions.signal,
});
if (!response.ok) {
const body = await response.json().catch(() => null) as { error?: { code?: string; message?: string } } | null;
if (response.status === 401) options.onUnauthorized?.();
throw new ApiRequestError(body?.error?.message || response.statusText, response.status, body?.error?.code);
}
if (!response.body) throw new ApiRequestError("Streaming response is unavailable", 502, "ai_stream_unavailable");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
buffer += decoder.decode(value, { stream: !done });
const frames = buffer.split("\n\n");
buffer = frames.pop() ?? "";
for (const frame of frames) {
const data = frame.split("\n").find((line) => line.startsWith("data: "))?.slice(6);
if (data) streamOptions.onEvent(JSON.parse(data) as AiStreamEvent);
}
if (done) break;
}
const trailingData = buffer.split("\n").find((line) => line.startsWith("data: "))?.slice(6);
if (trailingData) streamOptions.onEvent(JSON.parse(trailingData) as AiStreamEvent);
},
listUsers: () => request<ListUsersResponse>("/api/v1/users"),
createUser: (payload: { username: string; displayName?: string | null; password: string }) =>
+45
View File
@@ -269,6 +269,51 @@ export const enUS = {
switchHint: "Switching only affects new uploads. Existing attachments remain in their original store and are not migrated automatically.",
demoDisabled: "Object storage cannot be changed on the demo instance.",
},
aiModel: {
title: "AI model",
description: "Connect your own cloud model endpoint. Credentials are encrypted on the EdgeEver server and isolated to your workspace.",
provider: "API protocol",
providers: {
"openai-compatible": "OpenAI-compatible",
anthropic: "Anthropic Messages",
google: "Google Gemini",
},
displayName: "Configuration name",
baseUrl: "Base URL",
modelId: "Model ID",
apiKey: "API Key",
apiKeySavedHint: "The key is encrypted. Leave this blank to keep it unchanged.",
enabled: "Enable AI features",
enabledHint: "Use this model for note AI actions.",
test: "Test connection",
testSucceeded: "The model endpoint responded successfully.",
saved: "AI model settings saved.",
failed: "The AI model operation failed.",
encryptionKeyMissing: "Encrypted AI credentials require instance authentication. Configure authentication first; an independent credential key is optional for advanced deployments.",
},
aiAssistant: {
title: "AI note assistant",
description: "AI output is shown as a draft and never changes the note until you choose an action.",
open: "Open AI writing assistant",
actions: {
summarize: "Summarize",
"extract-key-points": "Key points",
"extract-todos": "Extract tasks",
"rewrite-proofread": "Rewrite & proofread",
translate: "Translate",
},
targetLanguage: "Target language",
targetLanguagePlaceholder: "For example: English, Simplified Chinese, Japanese",
generate: "Generate",
stop: "Stop",
result: "AI draft",
resultPlaceholder: "The generated draft will appear here.",
copy: "Copy draft",
copied: "Draft copied.",
append: "Append to note",
replace: "Replace note",
configure: "Configure an AI model in Me → AI Integrations first.",
},
accountInfo: {
title: "Current account",
description: "The account currently signed in on this device.",
+45
View File
@@ -269,6 +269,51 @@ export const zhCN = {
switchHint: "切换只影响后续上传;已有附件仍从原存储读取,不会自动迁移。",
demoDisabled: "演示实例不允许修改对象存储。",
},
aiModel: {
title: "AI 模型",
description: "连接你自己的云端模型服务。凭据由 EdgeEver 服务端加密保存,并按个人工作区隔离。",
provider: "API 协议",
providers: {
"openai-compatible": "OpenAI 兼容协议",
anthropic: "Anthropic Messages",
google: "Google Gemini",
},
displayName: "配置名称",
baseUrl: "Base URL",
modelId: "模型 ID",
apiKey: "API Key",
apiKeySavedHint: "密钥已加密保存;留空表示保持不变。",
enabled: "启用 AI 功能",
enabledHint: "将此模型用于笔记 AI 操作。",
test: "测试连接",
testSucceeded: "模型服务已成功响应。",
saved: "AI 模型设置已保存。",
failed: "AI 模型操作失败。",
encryptionKeyMissing: "加密保存 AI 凭据需要先启用实例认证;标准部署无需增加环境变量,高级部署可选用独立凭据密钥。",
},
aiAssistant: {
title: "AI 笔记助手",
description: "AI 输出会先作为草稿展示,只有你主动操作后才会修改笔记。",
open: "打开 AI 写作助手",
actions: {
summarize: "总结",
"extract-key-points": "提炼要点",
"extract-todos": "提取待办",
"rewrite-proofread": "改写与校对",
translate: "翻译",
},
targetLanguage: "目标语言",
targetLanguagePlaceholder: "例如:英语、简体中文、日语",
generate: "生成",
stop: "停止",
result: "AI 草稿",
resultPlaceholder: "生成的草稿会显示在这里。",
copy: "复制草稿",
copied: "草稿已复制。",
append: "追加到笔记",
replace: "替换笔记",
configure: "请先在“我的 → AI 集成”中配置 AI 模型。",
},
accountInfo: {
title: "当前账户",
description: "当前设备正在登录的账户信息。",
+51
View File
@@ -153,6 +153,54 @@ export const ObjectStorageConnectionTestSchema = z.object({
objectPrefix: z.string().trim().max(500).default(""),
});
export const AiProviderSchema = z.enum(["openai-compatible", "anthropic", "google"]);
const AiBaseUrlSchema = z.string().trim().url().max(500).superRefine((value, context) => {
let url: URL;
try {
url = new URL(value);
} catch {
return;
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
context.addIssue({ code: "custom", message: "AI Base URL must use HTTP or HTTPS." });
}
if (url.username || url.password) {
context.addIssue({ code: "custom", message: "AI Base URL must not contain credentials." });
}
});
export const AiModelSettingsUpdateSchema = z.object({
provider: AiProviderSchema,
displayName: z.string().trim().min(1).max(80),
baseUrl: AiBaseUrlSchema,
apiKey: z.string().min(1).max(4096).optional(),
modelId: z.string().trim().min(1).max(200),
isEnabled: z.boolean().default(true),
});
export const AiConnectionTestSchema = AiModelSettingsUpdateSchema.omit({ isEnabled: true });
export const AiGenerateSchema = z.object({
action: z.enum([
"summarize",
"extract-key-points",
"extract-todos",
"rewrite-proofread",
"translate",
]),
title: z.string().trim().max(160).default(""),
contentMarkdown: z.string().max(300_000),
targetLanguage: z.string().trim().min(1).max(80).optional(),
}).superRefine((input, context) => {
if (input.action === "translate" && !input.targetLanguage) {
context.addIssue({ code: "custom", path: ["targetLanguage"], message: "A target language is required for translation." });
}
if (!input.title && !input.contentMarkdown.trim()) {
context.addIssue({ code: "custom", path: ["contentMarkdown"], message: "Note content is required." });
}
});
export type NotebookCreateInput = z.infer<typeof NotebookCreateSchema>;
export type NotebookUpdateInput = z.infer<typeof NotebookUpdateSchema>;
export type MemoCreateInput = z.infer<typeof MemoCreateSchema>;
@@ -172,3 +220,6 @@ export type TagRenameInput = z.infer<typeof TagRenameSchema>;
export type ResourceUpdateInput = z.infer<typeof ResourceUpdateSchema>;
export type ObjectStorageSettingsUpdateInput = z.infer<typeof ObjectStorageSettingsUpdateSchema>;
export type ObjectStorageConnectionTestInput = z.infer<typeof ObjectStorageConnectionTestSchema>;
export type AiModelSettingsUpdateInput = z.infer<typeof AiModelSettingsUpdateSchema>;
export type AiConnectionTestInput = z.infer<typeof AiConnectionTestSchema>;
export type AiGenerateInput = z.infer<typeof AiGenerateSchema>;
+25
View File
@@ -116,6 +116,31 @@ export type ObjectStorageSettings = {
encryptionConfigured: boolean;
};
export type AiProvider = "openai-compatible" | "anthropic" | "google";
export type AiModelSettings = {
provider: AiProvider;
displayName: string;
baseUrl: string;
modelId: string;
isEnabled: boolean;
hasApiKey: boolean;
encryptionConfigured: boolean;
};
export type AiAction =
| "summarize"
| "extract-key-points"
| "extract-todos"
| "rewrite-proofread"
| "translate";
export type AiStreamEvent =
| { type: "start" }
| { type: "text-delta"; text: string }
| { type: "finish"; finishReason?: string; inputTokens?: number; outputTokens?: number }
| { type: "error"; code: string; message: string };
export type ApiToken = {
id: string;
name: string;
+9
View File
@@ -12,6 +12,10 @@ const databaseFile = resolve(process.env.EDGE_EVER_SQLITE_FILE ?? join(dataDirec
const resourcesDirectory = resolve(process.env.EDGE_EVER_RESOURCES_DIR ?? join(dataDirectory, "resources"));
const webDirectory = resolve(process.env.EDGE_EVER_WEB_DIR ?? join(projectRoot, "apps/web/dist"));
const port = Number(process.env.PORT ?? process.env.EDGE_EVER_PORT ?? 8787);
const configuredIdleTimeout = Number(process.env.EDGE_EVER_IDLE_TIMEOUT_SECONDS ?? 120);
const idleTimeout = Number.isFinite(configuredIdleTimeout)
? Math.min(255, Math.max(10, configuredIdleTimeout))
: 120;
await mkdir(dataDirectory, { recursive: true });
await mkdir(resourcesDirectory, { recursive: true });
@@ -77,6 +81,7 @@ const env = {
EDGE_EVER_AUTH_LOGIN_IP_MAX_ATTEMPTS: process.env.EDGE_EVER_AUTH_LOGIN_IP_MAX_ATTEMPTS,
EDGE_EVER_AUTH_LOGIN_IP_COOLDOWN_SECONDS: process.env.EDGE_EVER_AUTH_LOGIN_IP_COOLDOWN_SECONDS,
EDGE_EVER_STORAGE_ENCRYPTION_KEY: process.env.EDGE_EVER_STORAGE_ENCRYPTION_KEY,
EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY: process.env.EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY,
EDGE_EVER_DEMO_MODE: process.env.EDGE_EVER_DEMO_MODE,
EDGE_EVER_ALLOW_UNAUTHENTICATED: process.env.EDGE_EVER_ALLOW_UNAUTHENTICATED,
};
@@ -122,6 +127,9 @@ const serveStatic = async (request) => {
const server = Bun.serve({
port,
// Model providers may take longer than Bun's 10-second default to emit the
// first streaming token. Keep the connection alive within Bun's supported range.
idleTimeout,
async fetch(request) {
const pathname = new URL(request.url).pathname;
if (pathname.startsWith("/api/") || pathname === "/mcp" || pathname.startsWith("/mcp/")) {
@@ -134,3 +142,4 @@ const server = Bun.serve({
console.log(`[self-hosted] listening on ${server.url}`);
console.log(`[self-hosted] data directory: ${dataDirectory}`);
console.log(`[self-hosted] storage backend: ${storageBackend}`);
console.log(`[self-hosted] idle timeout: ${idleTimeout}s`);