Files
AFFiNE-Mirror/packages/backend/server/src/plugins/copilot/storage.ts
T
DarkSky ff1e3d9c94 fix(server): transcript key & bump models (#15485)
#### PR Dependency Tree


* **PR #15485** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Upgraded built-in AI actions and chat model selection to Gemini 3.7
Flash.
* Transcript attachments now retain storage keys and support secure
presigned URLs during processing.
* Completed transcript results preserve attachment metadata for reliable
access.

* **Bug Fixes**
* Improved transcript handling across uploads, retries, and native
processing.
  * Added safeguards for invalid or missing attachment URLs.

* **Maintenance**
* Added a migration to backfill storage keys for existing transcript
records without altering their URLs.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-16 00:49:27 +08:00

122 lines
3.3 KiB
TypeScript

import { createHash } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import {
type BlobInputType,
BlobQuotaExceeded,
CallMetric,
type FileUpload,
OneMB,
readBuffer,
toBuffer,
URLHelper,
} from '../../base';
import { QuotaService } from '../../core/quota';
import {
type StorageRuntimeGetObjectResult,
StorageRuntimeProvider,
} from '../../core/storage-runtime';
import { fetchRemoteAttachment } from '../../native';
const REMOTE_BLOB_MAX_BYTES = 20 * OneMB;
@Injectable()
export class CopilotStorage {
constructor(
private readonly url: URLHelper,
private readonly rt: StorageRuntimeProvider,
private readonly quota: QuotaService
) {}
@CallMetric('ai', 'blob_put')
async put(
userId: string,
workspaceId: string,
key: string,
blob: BlobInputType,
mimeType = 'image/png'
) {
const name = `${userId}/${workspaceId}/${key}`;
const buffer = await toBuffer(blob);
await this.rt.putObject('copilot', name, buffer, {
contentType: mimeType,
contentLength: buffer.length,
});
if (!env.prod) {
// return image base64url for dev environment
return `data:${mimeType};base64,${buffer.toString('base64')}`;
}
return this.url.link(`/api/copilot/blob/${name}`);
}
@CallMetric('ai', 'blob_get')
async get(
userId: string,
workspaceId: string,
key: string,
signedUrl?: boolean
): Promise<StorageRuntimeGetObjectResult> {
const name = `${userId}/${workspaceId}/${key}`;
if (signedUrl) {
const redirectUrl = await this.presignGet(userId, workspaceId, key);
if (redirectUrl) return { redirectUrl };
}
return this.rt.getObject('copilot', name);
}
async presignGet(userId: string, workspaceId: string, key: string) {
return (
await this.rt.presignGet('copilot', `${userId}/${workspaceId}/${key}`)
)?.url;
}
keyFromUrl(userId: string, workspaceId: string, url: string) {
try {
const parsed = new URL(url);
const prefix = `/api/copilot/blob/${encodeURIComponent(userId)}/${encodeURIComponent(workspaceId)}/`;
if (parsed.pathname.startsWith(prefix)) {
return decodeURIComponent(parsed.pathname.slice(prefix.length));
}
} catch {
return;
}
return undefined;
}
@CallMetric('ai', 'blob_delete')
async delete(userId: string, workspaceId: string, key: string) {
await this.rt.deleteObject('copilot', `${userId}/${workspaceId}/${key}`);
}
@CallMetric('ai', 'blob_upload')
async handleUpload(userId: string, blob: FileUpload) {
const checkExceeded = await this.quota.getUserQuotaCalculator(userId);
if (checkExceeded(0)) {
throw new BlobQuotaExceeded();
}
const buffer = await readBuffer(blob.createReadStream(), checkExceeded);
return {
buffer,
filename: blob.filename,
};
}
@CallMetric('ai', 'blob_proxy_remote_url')
async handleRemoteLink(userId: string, workspaceId: string, link: string) {
const { body, mimeType } = await fetchRemoteAttachment({
url: link,
maxBytes: REMOTE_BLOB_MAX_BYTES,
expectedContentTypePrefix: 'image/',
maxImageHeight: 4096,
maxImageWidth: 4096,
});
const buffer = Buffer.from(body);
const filename = createHash('sha256').update(buffer).digest('base64url');
return this.put(userId, workspaceId, filename, buffer, mimeType);
}
}