feat(speech): transcribe note media attachments without external downloads

This commit is contained in:
tianma-if committed 2026-10-07 19:36:19 +08:00
1 parent 2e15f325c5
commit f3b4226555
27 files changed
+1856 -9

No files matched your search

+31
View File
@@ -107,6 +107,37 @@ const validSettings = {
};
describe("AI route contracts", () => {
test("speech settings stay encrypted and transcription requires a note-owned resource", async () => {
const app = createApp();
const { environment: databaseEnvironment } = createDatabaseEnvironment();
const saved = await app.request(
"/api/v1/ai/transcription-providers",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...validSettings, initialModelId: "whisper-1" }),
},
databaseEnvironment,
);
expect(saved.status).toBe(201);
expect(JSON.stringify(await saved.json())).not.toContain("secret");
const transcript = await app.request(
"/api/v1/memos/missing/resources/missing/transcribe",
{ method: "POST" },
databaseEnvironment,
);
expect(transcript.status).toBe(404);
const scopedApp = createApp({ currentAuth: { ...auth, kind: "agent", scopes: ["read:resources"] } });
const denied = await scopedApp.request(
"/api/v1/memos/missing/resources/missing/transcribe",
{ method: "POST" },
databaseEnvironment,
);
expect(denied.status).toBe(403);
});
test("accepts the shared semantic action catalog with required parameters", () => {
for (const action of AI_ACTIONS) {
const parsed = AiGenerateSchema.safeParse({
+325
View File
@@ -1,6 +1,10 @@
import {
AiDefaultModelUpdateSchema,
AiGenerateSchema,
AiTranscriptionDefaultModelUpdateSchema,
AiTranscriptionModelCreateSchema,
AiTranscriptionProviderCreateSchema,
AiTranscriptionProviderUpdateSchema,
InfographicAgentRequestSchema,
AiModelConfigCreateSchema,
AiProviderConfigCreateSchema,
@@ -40,6 +44,11 @@ import {
normalizeAiGenerationText,
normalizeAiBaseUrl,
prepareAiGeneration,
aiTranscriptionDefaultStatement,
getAiTranscriptionModel,
getAiTranscriptionProvider,
getAiTranscriptionSettings,
getDefaultAiTranscriptionModelId,
resolvePrimaryAiCredentialEncryptionKey,
streamAiGeneration,
testAiModel,
@@ -59,6 +68,7 @@ import {
} from "./video-outline";
import { encryptSecret } from "./secret-encryption";
import { listTagSummaries } from "./tag-service";
import { transcribeNoteResource } from "./resource-transcription";
type AiRouteDependencies = {
isDemoMode: (environment: Bindings) => boolean;
@@ -109,6 +119,28 @@ const readSettings = (context: AppContext, dependencies: AiRouteDependencies) =>
context.env,
);
const readTranscriptionSettings = (context: AppContext, dependencies: AiRouteDependencies) =>
getAiTranscriptionSettings(
context.env.storage.db,
getWorkspaceId(context),
encryptionConfigured(context),
dependencies.isDemoMode(context.env),
context.env,
);
const invalidTranscriptionSettings = (
result: { success: boolean },
context: Context,
) => {
if (result.success) return;
return apiError(
context,
"ai_transcription_request_invalid",
"The speech transcription settings are invalid.",
400,
);
};
const denyMutation = (context: AppContext, dependencies: AiRouteDependencies) => {
const denied = requireUser(context);
if (denied) return denied;
@@ -214,6 +246,299 @@ export const registerAiRoutes = (app: Hono<AppEnv>, dependencies: AiRouteDepende
return context.json(await readSettings(context, dependencies));
});
app.get("/api/v1/ai/transcription-settings", async (context) => {
const denied = requireUser(context);
if (denied) return denied;
return context.json(await readTranscriptionSettings(context, dependencies));
});
app.post("/api/v1/memos/:memoId/resources/:resourceId/transcribe", async (context) => {
const denied = requireScopes(context, "read:resources", "ai:generate");
if (denied) return denied;
if (dependencies.isDemoMode(context.env)) {
return forbidden(context, "Speech transcription is unavailable in demo mode.");
}
try {
return context.json(await transcribeNoteResource(
context.env,
getWorkspaceId(context),
context.req.param("memoId"),
context.req.param("resourceId"),
fetch,
context.req.raw.signal,
));
} catch (error) {
return withAiError(context, error, "ai_transcription_failed");
}
});
app.post(
"/api/v1/ai/transcription-providers",
zValidator("json", AiTranscriptionProviderCreateSchema, invalidTranscriptionSettings),
async (context) => {
const denied = denyMutation(context, dependencies);
if (denied) return denied;
try {
const input = context.req.valid("json");
const workspaceId = getWorkspaceId(context);
const providerId = createId("atp");
const modelConfigId = input.initialModelId ? createId("atm") : null;
const now = isoNow();
const statements = [
context.env.storage.db.prepare(
`INSERT INTO ai_transcription_providers (
id, workspace_id, provider, display_name, base_url, api_key_encrypted,
is_enabled, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).bind(
providerId,
workspaceId,
input.provider,
input.displayName,
normalizeAiBaseUrl(input.baseUrl),
await encryptSecret(input.apiKey, requireEncryptionKey(context)),
input.isEnabled ? 1 : 0,
now,
now,
),
];
if (modelConfigId && input.initialModelId) {
statements.push(context.env.storage.db.prepare(
`INSERT INTO ai_transcription_models (
id, provider_id, model_id, display_name, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?)`,
).bind(
modelConfigId,
providerId,
input.initialModelId,
input.initialModelId.slice(0, 80),
now,
now,
));
if (input.isEnabled && !(await getDefaultAiTranscriptionModelId(context.env.storage.db, workspaceId))) {
statements.push(aiTranscriptionDefaultStatement(
context.env.storage.db,
workspaceId,
modelConfigId,
now,
));
}
}
statements.push(auditStatement(
context.env.storage.db,
"user",
context.get("auth").actorId,
"workspace.ai_transcription_provider.create",
"ai_transcription_provider",
providerId,
{ isEnabled: input.isEnabled, provider: input.provider, modelId: input.initialModelId ?? null },
));
await context.env.storage.db.batch(statements);
return context.json(await readTranscriptionSettings(context, dependencies), 201);
} catch (error) {
return withAiError(context, error, "ai_transcription_provider_create_failed");
}
},
);
app.put(
"/api/v1/ai/transcription-providers/:providerId",
zValidator("json", AiTranscriptionProviderUpdateSchema, invalidTranscriptionSettings),
async (context) => {
const denied = denyMutation(context, dependencies);
if (denied) return denied;
try {
const input = context.req.valid("json");
const providerId = context.req.param("providerId");
const workspaceId = getWorkspaceId(context);
const existing = await getAiTranscriptionProvider(context.env.storage.db, workspaceId, providerId);
if (!existing) return notFound(context, "Speech service not found.");
const now = isoNow();
const apiKeyEncrypted = input.apiKey
? await encryptSecret(input.apiKey, requireEncryptionKey(context))
: existing.api_key_encrypted;
await context.env.storage.db.batch([
context.env.storage.db.prepare(
`UPDATE ai_transcription_providers SET
provider = ?, display_name = ?, base_url = ?, api_key_encrypted = ?,
is_enabled = ?, updated_at = ?
WHERE id = ? AND workspace_id = ?`,
).bind(
input.provider,
input.displayName,
normalizeAiBaseUrl(input.baseUrl),
apiKeyEncrypted,
input.isEnabled ? 1 : 0,
now,
providerId,
workspaceId,
),
auditStatement(
context.env.storage.db,
"user",
context.get("auth").actorId,
"workspace.ai_transcription_provider.update",
"ai_transcription_provider",
providerId,
{ isEnabled: input.isEnabled, provider: input.provider, apiKeyUpdated: Boolean(input.apiKey) },
),
]);
return context.json(await readTranscriptionSettings(context, dependencies));
} catch (error) {
return withAiError(context, error, "ai_transcription_provider_update_failed");
}
},
);
app.delete("/api/v1/ai/transcription-providers/:providerId", async (context) => {
const denied = denyMutation(context, dependencies);
if (denied) return denied;
const providerId = context.req.param("providerId");
const workspaceId = getWorkspaceId(context);
const existing = await getAiTranscriptionProvider(context.env.storage.db, workspaceId, providerId);
if (!existing) return notFound(context, "Speech service not found.");
await context.env.storage.db.batch([
context.env.storage.db.prepare(
`DELETE FROM ai_transcription_providers WHERE id = ? AND workspace_id = ?`,
).bind(providerId, workspaceId),
auditStatement(
context.env.storage.db,
"user",
context.get("auth").actorId,
"workspace.ai_transcription_provider.delete",
"ai_transcription_provider",
providerId,
{},
),
]);
return context.json(await readTranscriptionSettings(context, dependencies));
});
app.post(
"/api/v1/ai/transcription-providers/:providerId/models",
zValidator("json", AiTranscriptionModelCreateSchema, invalidTranscriptionSettings),
async (context) => {
const denied = denyMutation(context, dependencies);
if (denied) return denied;
const providerId = context.req.param("providerId");
const workspaceId = getWorkspaceId(context);
const provider = await getAiTranscriptionProvider(context.env.storage.db, workspaceId, providerId);
if (!provider) return notFound(context, "Speech service not found.");
const input = context.req.valid("json");
const modelConfigId = createId("atm");
const now = isoNow();
const statements = [
context.env.storage.db.prepare(
`INSERT INTO ai_transcription_models (
id, provider_id, model_id, display_name, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?)`,
).bind(
modelConfigId,
providerId,
input.modelId,
input.displayName ?? input.modelId.slice(0, 80),
now,
now,
),
];
if (provider.is_enabled && !(await getDefaultAiTranscriptionModelId(context.env.storage.db, workspaceId))) {
statements.push(aiTranscriptionDefaultStatement(
context.env.storage.db,
workspaceId,
modelConfigId,
now,
));
}
statements.push(auditStatement(
context.env.storage.db,
"user",
context.get("auth").actorId,
"workspace.ai_transcription_model.create",
"ai_transcription_model",
modelConfigId,
{ providerId, modelId: input.modelId },
));
try {
await context.env.storage.db.batch(statements);
return context.json(await readTranscriptionSettings(context, dependencies), 201);
} catch (error) {
return withAiError(context, error, "ai_transcription_model_create_failed");
}
},
);
app.delete(
"/api/v1/ai/transcription-providers/:providerId/models/:modelConfigId",
async (context) => {
const denied = denyMutation(context, dependencies);
if (denied) return denied;
const workspaceId = getWorkspaceId(context);
const providerId = context.req.param("providerId");
const modelConfigId = context.req.param("modelConfigId");
const model = await getAiTranscriptionModel(context.env.storage.db, workspaceId, modelConfigId);
if (!model || model.provider_id !== providerId) {
return notFound(context, "Speech model not found.");
}
await context.env.storage.db.batch([
context.env.storage.db.prepare(
`DELETE FROM ai_transcription_models WHERE id = ? AND provider_id = ?`,
).bind(modelConfigId, providerId),
auditStatement(
context.env.storage.db,
"user",
context.get("auth").actorId,
"workspace.ai_transcription_model.delete",
"ai_transcription_model",
modelConfigId,
{ providerId, modelId: model.model_id },
),
]);
return context.json(await readTranscriptionSettings(context, dependencies));
},
);
app.put(
"/api/v1/ai/transcription-default-model",
zValidator("json", AiTranscriptionDefaultModelUpdateSchema, invalidTranscriptionSettings),
async (context) => {
const denied = denyMutation(context, dependencies);
if (denied) return denied;
const input = context.req.valid("json");
const workspaceId = getWorkspaceId(context);
if (input.modelConfigId) {
const model = await context.env.storage.db.prepare(
`SELECT models.id
FROM ai_transcription_models AS models
JOIN ai_transcription_providers AS providers ON providers.id = models.provider_id
WHERE models.id = ? AND providers.workspace_id = ? AND providers.is_enabled = 1
LIMIT 1`,
).bind(input.modelConfigId, workspaceId).first<{ id: string }>();
if (!model) {
return apiError(
context,
"ai_transcription_default_unavailable",
"The selected speech model belongs to a disabled or missing service.",
400,
);
}
}
const now = isoNow();
await context.env.storage.db.batch([
aiTranscriptionDefaultStatement(context.env.storage.db, workspaceId, input.modelConfigId, now),
auditStatement(
context.env.storage.db,
"user",
context.get("auth").actorId,
"workspace.ai_transcription_default.update",
"workspace",
workspaceId,
{ modelConfigId: input.modelConfigId },
),
]);
return context.json(await readTranscriptionSettings(context, dependencies));
},
);
app.post(
"/api/v1/ai/providers",
zValidator("json", AiProviderConfigCreateSchema),
+195
View File
@@ -8,6 +8,10 @@ import type {
AiProvider,
AiProviderConfig,
AiSettings,
AiTranscriptionModel,
AiTranscriptionProvider,
AiTranscriptionStandard,
AiTranscriptionSettings,
AiTargetLanguage,
AiTone,
} from "@edgeever/shared";
@@ -224,6 +228,197 @@ export const getAiSettings = async (
export const normalizeAiBaseUrl = (value: string) => value.trim().replace(/\/+$/, "");
export type AiTranscriptionProviderRow = {
id: string;
workspace_id: string;
provider: AiTranscriptionStandard;
display_name: string;
base_url: string;
api_key_encrypted: string;
is_enabled: number;
created_at: string;
updated_at: string;
};
export type AiTranscriptionModelRow = {
id: string;
provider_id: string;
model_id: string;
display_name: string;
created_at: string;
updated_at: string;
};
const selectTranscriptionProviderSql = `SELECT
id, workspace_id, provider, display_name, base_url, api_key_encrypted, is_enabled, created_at, updated_at
FROM ai_transcription_providers`;
const selectTranscriptionModelSql = `SELECT
id, provider_id, model_id, display_name, created_at, updated_at
FROM ai_transcription_models`;
export const getAiTranscriptionProvider = (
db: DatabaseAdapter,
workspaceId: string,
providerId: string,
) => db.prepare(
`${selectTranscriptionProviderSql} WHERE id = ? AND workspace_id = ? LIMIT 1`,
).bind(providerId, workspaceId).first<AiTranscriptionProviderRow>();
export const getAiTranscriptionModel = (
db: DatabaseAdapter,
workspaceId: string,
modelConfigId: string,
) => db.prepare(
`${selectTranscriptionModelSql}
WHERE id = ? AND provider_id IN (
SELECT id FROM ai_transcription_providers WHERE workspace_id = ?
)
LIMIT 1`,
).bind(modelConfigId, workspaceId).first<AiTranscriptionModelRow>();
export const getDefaultAiTranscriptionModelId = async (db: DatabaseAdapter, workspaceId: string) => {
const row = await db.prepare(
`SELECT default_model_id
FROM ai_transcription_workspace_settings
WHERE workspace_id = ?
LIMIT 1`,
).bind(workspaceId).first<{ default_model_id: string | null }>();
return row?.default_model_id ?? null;
};
export const aiTranscriptionDefaultStatement = (
db: DatabaseAdapter,
workspaceId: string,
modelConfigId: string | null,
now: string,
) => db.prepare(
`INSERT INTO ai_transcription_workspace_settings (
workspace_id, default_model_id, created_at, updated_at
) VALUES (?, ?, ?, ?)
ON CONFLICT(workspace_id) DO UPDATE SET
default_model_id = excluded.default_model_id,
updated_at = excluded.updated_at`,
).bind(workspaceId, modelConfigId, now, now);
const mapAiTranscriptionModel = (row: AiTranscriptionModelRow): AiTranscriptionModel => ({
id: row.id,
providerId: row.provider_id,
modelId: row.model_id,
displayName: row.display_name,
});
const mapAiTranscriptionProvider = (
row: AiTranscriptionProviderRow,
models: AiTranscriptionModelRow[],
credentialsUnavailable = false,
): AiTranscriptionProvider => ({
id: row.id,
provider: row.provider,
displayName: row.display_name,
baseUrl: row.base_url,
isEnabled: Boolean(row.is_enabled),
hasApiKey: Boolean(row.api_key_encrypted),
models: models.filter((model) => model.provider_id === row.id).map(mapAiTranscriptionModel),
...(credentialsUnavailable ? { credentialsUnavailable: true } : {}),
});
const withAiTranscriptionCredentialAvailability = async (
row: AiTranscriptionProviderRow,
models: AiTranscriptionModelRow[],
encryptionConfigured: boolean,
environment?: AiCredentialEnvironment,
): Promise<AiTranscriptionProvider> => {
const mapped = mapAiTranscriptionProvider(row, models);
if (!encryptionConfigured || !environment || !row.api_key_encrypted) return mapped;
try {
await decryptAiCredential(row.api_key_encrypted, environment);
return mapped;
} catch {
return mapAiTranscriptionProvider(row, models, true);
}
};
export const getAiTranscriptionSettings = async (
db: DatabaseAdapter,
workspaceId: string,
encryptionConfigured: boolean,
readOnly: boolean,
environment?: AiCredentialEnvironment,
): Promise<AiTranscriptionSettings> => {
const [providersResult, modelsResult, defaultModelId] = await Promise.all([
db.prepare(
`${selectTranscriptionProviderSql} WHERE workspace_id = ? ORDER BY created_at ASC, id ASC`,
).bind(workspaceId).all<AiTranscriptionProviderRow>(),
db.prepare(
`${selectTranscriptionModelSql}
WHERE provider_id IN (
SELECT id FROM ai_transcription_providers WHERE workspace_id = ?
)
ORDER BY created_at ASC, id ASC`,
).bind(workspaceId).all<AiTranscriptionModelRow>(),
getDefaultAiTranscriptionModelId(db, workspaceId),
]);
const providers = await Promise.all(providersResult.results.map((provider) =>
withAiTranscriptionCredentialAvailability(provider, modelsResult.results, encryptionConfigured, environment)));
const defaultProvider = providers.find((provider) =>
provider.models.some((model) => model.id === defaultModelId));
const enabled = Boolean(
encryptionConfigured
&& defaultProvider?.isEnabled
&& defaultProvider.hasApiKey
&& !defaultProvider.credentialsUnavailable
&& defaultProvider.models.some((model) => model.id === defaultModelId && model.modelId),
);
return {
providers,
defaultModelId,
enabled,
encryptionConfigured,
readOnly,
};
};
export const prepareAiTranscriptionCredentials = async (
db: DatabaseAdapter,
workspaceId: string,
environment: AiCredentialEnvironment,
) => {
const row = await db.prepare(
`SELECT
models.model_id,
providers.provider,
providers.base_url,
providers.api_key_encrypted
FROM ai_transcription_workspace_settings AS settings
JOIN ai_transcription_models AS models ON models.id = settings.default_model_id
JOIN ai_transcription_providers AS providers ON providers.id = models.provider_id
WHERE settings.workspace_id = ?
AND providers.workspace_id = ?
AND providers.is_enabled = 1
LIMIT 1`,
).bind(workspaceId, workspaceId).first<{
model_id: string;
provider: AiTranscriptionStandard;
base_url: string;
api_key_encrypted: string;
}>();
if (!row?.base_url || !row.model_id || !row.api_key_encrypted || !row.provider) {
return { enabled: false as const };
}
try {
return {
enabled: true as const,
provider: row.provider,
baseUrl: normalizeAiBaseUrl(row.base_url),
modelId: row.model_id,
apiKey: await decryptAiCredential(row.api_key_encrypted, environment),
};
} catch {
return { enabled: false as const };
}
};
const loadAiRuntime = () => import("./ai-runtime");
export const createAiModel = async (config: {
@@ -0,0 +1,75 @@
import { describe, expect, test } from "bun:test";
import { resolvePrimaryAiCredentialEncryptionKey } from "./ai-service.ts";
import { encryptSecret } from "./secret-encryption.ts";
import { isTranscribableAttachment, transcribeNoteResource } from "./resource-transcription.ts";
const makeEnvironment = async (resource) => {
const env = {
EDGE_EVER_AUTH_PASSWORD: "test-password-long-enough",
storage: {
resources: {
get: async () => ({ body: new Blob(["audio bytes"]).stream() }),
},
db: {
prepare: (sql) => ({
bind: (...args) => ({
first: async () => {
if (sql.includes("FROM resources r")) return args[0] === resource.id && args[1] === "ws_one" ? resource : null;
if (sql.includes("FROM ai_transcription_workspace_settings AS settings")) return {
model_id: "whisper-1",
provider: "openai-compatible",
base_url: "https://speech.example/v1",
api_key_encrypted: await encryptSecret("secret-token", resolvePrimaryAiCredentialEncryptionKey(env)),
};
if (sql.includes("FROM object_storage_configs")) return { id: "builtin", provider: "builtin" };
return null;
},
}),
}),
},
},
};
return env;
};
const resource = {
id: "res_one",
memo_id: "memo_one",
kind: "attachment",
filename: "meeting.mp4",
mime_type: "video/mp4",
byte_size: 11,
storage_config_id: "builtin",
object_key: "workspace/meeting.mp4",
};
describe("note attachment transcription boundary", () => {
test("accepts only supported audio and video attachments", () => {
expect(isTranscribableAttachment(resource)).toBe(true);
expect(isTranscribableAttachment({ ...resource, filename: "meeting.exe" })).toBe(false);
expect(isTranscribableAttachment({ ...resource, mime_type: "application/octet-stream" })).toBe(false);
expect(isTranscribableAttachment({ ...resource, kind: "image" })).toBe(false);
});
test("rejects an attachment from another note before reading it", async () => {
const env = await makeEnvironment(resource);
await expect(transcribeNoteResource(env, "ws_one", "memo_other", "res_one", () => {
throw new Error("Provider must not be called");
})).rejects.toMatchObject({ code: "resource_not_found", status: 404 });
});
test("sends only the note attachment to the configured model and returns text", async () => {
const env = await makeEnvironment(resource);
const calls = [];
const result = await transcribeNoteResource(env, "ws_one", "memo_one", "res_one", async (url, init) => {
calls.push({ url, init });
return Response.json({ text: " Meeting notes. " });
});
expect(result).toEqual({ text: "Meeting notes.", resourceId: "res_one", filename: "meeting.mp4" });
expect(calls).toHaveLength(1);
expect(calls[0].url).toBe("https://speech.example/v1/audio/transcriptions");
expect(calls[0].init.headers.Authorization).toBe("Bearer secret-token");
expect(calls[0].init.body.get("model")).toBe("whisper-1");
expect((await calls[0].init.body.get("file").text())).toBe("audio bytes");
});
});
+79
View File
@@ -0,0 +1,79 @@
import { AppError } from "./app-error";
import { prepareAiTranscriptionCredentials } from "./ai-service";
import { resolveObjectStorage } from "./object-storage";
import { getResourceRow } from "./resource-service";
import type { Bindings } from "./api-context";
export const MAX_TRANSCRIPTION_BYTES = 24 * 1024 * 1024;
const SUPPORTED_EXTENSIONS = new Set(["flac", "mp3", "mp4", "mpeg", "mpga", "m4a", "ogg", "wav", "webm"]);
export const isTranscribableAttachment = (resource: {
kind: string;
mime_type: string | null;
filename: string | null;
}) => {
if (resource.kind !== "attachment") return false;
const extension = /\.([a-z0-9]+)$/i.exec(resource.filename ?? "")?.[1]?.toLowerCase();
if (!extension || !SUPPORTED_EXTENSIONS.has(extension)) return false;
const mime = (resource.mime_type ?? "").toLowerCase();
return mime.startsWith("audio/") || mime === "video/mp4" || mime === "video/webm";
};
export const transcribeNoteResource = async (
env: Bindings,
workspaceId: string,
memoId: string,
resourceId: string,
fetchImpl: typeof fetch = fetch,
abortSignal?: AbortSignal,
) => {
const resource = await getResourceRow(env.storage.db, workspaceId, resourceId);
if (!resource || resource.memo_id !== memoId) {
throw new AppError("resource_not_found", "This attachment does not belong to the note.", 404);
}
if (!isTranscribableAttachment(resource)) {
throw new AppError("unsupported_media_type", "Choose an audio or MP4/WebM video attachment.", 415);
}
if (resource.byte_size < 1 || resource.byte_size > MAX_TRANSCRIPTION_BYTES) {
throw new AppError("audio_too_large", "The attachment must be 24 MiB or smaller.", 413);
}
const credentials = await prepareAiTranscriptionCredentials(env.storage.db, workspaceId, env);
if (!credentials.enabled) {
throw new AppError("ai_transcription_not_configured", "Choose a default speech model first.", 409);
}
const source = await resolveObjectStorage(env, resource.storage_config_id);
const object = await source.store.get(resource.object_key);
if (!object) throw new AppError("resource_not_found", "Attachment bytes are unavailable.", 404);
const bytes = await new Response(object.body).arrayBuffer();
if (bytes.byteLength < 1 || bytes.byteLength > MAX_TRANSCRIPTION_BYTES) {
throw new AppError("audio_too_large", "The attachment must be 24 MiB or smaller.", 413);
}
const body = new FormData();
body.set("model", credentials.modelId);
body.set("file", new File([bytes], resource.filename || "recording.mp3", {
type: resource.mime_type || "application/octet-stream",
}));
let response: Response;
try {
response = await fetchImpl(`${credentials.baseUrl}/audio/transcriptions`, {
method: "POST",
headers: { Authorization: `Bearer ${credentials.apiKey}` },
body,
signal: abortSignal
? AbortSignal.any([abortSignal, AbortSignal.timeout(180_000)])
: AbortSignal.timeout(180_000),
});
} catch {
throw new AppError("ai_transcription_failed", "The speech service could not be reached.", 502);
}
if (!response.ok) {
throw new AppError("ai_transcription_failed", `The speech service returned HTTP ${response.status}.`, 502);
}
const result = await response.json().catch(() => null) as { text?: unknown } | null;
const text = typeof result?.text === "string" ? result.text.trim() : "";
if (!text) throw new AppError("empty_transcript", "The speech service returned no transcript.", 502);
return { text, resourceId, filename: resource.filename ?? "" };
};
+72
View File
@@ -103,6 +103,7 @@ import { sanitizeAndScopeCss } from "@/lib/css-sandbox";
import { RevisionHistoryDialog } from "./dialogs/RevisionHistoryDialog";
import { ExternalLinkDialog } from "./dialogs/ExternalLinkDialog";
import { MathFormulaDialog } from "./dialogs/MathFormulaDialog";
import { AttachmentTranscriptDialog } from "./dialogs/AttachmentTranscriptDialog";
import { EditorBlockDragHandle } from "./editor/EditorBlockDragHandle";
import {
applyMathFormula,
@@ -497,6 +498,14 @@ const RichEditorPane = ({
canRemove: false,
});
const [mathFormulaOpen, setMathFormulaOpen] = useState(false);
const [attachmentTranscript, setAttachmentTranscript] = useState<{
memoId: string;
filename: string;
text: string;
loading: boolean;
error: string | null;
} | null>(null);
const attachmentTranscriptAbortRef = useRef<AbortController | null>(null);
const [mathFormulaDraft, setMathFormulaDraft] = useState<MathFormulaDraft | null>(null);
const {
menuTarget: resourceMenuTarget,
@@ -1820,6 +1829,11 @@ const RichEditorPane = ({
resetResourceActions();
}, [memo?.id, isMarkdownMode, resetResourceActions]);
useEffect(() => () => {
attachmentTranscriptAbortRef.current?.abort();
attachmentTranscriptAbortRef.current = null;
}, [memo?.id]);
useEffect(() => () => {
if (resourceMenuHideTimerRef.current !== null) {
window.clearTimeout(resourceMenuHideTimerRef.current);
@@ -3115,6 +3129,30 @@ const RichEditorPane = ({
downloadResourceDirectly(target);
}, [clearResourceActionError, downloadResourceDirectly, hideResourceMenu]);
const handleResourceTranscribe = useCallback(async (target: ResourceMenuTarget) => {
const currentMemoId = memoRef.current?.id;
if (!currentMemoId || !target.resourceId) return;
attachmentTranscriptAbortRef.current?.abort();
const controller = new AbortController();
attachmentTranscriptAbortRef.current = controller;
hideResourceMenu();
setAttachmentTranscript({ memoId: currentMemoId, filename: target.filename, text: "", loading: true, error: null });
try {
const result = await api.transcribeNoteResource(currentMemoId, target.resourceId, controller.signal);
if (attachmentTranscriptAbortRef.current !== controller) return;
setAttachmentTranscript((current) => current?.memoId === currentMemoId && current.filename === target.filename
? { ...current, text: result.text, loading: false }
: current);
} catch (error) {
if (attachmentTranscriptAbortRef.current !== controller || controller.signal.aborted) return;
setAttachmentTranscript((current) => current?.memoId === currentMemoId && current.filename === target.filename
? { ...current, loading: false, error: error instanceof Error ? error.message : t("speechTranscription.recognizeFailed") }
: current);
} finally {
if (attachmentTranscriptAbortRef.current === controller) attachmentTranscriptAbortRef.current = null;
}
}, [hideResourceMenu, t]);
const handleResourceSaveAs = useCallback(async (target: ResourceMenuTarget) => {
hideResourceMenu();
clearResourceActionError();
@@ -3602,6 +3640,7 @@ const RichEditorPane = ({
const resourceMenuLabels = {
download: t("editor.resourceActions.download"),
saveAs: t("editor.resourceActions.saveAs"),
transcribe: t("editor.resourceActions.transcribe"),
rename: t("editor.resourceActions.rename"),
delete: t("editor.resourceActions.delete"),
unavailable: t("editor.resourceActions.unavailable"),
@@ -4350,9 +4389,17 @@ const RichEditorPane = ({
resourceMenuTarget.resourceId && !resourceMenuTarget.resourceId.startsWith("local_resource_")
))
)}
canTranscribe={Boolean(
memo?.id &&
resourceMenuTarget.kind === "attachment" &&
resourceMenuTarget.resourceId &&
!resourceMenuTarget.resourceId.startsWith("local_resource_") &&
/\.(flac|mp3|mp4|mpeg|mpga|m4a|ogg|wav|webm)$/i.test(resourceMenuTarget.filename)
)}
labels={resourceMenuLabels}
onDownload={() => void handleResourceDownload(resourceMenuTarget)}
onSaveAs={() => void handleResourceSaveAs(resourceMenuTarget)}
onTranscribe={() => void handleResourceTranscribe(resourceMenuTarget)}
onRename={() => openResourceDialog("rename", resourceMenuTarget)}
onDelete={() => openResourceDialog("delete", resourceMenuTarget)}
onMouseEnter={cancelResourceMenuHide}
@@ -4360,6 +4407,31 @@ const RichEditorPane = ({
/>
)}
<AttachmentTranscriptDialog
open={Boolean(attachmentTranscript && attachmentTranscript.memoId === memo?.id)}
filename={attachmentTranscript?.filename ?? ""}
text={attachmentTranscript?.text ?? ""}
loading={attachmentTranscript?.loading ?? false}
error={attachmentTranscript?.error ?? null}
canInsert={Boolean(editor && editor.isEditable && !effectiveReadOnly && !useMarkdownSourceEditor && !useMobilePlainTextEditor)}
onOpenChange={(open) => {
if (!open) {
attachmentTranscriptAbortRef.current?.abort();
attachmentTranscriptAbortRef.current = null;
setAttachmentTranscript(null);
}
}}
onInsert={() => {
if (!editor || !attachmentTranscript?.text || attachmentTranscript.memoId !== memo?.id) return;
const paragraphs = attachmentTranscript.text.split(/\n+/).map((line) => line.trim()).filter(Boolean);
editor.chain().focus().insertContentAt(editor.state.doc.content.size, [
{ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: t("speechTranscription.resultTitle") }] },
...paragraphs.map((line) => ({ type: "paragraph", content: [{ type: "text", text: line }] })),
]).run();
setAttachmentTranscript(null);
}}
/>
<EditorResourceDialogs
dialog={resourceDialog}
labels={resourceDialogLabels}
+2
View File
@@ -37,6 +37,7 @@ import { PasswordCard } from "./settings/PasswordCard";
import { UserManagementCard } from "./settings/UserManagementCard";
import { ObjectStorageCard } from "./settings/ObjectStorageCard";
import { AiModelCard } from "./settings/AiModelCard";
import { SpeechTranscriptionCard } from "./settings/SpeechTranscriptionCard";
import { DesktopAcpAgentCard } from "./settings/DesktopAcpAgentCard";
import { ThemeToggle } from "./ThemeToggle";
import type { AuthUser } from "@edgeever/shared";
@@ -234,6 +235,7 @@ export const SettingsPane = ({
<SettingsGroup>
<DesktopAcpAgentCard />
<AiModelCard />
<SpeechTranscriptionCard demoMode={demoMode} />
</SettingsGroup>
);
case "mcp":
@@ -0,0 +1,52 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
export const AttachmentTranscriptDialog = ({
open,
filename,
text,
loading,
error,
canInsert,
onOpenChange,
onInsert,
}: {
open: boolean;
filename: string;
text: string;
loading: boolean;
error: string | null;
canInsert: boolean;
onOpenChange: (open: boolean) => void;
onInsert: () => void;
}) => {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle>{t("speechTranscription.resultTitle")}</DialogTitle>
</DialogHeader>
<p className="break-all text-xs text-slate-500">{filename}</p>
{loading ? <p className="text-sm" role="status">{t("speechTranscription.recognizing")}</p> : null}
{error ? <p className="text-sm text-rose-600" role="alert">{error}</p> : null}
{text ? (
<>
<p className="text-xs text-slate-500">{t("speechTranscription.attachmentNotice")}</p>
<textarea className="max-h-[45vh] min-h-48 w-full resize-y rounded-md border border-slate-200 bg-card p-3 text-sm" readOnly value={text} aria-label={t("speechTranscription.resultTitle")} />
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={async () => {
await navigator.clipboard.writeText(text);
setCopied(true);
}}>{t(copied ? "speechTranscription.copied" : "speechTranscription.copy")}</Button>
{canInsert ? <Button type="button" onClick={onInsert}>{t("speechTranscription.insertIntoNote")}</Button> : null}
</div>
</>
) : null}
</DialogContent>
</Dialog>
);
};
@@ -1,7 +1,7 @@
import { useEffect, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { autoUpdate, useFloating } from "@floating-ui/react-dom";
import { FileDown, Pencil, Save, Trash2, X } from "lucide-react";
import { AudioLines, FileDown, Pencil, Save, Trash2, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
@@ -74,6 +74,7 @@ export const NoteLinkInteractionHint = ({
type ResourceActionLabels = {
download: string;
saveAs: string;
transcribe: string;
rename: string;
delete: string;
unavailable: string;
@@ -83,9 +84,11 @@ export const ResourceActionMenu = ({
target,
canRename,
canDelete,
canTranscribe,
labels,
onDownload,
onSaveAs,
onTranscribe,
onRename,
onDelete,
onMouseEnter,
@@ -94,9 +97,11 @@ export const ResourceActionMenu = ({
target: ResourceMenuTarget;
canRename: boolean;
canDelete: boolean;
canTranscribe: boolean;
labels: ResourceActionLabels;
onDownload: () => void;
onSaveAs: () => void;
onTranscribe: () => void;
onRename: () => void;
onDelete: () => void;
onMouseEnter: () => void;
@@ -148,6 +153,14 @@ export const ResourceActionMenu = ({
{labels.saveAs}
</Button>
</IconTooltip>
{canTranscribe ? (
<IconTooltip label={labels.transcribe}>
<Button type="button" size="sm" variant="ghost" aria-label={labels.transcribe} onClick={onTranscribe}>
<AudioLines className="h-3.5 w-3.5" />
{labels.transcribe}
</Button>
</IconTooltip>
) : null}
<IconTooltip label={canRename ? labels.rename : labels.unavailable}>
<Button type="button" size="sm" variant="ghost" aria-label={labels.rename} disabled={!canRename} onClick={onRename}>
<Pencil className="h-3.5 w-3.5" />
@@ -0,0 +1,325 @@
import { useEffect, useState, type FormEvent, type ReactNode } from "react";
import { useMutation } from "@tanstack/react-query";
import type { AiTranscriptionProvider, AiTranscriptionSettings, AiTranscriptionStandard } from "@edgeever/shared";
import { Loader2, MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { aiErrorMessage, trimAiText } from "@/components/settings/ai-provider-options";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { api } from "@/lib/api";
export const SpeechProviderCard = ({
provider: saved,
defaultModelId,
readOnly,
onChanged,
}: {
provider: AiTranscriptionProvider;
defaultModelId: string | null;
readOnly: boolean;
onChanged: (settings: AiTranscriptionSettings) => void;
}) => {
const { t } = useTranslation();
const [provider, setProvider] = useState<AiTranscriptionStandard>(saved.provider);
const [displayName, setDisplayName] = useState(saved.displayName);
const [baseUrl, setBaseUrl] = useState(saved.baseUrl);
const [apiKey, setApiKey] = useState("");
const [modelId, setModelId] = useState("");
const [showConnection, setShowConnection] = useState(false);
const [showAddModel, setShowAddModel] = useState(false);
useEffect(() => {
setProvider(saved.provider);
setDisplayName(saved.displayName);
setBaseUrl(saved.baseUrl);
}, [saved.baseUrl, saved.displayName, saved.provider]);
const saveMutation = useMutation({
mutationFn: () => api.updateAiTranscriptionProvider(saved.id, {
provider,
displayName: displayName.trim(),
baseUrl: baseUrl.trim(),
isEnabled: saved.isEnabled,
...(apiKey.trim() ? { apiKey: apiKey.trim() } : {}),
}),
onSuccess: (settings) => {
setApiKey("");
setShowConnection(false);
onChanged(settings);
},
});
const toggleMutation = useMutation({
mutationFn: (isEnabled: boolean) => api.updateAiTranscriptionProvider(saved.id, {
provider: saved.provider,
displayName: saved.displayName,
baseUrl: saved.baseUrl,
isEnabled,
}),
onSuccess: onChanged,
});
const deleteMutation = useMutation({
mutationFn: () => api.deleteAiTranscriptionProvider(saved.id),
onSuccess: onChanged,
});
const addModelMutation = useMutation({
mutationFn: () => api.addAiTranscriptionModel(saved.id, { modelId: trimAiText(modelId) }),
onSuccess: (settings) => {
setModelId("");
setShowAddModel(false);
onChanged(settings);
},
});
const deleteModelMutation = useMutation({
mutationFn: (modelConfigId: string) => api.deleteAiTranscriptionModel(saved.id, modelConfigId),
onSuccess: onChanged,
});
const cardBusy = toggleMutation.isPending || deleteMutation.isPending || deleteModelMutation.isPending;
const cardError = toggleMutation.error ?? deleteMutation.error ?? addModelMutation.error ?? deleteModelMutation.error;
const resetConnectionForm = () => {
setProvider(saved.provider);
setDisplayName(saved.displayName);
setBaseUrl(saved.baseUrl);
setApiKey("");
saveMutation.reset();
};
const handleConnectionChange = (open: boolean) => {
setShowConnection(open);
if (!open) resetConnectionForm();
};
const handleAddModelChange = (open: boolean) => {
setShowAddModel(open);
if (!open) {
setModelId("");
addModelMutation.reset();
}
};
const deleteProvider = () => {
if (window.confirm(t("speechTranscription.deleteProviderConfirm", { name: saved.displayName }))) {
deleteMutation.mutate();
}
};
const deleteModel = (model: AiTranscriptionProvider["models"][number]) => {
if (window.confirm(t("speechTranscription.deleteModelConfirm", { name: model.displayName }))) {
deleteModelMutation.mutate(model.id);
}
};
return (
<section>
<div className="flex flex-col gap-2.5 p-3.5 sm:p-4">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="truncate text-xs font-normal text-slate-900">{saved.displayName}</span>
{saved.credentialsUnavailable ? (
<span className="rounded-md bg-amber-50 px-2 py-0.5 text-xs font-normal text-amber-800">
{t("aiModel.savedCredentialsUnavailableBadge")}
</span>
) : null}
<span className="max-w-full truncate font-mono text-xs text-slate-400">{formatBaseUrl(saved.baseUrl)}</span>
<span className="max-w-full truncate text-xs text-slate-400">{t(`speechTranscription.standards.${saved.provider}`)}</span>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<span className="hidden text-xs text-slate-400 sm:inline">
{saved.isEnabled ? t("speechTranscription.serviceEnabledStatus") : t("speechTranscription.serviceDisabledStatus")}
</span>
<Switch
checked={saved.isEnabled}
disabled={readOnly || toggleMutation.isPending}
aria-label={t("speechTranscription.serviceEnabled")}
onCheckedChange={(checked) => toggleMutation.mutate(checked)}
/>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-500 hover:text-slate-900"
disabled={readOnly}
onClick={() => setShowAddModel(true)}
aria-label={t("speechTranscription.addModel")}
>
<Plus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t("speechTranscription.addModel")}</TooltipContent>
</Tooltip>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-500 hover:text-slate-900"
disabled={cardBusy}
aria-label={t("speechTranscription.serviceActions")}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">{t("speechTranscription.serviceActions")}</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end">
<DropdownMenuItem disabled={readOnly} onSelect={() => { resetConnectionForm(); setShowConnection(true); }}>
<Pencil className="mr-2 h-4 w-4" />{t("speechTranscription.editConnection")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-rose-600 focus:text-rose-700" disabled={readOnly} onSelect={deleteProvider}>
<Trash2 className="mr-2 h-4 w-4" />{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TooltipProvider>
</div>
</div>
<div className="flex min-w-0 flex-wrap items-center gap-1.5 pt-0.5">
<TooltipProvider>
{saved.models.length ? saved.models.map((model) => (
<span
key={model.id}
className="inline-flex h-6.5 max-w-full min-w-0 items-center gap-1.5 rounded-md border border-slate-200/80 bg-slate-50/80 pl-2 pr-1 text-xs text-slate-700"
>
<span className="min-w-0 truncate font-normal">{model.displayName}</span>
{model.id === defaultModelId ? (
<span className="shrink-0 rounded border border-slate-200 bg-slate-100 px-1 py-0.5 text-xs font-normal text-slate-700">
{t("speechTranscription.defaultBadge")}
</span>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-4 w-4 shrink-0 text-slate-400 hover:bg-card hover:text-rose-600"
disabled={readOnly || deleteModelMutation.isPending}
onClick={() => deleteModel(model)}
aria-label={`${t("speechTranscription.removeModel")}: ${model.displayName}`}
>
<Trash2 className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t("speechTranscription.removeModel")}</TooltipContent>
</Tooltip>
</span>
)) : (
<span className="text-xs text-slate-400">{t("speechTranscription.noModels")}</span>
)}
</TooltipProvider>
</div>
</div>
{cardError ? (
<p className="border-t px-4 py-3 text-xs font-medium text-rose-600" role="alert">
{aiErrorMessage(cardError, t("speechTranscription.failed"), t("aiModel.encryptionKeyMissing"), t("speechTranscription.savedCredentialsUnavailable"))}
</p>
) : null}
<Dialog open={showConnection} onOpenChange={handleConnectionChange}>
<DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-lg">
<form className="grid gap-4" onSubmit={(event: FormEvent) => { event.preventDefault(); if (readOnly) return; saveMutation.mutate(); }}>
<DialogHeader>
<DialogTitle className="text-xs font-normal">{t("speechTranscription.editConnection")}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 sm:grid-cols-2">
<Field label={t("speechTranscription.displayName")}>
<Input className="h-9 text-xs" value={displayName} onChange={(event) => setDisplayName(event.target.value)} required maxLength={80} disabled={readOnly} />
</Field>
<Field label={t("speechTranscription.standard")}>
<Select value={provider} onValueChange={(value) => setProvider(value as AiTranscriptionStandard)} disabled={readOnly}>
<SelectTrigger className="h-9 bg-card text-xs font-normal"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="openai-compatible">{t("speechTranscription.standards.openai-compatible")}</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
<Field label={t("speechTranscription.baseUrl")} hint={t("speechTranscription.baseUrlHint")}>
<Input className="h-9 text-xs" value={baseUrl} onChange={(event) => setBaseUrl(event.target.value)} required inputMode="url" autoComplete="off" spellCheck={false} maxLength={500} disabled={readOnly} />
</Field>
<Field label={t("speechTranscription.apiToken")} hint={saved.hasApiKey ? t("speechTranscription.apiTokenSavedHint") : t("speechTranscription.apiTokenHint")}>
<Input
className="h-9 text-xs"
type="password"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
placeholder={saved.hasApiKey ? t("speechTranscription.apiTokenStoredPlaceholder") : ""}
autoComplete="new-password"
maxLength={4096}
disabled={readOnly}
/>
</Field>
{saveMutation.isError ? (
<p className="text-xs font-medium text-rose-600" role="alert">
{aiErrorMessage(saveMutation.error, t("speechTranscription.failed"), t("aiModel.encryptionKeyMissing"), t("speechTranscription.savedCredentialsUnavailable"))}
</p>
) : null}
<DialogFooter className="gap-2 sm:space-x-0">
<Button type="button" variant="outline" className="text-xs font-normal" onClick={() => handleConnectionChange(false)}>{t("common.cancel")}</Button>
<Button type="submit" variant="solid" className="text-xs font-normal" disabled={readOnly || saveMutation.isPending}>
{saveMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}{t("common.save")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog open={showAddModel} onOpenChange={handleAddModelChange}>
<DialogContent className="sm:max-w-md">
<form className="grid gap-4" onSubmit={(event: FormEvent) => { event.preventDefault(); if (readOnly || !trimAiText(modelId)) return; addModelMutation.mutate(); }}>
<DialogHeader>
<DialogTitle className="text-xs font-normal">{t("speechTranscription.addModel")}</DialogTitle>
</DialogHeader>
<Field label={t("speechTranscription.modelId")}>
<Input className="h-9 text-xs" value={modelId} onChange={(event) => setModelId(event.target.value)} placeholder={t("speechTranscription.modelIdPlaceholder")} autoComplete="off" spellCheck={false} maxLength={200} required disabled={readOnly} />
</Field>
{addModelMutation.isError ? (
<p className="text-xs font-medium text-rose-600" role="alert">
{aiErrorMessage(addModelMutation.error, t("speechTranscription.failed"), t("aiModel.encryptionKeyMissing"), t("speechTranscription.savedCredentialsUnavailable"))}
</p>
) : null}
<DialogFooter className="gap-2 sm:space-x-0">
<Button type="button" variant="outline" className="text-xs font-normal" onClick={() => handleAddModelChange(false)}>{t("common.cancel")}</Button>
<Button type="submit" variant="solid" className="text-xs font-normal" disabled={readOnly || addModelMutation.isPending || !trimAiText(modelId)}>
{addModelMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}{t("speechTranscription.addModel")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</section>
);
};
const formatBaseUrl = (baseUrl: string) => {
try {
return new URL(baseUrl).host;
} catch {
return baseUrl;
}
};
const Field = ({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) => (
<label className="grid gap-1.5 text-xs font-normal leading-5 text-slate-700">
{label}{children}{hint ? <span className="text-xs font-normal leading-4 text-slate-500">{hint}</span> : null}
</label>
);
@@ -0,0 +1,274 @@
import { useState, type FormEvent, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { type AiTranscriptionSettings, type AiTranscriptionStandard } from "@edgeever/shared";
import { AudioLines, Loader2, Plus, TriangleAlert } from "lucide-react";
import { useTranslation } from "react-i18next";
import { aiErrorMessage } from "@/components/settings/ai-provider-options";
import { SpeechProviderCard } from "@/components/settings/SpeechProviderCard";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
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 } from "@/lib/api";
import {
SETTINGS_CARD_HEADER_CLASSNAME,
SETTINGS_CARD_ICON_CLASSNAME,
SETTINGS_CARD_TITLE_CLASSNAME,
SETTINGS_ITEM_TITLE_CLASSNAME,
} from "./settings-ui";
const emptyDraft = {
provider: "openai-compatible" as AiTranscriptionStandard,
displayName: "",
baseUrl: "",
apiKey: "",
initialModelId: "",
};
export const SpeechTranscriptionCard = ({ demoMode }: { demoMode: boolean }) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const settingsQuery = useQuery({
queryKey: ["ai-transcription-settings"],
queryFn: api.getAiTranscriptionSettings,
});
const [showAdd, setShowAdd] = useState(false);
const [draft, setDraft] = useState(emptyDraft);
const settings = settingsQuery.data;
const providers = settings?.providers ?? [];
const readOnly = demoMode || Boolean(settings?.readOnly);
const encryptionConfigured = settings?.encryptionConfigured ?? false;
const canAdd = !readOnly && encryptionConfigured;
const hasUnavailableCredentials = providers.some((provider) => provider.credentialsUnavailable);
const allModels = providers.flatMap((provider) =>
provider.models.map((model) => ({ ...model, providerName: provider.displayName, providerEnabled: provider.isEnabled })));
const defaultModelAvailable = !settings?.defaultModelId
|| allModels.some((model) => model.id === settings.defaultModelId && model.providerEnabled);
const applySettings = (saved: AiTranscriptionSettings) => {
queryClient.setQueryData(["ai-transcription-settings"], saved);
};
const createMutation = useMutation({
mutationFn: () => api.createAiTranscriptionProvider({
provider: draft.provider,
displayName: draft.displayName.trim(),
baseUrl: draft.baseUrl.trim(),
apiKey: draft.apiKey.trim(),
isEnabled: true,
...(draft.initialModelId.trim() ? { initialModelId: draft.initialModelId.trim() } : {}),
}),
onSuccess: (saved) => {
applySettings(saved);
setShowAdd(false);
setDraft(emptyDraft);
},
});
const defaultMutation = useMutation({
mutationFn: api.updateDefaultAiTranscriptionModel,
onSuccess: applySettings,
});
const addDisabledReason = readOnly
? t("speechTranscription.demoDisabled")
: !encryptionConfigured
? t("aiModel.encryptionKeyMissing")
: undefined;
const handleAddDialogChange = (open: boolean) => {
setShowAdd(open);
if (!open) {
setDraft(emptyDraft);
createMutation.reset();
}
};
return (
<Card className="w-full min-w-0 overflow-hidden shadow-none">
<CardHeader className={SETTINGS_CARD_HEADER_CLASSNAME}>
<CardTitle className={SETTINGS_CARD_TITLE_CLASSNAME}>
<AudioLines className={SETTINGS_CARD_ICON_CLASSNAME} />
{t("speechTranscription.title")}
</CardTitle>
</CardHeader>
<CardContent className="grid gap-5 p-4 pt-0 sm:px-5 sm:pb-5">
{settingsQuery.isLoading ? (
<p className="flex items-center gap-2 text-xs leading-5 text-slate-500">
<Loader2 className="h-4 w-4 animate-spin" />
{t("common.loading")}
</p>
) : settingsQuery.isError ? (
<p className="text-xs font-medium text-rose-600" role="alert">
{aiErrorMessage(settingsQuery.error, t("speechTranscription.failed"), t("aiModel.encryptionKeyMissing"), t("speechTranscription.savedCredentialsUnavailable"))}
</p>
) : (
<>
{!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}
{hasUnavailableCredentials ? (
<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("speechTranscription.savedCredentialsUnavailable")}
</p>
) : null}
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<div className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("speechTranscription.defaultModel")}</div>
<div className="w-56 max-w-[60%] shrink-0 sm:w-72">
<Select
value={settings?.defaultModelId ?? "none"}
onValueChange={(value) => defaultMutation.mutate(value === "none" ? null : value)}
disabled={readOnly || defaultMutation.isPending}
>
<SelectTrigger className="h-8 bg-card text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="none">{t("speechTranscription.noDefaultModel")}</SelectItem>
{allModels.map((model) => (
<SelectItem key={model.id} value={model.id} disabled={!model.providerEnabled}>
{model.displayName} · {model.providerName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{!defaultModelAvailable ? (
<p className="flex items-center gap-1.5 text-xs text-amber-700">
<TriangleAlert className="h-3.5 w-3.5 shrink-0" />
{t("speechTranscription.defaultUnavailable")}
</p>
) : null}
{defaultMutation.isError ? (
<p className="text-xs font-medium text-rose-600" role="alert">
{aiErrorMessage(defaultMutation.error, t("speechTranscription.failed"), t("aiModel.encryptionKeyMissing"), t("speechTranscription.savedCredentialsUnavailable"))}
</p>
) : null}
</div>
<section className="grid gap-3">
<div className="flex items-center justify-end">
<DisabledActionTooltip label={!canAdd ? addDisabledReason : undefined}>
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5 bg-card text-xs font-normal" disabled={!canAdd} onClick={() => handleAddDialogChange(true)}>
<Plus className="h-3.5 w-3.5" />{t("speechTranscription.addProvider")}
</Button>
</DisabledActionTooltip>
</div>
{providers.length ? (
<div className="overflow-hidden rounded-lg border border-slate-200 divide-y divide-slate-100 bg-card">
{providers.map((provider) => (
<SpeechProviderCard
key={provider.id}
provider={provider}
defaultModelId={settings?.defaultModelId ?? null}
readOnly={readOnly}
onChanged={applySettings}
/>
))}
</div>
) : (
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-center text-xs text-slate-400">{t("speechTranscription.noProviders")}</p>
)}
</section>
{readOnly ? <p className="text-xs leading-5 text-slate-500">{t("speechTranscription.demoDisabled")}</p> : null}
<p className="text-xs leading-5 text-slate-500">{t("speechTranscription.attachmentNotice")}</p>
<Dialog open={showAdd} onOpenChange={handleAddDialogChange}>
<DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-lg">
<form
className="grid gap-4"
onSubmit={(event: FormEvent) => {
event.preventDefault();
if (!canAdd || createMutation.isPending) return;
createMutation.mutate();
}}
>
<DialogHeader>
<DialogTitle className="text-xs font-normal">{t("speechTranscription.addProvider")}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 sm:grid-cols-2">
<Field label={t("speechTranscription.displayName")}>
<Input className="h-9 text-xs" value={draft.displayName} onChange={(event) => setDraft({ ...draft, displayName: event.target.value })} required maxLength={80} />
</Field>
<SpeechStandardField
value={draft.provider}
onChange={(provider) => setDraft({ ...draft, provider })}
/>
</div>
<Field label={t("speechTranscription.baseUrl")} hint={t("speechTranscription.baseUrlHint")}>
<Input className="h-9 text-xs" value={draft.baseUrl} onChange={(event) => setDraft({ ...draft, baseUrl: event.target.value })} placeholder={t("speechTranscription.baseUrlPlaceholder")} required inputMode="url" autoComplete="off" spellCheck={false} maxLength={500} />
</Field>
<Field label={t("speechTranscription.modelId")}>
<Input className="h-9 text-xs" value={draft.initialModelId} onChange={(event) => setDraft({ ...draft, initialModelId: event.target.value })} placeholder={t("speechTranscription.modelIdPlaceholder")} required autoComplete="off" spellCheck={false} maxLength={200} />
</Field>
<Field label={t("speechTranscription.apiToken")} hint={t("speechTranscription.apiTokenHint")}>
<Input className="h-9 text-xs" type="password" value={draft.apiKey} onChange={(event) => setDraft({ ...draft, apiKey: event.target.value })} required autoComplete="new-password" maxLength={4096} />
</Field>
{createMutation.isError ? (
<p className="text-xs font-medium text-rose-600" role="alert">
{aiErrorMessage(createMutation.error, t("speechTranscription.failed"), t("aiModel.encryptionKeyMissing"), t("speechTranscription.savedCredentialsUnavailable"))}
</p>
) : null}
<DialogFooter className="gap-2 sm:space-x-0">
<Button type="button" variant="outline" className="text-xs font-normal" onClick={() => handleAddDialogChange(false)}>{t("common.cancel")}</Button>
<Button type="submit" variant="solid" className="text-xs font-normal" disabled={!canAdd || createMutation.isPending}>
{createMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}{t("speechTranscription.addProvider")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
)}
</CardContent>
</Card>
);
};
const SpeechStandardField = ({
value,
onChange,
disabled = false,
}: {
value: AiTranscriptionStandard;
onChange: (value: AiTranscriptionStandard) => void;
disabled?: boolean;
}) => {
const { t } = useTranslation();
return (
<Field label={t("speechTranscription.standard")}>
<Select value={value} onValueChange={(next) => onChange(next as AiTranscriptionStandard)} disabled={disabled}>
<SelectTrigger className="h-9 bg-card text-xs font-normal"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="openai-compatible">{t("speechTranscription.standards.openai-compatible")}</SelectItem>
</SelectContent>
</Select>
</Field>
);
};
const Field = ({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) => (
<label className="grid gap-1.5 text-xs font-normal leading-5 text-slate-700">
{label}{children}{hint ? <span className="text-xs font-normal leading-4 text-slate-500">{hint}</span> : null}
</label>
);
const DisabledActionTooltip = ({ label, children }: { label?: string; children: ReactNode }) => {
if (!label) return children;
return (
<TooltipProvider delayDuration={0} skipDelayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex" tabIndex={0}>{children}</span>
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
+10 -1
View File
@@ -35,6 +35,7 @@ import {
type ThemeTokens,
} from "@edgeever/plugin-api";
import { markdownToDoc } from "@edgeever/shared";
import { api } from "@/lib/api";
import type { EdgeEverRepository } from "@/lib/repository";
import { WebPluginSecretStore, type PluginSecretStorage } from "@/lib/plugins/plugin-secret-store";
import { WebPluginPackageStore, type CachedPluginPackage, type PluginPackageStorage } from "@/lib/plugins/plugin-package-store";
@@ -199,7 +200,7 @@ export interface PluginHostSnapshot {
interface PluginHostOptions {
repository: EdgeEverRepository;
scope: string;
aiAdapter?: PluginContext['ai'];
aiAdapter?: Pick<PluginContext['ai'], 'status' | 'generate'>;
publicNetworkAdapter?: {
fetchPublic(input: { url: string; method: 'GET' | 'HEAD'; headers: Record<string, string> }, options?: { signal?: AbortSignal }): Promise<{ status: number; statusText: string; url: string; headers: Record<string, string>; body: ArrayBuffer }>;
};
@@ -1081,6 +1082,14 @@ export class EdgeEverPluginHost {
const signal = AbortSignal.any([lifetime.signal, ...(input.signal ? [input.signal] : [])]);
const result = await this.aiAdapter.generate({ ...input, signal }); signal.throwIfAborted(); return result;
},
transcribeResource: async (noteId, resourceId) => {
assertPermission(manifest, 'ai:generate');
assertPermission(manifest, 'resources:read');
lifetime.signal.throwIfAborted();
const result = await api.transcribeNoteResource(noteId, resourceId, lifetime.signal);
lifetime.signal.throwIfAborted();
return result;
},
},
notes: {
query: async (input = {}) => {
+4
View File
@@ -598,10 +598,14 @@ const result = await context.ai.generate({
maxOutputTokens: 1000,
signal: controller.signal,
});
const transcript = await context.ai.transcribeResource(noteId, resourceId);
// { text, resourceId, filename }. The attachment must already belong to the note; online video URLs are not accepted.
```
`system` is limited to 8,000 characters and `prompt` to 90,000. `maxOutputTokens` must be a positive integer and defaults to 3,000 when omitted; the host does not impose a maximum output-token value. Generation is limited to 120 seconds. The model or provider may impose its own limit or reject a request based on available credits. The backend requires an interactive user session, disables AI in public demo mode, and redacts provider errors. AI calls have a four-request per-workspace guard in each backend instance; this is not a distributed quota. Model charges follow the configured provider. Plugin deactivation aborts outstanding calls.
`transcribeResource` accepts only audio or video attachments already uploaded to a note in the current workspace, up to 24 MiB. The instance reads the attachment and sends it to the user's configured speech model service. The plugin receives text and decides how to display or save it. This API does not resolve URLs, download third-party media, or expose speech model credentials to plugins.
The default `network.fetch(url, init)` transport is a trusted browser request. It accepts arbitrary HTTP/HTTPS destinations, methods, bodies, request headers such as `Authorization`, and the requested browser credential mode. It remains subject to the runtime browser's CORS and cookie policy. `networkHosts` is legacy descriptive metadata and is not a security boundary. To read a cross-origin public feed or API without credentials, explicitly select `transport: "public"`; listing `network` and `network:public` remains useful disclosure but is optional.
```json
+4
View File
@@ -598,10 +598,14 @@ const result = await context.ai.generate({
maxOutputTokens: 1000,
signal: controller.signal,
});
const transcript = await context.ai.transcribeResource(noteId, resourceId);
// { text, resourceId, filename }。附件必须已属于该笔记;不接受在线视频 URL。
```
`system` 最多 8,000 字符,`prompt` 最多 90,000 字符。`maxOutputTokens` 必须是正整数,省略时默认 3,000;宿主不设置输出 token 的最大值。生成最长 120 秒。模型或供应商可能有自己的限制,也可能因可用额度不足拒绝请求。后端要求交互式用户会话,公开演示模式禁用 AI,供应商错误脱敏。每个后端实例对每工作区的 AI 调用设置四路并发保护,不是分布式配额。模型费用沿用已配置供应商的计费;停用插件会中止其调用。
`transcribeResource` 仅识别当前工作区笔记中已上传、大小不超过 24 MiB 的音视频附件;实例读取附件并发送给用户配置的语音模型服务。插件拿到文本后自行决定如何展示或写入笔记。它不解析 URL、不下载第三方媒体,也不向插件返回语音模型密钥。
默认的 `network.fetch(url, init)` 是受信任的浏览器请求,可以访问任意 HTTP/HTTPS 地址,使用任意方法、正文、`Authorization` 等请求头以及调用方指定的浏览器凭据模式;它仍受所在运行时的 CORS 与 Cookie 策略约束。`networkHosts` 仅为兼容旧版保留,不是安全边界。需要无凭据读取跨域公开订阅或 API 时,显式选择 `transport: "public"` 即可;列出 `network` 和 `network:public` 仍有助于披露用途,但不是必需条件:
```json
+7 -3
View File
@@ -4,9 +4,13 @@ On a YouTube or Bilibili watch page, the user chooses **Save video note to EdgeE
Supported pages are ordinary YouTube videos and Shorts, and ordinary Bilibili videos including multipart videos. Live streams, series, courses, and other pages do not create video notes. Without captions, the note contains source information and a **No captions available for this video** message. It does not start speech transcription. Video notes have no command to extract captions again and do not download videos or audio tracks from external platforms.
## Withdrawn speech transcription design: technical notes
## Speech transcription for note attachments
The following records the important design choices of an unreleased proposal for possible future evaluation of authorized use. **It does not describe current product behavior or a planned feature.** The withdrawn scope includes external audio retrieval, browser session reuse, automatic yt-dlp installation and updates, video transcription jobs, speech model settings, and desktop polling.
Users can explicitly transcribe an audio or MP4/WebM video attachment already uploaded to a note. A workspace configures an OpenAI-compatible speech provider and default model. The API accepts only a note ID and an attachment ID belonging to that note; it does not accept a media URL. Attachments are limited to 24 MiB, and provider credentials remain encrypted on the instance. The EdgeEver instance reads the attachment and sends it to the configured `/audio/transcriptions` service. The result is shown for copying or insertion into the current note. Plugins can use the same capability through `context.ai.transcribeResource(noteId, resourceId)`.
## Withdrawn external media retrieval design: technical notes
The following records an unreleased external media retrieval design for possible future evaluation of authorized use. **It does not describe current product behavior or a planned feature.** The withdrawn scope includes external audio retrieval, browser session reuse, automatic yt-dlp installation and updates, external video transcription jobs, and desktop polling. Note attachment transcription above is a separate implementation.
### Jobs and data boundaries
@@ -18,7 +22,7 @@ The following records the important design choices of an unreleased proposal for
- The proposed design checked an HTTPS source against a platform allowlist, then used yt-dlp locally to select only the best audio track, without saving video. It limited known duration to roughly 30 minutes, audio to roughly 24 MiB, and both download and transcription time. Temporary audio was deleted after the job. Resource limits do not grant copyright permission.
- The standalone yt-dlp executable came from its releases and was checked against SHA-256; the desktop client checked for updates in the background. Optional `--cookies-from-browser` reused a local browser session, with the browser choice stored only on that device. This widened the range of accessible media; local execution did not establish permission from a platform or rights holder.
- A workspace could configure speech providers, models, and a default model. API tokens were stored encrypted on the instance and not returned by settings reads. An authenticated desktop client obtained credentials when running a job and sent audio directly to the user's chosen OpenAI-compatible `/audio/transcriptions` endpoint. Audio bypassed the note server but reached the model service, which would require clear disclosure.
- The old design let an authenticated desktop client obtain speech credentials and send audio directly to the chosen OpenAI-compatible `/audio/transcriptions` endpoint. Current attachment transcription sends media from the EdgeEver instance, so the old claim that audio bypasses the note server no longer applies.
- Segments with timestamps became transcript links back to the source; a plain-text-only response became a text block. Failures recorded error codes without writing API tokens to diagnostics.
Any future revival should first establish content rights, platform terms, target markets, and the transcription provider's data handling, then redesign the entry point and verification scope. The retrieval path above should not simply be re-enabled.
+7 -3
View File
@@ -4,9 +4,13 @@
支持 YouTube 普通视频及 Shorts、哔哩哔哩普通视频及分 P。直播、番剧、课程和其他页面不创建视频笔记。页面没有字幕时,仅保存来源信息和「这一集没有可用字幕」,不启动语音识别。视频笔记不提供再次提取字幕的菜单,也不下载外部平台的视频或音轨。
## 已撤回的语音识别方案:技术备忘
## 笔记附件语音识别
以下记录未上线方案的关键设计,供以后评估授权场景时参考,**不是当前产品行为或待实现需求**。决定撤回的范围包括外部音轨抓取、浏览器登录状态复用、yt-dlp 自动安装与更新、视频识别任务、语音模型配置及桌面转录轮询。
用户可以在已上传到笔记的音频或 MP4/WebM 视频附件上主动选择「转录」。工作区配置 OpenAI 兼容语音模型和默认模型;API 仅接受笔记 ID 与归属该笔记的附件 ID,不接受媒体 URL。支持的附件不超过 24 MiB,模型凭据由实例加密保存。附件由 EdgeEver 实例读取并发送到用户配置的 `/audio/transcriptions` 服务;识别文本先展示给用户,可复制或插入当前笔记。插件可通过 `context.ai.transcribeResource(noteId, resourceId)` 调用同一能力。
## 已撤回的外部媒体抓取方案:技术备忘
以下记录未上线的外部媒体抓取设计,供以后评估授权场景时参考,**不是当前产品行为或待实现需求**。撤回范围包括外部音轨抓取、浏览器登录状态复用、yt-dlp 自动安装与更新、外部视频识别任务和桌面轮询。上面的笔记附件语音识别是独立实现。
### 任务与数据边界
@@ -18,7 +22,7 @@
- 原方案按平台白名单验证 HTTPS 来源,在本机调用 yt-dlp 只选最优音轨,不保存视频;限制已知时长约 30 分钟、音频约 24 MiB、下载和识别超时。任务结束后清理临时音频。大小和时长限制只减少资源占用,不构成版权授权。
- yt-dlp 独立可执行文件从其发布页取得,并核对 SHA-256;桌面端后台检查版本。可选的 `--cookies-from-browser` 会复用本机浏览器登录状态,浏览器选择仅存在本机。这会扩大可获取的媒体范围,因此不能把「本地执行」等同于获得平台或权利人许可。
- 语音模型服务、模型和默认模型原本按工作区配置。API Token 加密存放在实例,设置读取不返回明文;已登录桌面端在执行时领取凭据,直接将音频发送到用户配置的 OpenAI 兼容 `/audio/transcriptions` 接口。音频不经过笔记服务器,但会到达该模型服务,必须如实告知用户。
- 原方案让已登录桌面端领取语音模型凭据,并把音频直接发送到用户配置的 OpenAI 兼容 `/audio/transcriptions` 接口。当前附件转录改由实例发送媒体,不能沿用原方案中「音频不经过笔记服务器」的说明。
- 模型返回分段时间戳时生成可点回原片的实录;仅返回全文时写入普通文本。任务失败记录错误码,不把 API Token 写入诊断信息。
若未来重新考虑,应先明确内容授权、平台条款、目标市场和识别服务的数据处理方式,再重新设计入口与验证范围;不能直接恢复上述抓取链路。
@@ -0,0 +1,42 @@
-- Restore speech model settings for note attachment transcription.
-- The retired, unreleased download jobs and legacy endpoint are intentionally absent.
PRAGMA foreign_keys = ON;
CREATE TABLE ai_transcription_providers (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
provider TEXT NOT NULL DEFAULT 'openai-compatible' CHECK (provider IN ('openai-compatible')),
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 DEFAULT '',
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
);
CREATE INDEX idx_ai_transcription_providers_workspace
ON ai_transcription_providers(workspace_id, is_enabled, created_at);
CREATE TABLE ai_transcription_models (
id TEXT PRIMARY KEY,
provider_id TEXT NOT NULL,
model_id TEXT NOT NULL CHECK (length(trim(model_id)) > 0),
display_name TEXT NOT NULL CHECK (length(trim(display_name)) > 0),
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 (provider_id) REFERENCES ai_transcription_providers(id) ON UPDATE CASCADE ON DELETE CASCADE,
UNIQUE (provider_id, model_id)
);
CREATE INDEX idx_ai_transcription_models_provider
ON ai_transcription_models(provider_id, created_at);
CREATE TABLE ai_transcription_workspace_settings (
workspace_id TEXT PRIMARY KEY,
default_model_id TEXT,
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,
FOREIGN KEY (default_model_id) REFERENCES ai_transcription_models(id) ON UPDATE CASCADE ON DELETE SET NULL
);
+63
View File
@@ -53,8 +53,10 @@ import type {
TagSummary,
TiptapDoc,
AiSettings,
AiTranscriptionSettings,
AiDiscoveredModel,
AiProvider,
AiTranscriptionStandard,
AiPromptTemplate,
AiPromptTemplateCreateInput,
AiPromptTemplateUpdateInput,
@@ -302,6 +304,23 @@ export type AiProviderUpdatePayload = {
isEnabled: boolean;
};
export type AiTranscriptionProviderCreatePayload = {
provider: AiTranscriptionStandard;
displayName: string;
baseUrl: string;
apiKey: string;
isEnabled: boolean;
initialModelId?: string;
};
export type AiTranscriptionProviderUpdatePayload = {
provider: AiTranscriptionStandard;
displayName: string;
baseUrl: string;
apiKey?: string;
isEnabled: boolean;
};
/** @deprecated Use SyncBootstrapResponse; kept for mobile source compatibility. */
export type MobileSyncBootstrapPage = SyncBootstrapResponse;
@@ -895,6 +914,50 @@ export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => {
return request<AiSettings>(`/api/v1/ai/settings${search}`);
},
getAiTranscriptionSettings: () =>
request<AiTranscriptionSettings>("/api/v1/ai/transcription-settings"),
createAiTranscriptionProvider: (payload: AiTranscriptionProviderCreatePayload) =>
request<AiTranscriptionSettings>("/api/v1/ai/transcription-providers", {
method: "POST",
body: JSON.stringify(payload),
}),
updateAiTranscriptionProvider: (providerId: string, payload: AiTranscriptionProviderUpdatePayload) =>
request<AiTranscriptionSettings>(`/api/v1/ai/transcription-providers/${encodeURIComponent(providerId)}`, {
method: "PUT",
body: JSON.stringify(payload),
}),
deleteAiTranscriptionProvider: (providerId: string) =>
request<AiTranscriptionSettings>(`/api/v1/ai/transcription-providers/${encodeURIComponent(providerId)}`, {
method: "DELETE",
}),
addAiTranscriptionModel: (providerId: string, payload: { modelId: string; displayName?: string }) =>
request<AiTranscriptionSettings>(`/api/v1/ai/transcription-providers/${encodeURIComponent(providerId)}/models`, {
method: "POST",
body: JSON.stringify(payload),
}),
deleteAiTranscriptionModel: (providerId: string, modelConfigId: string) =>
request<AiTranscriptionSettings>(
`/api/v1/ai/transcription-providers/${encodeURIComponent(providerId)}/models/${encodeURIComponent(modelConfigId)}`,
{ method: "DELETE" },
),
updateDefaultAiTranscriptionModel: (modelConfigId: string | null) =>
request<AiTranscriptionSettings>("/api/v1/ai/transcription-default-model", {
method: "PUT",
body: JSON.stringify({ modelConfigId }),
}),
transcribeNoteResource: (memoId: string, resourceId: string, signal?: AbortSignal) =>
request<{ text: string; resourceId: string; filename: string }>(
`/api/v1/memos/${encodeURIComponent(memoId)}/resources/${encodeURIComponent(resourceId)}/transcribe`,
{ method: "POST", signal },
),
createAiProvider: (payload: AiProviderCreatePayload) =>
request<AiSettings>("/api/v1/ai/providers", {
method: "POST",
+5
View File
@@ -481,6 +481,11 @@ export interface PluginContext {
}): Promise<{
text: string;
}>;
transcribeResource(noteId: string, resourceId: string): Promise<{
text: string;
resourceId: string;
filename: string;
}>;
};
notes: {
query(input?: PluginNoteQuery): Promise<PluginNoteQueryResult>;
+1
View File
@@ -497,6 +497,7 @@ export interface PluginContext {
ai: {
status(): Promise<{ configured: boolean; modelName?: string }>;
generate(input: { system: string; prompt: string; maxOutputTokens?: number; signal?: AbortSignal }): Promise<{ text: string }>;
transcribeResource(noteId: string, resourceId: string): Promise<{ text: string; resourceId: string; filename: string }>;
};
notes: {
query(input?: PluginNoteQuery): Promise<PluginNoteQueryResult>;
+48
View File
@@ -1168,6 +1168,53 @@ export const enUS = {
savedCredentialsUnavailable: "Saved model credentials cannot be decrypted. Open the service and enter the API key again.",
savedCredentialsUnavailableBadge: "Re-enter API key",
},
speechTranscription: {
title: "Speech recognition model settings",
baseUrl: "Endpoint URL",
baseUrlPlaceholder: "https://api.example.com/v1",
baseUrlHint: "Requests go to /audio/transcriptions under this URL.",
modelId: "Model ID",
modelIdPlaceholder: "whisper-1",
apiToken: "API token",
apiTokenHint: "The credential is encrypted on the EdgeEver server.",
apiTokenSavedHint: "The token is encrypted. Leave this blank to keep it unchanged.",
apiTokenStoredPlaceholder: "Saved; enter a new token to replace it",
apiTokenRequired: "An API token is required to turn speech transcription on.",
saved: "Speech transcription settings saved.",
failed: "Could not save speech transcription settings.",
demoDisabled: "Speech transcription cannot be changed on the demo instance.",
defaultModel: "Default speech model",
noDefaultModel: "No model selected",
defaultUnavailable: "The service for the current default model is disabled. Enable it or choose another model.",
defaultBadge: "Default",
addProvider: "Add a model service",
noProviders: "No speech services yet. Add one, enable it with the switch, and choose a default model above.",
displayName: "Name",
standard: "Compatibility standard",
standards: {
"openai-compatible": "OpenAI-compatible",
},
serviceEnabled: "Enable service",
serviceEnabledStatus: "Enabled",
serviceDisabledStatus: "Disabled",
serviceActions: "Service actions",
addModel: "Add a model",
editConnection: "Edit connection",
removeModel: "Remove model",
noModels: "No models yet",
deleteProviderConfirm: "Delete the speech service “{{name}}”? Its saved token is deleted too.",
deleteModelConfirm: "Remove the model “{{name}}”?",
savedCredentialsUnavailable: "The saved speech token cannot be decrypted. Open this service and enter it again.",
statusOn: "On",
statusOff: "Off",
resultTitle: "Transcript",
recognizing: "Transcribing media…",
recognizeFailed: "Transcription failed",
copy: "Copy text",
copied: "Copied",
insertIntoNote: "Insert into note",
attachmentNotice: "Only audio and video attachments already uploaded to notes can be transcribed (up to 24 MiB). The attachment is sent through your EdgeEver instance to the model service you configured.",
},
aiPrompts: {
title: "Prompt library",
description: "Manage reusable note-processing prompts. New workspaces start with editable starters you can change or delete.",
@@ -1968,6 +2015,7 @@ export const enUS = {
closeImagePreview: "Close image viewer",
resourceActions: {
download: "Download",
transcribe: "Transcribe",
saveAs: "Export",
rename: "Rename",
delete: "Delete",
+48
View File
@@ -1168,6 +1168,53 @@ export const ja = {
savedCredentialsUnavailable: "保存済みのモデル認証情報を復号できません。サービスを開いて API Key を再入力してください。",
savedCredentialsUnavailableBadge: "API Key を再入力",
},
speechTranscription: {
title: "音声認識モデル設定",
baseUrl: "エンドポイント URL",
baseUrlPlaceholder: "https://api.example.com/v1",
baseUrlHint: "リクエストはこの URL 配下の /audio/transcriptions に送られます。",
modelId: "モデル ID",
modelIdPlaceholder: "whisper-1",
apiToken: "API トークン",
apiTokenHint: "認証情報は EdgeEver サーバーで暗号化されます。",
apiTokenSavedHint: "トークンは暗号化されています。空欄のままなら変更しません。",
apiTokenStoredPlaceholder: "保存済み。新しいトークンを入力すると置き換えます",
apiTokenRequired: "音声認識を有効にするには API トークンが必要です。",
saved: "音声認識の設定を保存しました。",
failed: "音声認識の設定を保存できませんでした。",
demoDisabled: "デモインスタンスでは音声認識の設定を変更できません。",
defaultModel: "デフォルト音声モデル",
noDefaultModel: "モデル未選択",
defaultUnavailable: "現在のデフォルトモデルのサービスは無効です。有効にするか、別のモデルを選んでください。",
defaultBadge: "デフォルト",
addProvider: "モデルサービスを追加",
noProviders: "音声認識サービスはまだありません。追加してスイッチで有効にし、上でデフォルトモデルを選んでください。",
displayName: "名前",
standard: "互換規格",
standards: {
"openai-compatible": "OpenAI 互換",
},
serviceEnabled: "サービスを有効にする",
serviceEnabledStatus: "有効",
serviceDisabledStatus: "無効",
serviceActions: "サービスの操作",
addModel: "モデルを追加",
editConnection: "接続を編集",
removeModel: "モデルを削除",
noModels: "モデルはまだありません",
deleteProviderConfirm: "音声サービス「{{name}}」を削除しますか?保存したトークンも削除されます。",
deleteModelConfirm: "モデル「{{name}}」を削除しますか?",
savedCredentialsUnavailable: "保存済みの音声認識トークンを復号できません。このサービスを開いて、トークンを入力し直してください。",
statusOn: "有効",
statusOff: "無効",
resultTitle: "文字起こし結果",
recognizing: "文字起こし中…",
recognizeFailed: "文字起こしに失敗しました",
copy: "テキストをコピー",
copied: "コピーしました",
insertIntoNote: "ノートに挿入",
attachmentNotice: "ノートにアップロード済みの音声・動画添付ファイルのみ文字起こしできます(最大 24 MiB)。添付ファイルは EdgeEver インスタンスを経由して設定したモデルサービスに送信されます。",
},
aiPrompts: {
title: "プロンプトライブラリ",
description: "再利用できるノート処理プロンプトを管理します。新しいワークスペースには、編集や削除できるスターターが入ります。",
@@ -1968,6 +2015,7 @@ export const ja = {
closeImagePreview: "画像ビューアを閉じる",
resourceActions: {
download: "ダウンロード",
transcribe: "文字起こし",
saveAs: "エクスポート",
rename: "名前を変更",
delete: "削除",
+48
View File
@@ -1168,6 +1168,53 @@ export const pl = {
savedCredentialsUnavailable: "Nie można odszyfrować zapisanych danych uwierzytelniających modelu. Otwórz usługę i ponownie wpisz klucz API.",
savedCredentialsUnavailableBadge: "Wpisz ponownie klucz API",
},
speechTranscription: {
title: "Ustawienia modeli rozpoznawania mowy",
baseUrl: "Adres interfejsu",
baseUrlPlaceholder: "https://api.example.com/v1",
baseUrlHint: "Żądanie trafia na /audio/transcriptions pod tym adresem.",
modelId: "ID modelu",
modelIdPlaceholder: "whisper-1",
apiToken: "Token API",
apiTokenHint: "Dane uwierzytelniające są szyfrowane na serwerze EdgeEver.",
apiTokenSavedHint: "Token jest zaszyfrowany. Pozostaw puste, aby go nie zmieniać.",
apiTokenStoredPlaceholder: "Zapisano; wpisz nowy token, aby go zastąpić",
apiTokenRequired: "Aby włączyć rozpoznawanie mowy, wpisz token API.",
saved: "Zapisano ustawienia rozpoznawania mowy.",
failed: "Nie udało się zapisać ustawień rozpoznawania mowy.",
demoDisabled: "Na instancji demonstracyjnej nie można zmieniać rozpoznawania mowy.",
defaultModel: "Domyślny model mowy",
noDefaultModel: "Nie wybrano modelu",
defaultUnavailable: "Usługa obecnego modelu domyślnego jest wyłączona. Włącz ją lub wybierz inny model.",
defaultBadge: "Domyślny",
addProvider: "Dodaj usługę modelu",
noProviders: "Nie ma jeszcze usług rozpoznawania mowy. Dodaj jedną, włącz ją przełącznikiem i wybierz powyżej model domyślny.",
displayName: "Nazwa",
standard: "Standard zgodności",
standards: {
"openai-compatible": "Zgodny z OpenAI",
},
serviceEnabled: "Włącz usługę",
serviceEnabledStatus: "Włączona",
serviceDisabledStatus: "Wyłączona",
serviceActions: "Działania usługi",
addModel: "Dodaj model",
editConnection: "Edytuj połączenie",
removeModel: "Usuń model",
noModels: "Nie ma jeszcze modeli",
deleteProviderConfirm: "Usunąć usługę mowy „{{name}}”? Zapisany token też zostanie usunięty.",
deleteModelConfirm: "Usunąć model „{{name}}”?",
savedCredentialsUnavailable: "Nie można odszyfrować zapisanego tokenu rozpoznawania mowy. Otwórz tę usługę i wpisz token ponownie.",
statusOn: "Włączone",
statusOff: "Wyłączone",
resultTitle: "Transkrypcja",
recognizing: "Trwa transkrypcja…",
recognizeFailed: "Transkrypcja nie powiodła się",
copy: "Kopiuj tekst",
copied: "Skopiowano",
insertIntoNote: "Wstaw do notatki",
attachmentNotice: "Transkrypcja działa tylko dla plików audio i wideo już dodanych do notatek (do 24 MiB). Załącznik jest wysyłany przez instancję EdgeEver do skonfigurowanej usługi modelu.",
},
aiPrompts: {
title: "Biblioteka promptów",
description: "Zarządzaj promptami wielokrotnego użytku do przetwarzania notatek. Nowe obszary robocze zaczynają z przykładowymi promptami, które możesz zmienić lub usunąć.",
@@ -1968,6 +2015,7 @@ export const pl = {
closeImagePreview: "Zamknij przeglądarkę obrazów",
resourceActions: {
download: "Pobierz",
transcribe: "Transkrybuj",
saveAs: "Eksportuj",
rename: "Zmień nazwę",
delete: "Usuń",
+48
View File
@@ -1168,6 +1168,53 @@ export const zhCN = {
savedCredentialsUnavailable: "已保存的模型凭据无法解密,请打开该服务并重新填写 API Key。",
savedCredentialsUnavailableBadge: "需重新填写密钥",
},
speechTranscription: {
title: "语音识别模型配置",
baseUrl: "接口地址",
baseUrlPlaceholder: "https://api.example.com/v1",
baseUrlHint: "请求会发到该地址下的 /audio/transcriptions。",
modelId: "模型 ID",
modelIdPlaceholder: "whisper-1",
apiToken: "API Token",
apiTokenHint: "凭据将由 EdgeEver 服务端加密保存。",
apiTokenSavedHint: "密钥已加密保存;留空表示保持不变。",
apiTokenStoredPlaceholder: "已保存;输入新密钥可替换",
apiTokenRequired: "开启语音识别需要填写 API Token。",
saved: "语音识别设置已保存。",
failed: "语音识别设置保存失败。",
demoDisabled: "演示实例不允许修改语音识别设置。",
defaultModel: "默认语音模型",
noDefaultModel: "未选择模型",
defaultUnavailable: "当前默认模型所在的服务已停用,请启用该服务或另选一个模型。",
defaultBadge: "默认",
addProvider: "添加模型服务",
noProviders: "还没有语音识别服务。添加后用开关启用,并在上面选择默认模型。",
displayName: "名称",
standard: "兼容标准",
standards: {
"openai-compatible": "OpenAI 兼容标准",
},
serviceEnabled: "启用服务",
serviceEnabledStatus: "已启用",
serviceDisabledStatus: "已停用",
serviceActions: "服务操作",
addModel: "添加模型",
editConnection: "编辑连接",
removeModel: "移除模型",
noModels: "还没有模型",
deleteProviderConfirm: "删除语音服务「{{name}}」?保存的 Token 会一起删除。",
deleteModelConfirm: "移除模型「{{name}}」?",
savedCredentialsUnavailable: "已保存的语音识别 Token 无法解密,请打开该服务并重新填写。",
statusOn: "已开启",
statusOff: "未开启",
resultTitle: "语音识别结果",
recognizing: "正在识别音视频…",
recognizeFailed: "语音识别失败",
copy: "复制文本",
copied: "已复制",
insertIntoNote: "插入笔记",
attachmentNotice: "仅识别笔记内已上传的音视频附件(不超过 24 MiB)。识别时附件会经 EdgeEver 实例发送到您配置的模型服务。",
},
aiPrompts: {
title: "指令库",
description: "管理可复用的笔记处理指令。新工作区会预置常用示例,你可随意编辑或删除。",
@@ -1968,6 +2015,7 @@ export const zhCN = {
closeImagePreview: "关闭图片查看器",
resourceActions: {
download: "下载",
transcribe: "转录",
saveAs: "导出",
rename: "重命名",
delete: "删除",
+36
View File
@@ -250,6 +250,8 @@ export const ObjectStorageConnectionTestSchema = z.object({
export const AiProviderSchema = z.enum(["openai-compatible", "anthropic", "google"]);
export const AiTranscriptionStandardSchema = z.enum(["openai-compatible"]);
const rejectUnsafeAiBaseUrl = (value: string, context: z.RefinementCtx) => {
let url: URL;
try {
@@ -268,6 +270,40 @@ const rejectUnsafeAiBaseUrl = (value: string, context: z.RefinementCtx) => {
const AiBaseUrlSchema = z.string().trim().url().max(500).superRefine(rejectUnsafeAiBaseUrl);
const AiTranscriptionProviderBaseUrlSchema = z.string().trim().min(1).max(500).superRefine((value, context) => {
if (!z.string().url().safeParse(value).success) {
context.addIssue({ code: "custom", message: "AI Base URL must be a valid URL." });
return;
}
rejectUnsafeAiBaseUrl(value, context);
});
export const AiTranscriptionProviderCreateSchema = z.object({
provider: AiTranscriptionStandardSchema.default("openai-compatible"),
displayName: z.string().trim().min(1).max(80),
baseUrl: AiTranscriptionProviderBaseUrlSchema,
apiKey: z.string().trim().min(1).max(4096),
isEnabled: z.boolean().default(true),
initialModelId: z.string().trim().min(1).max(200).optional(),
});
export const AiTranscriptionProviderUpdateSchema = z.object({
provider: AiTranscriptionStandardSchema.default("openai-compatible"),
displayName: z.string().trim().min(1).max(80),
baseUrl: AiTranscriptionProviderBaseUrlSchema,
apiKey: z.string().trim().min(1).max(4096).optional(),
isEnabled: z.boolean().default(true),
});
export const AiTranscriptionModelCreateSchema = z.object({
modelId: z.string().trim().min(1).max(200),
displayName: z.string().trim().min(1).max(80).optional(),
});
export const AiTranscriptionDefaultModelUpdateSchema = z.object({
modelConfigId: z.string().trim().min(1).nullable(),
});
const AiProviderConfigFieldsSchema = z.object({
provider: AiProviderSchema,
displayName: z.string().trim().min(1).max(80),
+29
View File
@@ -29,6 +29,7 @@ export type MemoSummary = {
structuredTable?: boolean;
tablePreview?: TableSummaryPreview;
infographic?: boolean;
videoNote?: boolean;
tags: string[];
isPinned: boolean;
isArchived: boolean;
@@ -181,6 +182,8 @@ export type ObjectStorageSettings = {
export type AiProvider = "openai-compatible" | "anthropic" | "google";
export type AiTranscriptionStandard = "openai-compatible";
export type AiModelConfig = {
id: string;
providerConfigId: string;
@@ -206,6 +209,32 @@ export type AiSettings = {
readOnly: boolean;
};
export type AiTranscriptionModel = {
id: string;
providerId: string;
modelId: string;
displayName: string;
};
export type AiTranscriptionProvider = {
id: string;
provider: AiTranscriptionStandard;
displayName: string;
baseUrl: string;
isEnabled: boolean;
hasApiKey: boolean;
models: AiTranscriptionModel[];
credentialsUnavailable?: boolean;
};
export type AiTranscriptionSettings = {
providers: AiTranscriptionProvider[];
defaultModelId: string | null;
enabled: boolean;
encryptionConfigured: boolean;
readOnly: boolean;
};
export type AiPromptTemplate = {
id: string;
origin: "default" | "custom";
@@ -2,14 +2,16 @@ import { globSync, readFileSync } from "node:fs";
import { Database } from "bun:sqlite";
import { expect, test } from "bun:test";
test("an existing speech configuration is removed without changing note and AI tables", () => {
test("retired video jobs stay removed when note attachment speech settings are restored", () => {
const db = new Database(":memory:");
try {
const migrations = globSync("migrations/*.sql").sort();
const providerIndex = migrations.findIndex((path) => path.endsWith("0062_ai_transcription_providers.sql"));
const retirementIndex = migrations.findIndex((path) => path.endsWith("0064_retire_video_transcription.sql"));
const restorationIndex = migrations.findIndex((path) => path.endsWith("0065_restore_speech_transcription.sql"));
expect(providerIndex).toBeGreaterThan(0);
expect(retirementIndex).toBeGreaterThan(providerIndex);
expect(restorationIndex).toBeGreaterThan(retirementIndex);
for (const migration of migrations.slice(0, providerIndex)) {
db.exec(readFileSync(migration, "utf8"));
@@ -40,6 +42,15 @@ test("an existing speech configuration is removed without changing note and AI t
expect(tables).toContain(kept);
}
expect(db.query("PRAGMA foreign_key_check").all()).toEqual([]);
db.exec(readFileSync(migrations[restorationIndex], "utf8"));
const restored = db.query("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((row) => row.name);
expect(restored).toContain("ai_transcription_providers");
expect(restored).toContain("ai_transcription_models");
expect(restored).toContain("ai_transcription_workspace_settings");
expect(restored).not.toContain("video_transcript_jobs");
expect(restored).not.toContain("ai_transcription_settings");
expect(db.query("PRAGMA foreign_key_check").all()).toEqual([]);
} finally {
db.close();
}