Compare commits

..

14 Commits

Author SHA1 Message Date
CatsJuice
5e8fe28326 chore(core): replace ai onboarding videos (#6730) 2024-04-29 07:07:43 +00:00
pengx17
964e475c5f chore: bump @pengx17/electron-forge-maker-appimage (#6726)
fix https://github.com/toeverything/AFFiNE/issues/6722
add `--no-sandbox --disable-setuid-sandbox` to appimage run
2024-04-29 04:57:52 +00:00
pengx17
905d7d18e4 chore: bump blocksuite (#6725) 2024-04-29 04:57:46 +00:00
Brooooooklyn
81729703d9 chore(server): use native file-type implementation (#6686) 2024-04-29 04:46:26 +00:00
EYHN
f98db24391 fix(core): fix opt out telemetry cause error (#6723) 2024-04-29 04:03:31 +00:00
EYHN
704532bd2f fix(core): adjust notify style (#6724) 2024-04-29 03:51:41 +00:00
CatsJuice
8d342f85ad fix(core): workspace avatar hover radius (#6717) 2024-04-29 02:27:21 +00:00
Brooooooklyn
fed2503782 refactor(server): rename @affine/storage to @affine/server-native (#6682)
- Close https://github.com/toeverything/AFFiNE/issues/6680
2024-04-29 02:14:20 +00:00
EYHN
236c6e00df fix(infra): reduce workspace avatar request (#6713) 2024-04-28 07:06:27 +00:00
fundon
7584ab4b91 feat(core): add seed to fal (#6712) 2024-04-26 11:40:09 +00:00
darkskygit
b639e52dca feat: allow custom seed (#6709) 2024-04-26 11:40:07 +00:00
pengx17
5d114ea965 refactor(electron): cleanup secondary db logic (#6710) 2024-04-26 10:57:09 +00:00
pengx17
d015be24e6 fix: incorrect mixpanel param (#6706) 2024-04-26 10:45:29 +00:00
darkskygit
850bbee629 test: copilot unit & e2e test (#6649)
fix CLOUD-31
2024-04-26 09:43:35 +00:00
87 changed files with 1513 additions and 1494 deletions

View File

@@ -9,10 +9,10 @@ corepack prepare yarn@stable --activate
yarn install
# Build Server Dependencies
yarn workspace @affine/storage build
yarn workspace @affine/server-native build
# Create database
yarn workspace @affine/server prisma db push
# Create user username: affine, password: affine
echo "INSERT INTO \"users\"(\"id\",\"name\",\"email\",\"email_verified\",\"created_at\",\"password\") VALUES('99f3ad04-7c9b-441e-a6db-79f73aa64db9','affine','affine@affine.pro','2024-02-26 15:54:16.974','2024-02-26 15:54:16.974+00','\$argon2id\$v=19\$m=19456,t=2,p=1\$esDS3QCHRH0Kmeh87YPm5Q\$9S+jf+xzw2Hicj6nkWltvaaaXX3dQIxAFwCfFa9o38A');" | yarn workspace @affine/server prisma db execute --stdin
echo "INSERT INTO \"users\"(\"id\",\"name\",\"email\",\"email_verified\",\"created_at\",\"password\") VALUES('99f3ad04-7c9b-441e-a6db-79f73aa64db9','affine','affine@affine.pro','2024-02-26 15:54:16.974','2024-02-26 15:54:16.974+00','\$argon2id\$v=19\$m=19456,t=2,p=1\$esDS3QCHRH0Kmeh87YPm5Q\$9S+jf+xzw2Hicj6nkWltvaaaXX3dQIxAFwCfFa9o38A');" | yarn workspace @affine/server prisma db execute --stdin

4
.github/labeler.yml vendored
View File

@@ -44,10 +44,10 @@ mod:component:
- any-glob-to-any-file:
- 'packages/frontend/component/**/*'
mod:storage:
mod:server-native:
- changed-files:
- any-glob-to-any-file:
- 'packages/backend/storage/**/*'
- 'packages/backend/native/**/*'
mod:native:
- changed-files:

View File

@@ -66,18 +66,18 @@ jobs:
path: ./packages/frontend/web/dist
if-no-files-found: error
build-storage:
name: Build Storage - ${{ matrix.targets.name }}
build-server-native:
name: Build Server native - ${{ matrix.targets.name }}
runs-on: ubuntu-latest
strategy:
matrix:
targets:
- name: x86_64-unknown-linux-gnu
file: storage.node
file: server-native.node
- name: aarch64-unknown-linux-gnu
file: storage.arm64.node
file: server-native.arm64.node
- name: armv7-unknown-linux-gnueabihf
file: storage.armv7.node
file: server-native.armv7.node
steps:
- uses: actions/checkout@v4
@@ -88,18 +88,18 @@ jobs:
uses: ./.github/actions/setup-node
with:
electron-install: false
extra-flags: workspaces focus @affine/storage
extra-flags: workspaces focus @affine/server-native
- name: Build Rust
uses: ./.github/actions/build-rust
with:
target: ${{ matrix.targets.name }}
package: '@affine/storage'
package: '@affine/server-native'
nx_token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
- name: Upload ${{ matrix.targets.file }}
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.targets.file }}
path: ./packages/backend/storage/storage.node
path: ./packages/backend/native/server-native.node
if-no-files-found: error
build-docker:
@@ -108,7 +108,7 @@ jobs:
needs:
- build-server
- build-web-selfhost
- build-storage
- build-server-native
steps:
- uses: actions/checkout@v4
- name: Download server dist
@@ -116,25 +116,25 @@ jobs:
with:
name: server-dist
path: ./packages/backend/server/dist
- name: Download storage.node
- name: Download server-native.node
uses: actions/download-artifact@v4
with:
name: storage.node
name: server-native.node
path: ./packages/backend/server
- name: Download storage.node arm64
- name: Download server-native.node arm64
uses: actions/download-artifact@v4
with:
name: storage.arm64.node
path: ./packages/backend/storage
- name: Download storage.node arm64
name: server-native.arm64.node
path: ./packages/backend/native
- name: Download server-native.node arm64
uses: actions/download-artifact@v4
with:
name: storage.armv7.node
name: server-native.armv7.node
path: .
- name: move storage files
- name: move server-native files
run: |
mv ./packages/backend/storage/storage.node ./packages/backend/server/storage.arm64.node
mv storage.node ./packages/backend/server/storage.armv7.node
mv ./packages/backend/native/server-native.node ./packages/backend/server/server-native.arm64.node
mv server-native.node ./packages/backend/server/server-native.armv7.node
- name: Setup env
run: |
echo "GIT_SHORT_HASH=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"

View File

@@ -241,8 +241,8 @@ jobs:
path: ./packages/frontend/native/${{ steps.filename.outputs.filename }}
if-no-files-found: error
build-storage:
name: Build Storage
build-server-native:
name: Build Server native
runs-on: ubuntu-latest
env:
CARGO_PROFILE_RELEASE_DEBUG: '1'
@@ -251,19 +251,19 @@ jobs:
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
extra-flags: workspaces focus @affine/storage
extra-flags: workspaces focus @affine/server-native
electron-install: false
- name: Build Rust
uses: ./.github/actions/build-rust
with:
target: 'x86_64-unknown-linux-gnu'
package: '@affine/storage'
package: '@affine/server-native'
nx_token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
- name: Upload storage.node
- name: Upload server-native.node
uses: actions/upload-artifact@v4
with:
name: storage.node
path: ./packages/backend/storage/storage.node
name: server-native.node
path: ./packages/backend/native/server-native.node
if-no-files-found: error
build-web:
@@ -294,7 +294,7 @@ jobs:
server-test:
name: Server Test
runs-on: ubuntu-latest
needs: build-storage
needs: build-server-native
env:
NODE_ENV: test
DISTRIBUTION: browser
@@ -324,10 +324,10 @@ jobs:
electron-install: false
full-cache: true
- name: Download storage.node
- name: Download server-native.node
uses: actions/download-artifact@v4
with:
name: storage.node
name: server-native.node
path: ./packages/backend/server
- name: Initialize database
@@ -383,7 +383,7 @@ jobs:
yarn workspace @affine/electron build:dev
xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- yarn workspace @affine-test/affine-desktop-cloud e2e
needs:
- build-storage
- build-server-native
- build-native
services:
postgres:
@@ -411,10 +411,10 @@ jobs:
playwright-install: true
hard-link-nm: false
- name: Download storage.node
- name: Download server-native.node
uses: actions/download-artifact@v4
with:
name: storage.node
name: server-native.node
path: ./packages/backend/server
- name: Download affine.linux-x64-gnu.node

View File

@@ -21,6 +21,6 @@ packages/frontend/templates/onboarding
# auto-generated by NAPI-RS
# fixme(@joooye34): need script to check and generate ignore list here
packages/backend/storage/index.d.ts
packages/backend/native/index.d.ts
packages/frontend/native/index.d.ts
packages/frontend/native/index.js

9
Cargo.lock generated
View File

@@ -45,10 +45,11 @@ name = "affine_schema"
version = "0.0.0"
[[package]]
name = "affine_storage"
name = "affine_server_native"
version = "1.0.0"
dependencies = [
"chrono",
"file-format",
"napi",
"napi-build",
"napi-derive",
@@ -434,6 +435,12 @@ version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "658bd65b1cf4c852a3cc96f18a8ce7b5640f6b703f905c7d74532294c2a63984"
[[package]]
name = "file-format"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ba1b81b3c213cf1c071f8bf3b83531f310df99642e58c48247272eef006cae5"
[[package]]
name = "filetime"
version = "0.2.23"

View File

@@ -3,7 +3,7 @@ resolver = "2"
members = [
"./packages/frontend/native",
"./packages/frontend/native/schema",
"./packages/backend/storage",
"./packages/backend/native",
]
[profile.dev.package.sqlx-macros]

View File

@@ -93,7 +93,7 @@ yarn workspace @affine/native build
### Build Server Dependencies
```sh
yarn workspace @affine/storage build
yarn workspace @affine/server-native build
```
## Testing

View File

@@ -81,7 +81,7 @@ yarn workspace @affine/server prisma studio
```
# build native
yarn workspace @affine/storage build
yarn workspace @affine/server-native build
yarn workspace @affine/native build
```

View File

@@ -21,7 +21,7 @@
"dev:electron": "yarn workspace @affine/electron dev",
"build": "yarn nx build @affine/web",
"build:electron": "yarn nx build @affine/electron",
"build:storage": "yarn nx run-many -t build -p @affine/storage",
"build:server-native": "yarn nx run-many -t build -p @affine/server-native",
"start:web-static": "yarn workspace @affine/web static-server",
"serve:test-static": "yarn exec serve tests/fixtures --cors -p 8081",
"lint:eslint": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" eslint . --ext .js,mjs,.ts,.tsx --cache",

View File

@@ -1,5 +1,5 @@
[package]
name = "affine_storage"
name = "affine_server_native"
version = "1.0.0"
edition = "2021"
@@ -8,6 +8,7 @@ crate-type = ["cdylib"]
[dependencies]
chrono = "0.4"
file-format = { version = "0.24", features = ["reader"] }
napi = { version = "2", default-features = false, features = [
"napi5",
"async",

View File

@@ -1,6 +1,8 @@
/* auto-generated by NAPI-RS */
/* eslint-disable */
export function getMime(input: Uint8Array): string
/**
* Merge updates in form like `Y.applyUpdate(doc, update)` way and return the
* result binary.

View File

@@ -3,9 +3,9 @@ import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
/** @type {import('.')} */
const binding = require('./storage.node');
const binding = require('./server-native.node');
export const Storage = binding.Storage;
export const mergeUpdatesInApplyWay = binding.mergeUpdatesInApplyWay;
export const verifyChallengeResponse = binding.verifyChallengeResponse;
export const mintChallengeResponse = binding.mintChallengeResponse;
export const getMime = binding.getMime;

View File

@@ -1,5 +1,5 @@
{
"name": "@affine/storage",
"name": "@affine/server-native",
"version": "0.14.0",
"engines": {
"node": ">= 10.16.0 < 11 || >= 11.8.0"
@@ -10,13 +10,13 @@
"types": "index.d.ts",
"exports": {
".": {
"require": "./storage.node",
"require": "./server-native.node",
"import": "./index.js",
"types": "./index.d.ts"
}
},
"napi": {
"binaryName": "storage",
"binaryName": "server-native",
"targets": [
"aarch64-apple-darwin",
"aarch64-unknown-linux-gnu",
@@ -29,10 +29,7 @@
"scripts": {
"test": "node --test ./__tests__/**/*.spec.js",
"build": "napi build --release --strip --no-const-enum",
"build:debug": "napi build",
"prepublishOnly": "napi prepublish -t npm",
"artifacts": "napi artifacts",
"version": "napi version"
"build:debug": "napi build"
},
"devDependencies": {
"@napi-rs/cli": "3.0.0-alpha.46",

View File

@@ -1,9 +1,9 @@
{
"name": "@affine/storage",
"name": "@affine/server-native",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"root": "packages/backend/storage",
"sourceRoot": "packages/backend/storage/src",
"root": "packages/backend/native",
"sourceRoot": "packages/backend/native/src",
"targets": {
"build": {
"executor": "nx:run-script",

View File

@@ -0,0 +1,8 @@
use napi_derive::napi;
#[napi]
pub fn get_mime(input: &[u8]) -> String {
file_format::FileFormat::from_bytes(input)
.media_type()
.to_string()
}

View File

@@ -1,5 +1,6 @@
#![deny(clippy::all)]
pub mod file_type;
pub mod hashcash;
use std::fmt::{Debug, Display};

View File

@@ -11,7 +11,7 @@ yarn
### Build Native binding
```bash
yarn workspace @affine/storage build
yarn workspace @affine/server-native build
```
### Run server

View File

@@ -61,7 +61,6 @@
"dotenv": "^16.4.5",
"dotenv-cli": "^7.4.1",
"express": "^4.19.2",
"file-type": "^19.0.0",
"get-stream": "^9.0.1",
"graphql": "^16.8.1",
"graphql-scalars": "^1.23.0",
@@ -96,7 +95,7 @@
},
"devDependencies": {
"@affine-test/kit": "workspace:*",
"@affine/storage": "workspace:*",
"@affine/server-native": "workspace:*",
"@napi-rs/image": "^1.9.1",
"@nestjs/testing": "^10.3.7",
"@types/cookie-parser": "^1.4.7",

View File

@@ -45,6 +45,7 @@ if (env.R2_OBJECT_STORAGE_ACCOUNT_ID) {
AFFiNE.plugins.use('copilot', {
openai: {},
fal: {},
});
AFFiNE.plugins.use('redis');
AFFiNE.plugins.use('payment', {

View File

@@ -1,19 +1,19 @@
import { createRequire } from 'node:module';
let storageModule: typeof import('@affine/storage');
let serverNativeModule: typeof import('@affine/server-native');
try {
storageModule = await import('@affine/storage');
serverNativeModule = await import('@affine/server-native');
} catch {
const require = createRequire(import.meta.url);
storageModule =
serverNativeModule =
process.arch === 'arm64'
? require('../../../storage.arm64.node')
? require('../../../server-native.arm64.node')
: process.arch === 'arm'
? require('../../../storage.armv7.node')
: require('../../../storage.node');
? require('../../../server-native.armv7.node')
: require('../../../server-native.node');
}
export const mergeUpdatesInApplyWay = storageModule.mergeUpdatesInApplyWay;
export const mergeUpdatesInApplyWay = serverNativeModule.mergeUpdatesInApplyWay;
export const verifyChallengeResponse = async (
response: any,
@@ -21,10 +21,12 @@ export const verifyChallengeResponse = async (
resource: string
) => {
if (typeof response !== 'string' || !response || !resource) return false;
return storageModule.verifyChallengeResponse(response, bits, resource);
return serverNativeModule.verifyChallengeResponse(response, bits, resource);
};
export const mintChallengeResponse = async (resource: string, bits: number) => {
if (!resource) return null;
return storageModule.mintChallengeResponse(resource, bits);
return serverNativeModule.mintChallengeResponse(resource, bits);
};
export const getMime = serverNativeModule.getMime;

View File

@@ -1,9 +1,9 @@
import { Readable } from 'node:stream';
import { crc32 } from '@node-rs/crc32';
import { fileTypeFromBuffer } from 'file-type';
import { getStreamAsBuffer } from 'get-stream';
import { getMime } from '../native';
import { BlobInputType, PutObjectMetadata } from './provider';
export async function toBuffer(input: BlobInputType): Promise<Buffer> {
@@ -35,8 +35,7 @@ export async function autoMetadata(
// mime type
if (!metadata.contentType) {
try {
const typeResult = await fileTypeFromBuffer(blob);
metadata.contentType = typeResult?.mime ?? 'application/octet-stream';
metadata.contentType = getMime(blob);
} catch {
// ignore
}

View File

@@ -42,6 +42,11 @@ export interface ChatEvent {
data: string;
}
type CheckResult = {
model: string | undefined;
hasAttachment?: boolean;
};
@Controller('/api/copilot')
export class CopilotController {
private readonly logger = new Logger(CopilotController.name);
@@ -53,17 +58,26 @@ export class CopilotController {
private readonly storage: CopilotStorage
) {}
private async hasAttachment(sessionId: string, messageId: string) {
private async checkRequest(
userId: string,
sessionId: string,
messageId?: string
): Promise<CheckResult> {
await this.chatSession.checkQuota(userId);
const session = await this.chatSession.get(sessionId);
if (!session) {
if (!session || session.config.userId !== userId) {
throw new BadRequestException('Session not found');
}
const message = await session.getMessageById(messageId);
if (Array.isArray(message.attachments) && message.attachments.length) {
return true;
const ret: CheckResult = { model: session.model };
if (messageId) {
const message = await session.getMessageById(messageId);
ret.hasAttachment =
Array.isArray(message.attachments) && !!message.attachments.length;
}
return false;
return ret;
}
private async appendSessionMessage(
@@ -86,6 +100,17 @@ export class CopilotController {
return controller.signal;
}
private parseNumber(value: string | string[] | undefined) {
if (!value) {
return undefined;
}
const num = Number.parseInt(Array.isArray(value) ? value[0] : value, 10);
if (Number.isNaN(num)) {
return undefined;
}
return num;
}
private handleError(err: any) {
if (err instanceof Error) {
const ret = {
@@ -107,9 +132,7 @@ export class CopilotController {
@Query('messageId') messageId: string,
@Query() params: Record<string, string | string[]>
): Promise<string> {
await this.chatSession.checkQuota(user.id);
const model = await this.chatSession.get(sessionId).then(s => s?.model);
const { model } = await this.checkRequest(user.id, sessionId);
const provider = this.provider.getProviderByCapability(
CopilotCapability.TextToText,
model
@@ -155,60 +178,58 @@ export class CopilotController {
@Query() params: Record<string, string>
): Promise<Observable<ChatEvent>> {
try {
await this.chatSession.checkQuota(user.id);
const { model } = await this.checkRequest(user.id, sessionId);
const provider = this.provider.getProviderByCapability(
CopilotCapability.TextToText,
model
);
if (!provider) {
throw new InternalServerErrorException('No provider available');
}
const session = await this.appendSessionMessage(sessionId, messageId);
delete params.messageId;
return from(
provider.generateTextStream(session.finish(params), session.model, {
signal: this.getSignal(req),
user: user.id,
})
).pipe(
connect(shared$ =>
merge(
// actual chat event stream
shared$.pipe(
map(data => ({ type: 'message' as const, id: messageId, data }))
),
// save the generated text to the session
shared$.pipe(
toArray(),
concatMap(values => {
session.push({
role: 'assistant',
content: values.join(''),
createdAt: new Date(),
});
return from(session.save());
}),
switchMap(() => EMPTY)
)
)
),
catchError(err =>
of({
type: 'error' as const,
data: this.handleError(err),
})
)
);
} catch (err) {
return of({
type: 'error' as const,
data: this.handleError(err),
});
}
const model = await this.chatSession.get(sessionId).then(s => s?.model);
const provider = this.provider.getProviderByCapability(
CopilotCapability.TextToText,
model
);
if (!provider) {
throw new InternalServerErrorException('No provider available');
}
const session = await this.appendSessionMessage(sessionId, messageId);
delete params.messageId;
return from(
provider.generateTextStream(session.finish(params), session.model, {
signal: this.getSignal(req),
user: user.id,
})
).pipe(
connect(shared$ =>
merge(
// actual chat event stream
shared$.pipe(
map(data => ({ type: 'message' as const, id: sessionId, data }))
),
// save the generated text to the session
shared$.pipe(
toArray(),
concatMap(values => {
session.push({
role: 'assistant',
content: values.join(''),
createdAt: new Date(),
});
return from(session.save());
}),
switchMap(() => EMPTY)
)
)
),
catchError(err =>
of({
type: 'error' as const,
data: this.handleError(err),
})
)
);
}
@Sse('/chat/:sessionId/images')
@@ -220,75 +241,77 @@ export class CopilotController {
@Query() params: Record<string, string>
): Promise<Observable<ChatEvent>> {
try {
await this.chatSession.checkQuota(user.id);
const { model, hasAttachment } = await this.checkRequest(
user.id,
sessionId,
messageId
);
const provider = this.provider.getProviderByCapability(
hasAttachment
? CopilotCapability.ImageToImage
: CopilotCapability.TextToImage,
model
);
if (!provider) {
throw new InternalServerErrorException('No provider available');
}
const session = await this.appendSessionMessage(sessionId, messageId);
delete params.messageId;
const handleRemoteLink = this.storage.handleRemoteLink.bind(
this.storage,
user.id,
sessionId
);
return from(
provider.generateImagesStream(session.finish(params), session.model, {
seed: this.parseNumber(params.seed),
signal: this.getSignal(req),
user: user.id,
})
).pipe(
mergeMap(handleRemoteLink),
connect(shared$ =>
merge(
// actual chat event stream
shared$.pipe(
map(attachment => ({
type: 'attachment' as const,
id: messageId,
data: attachment,
}))
),
// save the generated text to the session
shared$.pipe(
toArray(),
concatMap(attachments => {
session.push({
role: 'assistant',
content: '',
attachments: attachments,
createdAt: new Date(),
});
return from(session.save());
}),
switchMap(() => EMPTY)
)
)
),
catchError(err =>
of({
type: 'error' as const,
data: this.handleError(err),
})
)
);
} catch (err) {
return of({
type: 'error' as const,
data: this.handleError(err),
});
}
const hasAttachment = await this.hasAttachment(sessionId, messageId);
const model = await this.chatSession.get(sessionId).then(s => s?.model);
const provider = this.provider.getProviderByCapability(
hasAttachment
? CopilotCapability.ImageToImage
: CopilotCapability.TextToImage,
model
);
if (!provider) {
throw new InternalServerErrorException('No provider available');
}
const session = await this.appendSessionMessage(sessionId, messageId);
delete params.messageId;
const handleRemoteLink = this.storage.handleRemoteLink.bind(
this.storage,
user.id,
sessionId
);
return from(
provider.generateImagesStream(session.finish(params), session.model, {
signal: this.getSignal(req),
user: user.id,
})
).pipe(
mergeMap(handleRemoteLink),
connect(shared$ =>
merge(
// actual chat event stream
shared$.pipe(
map(attachment => ({
type: 'attachment' as const,
id: sessionId,
data: attachment,
}))
),
// save the generated text to the session
shared$.pipe(
toArray(),
concatMap(attachments => {
session.push({
role: 'assistant',
content: '',
attachments: attachments,
createdAt: new Date(),
});
return from(session.save());
}),
switchMap(() => EMPTY)
)
)
),
catchError(err =>
of({
type: 'error' as const,
data: this.handleError(err),
})
)
);
}
@Get('/unsplash/photos')

View File

@@ -193,11 +193,12 @@ export class PromptService {
return null;
}
async set(name: string, messages: PromptMessage[]) {
async set(name: string, model: string, messages: PromptMessage[]) {
return await this.db.aiPrompt
.create({
data: {
name,
model,
messages: {
create: messages.map((m, idx) => ({
idx,

View File

@@ -2,6 +2,7 @@ import assert from 'node:assert';
import {
CopilotCapability,
CopilotImageOptions,
CopilotImageToImageProvider,
CopilotProviderType,
CopilotTextToImageProvider,
@@ -41,6 +42,10 @@ export class FalProvider
return !!config.apiKey;
}
get type(): CopilotProviderType {
return FalProvider.type;
}
getCapabilities(): CopilotCapability[] {
return FalProvider.capabilities;
}
@@ -53,10 +58,7 @@ export class FalProvider
async generateImages(
messages: PromptMessage[],
model: string = this.availableModels[0],
options: {
signal?: AbortSignal;
user?: string;
} = {}
options: CopilotImageOptions = {}
): Promise<Array<string>> {
const { content, attachments } = messages.pop() || {};
if (!this.availableModels.includes(model)) {
@@ -78,7 +80,7 @@ export class FalProvider
image_url: attachments?.[0],
prompt: content,
sync_mode: true,
seed: 42,
seed: options.seed || 42,
enable_safety_checks: false,
}),
signal: options.signal,
@@ -96,10 +98,7 @@ export class FalProvider
async *generateImagesStream(
messages: PromptMessage[],
model: string = this.availableModels[0],
options: {
signal?: AbortSignal;
user?: string;
} = {}
options: CopilotImageOptions = {}
): AsyncIterable<string> {
const ret = await this.generateImages(messages, model, options);
for (const url of ret) {

View File

@@ -5,6 +5,9 @@ import { ClientOptions, OpenAI } from 'openai';
import {
ChatMessageRole,
CopilotCapability,
CopilotChatOptions,
CopilotEmbeddingOptions,
CopilotImageOptions,
CopilotImageToTextProvider,
CopilotProviderType,
CopilotTextToEmbeddingProvider,
@@ -13,7 +16,7 @@ import {
PromptMessage,
} from '../types';
const DEFAULT_DIMENSIONS = 256;
export const DEFAULT_DIMENSIONS = 256;
const SIMPLE_IMAGE_URL_REGEX = /^(https?:\/\/|data:image\/)/;
@@ -59,6 +62,10 @@ export class OpenAIProvider
return !!config.apiKey;
}
get type(): CopilotProviderType {
return OpenAIProvider.type;
}
getCapabilities(): CopilotCapability[] {
return OpenAIProvider.capabilities;
}
@@ -67,7 +74,7 @@ export class OpenAIProvider
return this.availableModels.includes(model);
}
private chatToGPTMessage(
protected chatToGPTMessage(
messages: PromptMessage[]
): OpenAI.Chat.Completions.ChatCompletionMessageParam[] {
// filter redundant fields
@@ -92,7 +99,7 @@ export class OpenAIProvider
});
}
private checkParams({
protected checkParams({
messages,
embeddings,
model,
@@ -143,12 +150,7 @@ export class OpenAIProvider
async generateText(
messages: PromptMessage[],
model: string = 'gpt-3.5-turbo',
options: {
temperature?: number;
maxTokens?: number;
signal?: AbortSignal;
user?: string;
} = {}
options: CopilotChatOptions = {}
): Promise<string> {
this.checkParams({ messages, model });
const result = await this.instance.chat.completions.create(
@@ -171,12 +173,7 @@ export class OpenAIProvider
async *generateTextStream(
messages: PromptMessage[],
model: string = 'gpt-3.5-turbo',
options: {
temperature?: number;
maxTokens?: number;
signal?: AbortSignal;
user?: string;
} = {}
options: CopilotChatOptions = {}
): AsyncIterable<string> {
this.checkParams({ messages, model });
const result = await this.instance.chat.completions.create(
@@ -210,11 +207,7 @@ export class OpenAIProvider
async generateEmbedding(
messages: string | string[],
model: string,
options: {
dimensions: number;
signal?: AbortSignal;
user?: string;
} = { dimensions: DEFAULT_DIMENSIONS }
options: CopilotEmbeddingOptions = { dimensions: DEFAULT_DIMENSIONS }
): Promise<number[][]> {
messages = Array.isArray(messages) ? messages : [messages];
this.checkParams({ embeddings: messages, model });
@@ -232,10 +225,7 @@ export class OpenAIProvider
async generateImages(
messages: PromptMessage[],
model: string = 'dall-e-3',
options: {
signal?: AbortSignal;
user?: string;
} = {}
options: CopilotImageOptions = {}
): Promise<Array<string>> {
const { content: prompt } = messages.pop() || {};
if (!prompt) {
@@ -257,10 +247,7 @@ export class OpenAIProvider
async *generateImagesStream(
messages: PromptMessage[],
model: string = 'dall-e-3',
options: {
signal?: AbortSignal;
user?: string;
} = {}
options: CopilotImageOptions = {}
): AsyncIterable<string> {
const ret = await this.generateImages(messages, model, options);
for (const url of ret) {

View File

@@ -278,7 +278,9 @@ export class CopilotResolver {
return new TooManyRequestsException('Server is busy');
}
const session = await this.chatSession.get(options.sessionId);
if (!session) return new BadRequestException('Session not found');
if (!session || session.config.userId !== user.id) {
return new BadRequestException('Session not found');
}
if (options.blobs) {
options.attachments = options.attachments || [];

View File

@@ -81,7 +81,7 @@ export class ChatSession implements AsyncDisposable {
}
pop() {
this.state.messages.pop();
return this.state.messages.pop();
}
private takeMessages(): ChatMessage[] {
@@ -115,7 +115,7 @@ export class ChatSession implements AsyncDisposable {
Object.keys(params).length ? params : messages[0]?.params || {},
this.config.sessionId
),
...messages.filter(m => m.content || m.attachments?.length),
...messages.filter(m => m.content?.trim() || m.attachments?.length),
];
}

View File

@@ -15,6 +15,7 @@ export interface CopilotConfig {
openai: OpenAIClientOptions;
fal: FalConfig;
unsplashKey: string;
test: never;
}
export enum AvailableModels {
@@ -130,6 +131,8 @@ export type ListHistoriesOptions = {
export enum CopilotProviderType {
FAL = 'fal',
OpenAI = 'openai',
// only for test
Test = 'test',
}
export enum CopilotCapability {
@@ -140,7 +143,34 @@ export enum CopilotCapability {
ImageToText = 'image-to-text',
}
const CopilotProviderOptionsSchema = z.object({
signal: z.instanceof(AbortSignal).optional(),
user: z.string().optional(),
});
const CopilotChatOptionsSchema = CopilotProviderOptionsSchema.extend({
temperature: z.number().optional(),
maxTokens: z.number().optional(),
}).optional();
export type CopilotChatOptions = z.infer<typeof CopilotChatOptionsSchema>;
const CopilotEmbeddingOptionsSchema = CopilotProviderOptionsSchema.extend({
dimensions: z.number(),
}).optional();
export type CopilotEmbeddingOptions = z.infer<
typeof CopilotEmbeddingOptionsSchema
>;
const CopilotImageOptionsSchema = CopilotProviderOptionsSchema.extend({
seed: z.number().optional(),
}).optional();
export type CopilotImageOptions = z.infer<typeof CopilotImageOptionsSchema>;
export interface CopilotProvider {
readonly type: CopilotProviderType;
getCapabilities(): CopilotCapability[];
isModelAvailable(model: string): boolean;
}
@@ -149,22 +179,12 @@ export interface CopilotTextToTextProvider extends CopilotProvider {
generateText(
messages: PromptMessage[],
model?: string,
options?: {
temperature?: number;
maxTokens?: number;
signal?: AbortSignal;
user?: string;
}
options?: CopilotChatOptions
): Promise<string>;
generateTextStream(
messages: PromptMessage[],
model?: string,
options?: {
temperature?: number;
maxTokens?: number;
signal?: AbortSignal;
user?: string;
}
options?: CopilotChatOptions
): AsyncIterable<string>;
}
@@ -172,11 +192,7 @@ export interface CopilotTextToEmbeddingProvider extends CopilotProvider {
generateEmbedding(
messages: string[] | string,
model: string,
options: {
dimensions: number;
signal?: AbortSignal;
user?: string;
}
options?: CopilotEmbeddingOptions
): Promise<number[][]>;
}
@@ -184,18 +200,12 @@ export interface CopilotTextToImageProvider extends CopilotProvider {
generateImages(
messages: PromptMessage[],
model: string,
options: {
signal?: AbortSignal;
user?: string;
}
options?: CopilotImageOptions
): Promise<Array<string>>;
generateImagesStream(
messages: PromptMessage[],
model?: string,
options?: {
signal?: AbortSignal;
user?: string;
}
options?: CopilotImageOptions
): AsyncIterable<string>;
}
@@ -203,22 +213,12 @@ export interface CopilotImageToTextProvider extends CopilotProvider {
generateText(
messages: PromptMessage[],
model: string,
options: {
temperature?: number;
maxTokens?: number;
signal?: AbortSignal;
user?: string;
}
options?: CopilotChatOptions
): Promise<string>;
generateTextStream(
messages: PromptMessage[],
model: string,
options: {
temperature?: number;
maxTokens?: number;
signal?: AbortSignal;
user?: string;
}
options?: CopilotChatOptions
): AsyncIterable<string>;
}
@@ -226,18 +226,12 @@ export interface CopilotImageToImageProvider extends CopilotProvider {
generateImages(
messages: PromptMessage[],
model: string,
options: {
signal?: AbortSignal;
user?: string;
}
options?: CopilotImageOptions
): Promise<Array<string>>;
generateImagesStream(
messages: PromptMessage[],
model?: string,
options?: {
signal?: AbortSignal;
user?: string;
}
options?: CopilotImageOptions
): AsyncIterable<string>;
}

View File

@@ -0,0 +1,382 @@
/// <reference types="../src/global.d.ts" />
import { randomUUID } from 'node:crypto';
import { INestApplication } from '@nestjs/common';
import type { TestFn } from 'ava';
import ava from 'ava';
import Sinon from 'sinon';
import { AuthService } from '../src/core/auth';
import { WorkspaceModule } from '../src/core/workspaces';
import { ConfigModule } from '../src/fundamentals/config';
import { CopilotModule } from '../src/plugins/copilot';
import { PromptService } from '../src/plugins/copilot/prompt';
import {
CopilotProviderService,
registerCopilotProvider,
} from '../src/plugins/copilot/providers';
import { CopilotStorage } from '../src/plugins/copilot/storage';
import {
acceptInviteById,
createTestingApp,
createWorkspace,
inviteUser,
signUp,
} from './utils';
import {
chatWithImages,
chatWithText,
chatWithTextStream,
createCopilotMessage,
createCopilotSession,
getHistories,
MockCopilotTestProvider,
textToEventStream,
} from './utils/copilot';
const test = ava as TestFn<{
auth: AuthService;
app: INestApplication;
prompt: PromptService;
provider: CopilotProviderService;
storage: CopilotStorage;
}>;
test.beforeEach(async t => {
const { app } = await createTestingApp({
imports: [
ConfigModule.forRoot({
plugins: {
copilot: {
openai: {
apiKey: '1',
},
fal: {
apiKey: '1',
},
},
},
}),
WorkspaceModule,
CopilotModule,
],
});
const auth = app.get(AuthService);
const prompt = app.get(PromptService);
const storage = app.get(CopilotStorage);
t.context.app = app;
t.context.auth = auth;
t.context.prompt = prompt;
t.context.storage = storage;
});
let token: string;
const promptName = 'prompt';
test.beforeEach(async t => {
const { app, prompt } = t.context;
const user = await signUp(app, 'test', 'darksky@affine.pro', '123456');
token = user.token.token;
registerCopilotProvider(MockCopilotTestProvider);
await prompt.set(promptName, 'test', [
{ role: 'system', content: 'hello {{word}}' },
]);
});
test.afterEach.always(async t => {
await t.context.app.close();
});
// ==================== session ====================
test('should create session correctly', async t => {
const { app } = t.context;
const assertCreateSession = async (
workspaceId: string,
error: string,
asserter = async (x: any) => {
t.truthy(await x, error);
}
) => {
await asserter(
createCopilotSession(app, token, workspaceId, randomUUID(), promptName)
);
};
{
const { id } = await createWorkspace(app, token);
await assertCreateSession(
id,
'should be able to create session with cloud workspace that user can access'
);
}
{
await assertCreateSession(
randomUUID(),
'should be able to create session with local workspace'
);
}
{
const {
token: { token },
} = await signUp(app, 'test', 'test@affine.pro', '123456');
const { id } = await createWorkspace(app, token);
await assertCreateSession(id, '', async x => {
await t.throwsAsync(
x,
{ instanceOf: Error },
'should not able to create session with cloud workspace that user cannot access'
);
});
const inviteId = await inviteUser(
app,
token,
id,
'darksky@affine.pro',
'Admin'
);
await acceptInviteById(app, id, inviteId, false);
await assertCreateSession(
id,
'should able to create session after user have permission'
);
}
});
test('should be able to use test provider', async t => {
const { app } = t.context;
const { id } = await createWorkspace(app, token);
t.truthy(
await createCopilotSession(app, token, id, randomUUID(), promptName),
'failed to create session'
);
});
// ==================== message ====================
test('should create message correctly', async t => {
const { app } = t.context;
{
const { id } = await createWorkspace(app, token);
const sessionId = await createCopilotSession(
app,
token,
id,
randomUUID(),
promptName
);
const messageId = await createCopilotMessage(app, token, sessionId);
t.truthy(messageId, 'should be able to create message with valid session');
}
{
await t.throwsAsync(
createCopilotMessage(app, token, randomUUID()),
{ instanceOf: Error },
'should not able to create message with invalid session'
);
}
});
// ==================== chat ====================
test('should be able to chat with api', async t => {
const { app, storage } = t.context;
Sinon.stub(storage, 'handleRemoteLink').resolvesArg(2);
const { id } = await createWorkspace(app, token);
const sessionId = await createCopilotSession(
app,
token,
id,
randomUUID(),
promptName
);
const messageId = await createCopilotMessage(app, token, sessionId);
const ret = await chatWithText(app, token, sessionId, messageId);
t.is(ret, 'generate text to text', 'should be able to chat with text');
const ret2 = await chatWithTextStream(app, token, sessionId, messageId);
t.is(
ret2,
textToEventStream('generate text to text stream', messageId),
'should be able to chat with text stream'
);
const ret3 = await chatWithImages(app, token, sessionId, messageId);
t.is(
ret3,
textToEventStream(
['https://example.com/image.jpg'],
messageId,
'attachment'
),
'should be able to chat with images'
);
Sinon.restore();
});
test('should reject message from different session', async t => {
const { app } = t.context;
const { id } = await createWorkspace(app, token);
const sessionId = await createCopilotSession(
app,
token,
id,
randomUUID(),
promptName
);
const anotherSessionId = await createCopilotSession(
app,
token,
id,
randomUUID(),
promptName
);
const anotherMessageId = await createCopilotMessage(
app,
token,
anotherSessionId
);
await t.throwsAsync(
chatWithText(app, token, sessionId, anotherMessageId),
{ instanceOf: Error },
'should reject message from different session'
);
});
test('should reject request from different user', async t => {
const { app } = t.context;
const { id } = await createWorkspace(app, token);
const sessionId = await createCopilotSession(
app,
token,
id,
randomUUID(),
promptName
);
// should reject message from different user
{
const { token } = await signUp(app, 'a1', 'a1@affine.pro', '123456');
await t.throwsAsync(
createCopilotMessage(app, token.token, sessionId),
{ instanceOf: Error },
'should reject message from different user'
);
}
// should reject chat from different user
{
const messageId = await createCopilotMessage(app, token, sessionId);
{
const { token } = await signUp(app, 'a2', 'a2@affine.pro', '123456');
await t.throwsAsync(
chatWithText(app, token.token, sessionId, messageId),
{ instanceOf: Error },
'should reject chat from different user'
);
}
}
});
// ==================== history ====================
test('should be able to list history', async t => {
const { app } = t.context;
const { id: workspaceId } = await createWorkspace(app, token);
const sessionId = await createCopilotSession(
app,
token,
workspaceId,
randomUUID(),
promptName
);
const messageId = await createCopilotMessage(app, token, sessionId);
await chatWithText(app, token, sessionId, messageId);
const histories = await getHistories(app, token, { workspaceId });
t.deepEqual(
histories.map(h => h.messages.map(m => m.content)),
[['generate text to text']],
'should be able to list history'
);
});
test('should reject request that user have not permission', async t => {
const { app } = t.context;
const {
token: { token: anotherToken },
} = await signUp(app, 'a1', 'a1@affine.pro', '123456');
const { id: workspaceId } = await createWorkspace(app, anotherToken);
// should reject request that user have not permission
{
await t.throwsAsync(
getHistories(app, token, { workspaceId }),
{ instanceOf: Error },
'should reject request that user have not permission'
);
}
// should able to list history after user have permission
{
const inviteId = await inviteUser(
app,
anotherToken,
workspaceId,
'darksky@affine.pro',
'Admin'
);
await acceptInviteById(app, workspaceId, inviteId, false);
t.deepEqual(
await getHistories(app, token, { workspaceId }),
[],
'should able to list history after user have permission'
);
}
{
const sessionId = await createCopilotSession(
app,
anotherToken,
workspaceId,
randomUUID(),
promptName
);
const messageId = await createCopilotMessage(app, anotherToken, sessionId);
await chatWithText(app, anotherToken, sessionId, messageId);
const histories = await getHistories(app, anotherToken, { workspaceId });
t.deepEqual(
histories.map(h => h.messages.map(m => m.content)),
[['generate text to text']],
'should able to list history'
);
t.deepEqual(
await getHistories(app, token, { workspaceId }),
[],
'should not list history created by another user'
);
}
});

View File

@@ -5,17 +5,28 @@ import type { TestFn } from 'ava';
import ava from 'ava';
import { AuthService } from '../src/core/auth';
import { QuotaManagementService, QuotaModule } from '../src/core/quota';
import { QuotaModule } from '../src/core/quota';
import { ConfigModule } from '../src/fundamentals/config';
import { CopilotModule } from '../src/plugins/copilot';
import { PromptService } from '../src/plugins/copilot/prompt';
import {
CopilotProviderService,
registerCopilotProvider,
} from '../src/plugins/copilot/providers';
import { ChatSessionService } from '../src/plugins/copilot/session';
import {
CopilotCapability,
CopilotProviderType,
} from '../src/plugins/copilot/types';
import { createTestingModule } from './utils';
import { MockCopilotTestProvider } from './utils/copilot';
const test = ava as TestFn<{
auth: AuthService;
quotaManager: QuotaManagementService;
module: TestingModule;
prompt: PromptService;
provider: CopilotProviderService;
session: ChatSessionService;
}>;
test.beforeEach(async t => {
@@ -27,6 +38,9 @@ test.beforeEach(async t => {
openai: {
apiKey: '1',
},
fal: {
apiKey: '1',
},
},
},
}),
@@ -35,26 +49,37 @@ test.beforeEach(async t => {
],
});
const quotaManager = module.get(QuotaManagementService);
const auth = module.get(AuthService);
const prompt = module.get(PromptService);
const provider = module.get(CopilotProviderService);
const session = module.get(ChatSessionService);
t.context.module = module;
t.context.quotaManager = quotaManager;
t.context.auth = auth;
t.context.prompt = prompt;
t.context.provider = provider;
t.context.session = session;
});
test.afterEach.always(async t => {
await t.context.module.close();
});
let userId: string;
test.beforeEach(async t => {
const { auth } = t.context;
const user = await auth.signUp('test', 'darksky@affine.pro', '123456');
userId = user.id;
});
// ==================== prompt ====================
test('should be able to manage prompt', async t => {
const { prompt } = t.context;
t.is((await prompt.list()).length, 0, 'should have no prompt');
await prompt.set('test', [
await prompt.set('test', 'test', [
{ role: 'system', content: 'hello' },
{ role: 'user', content: 'hello' },
]);
@@ -91,7 +116,7 @@ test('should be able to render prompt', async t => {
content: 'hello world',
};
await prompt.set('test', [msg]);
await prompt.set('test', 'test', [msg]);
const testPrompt = await prompt.get('test');
t.assert(testPrompt, 'should have prompt');
t.is(
@@ -126,7 +151,7 @@ test('should be able to render listed prompt', async t => {
links: ['https://affine.pro', 'https://github.com/toeverything/affine'],
};
await prompt.set('test', [msg]);
await prompt.set('test', 'test', [msg]);
const testPrompt = await prompt.get('test');
t.is(
@@ -135,3 +160,265 @@ test('should be able to render listed prompt', async t => {
'should render the prompt'
);
});
// ==================== session ====================
test('should be able to manage chat session', async t => {
const { prompt, session } = t.context;
await prompt.set('prompt', 'model', [
{ role: 'system', content: 'hello {{word}}' },
]);
const sessionId = await session.create({
docId: 'test',
workspaceId: 'test',
userId,
promptName: 'prompt',
});
t.truthy(sessionId, 'should create session');
const s = (await session.get(sessionId))!;
t.is(s.config.sessionId, sessionId, 'should get session');
t.is(s.config.promptName, 'prompt', 'should have prompt name');
t.is(s.model, 'model', 'should have model');
const params = { word: 'world' };
s.push({ role: 'user', content: 'hello', createdAt: new Date() });
// @ts-expect-error
const finalMessages = s.finish(params).map(({ createdAt: _, ...m }) => m);
t.deepEqual(
finalMessages,
[
{ content: 'hello world', params, role: 'system' },
{ content: 'hello', role: 'user' },
],
'should generate the final message'
);
await s.save();
const s1 = (await session.get(sessionId))!;
t.deepEqual(
// @ts-expect-error
s1.finish(params).map(({ createdAt: _, ...m }) => m),
finalMessages,
'should same as before message'
);
t.deepEqual(
// @ts-expect-error
s1.finish({}).map(({ createdAt: _, ...m }) => m),
[
{ content: 'hello ', params: {}, role: 'system' },
{ content: 'hello', role: 'user' },
],
'should generate different message with another params'
);
});
test('should be able to process message id', async t => {
const { prompt, session } = t.context;
await prompt.set('prompt', 'model', [
{ role: 'system', content: 'hello {{word}}' },
]);
const sessionId = await session.create({
docId: 'test',
workspaceId: 'test',
userId,
promptName: 'prompt',
});
const s = (await session.get(sessionId))!;
const textMessage = (await session.createMessage({
sessionId,
content: 'hello',
}))!;
const anotherSessionMessage = (await session.createMessage({
sessionId: 'another-session-id',
}))!;
await t.notThrowsAsync(
s.pushByMessageId(textMessage),
'should push by message id'
);
await t.throwsAsync(
s.pushByMessageId(anotherSessionMessage),
{
instanceOf: Error,
},
'should throw error if push by another session message id'
);
await t.throwsAsync(
s.pushByMessageId('invalid'),
{ instanceOf: Error },
'should throw error if push by invalid message id'
);
});
test('should be able to generate with message id', async t => {
const { prompt, session } = t.context;
await prompt.set('prompt', 'model', [
{ role: 'system', content: 'hello {{word}}' },
]);
// text message
{
const sessionId = await session.create({
docId: 'test',
workspaceId: 'test',
userId,
promptName: 'prompt',
});
const s = (await session.get(sessionId))!;
const message = (await session.createMessage({
sessionId,
content: 'hello',
}))!;
await s.pushByMessageId(message);
const finalMessages = s
.finish({ word: 'world' })
.map(({ content }) => content);
t.deepEqual(finalMessages, ['hello world', 'hello']);
}
// attachment message
{
const sessionId = await session.create({
docId: 'test',
workspaceId: 'test',
userId,
promptName: 'prompt',
});
const s = (await session.get(sessionId))!;
const message = (await session.createMessage({
sessionId,
attachments: ['https://affine.pro/example.jpg'],
}))!;
await s.pushByMessageId(message);
const finalMessages = s
.finish({ word: 'world' })
.map(({ attachments }) => attachments);
t.deepEqual(finalMessages, [
// system prompt
undefined,
// user prompt
['https://affine.pro/example.jpg'],
]);
}
// empty message
{
const sessionId = await session.create({
docId: 'test',
workspaceId: 'test',
userId,
promptName: 'prompt',
});
const s = (await session.get(sessionId))!;
const message = (await session.createMessage({
sessionId,
}))!;
await s.pushByMessageId(message);
const finalMessages = s
.finish({ word: 'world' })
.map(({ content }) => content);
// empty message should be filtered
t.deepEqual(finalMessages, ['hello world']);
}
});
// ==================== provider ====================
test('should be able to get provider', async t => {
const { provider } = t.context;
{
const p = provider.getProviderByCapability(CopilotCapability.TextToText);
t.is(
p?.type.toString(),
'openai',
'should get provider support text-to-text'
);
}
{
const p = provider.getProviderByCapability(
CopilotCapability.TextToEmbedding
);
t.is(
p?.type.toString(),
'openai',
'should get provider support text-to-embedding'
);
}
{
const p = provider.getProviderByCapability(CopilotCapability.TextToImage);
t.is(
p?.type.toString(),
'fal',
'should get provider support text-to-image'
);
}
{
const p = provider.getProviderByCapability(CopilotCapability.ImageToImage);
t.is(
p?.type.toString(),
'fal',
'should get provider support image-to-image'
);
}
{
const p = provider.getProviderByCapability(CopilotCapability.ImageToText);
t.is(
p?.type.toString(),
'openai',
'should get provider support image-to-text'
);
}
// text-to-image use fal by default, but this case can use
// model dall-e-3 to select openai provider
{
const p = provider.getProviderByCapability(
CopilotCapability.TextToImage,
'dall-e-3'
);
t.is(
p?.type.toString(),
'openai',
'should get provider support text-to-image and model'
);
}
});
test('should be able to register test provider', async t => {
const { provider } = t.context;
registerCopilotProvider(MockCopilotTestProvider);
const assertProvider = (cap: CopilotCapability) => {
const p = provider.getProviderByCapability(cap, 'test');
t.is(
p?.type,
CopilotProviderType.Test,
`should get test provider with ${cap}`
);
};
assertProvider(CopilotCapability.TextToText);
assertProvider(CopilotCapability.TextToEmbedding);
assertProvider(CopilotCapability.TextToImage);
assertProvider(CopilotCapability.ImageToImage);
assertProvider(CopilotCapability.ImageToText);
});

View File

@@ -0,0 +1,305 @@
import { randomBytes } from 'node:crypto';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import {
DEFAULT_DIMENSIONS,
OpenAIProvider,
} from '../../src/plugins/copilot/providers/openai';
import {
CopilotCapability,
CopilotImageToImageProvider,
CopilotImageToTextProvider,
CopilotProviderType,
CopilotTextToEmbeddingProvider,
CopilotTextToImageProvider,
CopilotTextToTextProvider,
PromptMessage,
} from '../../src/plugins/copilot/types';
import { gql } from './common';
import { handleGraphQLError } from './utils';
export class MockCopilotTestProvider
extends OpenAIProvider
implements
CopilotTextToTextProvider,
CopilotTextToEmbeddingProvider,
CopilotTextToImageProvider,
CopilotImageToImageProvider,
CopilotImageToTextProvider
{
override readonly availableModels = ['test'];
static override readonly capabilities = [
CopilotCapability.TextToText,
CopilotCapability.TextToEmbedding,
CopilotCapability.TextToImage,
CopilotCapability.ImageToImage,
CopilotCapability.ImageToText,
];
override get type(): CopilotProviderType {
return CopilotProviderType.Test;
}
override getCapabilities(): CopilotCapability[] {
return MockCopilotTestProvider.capabilities;
}
override isModelAvailable(model: string): boolean {
return this.availableModels.includes(model);
}
// ====== text to text ======
override async generateText(
messages: PromptMessage[],
model: string = 'test',
_options: {
temperature?: number;
maxTokens?: number;
signal?: AbortSignal;
user?: string;
} = {}
): Promise<string> {
this.checkParams({ messages, model });
return 'generate text to text';
}
override async *generateTextStream(
messages: PromptMessage[],
model: string = 'gpt-3.5-turbo',
options: {
temperature?: number;
maxTokens?: number;
signal?: AbortSignal;
user?: string;
} = {}
): AsyncIterable<string> {
this.checkParams({ messages, model });
const result = 'generate text to text stream';
for await (const message of result) {
yield message;
if (options.signal?.aborted) {
break;
}
}
}
// ====== text to embedding ======
override async generateEmbedding(
messages: string | string[],
model: string,
options: {
dimensions: number;
signal?: AbortSignal;
user?: string;
} = { dimensions: DEFAULT_DIMENSIONS }
): Promise<number[][]> {
messages = Array.isArray(messages) ? messages : [messages];
this.checkParams({ embeddings: messages, model });
return [Array.from(randomBytes(options.dimensions)).map(v => v % 128)];
}
// ====== text to image ======
override async generateImages(
messages: PromptMessage[],
_model: string = 'test',
_options: {
signal?: AbortSignal;
user?: string;
} = {}
): Promise<Array<string>> {
const { content: prompt } = messages.pop() || {};
if (!prompt) {
throw new Error('Prompt is required');
}
return ['https://example.com/image.jpg'];
}
override async *generateImagesStream(
messages: PromptMessage[],
model: string = 'dall-e-3',
options: {
signal?: AbortSignal;
user?: string;
} = {}
): AsyncIterable<string> {
const ret = await this.generateImages(messages, model, options);
for (const url of ret) {
yield url;
}
}
}
export async function createCopilotSession(
app: INestApplication,
userToken: string,
workspaceId: string,
docId: string,
promptName: string
): Promise<string> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(userToken, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation createCopilotSession($options: CreateChatSessionInput!) {
createCopilotSession(options: $options)
}
`,
variables: { options: { workspaceId, docId, promptName } },
})
.expect(200);
handleGraphQLError(res);
return res.body.data.createCopilotSession;
}
export async function createCopilotMessage(
app: INestApplication,
userToken: string,
sessionId: string,
content?: string,
attachments?: string[],
blobs?: ArrayBuffer[],
params?: Record<string, string>
): Promise<string> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(userToken, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation createCopilotMessage($options: CreateChatMessageInput!) {
createCopilotMessage(options: $options)
}
`,
variables: {
options: { sessionId, content, attachments, blobs, params },
},
})
.expect(200);
handleGraphQLError(res);
return res.body.data.createCopilotMessage;
}
export async function chatWithText(
app: INestApplication,
userToken: string,
sessionId: string,
messageId: string,
prefix = ''
): Promise<string> {
const res = await request(app.getHttpServer())
.get(`/api/copilot/chat/${sessionId}${prefix}?messageId=${messageId}`)
.auth(userToken, { type: 'bearer' })
.expect(200);
return res.text;
}
export async function chatWithTextStream(
app: INestApplication,
userToken: string,
sessionId: string,
messageId: string
) {
return chatWithText(app, userToken, sessionId, messageId, '/stream');
}
export async function chatWithImages(
app: INestApplication,
userToken: string,
sessionId: string,
messageId: string
) {
return chatWithText(app, userToken, sessionId, messageId, '/images');
}
export function textToEventStream(
content: string | string[],
id: string,
event = 'message'
): string {
return (
Array.from(content)
.map(x => `\nevent: ${event}\nid: ${id}\ndata: ${x}`)
.join('\n') + '\n\n'
);
}
type ChatMessage = {
role: string;
content: string;
attachments: string[] | null;
createdAt: string;
};
type History = {
sessionId: string;
tokens: number;
action: string | null;
createdAt: string;
messages: ChatMessage[];
};
export async function getHistories(
app: INestApplication,
userToken: string,
variables: {
workspaceId: string;
docId?: string;
options?: {
sessionId?: string;
action?: boolean;
limit?: number;
skip?: number;
};
}
): Promise<History[]> {
const res = await request(app.getHttpServer())
.post(gql)
.auth(userToken, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
query getCopilotHistories(
$workspaceId: String!
$docId: String
$options: QueryChatHistoriesInput
) {
currentUser {
copilot(workspaceId: $workspaceId) {
histories(docId: $docId, options: $options) {
sessionId
tokens
action
createdAt
messages {
role
content
attachments
createdAt
}
}
}
}
}
`,
variables,
})
.expect(200);
handleGraphQLError(res);
return res.body.data.currentUser?.copilot?.histories || [];
}

View File

@@ -5,6 +5,7 @@ import { Test, TestingModuleBuilder } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import cookieParser from 'cookie-parser';
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import type { Response } from 'supertest';
import { AppModule, FunctionalityModules } from '../../src/app.module';
import { AuthGuard, AuthModule } from '../../src/core/auth';
@@ -136,3 +137,12 @@ export async function createTestingApp(moduleDef: TestingModuleMeatdata = {}) {
app,
};
}
export function handleGraphQLError(resp: Response) {
const { errors } = resp.body;
if (errors) {
const cause = errors[0];
const stacktrace = cause.extensions?.stacktrace;
throw new Error(stacktrace ? stacktrace.join('\n') : cause.message, cause);
}
}

View File

@@ -23,7 +23,7 @@
"path": "./tsconfig.node.json"
},
{
"path": "../storage/tsconfig.json"
"path": "../native/tsconfig.json"
}
],
"ts-node": {

View File

@@ -3,8 +3,8 @@
"private": true,
"type": "module",
"devDependencies": {
"@blocksuite/global": "0.14.0-canary-202404260628-ddb1941",
"@blocksuite/store": "0.14.0-canary-202404260628-ddb1941",
"@blocksuite/global": "0.14.0-canary-202404280529-c8e5f89",
"@blocksuite/store": "0.14.0-canary-202404280529-c8e5f89",
"react": "18.2.0",
"react-dom": "18.2.0",
"vitest": "1.4.0"

View File

@@ -26,7 +26,6 @@ export const runtimeFlagsSchema = z.object({
allowLocalWorkspace: z.boolean(),
// this is for the electron app
serverUrlPrefix: z.string(),
enableMoveDatabase: z.boolean(),
appVersion: z.string(),
editorVersion: z.string(),
appBuildType: z.union([

View File

@@ -11,9 +11,9 @@
"@affine/debug": "workspace:*",
"@affine/env": "workspace:*",
"@affine/templates": "workspace:*",
"@blocksuite/blocks": "0.14.0-canary-202404260628-ddb1941",
"@blocksuite/global": "0.14.0-canary-202404260628-ddb1941",
"@blocksuite/store": "0.14.0-canary-202404260628-ddb1941",
"@blocksuite/blocks": "0.14.0-canary-202404280529-c8e5f89",
"@blocksuite/global": "0.14.0-canary-202404280529-c8e5f89",
"@blocksuite/store": "0.14.0-canary-202404280529-c8e5f89",
"@datastructures-js/binary-search-tree": "^5.3.2",
"foxact": "^0.2.33",
"jotai": "^2.8.0",
@@ -28,8 +28,8 @@
"devDependencies": {
"@affine-test/fixtures": "workspace:*",
"@affine/templates": "workspace:*",
"@blocksuite/block-std": "0.14.0-canary-202404260628-ddb1941",
"@blocksuite/presets": "0.14.0-canary-202404260628-ddb1941",
"@blocksuite/block-std": "0.14.0-canary-202404280529-c8e5f89",
"@blocksuite/presets": "0.14.0-canary-202404280529-c8e5f89",
"@testing-library/react": "^15.0.0",
"async-call-rpc": "^6.4.0",
"react": "^18.2.0",

View File

@@ -1,5 +1,5 @@
import { DebugLogger } from '@affine/debug';
import { catchError, EMPTY, mergeMap, switchMap } from 'rxjs';
import { catchError, EMPTY, exhaustMap, mergeMap } from 'rxjs';
import { Entity } from '../../../framework';
import {
@@ -59,7 +59,7 @@ export class WorkspaceProfile extends Entity<{ metadata: WorkspaceMetadata }> {
}
revalidate = effect(
switchMap(() => {
exhaustMap(() => {
const provider = this.provider;
if (!provider) {
return EMPTY;

View File

@@ -32,14 +32,14 @@
}
},
"dependencies": {
"@blocksuite/global": "0.14.0-canary-202404260628-ddb1941",
"@blocksuite/global": "0.14.0-canary-202404280529-c8e5f89",
"idb": "^8.0.0",
"nanoid": "^5.0.7",
"y-provider": "workspace:*"
},
"devDependencies": {
"@blocksuite/blocks": "0.14.0-canary-202404260628-ddb1941",
"@blocksuite/store": "0.14.0-canary-202404260628-ddb1941",
"@blocksuite/blocks": "0.14.0-canary-202404280529-c8e5f89",
"@blocksuite/store": "0.14.0-canary-202404280529-c8e5f89",
"fake-indexeddb": "^5.0.2",
"vite": "^5.2.8",
"vite-plugin-dts": "3.8.1",

View File

@@ -24,7 +24,7 @@
"build": "vite build"
},
"devDependencies": {
"@blocksuite/store": "0.14.0-canary-202404260628-ddb1941",
"@blocksuite/store": "0.14.0-canary-202404280529-c8e5f89",
"vite": "^5.1.4",
"vite-plugin-dts": "3.7.3",
"vitest": "1.4.0",

View File

@@ -75,12 +75,12 @@
"zod": "^3.22.4"
},
"devDependencies": {
"@blocksuite/block-std": "0.14.0-canary-202404260628-ddb1941",
"@blocksuite/blocks": "0.14.0-canary-202404260628-ddb1941",
"@blocksuite/global": "0.14.0-canary-202404260628-ddb1941",
"@blocksuite/block-std": "0.14.0-canary-202404280529-c8e5f89",
"@blocksuite/blocks": "0.14.0-canary-202404280529-c8e5f89",
"@blocksuite/global": "0.14.0-canary-202404280529-c8e5f89",
"@blocksuite/icons": "2.1.46",
"@blocksuite/presets": "0.14.0-canary-202404260628-ddb1941",
"@blocksuite/store": "0.14.0-canary-202404260628-ddb1941",
"@blocksuite/presets": "0.14.0-canary-202404280529-c8e5f89",
"@blocksuite/store": "0.14.0-canary-202404280529-c8e5f89",
"@storybook/addon-actions": "^7.6.17",
"@storybook/addon-essentials": "^7.6.17",
"@storybook/addon-interactions": "^7.6.17",

View File

@@ -18,13 +18,13 @@
"@affine/graphql": "workspace:*",
"@affine/i18n": "workspace:*",
"@affine/templates": "workspace:*",
"@blocksuite/block-std": "0.14.0-canary-202404260628-ddb1941",
"@blocksuite/blocks": "0.14.0-canary-202404260628-ddb1941",
"@blocksuite/global": "0.14.0-canary-202404260628-ddb1941",
"@blocksuite/block-std": "0.14.0-canary-202404280529-c8e5f89",
"@blocksuite/blocks": "0.14.0-canary-202404280529-c8e5f89",
"@blocksuite/global": "0.14.0-canary-202404280529-c8e5f89",
"@blocksuite/icons": "2.1.46",
"@blocksuite/inline": "0.14.0-canary-202404260628-ddb1941",
"@blocksuite/presets": "0.14.0-canary-202404260628-ddb1941",
"@blocksuite/store": "0.14.0-canary-202404260628-ddb1941",
"@blocksuite/inline": "0.14.0-canary-202404280529-c8e5f89",
"@blocksuite/presets": "0.14.0-canary-202404280529-c8e5f89",
"@blocksuite/store": "0.14.0-canary-202404280529-c8e5f89",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",

View File

@@ -26,27 +26,27 @@ type Translate = ReturnType<typeof useAFFiNEI18N>;
const getPlayList = (t: Translate): Array<PlayListItem> => [
{
video: '/onboarding/ai-onboarding.general.1.mov',
video: '/onboarding/ai-onboarding.general.1.mp4',
title: t['com.affine.ai-onboarding.general.1.title'](),
desc: t['com.affine.ai-onboarding.general.1.description'](),
},
{
video: '/onboarding/ai-onboarding.general.2.mov',
video: '/onboarding/ai-onboarding.general.2.mp4',
title: t['com.affine.ai-onboarding.general.2.title'](),
desc: t['com.affine.ai-onboarding.general.2.description'](),
},
{
video: '/onboarding/ai-onboarding.general.3.mov',
video: '/onboarding/ai-onboarding.general.3.mp4',
title: t['com.affine.ai-onboarding.general.3.title'](),
desc: t['com.affine.ai-onboarding.general.3.description'](),
},
{
video: '/onboarding/ai-onboarding.general.4.mov',
video: '/onboarding/ai-onboarding.general.4.mp4',
title: t['com.affine.ai-onboarding.general.4.title'](),
desc: t['com.affine.ai-onboarding.general.4.description'](),
},
{
video: '/onboarding/ai-onboarding.general.1.mov',
video: '/onboarding/ai-onboarding.general.5.mp4',
title: t['com.affine.ai-onboarding.general.5.title'](),
desc: (
<Trans
@@ -241,17 +241,17 @@ export const AIOnboardingGeneral = ({
<Button
className={styles.baseActionButton}
size="large"
onClick={closeAndDismiss}
onClick={goToPricingPlans}
>
{t['com.affine.ai-onboarding.general.try-for-free']()}
{t['com.affine.ai-onboarding.general.purchase']()}
</Button>
<Button
className={styles.baseActionButton}
size="large"
onClick={goToPricingPlans}
onClick={closeAndDismiss}
type="primary"
>
{t['com.affine.ai-onboarding.general.purchase']()}
{t['com.affine.ai-onboarding.general.try-for-free']()}
</Button>
</div>
)}

View File

@@ -64,7 +64,7 @@ export const AfterSignInSendEmail = ({
} catch (err) {
console.error(err);
notify.error({
message: 'Failed to send email, please try again.',
title: 'Failed to send email, please try again.',
});
}
setIsSending(false);

View File

@@ -64,7 +64,7 @@ export const AfterSignUpSendEmail: FC<AuthPanelProps> = ({
} catch (err) {
console.error(err);
notify.error({
message: 'Failed to send email, please try again.',
title: 'Failed to send email, please try again.',
});
}
setIsSending(false);

View File

@@ -67,7 +67,7 @@ function OAuthProvider({
await authService.signInOauth(provider, redirectUri);
} catch (err) {
console.error(err);
notify.error({ message: 'Failed to sign in, please try again.' });
notify.error({ title: 'Failed to sign in, please try again.' });
} finally {
setIsConnecting(false);
mixpanel.track('OAuth', { provider });

View File

@@ -60,7 +60,7 @@ export const SignInWithPassword: FC<AuthPanelProps> = ({
} catch (err) {
console.error(err);
notify.error({
message: 'Failed to send email, please try again.',
title: 'Failed to send email, please try again.',
});
// TODO: handle error better
}

View File

@@ -101,7 +101,7 @@ export const SignIn: FC<AuthPanelProps> = ({
// TODO: better error handling
notify.error({
message: 'Failed to send email. Please try again.',
title: 'Failed to send email. Please try again.',
});
}

View File

@@ -17,7 +17,6 @@ import { ExportPanel } from './export';
import { LabelsPanel } from './labels';
import { MembersPanel } from './members';
import { ProfilePanel } from './profile';
import { StoragePanel } from './storage';
import type { WorkspaceSettingDetailProps } from './types';
export const WorkspaceSettingDetail = ({
@@ -70,9 +69,6 @@ export const WorkspaceSettingDetail = ({
</SettingWrapper>
{environment.isDesktop && (
<SettingWrapper title={t['Storage and Export']()}>
{runtimeConfig.enableMoveDatabase ? (
<StoragePanel workspaceMetadata={workspaceMetadata} />
) : null}
<ExportPanel
workspace={workspace}
workspaceMetadata={workspaceMetadata}

View File

@@ -155,6 +155,7 @@ export const ProfilePanel = () => {
name={name}
imageProps={avatarImageProps}
fallbackProps={avatarImageProps}
hoverWrapperProps={avatarImageProps}
colorfulFallback
hoverIcon={isOwner ? <CameraIcon /> : undefined}
onRemove={canAdjustAvatar ? handleRemoveUserAvatar : undefined}

View File

@@ -1,123 +0,0 @@
import { FlexWrapper, toast } from '@affine/component';
import { SettingRow } from '@affine/component/setting-components';
import { Button } from '@affine/component/ui/button';
import { Tooltip } from '@affine/component/ui/tooltip';
import { apis, events } from '@affine/electron-api';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { WorkspaceMetadata } from '@toeverything/infra';
import { useCallback, useEffect, useMemo, useState } from 'react';
const useDBFileSecondaryPath = (workspaceId: string) => {
const [path, setPath] = useState<string | undefined>(undefined);
useEffect(() => {
if (apis && events && environment.isDesktop) {
apis?.workspace
.getMeta(workspaceId)
.then(meta => {
setPath(meta.secondaryDBPath);
})
.catch(err => {
console.error(err);
});
return events.workspace.onMetaChange((newMeta: any) => {
if (newMeta.workspaceId === workspaceId) {
const meta = newMeta.meta;
setPath(meta.secondaryDBPath);
}
});
}
return;
}, [workspaceId]);
return path;
};
interface StoragePanelProps {
workspaceMetadata: WorkspaceMetadata;
}
export const StoragePanel = ({ workspaceMetadata }: StoragePanelProps) => {
const workspaceId = workspaceMetadata.id;
const t = useAFFiNEI18N();
const secondaryPath = useDBFileSecondaryPath(workspaceId);
const [moveToInProgress, setMoveToInProgress] = useState<boolean>(false);
const onRevealDBFile = useCallback(() => {
apis?.dialog.revealDBFile(workspaceId).catch(err => {
console.error(err);
});
}, [workspaceId]);
const handleMoveTo = useCallback(() => {
if (moveToInProgress) {
return;
}
setMoveToInProgress(true);
apis?.dialog
.moveDBFile(workspaceId)
.then(result => {
if (!result?.error && !result?.canceled) {
toast(t['Move folder success']());
} else if (result?.error) {
toast(t[result.error]());
}
})
.catch(() => {
toast(t['UNKNOWN_ERROR']());
})
.finally(() => {
setMoveToInProgress(false);
});
}, [moveToInProgress, t, workspaceId]);
const rowContent = useMemo(
() =>
secondaryPath ? (
<FlexWrapper justifyContent="space-between">
<Tooltip
content={t['com.affine.settings.storage.db-location.change-hint']()}
side="top"
align="start"
>
<Button
data-testid="move-folder"
// className={style.urlButton}
size="large"
onClick={handleMoveTo}
>
{secondaryPath}
</Button>
</Tooltip>
<Button
data-testid="reveal-folder"
data-disabled={moveToInProgress}
onClick={onRevealDBFile}
>
{t['Open folder']()}
</Button>
</FlexWrapper>
) : (
<Button
data-testid="move-folder"
data-disabled={moveToInProgress}
onClick={handleMoveTo}
>
{t['Move folder']()}
</Button>
),
[handleMoveTo, moveToInProgress, onRevealDBFile, secondaryPath, t]
);
return (
<SettingRow
name={t['Storage']()}
desc={t[
secondaryPath
? 'com.affine.settings.storage.description-alt'
: 'com.affine.settings.storage.description'
]()}
spreadCol={!secondaryPath}
>
{rowContent}
</SettingRow>
);
};

View File

@@ -158,9 +158,14 @@ export class CopilotClient {
}
// Text or image to images
imagesStream(messageId: string, sessionId: string) {
return new EventSource(
`${this.backendUrl}/api/copilot/chat/${sessionId}/images?messageId=${messageId}`
imagesStream(messageId: string, sessionId: string, seed?: string) {
const url = new URL(
`${this.backendUrl}/api/copilot/chat/${sessionId}/images`
);
url.searchParams.set('messageId', messageId);
if (seed) {
url.searchParams.set('seed', seed);
}
return new EventSource(url);
}
}

View File

@@ -54,7 +54,7 @@ const provideAction = <T extends AIAction>(
if (TRACKED_ACTIONS[id]) {
const wrappedFn: typeof action = (opts, ...rest) => {
mixpanel.track('AI', {
resolve: action,
resolve: id,
docId: opts.docId,
workspaceId: opts.workspaceId,
});
@@ -278,10 +278,10 @@ export function setupAIProvider() {
});
provideAction('makeItReal', options => {
return textToText({
return toImage({
...options,
promptName: 'Make it real',
params: options.params,
seed: options.seed,
content:
options.content ||
'Here are the latest wireframes. Could you make a new website based on these wireframes and notes and send back just the html file?',

View File

@@ -31,6 +31,10 @@ export type TextToTextOptions = {
signal?: AbortSignal;
};
export type ToImageOptions = TextToTextOptions & {
seed?: string;
};
export function createChatSession({
workspaceId,
docId,
@@ -175,8 +179,9 @@ export function toImage({
content,
attachments,
params,
seed,
timeout = TIMEOUT,
}: TextToTextOptions) {
}: ToImageOptions) {
return {
[Symbol.asyncIterator]: async function* () {
const { messageId, sessionId } = await createSessionMessage({
@@ -188,7 +193,7 @@ export function toImage({
params,
});
const eventSource = client.imagesStream(messageId, sessionId);
const eventSource = client.imagesStream(messageId, sessionId, seed);
for await (const event of toTextStream(eventSource, { timeout })) {
if (event.type === 'attachment') {
yield event.data;

View File

@@ -233,9 +233,8 @@ export const SignOutConfirmModal = () => {
} catch (err) {
console.error(err);
// TODO: i18n
notify({
style: 'alert',
message: 'Failed to sign out',
notify.error({
title: 'Failed to sign out',
});
}

View File

@@ -11,9 +11,9 @@ export function Telemetry() {
track_pageview: true,
persistence: 'localStorage',
});
}
if (settings.enableTelemetry === false) {
mixpanel.opt_out_tracking();
if (settings.enableTelemetry === false) {
mixpanel.opt_out_tracking();
}
}
}, [settings.enableTelemetry]);
return null;

View File

@@ -29,10 +29,10 @@
"@affine/env": "workspace:*",
"@affine/i18n": "workspace:*",
"@affine/native": "workspace:*",
"@blocksuite/block-std": "0.14.0-canary-202404260628-ddb1941",
"@blocksuite/blocks": "0.14.0-canary-202404260628-ddb1941",
"@blocksuite/presets": "0.14.0-canary-202404260628-ddb1941",
"@blocksuite/store": "0.14.0-canary-202404260628-ddb1941",
"@blocksuite/block-std": "0.14.0-canary-202404280529-c8e5f89",
"@blocksuite/blocks": "0.14.0-canary-202404280529-c8e5f89",
"@blocksuite/presets": "0.14.0-canary-202404280529-c8e5f89",
"@blocksuite/store": "0.14.0-canary-202404280529-c8e5f89",
"@electron-forge/cli": "^7.3.0",
"@electron-forge/core": "^7.3.0",
"@electron-forge/core-utils": "^7.3.0",
@@ -43,7 +43,7 @@
"@electron-forge/plugin-auto-unpack-natives": "^7.3.0",
"@electron-forge/shared-types": "^7.3.0",
"@emotion/react": "^11.11.4",
"@pengx17/electron-forge-maker-appimage": "^1.2.0",
"@pengx17/electron-forge-maker-appimage": "^1.2.1",
"@sentry/electron": "^4.22.0",
"@sentry/esbuild-plugin": "^2.16.1",
"@sentry/react": "^7.109.0",

View File

@@ -1,145 +1,38 @@
import type { Subject } from 'rxjs';
import {
concat,
defer,
from,
fromEvent,
interval,
lastValueFrom,
merge,
Observable,
} from 'rxjs';
import {
concatMap,
distinctUntilChanged,
filter,
ignoreElements,
last,
map,
shareReplay,
startWith,
switchMap,
take,
takeUntil,
tap,
} from 'rxjs/operators';
import { logger } from '../logger';
import { getWorkspaceMeta } from '../workspace/meta';
import { workspaceSubjects } from '../workspace/subjects';
import { SecondaryWorkspaceSQLiteDB } from './secondary-db';
import type { WorkspaceSQLiteDB } from './workspace-db-adapter';
import { openWorkspaceDatabase } from './workspace-db-adapter';
// export for testing
export const db$Map = new Map<string, Observable<WorkspaceSQLiteDB>>();
export const db$Map = new Map<string, Promise<WorkspaceSQLiteDB>>();
// use defer to prevent `app` is undefined while running tests
const beforeQuit$ = defer(() => fromEvent(process, 'beforeExit'));
// return a stream that emit a single event when the subject completes
function completed<T>(subject$: Subject<T>) {
return new Observable(subscriber => {
const sub = subject$.subscribe({
complete: () => {
subscriber.next();
subscriber.complete();
},
});
return () => sub.unsubscribe();
});
}
function getWorkspaceDB(id: string) {
async function getWorkspaceDB(id: string) {
let db = await db$Map.get(id);
if (!db$Map.has(id)) {
db$Map.set(
id,
from(openWorkspaceDatabase(id)).pipe(
tap({
next: db => {
logger.info(
'[ensureSQLiteDB] db connection established',
db.workspaceId
);
},
}),
switchMap(db =>
// takeUntil the polling stream, and then destroy the db
concat(
startPollingSecondaryDB(db).pipe(
ignoreElements(),
startWith(db),
takeUntil(merge(beforeQuit$, completed(db.update$))),
last(),
tap({
next() {
logger.info(
'[ensureSQLiteDB] polling secondary db complete',
db.workspaceId
);
},
})
),
defer(async () => {
try {
await db.destroy();
db$Map.delete(id);
return db;
} catch (err) {
logger.error('[ensureSQLiteDB] destroy db failed', err);
throw err;
}
})
).pipe(startWith(db))
),
shareReplay(1)
)
);
const promise = openWorkspaceDatabase(id);
db$Map.set(id, promise);
const _db = (db = await promise);
const cleanup = () => {
db$Map.delete(id);
_db
.destroy()
.then(() => {
logger.info('[ensureSQLiteDB] db connection closed', _db.workspaceId);
})
.catch(err => {
logger.error('[ensureSQLiteDB] destroy db failed', err);
});
};
db.update$.subscribe({
complete: cleanup,
});
process.on('beforeExit', cleanup);
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return db$Map.get(id)!;
}
function startPollingSecondaryDB(db: WorkspaceSQLiteDB) {
return merge(
getWorkspaceMeta(db.workspaceId),
workspaceSubjects.meta$.pipe(
map(({ meta }) => meta),
filter(meta => meta.id === db.workspaceId)
)
).pipe(
map(meta => meta?.secondaryDBPath),
filter((p): p is string => !!p),
distinctUntilChanged(),
switchMap(path => {
// on secondary db path change, destroy the old db and create a new one
const secondaryDB = new SecondaryWorkspaceSQLiteDB(path, db);
return new Observable<SecondaryWorkspaceSQLiteDB>(subscriber => {
subscriber.next(secondaryDB);
return () => {
secondaryDB.destroy().catch(err => {
subscriber.error(err);
});
};
});
}),
switchMap(secondaryDB => {
return interval(300000).pipe(
startWith(0),
concatMap(() => secondaryDB.pull()),
tap({
error: err => {
logger.error(`[ensureSQLiteDB] polling secondary db error`, err);
},
complete: () => {
logger.info('[ensureSQLiteDB] polling secondary db complete');
},
})
);
})
);
return db!;
}
export function ensureSQLiteDB(id: string) {
return lastValueFrom(getWorkspaceDB(id).pipe(take(1)));
return getWorkspaceDB(id);
}

View File

@@ -1,10 +1,8 @@
import { mainRPC } from '../main-rpc';
import type { MainEventRegister } from '../type';
import { ensureSQLiteDB } from './ensure-db';
import { dbSubjects } from './subjects';
export * from './ensure-db';
export * from './subjects';
export const dbHandlers = {
getDocAsUpdates: async (workspaceId: string, subdocId?: string) => {
@@ -17,7 +15,12 @@ export const dbHandlers = {
subdocId?: string
) => {
const workspaceDB = await ensureSQLiteDB(workspaceId);
return workspaceDB.applyUpdate(update, 'renderer', subdocId);
return workspaceDB.addUpdateToSQLite([
{
data: update,
docId: subdocId,
},
]);
},
addBlob: async (workspaceId: string, key: string, data: Uint8Array) => {
const workspaceDB = await ensureSQLiteDB(workspaceId);
@@ -40,17 +43,4 @@ export const dbHandlers = {
},
};
export const dbEvents = {
onExternalUpdate: (
fn: (update: {
workspaceId: string;
update: Uint8Array;
docId?: string;
}) => void
) => {
const sub = dbSubjects.externalUpdate$.subscribe(fn);
return () => {
sub.unsubscribe();
};
},
} satisfies Record<string, MainEventRegister>;
export const dbEvents = {} satisfies Record<string, MainEventRegister>;

View File

@@ -1,304 +0,0 @@
import assert from 'node:assert';
import type { InsertRow } from '@affine/native';
import { debounce } from 'lodash-es';
import { applyUpdate, Doc as YDoc } from 'yjs';
import { logger } from '../logger';
import type { YOrigin } from '../type';
import { getWorkspaceMeta } from '../workspace/meta';
import { BaseSQLiteAdapter } from './base-db-adapter';
import type { WorkspaceSQLiteDB } from './workspace-db-adapter';
const FLUSH_WAIT_TIME = 5000;
const FLUSH_MAX_WAIT_TIME = 10000;
// todo: trim db when it is too big
export class SecondaryWorkspaceSQLiteDB extends BaseSQLiteAdapter {
role = 'secondary';
yDoc = new YDoc();
firstConnected = false;
destroyed = false;
updateQueue: { data: Uint8Array; docId?: string }[] = [];
unsubscribers = new Set<() => void>();
constructor(
public override path: string,
public upstream: WorkspaceSQLiteDB
) {
super(path);
this.init();
logger.debug('[SecondaryWorkspaceSQLiteDB] created', this.workspaceId);
}
getDoc(docId?: string) {
if (!docId) {
return this.yDoc;
}
// this should be pretty fast and we don't need to cache it
for (const subdoc of this.yDoc.subdocs) {
if (subdoc.guid === docId) {
return subdoc;
}
}
return null;
}
override async destroy() {
await this.flushUpdateQueue();
this.unsubscribers.forEach(unsub => unsub());
this.yDoc.destroy();
await super.destroy();
this.destroyed = true;
}
get workspaceId() {
return this.upstream.workspaceId;
}
// do not update db immediately, instead, push to a queue
// and flush the queue in a future time
async addUpdateToUpdateQueue(update: InsertRow) {
this.updateQueue.push(update);
await this.debouncedFlush();
}
async flushUpdateQueue() {
if (this.destroyed) {
return;
}
logger.debug(
'flushUpdateQueue',
this.workspaceId,
'queue',
this.updateQueue.length
);
const updates = [...this.updateQueue];
this.updateQueue = [];
await this.run(async () => {
await this.addUpdateToSQLite(updates);
});
}
// flush after 5s, but will not wait for more than 10s
debouncedFlush = debounce(this.flushUpdateQueue, FLUSH_WAIT_TIME, {
maxWait: FLUSH_MAX_WAIT_TIME,
});
runCounter = 0;
// wrap the fn with connect and close
async run<T extends (...args: any[]) => any>(
fn: T
): Promise<
(T extends (...args: any[]) => infer U ? Awaited<U> : unknown) | undefined
> {
try {
if (this.destroyed) {
return;
}
await this.connectIfNeeded();
this.runCounter++;
return await fn();
} catch (err) {
logger.error(err);
throw err;
} finally {
this.runCounter--;
if (this.runCounter === 0) {
// just close db, but not the yDoc
await super.destroy();
}
}
}
setupListener(docId?: string) {
logger.debug(
'SecondaryWorkspaceSQLiteDB:setupListener',
this.workspaceId,
docId
);
const doc = this.getDoc(docId);
const upstreamDoc = this.upstream.getDoc(docId);
if (!doc || !upstreamDoc) {
logger.warn(
'[SecondaryWorkspaceSQLiteDB] setupListener: doc not found',
docId
);
return;
}
const onUpstreamUpdate = (update: Uint8Array, origin: YOrigin) => {
logger.debug(
'SecondaryWorkspaceSQLiteDB:onUpstreamUpdate',
origin,
this.workspaceId,
docId,
update.length
);
if (origin === 'renderer' || origin === 'self') {
// update to upstream yDoc should be replicated to self yDoc
this.applyUpdate(update, 'upstream', docId);
}
};
const onSelfUpdate = async (update: Uint8Array, origin: YOrigin) => {
logger.debug(
'SecondaryWorkspaceSQLiteDB:onSelfUpdate',
origin,
this.workspaceId,
docId,
update.length
);
// for self update from upstream, we need to push it to external DB
if (origin === 'upstream') {
await this.addUpdateToUpdateQueue({
data: update,
docId,
});
}
if (origin === 'self') {
this.upstream.applyUpdate(update, 'external', docId);
}
};
const onSubdocs = ({ added }: { added: Set<YDoc> }) => {
added.forEach(subdoc => {
this.setupListener(subdoc.guid);
});
};
doc.subdocs.forEach(subdoc => {
this.setupListener(subdoc.guid);
});
// listen to upstream update
this.upstream.yDoc.on('update', onUpstreamUpdate);
doc.on('update', (update, origin) => {
onSelfUpdate(update, origin).catch(err => {
logger.error(err);
});
});
doc.on('subdocs', onSubdocs);
this.unsubscribers.add(() => {
this.upstream.yDoc.off('update', onUpstreamUpdate);
doc.off('update', (update, origin) => {
onSelfUpdate(update, origin).catch(err => {
logger.error(err);
});
});
doc.off('subdocs', onSubdocs);
});
}
init() {
if (this.firstConnected) {
return;
}
this.firstConnected = true;
this.setupListener();
// apply all updates from upstream
// we assume here that the upstream ydoc is already sync'ed
const syncUpstreamDoc = (docId?: string) => {
const update = this.upstream.getDocAsUpdates(docId);
if (update) {
this.applyUpdate(update, 'upstream');
}
};
syncUpstreamDoc();
this.upstream.yDoc.subdocs.forEach(subdoc => {
syncUpstreamDoc(subdoc.guid);
});
}
applyUpdate = (
data: Uint8Array,
origin: YOrigin = 'upstream',
docId?: string
) => {
const doc = this.getDoc(docId);
if (doc) {
applyUpdate(this.yDoc, data, origin);
} else {
logger.warn(
'[SecondaryWorkspaceSQLiteDB] applyUpdate: doc not found',
docId
);
}
};
// TODO: have a better solution to handle blobs
async syncBlobs() {
await this.run(async () => {
// skip if upstream db is not connected (maybe it is already closed)
const blobsKeys = await this.getBlobKeys();
if (!this.upstream.db || this.upstream.db?.isClose) {
return;
}
const upstreamBlobsKeys = await this.upstream.getBlobKeys();
// put every missing blob to upstream
for (const key of blobsKeys) {
if (!upstreamBlobsKeys.includes(key)) {
const blob = await this.getBlob(key);
if (blob) {
await this.upstream.addBlob(key, blob);
logger.debug('syncBlobs', this.workspaceId, key);
}
}
}
});
}
/**
* pull from external DB file and apply to embedded yDoc
* workflow:
* - connect to external db
* - get updates
* - apply updates to local yDoc
* - get blobs and put new blobs to upstream
* - disconnect
*/
async pull() {
const start = performance.now();
assert(this.upstream.db, 'upstream db should be connected');
const rows = await this.run(async () => {
// TODO: no need to get all updates, just get the latest ones (using a cursor, etc)?
await this.syncBlobs();
return await this.getAllUpdates();
});
if (!rows || this.destroyed) {
return;
}
// apply root doc first
rows.forEach(row => {
if (!row.docId) {
this.applyUpdate(row.data, 'self');
}
});
rows.forEach(row => {
if (row.docId) {
this.applyUpdate(row.data, 'self', row.docId);
}
});
logger.debug(
'pull external updates',
this.path,
rows.length,
(performance.now() - start).toFixed(2),
'ms'
);
}
}
export async function getSecondaryWorkspaceDBPath(workspaceId: string) {
const meta = await getWorkspaceMeta(workspaceId);
return meta?.secondaryDBPath;
}

View File

@@ -1,9 +0,0 @@
import { Subject } from 'rxjs';
export const dbSubjects = {
externalUpdate$: new Subject<{
workspaceId: string;
update: Uint8Array;
docId?: string;
}>(),
};

View File

@@ -1,20 +1,16 @@
import type { InsertRow } from '@affine/native';
import { debounce } from 'lodash-es';
import { Subject } from 'rxjs';
import { applyUpdate, Doc as YDoc, encodeStateAsUpdate } from 'yjs';
import { applyUpdate, Doc as YDoc } from 'yjs';
import { logger } from '../logger';
import type { YOrigin } from '../type';
import { getWorkspaceMeta } from '../workspace/meta';
import { BaseSQLiteAdapter } from './base-db-adapter';
import { dbSubjects } from './subjects';
import { mergeUpdate } from './merge-update';
const TRIM_SIZE = 500;
export class WorkspaceSQLiteDB extends BaseSQLiteAdapter {
role = 'primary';
yDoc = new YDoc();
firstConnected = false;
update$ = new Subject<void>();
@@ -27,131 +23,30 @@ export class WorkspaceSQLiteDB extends BaseSQLiteAdapter {
override async destroy() {
await super.destroy();
this.yDoc.destroy();
// when db is closed, we can safely remove it from ensure-db list
this.update$.complete();
this.firstConnected = false;
}
getDoc(docId?: string) {
if (!docId) {
return this.yDoc;
}
// this should be pretty fast and we don't need to cache it
for (const subdoc of this.yDoc.subdocs) {
if (subdoc.guid === docId) {
return subdoc;
}
}
return null;
}
getWorkspaceName = () => {
return this.yDoc.getMap('meta').get('name') as string;
getWorkspaceName = async () => {
const ydoc = new YDoc();
const updates = await this.getUpdates();
updates.forEach(update => {
applyUpdate(ydoc, update.data);
});
return ydoc.getMap('meta').get('name') as string;
};
setupListener(docId?: string) {
logger.debug('WorkspaceSQLiteDB:setupListener', this.workspaceId, docId);
const doc = this.getDoc(docId);
if (doc) {
const onUpdate = async (update: Uint8Array, origin: YOrigin) => {
logger.debug(
'WorkspaceSQLiteDB:onUpdate',
this.workspaceId,
docId,
update.length
);
const insertRows = [{ data: update, docId }];
if (origin === 'renderer') {
await this.addUpdateToSQLite(insertRows);
} else if (origin === 'external') {
dbSubjects.externalUpdate$.next({
workspaceId: this.workspaceId,
update,
docId,
});
await this.addUpdateToSQLite(insertRows);
logger.debug('external update', this.workspaceId);
}
};
doc.subdocs.forEach(subdoc => {
this.setupListener(subdoc.guid);
});
const onSubdocs = ({ added }: { added: Set<YDoc> }) => {
logger.info('onSubdocs', this.workspaceId, docId, added);
added.forEach(subdoc => {
this.setupListener(subdoc.guid);
});
};
doc.on('update', (update, origin) => {
onUpdate(update, origin).catch(err => {
logger.error(err);
});
});
doc.on('subdocs', onSubdocs);
} else {
logger.error('setupListener: doc not found', docId);
}
}
async init() {
const db = await super.connectIfNeeded();
if (!this.firstConnected) {
this.setupListener();
}
const updates = await this.getAllUpdates();
// apply root first (without ID).
// subdoc will be available after root is applied
updates.forEach(update => {
if (!update.docId) {
this.applyUpdate(update.data, 'self');
}
});
// then, for all subdocs, apply the updates
updates.forEach(update => {
if (update.docId) {
this.applyUpdate(update.data, 'self', update.docId);
}
});
this.firstConnected = true;
this.update$.next();
await this.tryTrim();
return db;
}
// unlike getUpdates, this will return updates in yDoc
getDocAsUpdates = (docId?: string) => {
const doc = docId ? this.getDoc(docId) : this.yDoc;
if (doc) {
return encodeStateAsUpdate(doc);
}
return false;
};
// non-blocking and use yDoc to validate the update
// after that, the update is added to the db
applyUpdate = (
data: Uint8Array,
origin: YOrigin = 'renderer',
docId?: string
) => {
// todo: trim the updates when the number of records is too large
// 1. store the current ydoc state in the db
// 2. then delete the old updates
// yjs-idb will always trim the db for the first time after DB is loaded
const doc = this.getDoc(docId);
if (doc) {
applyUpdate(doc, data, origin);
} else {
logger.warn('[WorkspaceSQLiteDB] applyUpdate: doc not found', docId);
}
// getUpdates then encode
getDocAsUpdates = async (docId?: string) => {
const updates = await this.getUpdates(docId);
return mergeUpdate(updates.map(row => row.data));
};
override async addBlob(key: string, value: Uint8Array) {
@@ -167,28 +62,21 @@ export class WorkspaceSQLiteDB extends BaseSQLiteAdapter {
override async addUpdateToSQLite(data: InsertRow[]) {
this.update$.next();
data.forEach(row => {
this.trimWhenNecessary(row.docId)?.catch(err => {
logger.error('trimWhenNecessary failed', err);
});
});
await super.addUpdateToSQLite(data);
}
trimWhenNecessary = debounce(async (docId?: string) => {
if (this.firstConnected) {
const count = (await this.db?.getUpdatesCount(docId)) ?? 0;
if (count > TRIM_SIZE) {
logger.debug(`trim ${this.workspaceId}:${docId} ${count}`);
const update = this.getDocAsUpdates(docId);
if (update) {
const insertRows = [{ data: update, docId }];
await this.db?.replaceUpdates(docId, insertRows);
logger.debug(`trim ${this.workspaceId}:${docId} successfully`);
}
private readonly tryTrim = async (docId?: string) => {
const count = (await this.db?.getUpdatesCount(docId)) ?? 0;
if (count > TRIM_SIZE) {
logger.debug(`trim ${this.workspaceId}:${docId} ${count}`);
const update = await this.getDocAsUpdates(docId);
if (update) {
const insertRows = [{ data: update, docId }];
await this.db?.replaceUpdates(docId, insertRows);
logger.debug(`trim ${this.workspaceId}:${docId} successfully`);
}
}
}, 1000);
};
}
export async function openWorkspaceDatabase(workspaceId: string) {

View File

@@ -1,5 +1,3 @@
import path from 'node:path';
import { ValidationResult } from '@affine/native';
import { WorkspaceVersion } from '@toeverything/infra/blocksuite';
import fs from 'fs-extra';
@@ -11,10 +9,9 @@ import {
migrateToLatest,
migrateToSubdocAndReplaceDatabase,
} from '../db/migration';
import type { WorkspaceSQLiteDB } from '../db/workspace-db-adapter';
import { logger } from '../logger';
import { mainRPC } from '../main-rpc';
import { listWorkspaces, storeWorkspaceMeta } from '../workspace';
import { storeWorkspaceMeta } from '../workspace';
import {
getWorkspaceDBPath,
getWorkspaceMeta,
@@ -47,12 +44,6 @@ export interface SelectDBFileLocationResult {
canceled?: boolean;
}
export interface MoveDBFileResult {
filePath?: string;
error?: ErrorMessage;
canceled?: boolean;
}
// provide a backdoor to set dialog path for testing in playwright
export interface FakeDialogResult {
canceled?: boolean;
@@ -68,7 +59,7 @@ export async function revealDBFile(workspaceId: string) {
if (!meta) {
return;
}
await mainRPC.showItemInFolder(meta.secondaryDBPath ?? meta.mainDBPath);
await mainRPC.showItemInFolder(meta.mainDBPath);
}
// result will be used in the next call to showOpenDialog
@@ -120,7 +111,10 @@ export async function saveDBFileAs(
name: '',
},
],
defaultPath: getDefaultDBFileName(db.getWorkspaceName(), workspaceId),
defaultPath: getDefaultDBFileName(
await db.getWorkspaceName(),
workspaceId
),
message: 'Save Workspace as a SQLite Database file',
}));
const filePath = ret.filePath;
@@ -213,11 +207,6 @@ export async function loadDBFile(): Promise<LoadDBFileResult> {
return { error: 'DB_FILE_PATH_INVALID' };
}
if (await dbFileAlreadyLoaded(originalPath)) {
logger.warn('loadDBFile: db file already loaded');
return { error: 'DB_FILE_ALREADY_LOADED' };
}
const { SqliteConnection } = await import('@affine/native');
const validationResult = await SqliteConnection.validate(originalPath);
@@ -294,100 +283,3 @@ export async function loadDBFile(): Promise<LoadDBFileResult> {
};
}
}
/**
* This function is called when the user clicks the "Move" button in the "Move Workspace Storage" setting.
*
* It will
* - copy the source db file to a new location
* - remove the old db external file
* - update the external db file path in the workspace meta
* - return the new file path
*/
export async function moveDBFile(
workspaceId: string,
dbFileDir?: string
): Promise<MoveDBFileResult> {
let db: WorkspaceSQLiteDB | null = null;
try {
db = await ensureSQLiteDB(workspaceId);
const meta = await getWorkspaceMeta(workspaceId);
const oldDir = meta.secondaryDBPath
? path.dirname(meta.secondaryDBPath)
: null;
const defaultDir = oldDir ?? (await mainRPC.getPath('documents'));
const newName = getDefaultDBFileName(db.getWorkspaceName(), workspaceId);
const newDirPath =
dbFileDir ??
(
getFakedResult() ??
(await mainRPC.showOpenDialog({
properties: ['openDirectory'],
title: 'Move Workspace Storage',
buttonLabel: 'Move',
defaultPath: defaultDir,
message: 'Move Workspace storage file',
}))
).filePaths?.[0];
// skips if
// - user canceled the dialog
// - user selected the same dir
if (!newDirPath || newDirPath === oldDir) {
return {
canceled: true,
};
}
const newFilePath = path.join(newDirPath, newName);
if (await fs.pathExists(newFilePath)) {
return {
error: 'FILE_ALREADY_EXISTS',
};
}
logger.info(`[moveDBFile] copy ${meta.mainDBPath} -> ${newFilePath}`);
await fs.copy(meta.mainDBPath, newFilePath);
// remove the old db file, but we don't care if it fails
if (meta.secondaryDBPath) {
await fs
.remove(meta.secondaryDBPath)
.then(() => {
logger.info(`[moveDBFile] removed ${meta.secondaryDBPath}`);
})
.catch(err => {
logger.error(
`[moveDBFile] remove ${meta.secondaryDBPath} failed`,
err
);
});
}
// update meta
await storeWorkspaceMeta(workspaceId, {
secondaryDBPath: newFilePath,
});
return {
filePath: newFilePath,
};
} catch (err) {
await db?.destroy();
logger.error('[moveDBFile]', err);
return {
error: 'UNKNOWN_ERROR',
};
}
}
async function dbFileAlreadyLoaded(path: string) {
const meta = await listWorkspaces();
const paths = meta.map(m => m[1].secondaryDBPath);
return paths.includes(path);
}

View File

@@ -1,6 +1,5 @@
import {
loadDBFile,
moveDBFile,
revealDBFile,
saveDBFileAs,
selectDBFileLocation,
@@ -17,9 +16,6 @@ export const dialogHandlers = {
saveDBFileAs: async (workspaceId: string) => {
return saveDBFileAs(workspaceId);
},
moveDBFile: (workspaceId: string, dbFileLocation?: string) => {
return moveDBFile(workspaceId, dbFileLocation);
},
selectDBFileLocation: async () => {
return selectDBFileLocation();
},

View File

@@ -12,7 +12,7 @@ function setupRendererConnection(rendererPort: Electron.MessagePortMain) {
try {
const start = performance.now();
const result = await handler(...args);
logger.info(
logger.debug(
'[async-api]',
`${namespace}.${name}`,
args.filter(

View File

@@ -1,7 +1,6 @@
export interface WorkspaceMeta {
id: string;
mainDBPath: string;
secondaryDBPath?: string; // assume there will be only one
}
export type YOrigin = 'self' | 'external' | 'upstream' | 'renderer';

View File

@@ -52,26 +52,12 @@ export async function getWorkspaceMeta(
.then(() => true)
.catch(() => false))
) {
// since not meta is found, we will migrate symlinked db file if needed
await fs.ensureDir(basePath);
const dbPath = await getWorkspaceDBPath(workspaceId);
// todo: remove this after migration (in stable version)
const realDBPath = (await fs
.access(dbPath)
.then(() => true)
.catch(() => false))
? await fs.realpath(dbPath)
: dbPath;
const isLink = realDBPath !== dbPath;
if (isLink) {
await fs.copy(realDBPath, dbPath);
}
// create one if not exists
const meta = {
id: workspaceId,
mainDBPath: dbPath,
secondaryDBPath: isLink ? realDBPath : undefined,
};
await fs.writeJSON(metaPath, meta);
return meta;

View File

@@ -99,47 +99,3 @@ test('db should be removed in db$Map after destroyed', async () => {
await setTimeout(100);
expect(db$Map.has(workspaceId)).toBe(false);
});
// we have removed secondary db feature
test.skip('if db has a secondary db path, we should also poll that', async () => {
const { ensureSQLiteDB } = await import(
'@affine/electron/helper/db/ensure-db'
);
const { storeWorkspaceMeta } = await import(
'@affine/electron/helper/workspace'
);
const workspaceId = v4();
await storeWorkspaceMeta(workspaceId, {
secondaryDBPath: path.join(tmpDir, 'secondary.db'),
});
const db = await ensureSQLiteDB(workspaceId);
await setTimeout(10);
expect(constructorStub).toBeCalledTimes(1);
expect(constructorStub).toBeCalledWith(path.join(tmpDir, 'secondary.db'), db);
// if secondary meta is changed
await storeWorkspaceMeta(workspaceId, {
secondaryDBPath: path.join(tmpDir, 'secondary2.db'),
});
// wait the async `db.destroy()` to be called
await setTimeout(100);
expect(constructorStub).toBeCalledTimes(2);
expect(destroyStub).toBeCalledTimes(1);
// if secondary meta is changed (but another workspace)
await storeWorkspaceMeta(v4(), {
secondaryDBPath: path.join(tmpDir, 'secondary3.db'),
});
await vi.advanceTimersByTimeAsync(1500);
expect(constructorStub).toBeCalledTimes(2);
expect(destroyStub).toBeCalledTimes(1);
// if primary is destroyed, secondary should also be destroyed
await db.destroy();
await setTimeout(100);
expect(destroyStub).toBeCalledTimes(2);
});

View File

@@ -1,11 +1,9 @@
import path from 'node:path';
import { dbSubjects } from '@affine/electron/helper/db/subjects';
import { removeWithRetry } from '@affine-test/kit/utils/utils';
import fs from 'fs-extra';
import { v4 } from 'uuid';
import { afterAll, afterEach, beforeAll, expect, test, vi } from 'vitest';
import { Doc as YDoc, encodeStateAsUpdate } from 'yjs';
const tmpDir = path.join(__dirname, 'tmp');
const appDataPath = path.join(tmpDir, 'app-data');
@@ -26,31 +24,6 @@ afterAll(() => {
vi.doUnmock('@affine/electron/helper/main-rpc');
});
let testYDoc: YDoc;
let testYSubDoc: YDoc;
function getTestUpdates() {
testYDoc = new YDoc();
const yText = testYDoc.getText('test');
yText.insert(0, 'hello');
testYSubDoc = new YDoc();
testYDoc.getMap('subdocs').set('test-subdoc', testYSubDoc);
const updates = encodeStateAsUpdate(testYDoc);
return updates;
}
function getTestSubDocUpdates() {
const yText = testYSubDoc.getText('test');
yText.insert(0, 'hello');
const updates = encodeStateAsUpdate(testYSubDoc);
return updates;
}
test('can create new db file if not exists', async () => {
const { openWorkspaceDatabase } = await import(
'@affine/electron/helper/db/workspace-db-adapter'
@@ -66,82 +39,6 @@ test('can create new db file if not exists', async () => {
await db.destroy();
});
test('on applyUpdate (from self), will not trigger update', async () => {
const { openWorkspaceDatabase } = await import(
'@affine/electron/helper/db/workspace-db-adapter'
);
const workspaceId = v4();
const onUpdate = vi.fn();
const db = await openWorkspaceDatabase(workspaceId);
db.update$.subscribe(onUpdate);
db.applyUpdate(getTestUpdates(), 'self');
expect(onUpdate).not.toHaveBeenCalled();
await db.destroy();
});
test('on applyUpdate (from renderer), will trigger update', async () => {
const { openWorkspaceDatabase } = await import(
'@affine/electron/helper/db/workspace-db-adapter'
);
const workspaceId = v4();
const onUpdate = vi.fn();
const onExternalUpdate = vi.fn();
const db = await openWorkspaceDatabase(workspaceId);
db.update$.subscribe(onUpdate);
const sub = dbSubjects.externalUpdate$.subscribe(onExternalUpdate);
db.applyUpdate(getTestUpdates(), 'renderer');
expect(onUpdate).toHaveBeenCalled();
sub.unsubscribe();
await db.destroy();
});
test('on applyUpdate (from renderer, subdoc), will trigger update', async () => {
const { openWorkspaceDatabase } = await import(
'@affine/electron/helper/db/workspace-db-adapter'
);
const workspaceId = v4();
const onUpdate = vi.fn();
const insertUpdates = vi.fn();
const db = await openWorkspaceDatabase(workspaceId);
db.applyUpdate(getTestUpdates(), 'renderer');
db.db!.insertUpdates = insertUpdates;
db.update$.subscribe(onUpdate);
const subdocUpdates = getTestSubDocUpdates();
db.applyUpdate(subdocUpdates, 'renderer', testYSubDoc.guid);
expect(onUpdate).toHaveBeenCalled();
expect(insertUpdates).toHaveBeenCalledWith([
{
docId: testYSubDoc.guid,
data: subdocUpdates,
},
]);
await db.destroy();
});
test('on applyUpdate (from external), will trigger update & send external update event', async () => {
const { openWorkspaceDatabase } = await import(
'@affine/electron/helper/db/workspace-db-adapter'
);
const workspaceId = v4();
const onUpdate = vi.fn();
const onExternalUpdate = vi.fn();
const db = await openWorkspaceDatabase(workspaceId);
db.update$.subscribe(onUpdate);
const sub = dbSubjects.externalUpdate$.subscribe(onExternalUpdate);
db.applyUpdate(getTestUpdates(), 'external');
expect(onUpdate).toHaveBeenCalled();
expect(onExternalUpdate).toHaveBeenCalled();
sub.unsubscribe();
await db.destroy();
});
test('on destroy, check if resources have been released', async () => {
const { openWorkspaceDatabase } = await import(
'@affine/electron/helper/db/workspace-db-adapter'

View File

@@ -127,7 +127,6 @@ describe('getWorkspaceMeta', () => {
expect(await getWorkspaceMeta(workspaceId)).toEqual({
id: workspaceId,
mainDBPath: path.join(workspacePath, 'storage.db'),
secondaryDBPath: sourcePath,
});
expect(
@@ -151,11 +150,4 @@ test('storeWorkspaceMeta', async () => {
expect(await fs.readJSON(path.join(workspacePath, 'meta.json'))).toEqual(
meta
);
await storeWorkspaceMeta(workspaceId, {
secondaryDBPath: path.join(tmpDir, 'test.db'),
});
expect(await fs.readJSON(path.join(workspacePath, 'meta.json'))).toEqual({
...meta,
secondaryDBPath: path.join(tmpDir, 'test.db'),
});
});

View File

@@ -6,7 +6,7 @@
"@affine/env": "workspace:*",
"@affine/templates": "workspace:*",
"@aws-sdk/client-s3": "3.537.0",
"@blocksuite/presets": "0.14.0-canary-202404260628-ddb1941",
"@blocksuite/presets": "0.14.0-canary-202404280529-c8e5f89",
"@clack/core": "^0.3.4",
"@clack/prompts": "^0.7.0",
"@magic-works/i18n-codegen": "^0.5.0",

View File

@@ -17,7 +17,6 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
enablePreloading: true,
enableNewSettingModal: true,
enableNewSettingUnstableApi: false,
enableMoveDatabase: false,
enableCloud: true,
enableCaptcha: true,
enableEnhanceShareMode: false,
@@ -57,7 +56,6 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
enablePreloading: true,
enableNewSettingModal: true,
enableNewSettingUnstableApi: false,
enableMoveDatabase: false,
enableCloud: true,
enableCaptcha: true,
enableEnhanceShareMode: false,
@@ -107,9 +105,6 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
enableEnhanceShareMode: process.env.ENABLE_ENHANCE_SHARE_MODE
? process.env.ENABLE_ENHANCE_SHARE_MODE === 'true'
: currentBuildPreset.enableEnhanceShareMode,
enableMoveDatabase: process.env.ENABLE_MOVE_DATABASE
? process.env.ENABLE_MOVE_DATABASE === 'true'
: currentBuildPreset.enableMoveDatabase,
enablePayment: process.env.ENABLE_PAYMENT
? process.env.ENABLE_PAYMENT !== 'false'
: buildFlags.mode === 'development'

View File

@@ -69,7 +69,7 @@
"@toeverything/infra": ["./packages/common/infra/src"],
"@affine/native": ["./packages/frontend/native/index.d.ts"],
"@affine/native/*": ["./packages/frontend/native/*"],
"@affine/storage": ["./packages/backend/storage/index.d.ts"],
"@affine/server-native": ["./packages/backend/native/index.d.ts"],
// Development only
"@affine/electron/*": ["./packages/frontend/electron/src/*"]
}

221
yarn.lock
View File

@@ -173,7 +173,7 @@ __metadata:
"@affine/env": "workspace:*"
"@affine/templates": "workspace:*"
"@aws-sdk/client-s3": "npm:3.537.0"
"@blocksuite/presets": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/presets": "npm:0.14.0-canary-202404280529-c8e5f89"
"@clack/core": "npm:^0.3.4"
"@clack/prompts": "npm:^0.7.0"
"@magic-works/i18n-codegen": "npm:^0.5.0"
@@ -226,12 +226,12 @@ __metadata:
"@affine/electron-api": "workspace:*"
"@affine/graphql": "workspace:*"
"@affine/i18n": "workspace:*"
"@blocksuite/block-std": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/blocks": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/global": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/block-std": "npm:0.14.0-canary-202404280529-c8e5f89"
"@blocksuite/blocks": "npm:0.14.0-canary-202404280529-c8e5f89"
"@blocksuite/global": "npm:0.14.0-canary-202404280529-c8e5f89"
"@blocksuite/icons": "npm:2.1.46"
"@blocksuite/presets": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/store": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/presets": "npm:0.14.0-canary-202404280529-c8e5f89"
"@blocksuite/store": "npm:0.14.0-canary-202404280529-c8e5f89"
"@dnd-kit/core": "npm:^6.1.0"
"@dnd-kit/modifiers": "npm:^7.0.0"
"@dnd-kit/sortable": "npm:^8.0.0"
@@ -327,13 +327,13 @@ __metadata:
"@affine/graphql": "workspace:*"
"@affine/i18n": "workspace:*"
"@affine/templates": "workspace:*"
"@blocksuite/block-std": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/blocks": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/global": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/block-std": "npm:0.14.0-canary-202404280529-c8e5f89"
"@blocksuite/blocks": "npm:0.14.0-canary-202404280529-c8e5f89"
"@blocksuite/global": "npm:0.14.0-canary-202404280529-c8e5f89"
"@blocksuite/icons": "npm:2.1.46"
"@blocksuite/inline": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/presets": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/store": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/inline": "npm:0.14.0-canary-202404280529-c8e5f89"
"@blocksuite/presets": "npm:0.14.0-canary-202404280529-c8e5f89"
"@blocksuite/store": "npm:0.14.0-canary-202404280529-c8e5f89"
"@dnd-kit/core": "npm:^6.1.0"
"@dnd-kit/modifiers": "npm:^7.0.0"
"@dnd-kit/sortable": "npm:^8.0.0"
@@ -455,10 +455,10 @@ __metadata:
"@affine/env": "workspace:*"
"@affine/i18n": "workspace:*"
"@affine/native": "workspace:*"
"@blocksuite/block-std": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/blocks": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/presets": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/store": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/block-std": "npm:0.14.0-canary-202404280529-c8e5f89"
"@blocksuite/blocks": "npm:0.14.0-canary-202404280529-c8e5f89"
"@blocksuite/presets": "npm:0.14.0-canary-202404280529-c8e5f89"
"@blocksuite/store": "npm:0.14.0-canary-202404280529-c8e5f89"
"@electron-forge/cli": "npm:^7.3.0"
"@electron-forge/core": "npm:^7.3.0"
"@electron-forge/core-utils": "npm:^7.3.0"
@@ -469,7 +469,7 @@ __metadata:
"@electron-forge/plugin-auto-unpack-natives": "npm:^7.3.0"
"@electron-forge/shared-types": "npm:^7.3.0"
"@emotion/react": "npm:^11.11.4"
"@pengx17/electron-forge-maker-appimage": "npm:^1.2.0"
"@pengx17/electron-forge-maker-appimage": "npm:^1.2.1"
"@sentry/electron": "npm:^4.22.0"
"@sentry/esbuild-plugin": "npm:^2.16.1"
"@sentry/react": "npm:^7.109.0"
@@ -516,8 +516,8 @@ __metadata:
version: 0.0.0-use.local
resolution: "@affine/env@workspace:packages/common/env"
dependencies:
"@blocksuite/global": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/store": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/global": "npm:0.14.0-canary-202404280529-c8e5f89"
"@blocksuite/store": "npm:0.14.0-canary-202404280529-c8e5f89"
lit: "npm:^3.1.2"
react: "npm:18.2.0"
react-dom: "npm:18.2.0"
@@ -644,12 +644,24 @@ __metadata:
languageName: unknown
linkType: soft
"@affine/server-native@workspace:*, @affine/server-native@workspace:packages/backend/native":
version: 0.0.0-use.local
resolution: "@affine/server-native@workspace:packages/backend/native"
dependencies:
"@napi-rs/cli": "npm:3.0.0-alpha.46"
lib0: "npm:^0.2.93"
nx: "npm:^18.2.4"
nx-cloud: "npm:^18.0.0"
yjs: "npm:^13.6.14"
languageName: unknown
linkType: soft
"@affine/server@workspace:packages/backend/server":
version: 0.0.0-use.local
resolution: "@affine/server@workspace:packages/backend/server"
dependencies:
"@affine-test/kit": "workspace:*"
"@affine/storage": "workspace:*"
"@affine/server-native": "workspace:*"
"@apollo/server": "npm:^4.10.2"
"@aws-sdk/client-s3": "npm:^3.552.0"
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "npm:^0.17.0"
@@ -712,7 +724,6 @@ __metadata:
dotenv: "npm:^16.4.5"
dotenv-cli: "npm:^7.4.1"
express: "npm:^4.19.2"
file-type: "npm:^19.0.0"
get-stream: "npm:^9.0.1"
graphql: "npm:^16.8.1"
graphql-scalars: "npm:^1.23.0"
@@ -752,18 +763,6 @@ __metadata:
languageName: unknown
linkType: soft
"@affine/storage@workspace:*, @affine/storage@workspace:packages/backend/storage":
version: 0.0.0-use.local
resolution: "@affine/storage@workspace:packages/backend/storage"
dependencies:
"@napi-rs/cli": "npm:3.0.0-alpha.46"
lib0: "npm:^0.2.93"
nx: "npm:^18.2.4"
nx-cloud: "npm:^18.0.0"
yjs: "npm:^13.6.14"
languageName: unknown
linkType: soft
"@affine/templates@workspace:*, @affine/templates@workspace:packages/frontend/templates":
version: 0.0.0-use.local
resolution: "@affine/templates@workspace:packages/frontend/templates"
@@ -3732,30 +3731,30 @@ __metadata:
languageName: node
linkType: hard
"@blocksuite/block-std@npm:0.14.0-canary-202404260628-ddb1941":
version: 0.14.0-canary-202404260628-ddb1941
resolution: "@blocksuite/block-std@npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/block-std@npm:0.14.0-canary-202404280529-c8e5f89":
version: 0.14.0-canary-202404280529-c8e5f89
resolution: "@blocksuite/block-std@npm:0.14.0-canary-202404280529-c8e5f89"
dependencies:
"@blocksuite/global": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/global": "npm:0.14.0-canary-202404280529-c8e5f89"
lit: "npm:^3.1.3"
lz-string: "npm:^1.5.0"
w3c-keyname: "npm:^2.2.8"
zod: "npm:^3.22.4"
peerDependencies:
"@blocksuite/inline": 0.14.0-canary-202404260628-ddb1941
"@blocksuite/store": 0.14.0-canary-202404260628-ddb1941
checksum: 10/bafca28b660194351bb136687b1e881d1b1b5ca8476742303ca18a34a7bdf262f46c21c6b037074a3b83affe482395aef727571f3011477c57740cbb9ef51cb5
"@blocksuite/inline": 0.14.0-canary-202404280529-c8e5f89
"@blocksuite/store": 0.14.0-canary-202404280529-c8e5f89
checksum: 10/ed48717fc653f8fcdcb513342de54ba063fbbc28ea85bac4a342c7926a96776f16fd8ff7d26921c6856767723994c77a35be1a10050d191c3063a67a5f0c712a
languageName: node
linkType: hard
"@blocksuite/blocks@npm:0.14.0-canary-202404260628-ddb1941":
version: 0.14.0-canary-202404260628-ddb1941
resolution: "@blocksuite/blocks@npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/blocks@npm:0.14.0-canary-202404280529-c8e5f89":
version: 0.14.0-canary-202404280529-c8e5f89
resolution: "@blocksuite/blocks@npm:0.14.0-canary-202404280529-c8e5f89"
dependencies:
"@blocksuite/block-std": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/global": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/inline": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/store": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/block-std": "npm:0.14.0-canary-202404280529-c8e5f89"
"@blocksuite/global": "npm:0.14.0-canary-202404280529-c8e5f89"
"@blocksuite/inline": "npm:0.14.0-canary-202404280529-c8e5f89"
"@blocksuite/store": "npm:0.14.0-canary-202404280529-c8e5f89"
"@dotlottie/player-component": "npm:^2.7.12"
"@fal-ai/serverless-client": "npm:^0.9.3"
"@floating-ui/dom": "npm:^1.6.3"
@@ -3794,16 +3793,16 @@ __metadata:
unified: "npm:^11.0.4"
webfontloader: "npm:^1.6.28"
zod: "npm:^3.22.4"
checksum: 10/8860fa8e2f858b07ca36698025994ece9591c775946bd2e33581b339c2a52e546e670603dd18d6dedfa4ebd60a144155be8f154abfeaf82979b31fcf5bfa65bf
checksum: 10/48e5232b2e720492ad77616ffa2cfbaf045e10af5925534e034dfda7310d6abc9a316fbb76ea456292f3612aaec2cda4e475e7465ae474deabcf20d5ded6e040
languageName: node
linkType: hard
"@blocksuite/global@npm:0.14.0-canary-202404260628-ddb1941":
version: 0.14.0-canary-202404260628-ddb1941
resolution: "@blocksuite/global@npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/global@npm:0.14.0-canary-202404280529-c8e5f89":
version: 0.14.0-canary-202404280529-c8e5f89
resolution: "@blocksuite/global@npm:0.14.0-canary-202404280529-c8e5f89"
dependencies:
zod: "npm:^3.22.4"
checksum: 10/cafdfbece39027dcd38ed77c61718c4d45718e76c255dd652ab51f6ecee6b6e318a6e6a081ec1ec79af88694d413e615351b207b1bd6e907fa52642a54ac759d
checksum: 10/14f55505be9497db26c651454eb2d52336f525b34f916d6aee8da7921d504ac64ed7db30c174188ff83730b5fab58ce39cde4d9c1409ac1c6964e17684164795
languageName: node
linkType: hard
@@ -3817,45 +3816,45 @@ __metadata:
languageName: node
linkType: hard
"@blocksuite/inline@npm:0.14.0-canary-202404260628-ddb1941":
version: 0.14.0-canary-202404260628-ddb1941
resolution: "@blocksuite/inline@npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/inline@npm:0.14.0-canary-202404280529-c8e5f89":
version: 0.14.0-canary-202404280529-c8e5f89
resolution: "@blocksuite/inline@npm:0.14.0-canary-202404280529-c8e5f89"
dependencies:
"@blocksuite/global": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/global": "npm:0.14.0-canary-202404280529-c8e5f89"
zod: "npm:^3.22.4"
peerDependencies:
lit: ^3.1.1
yjs: ^13
checksum: 10/79f00d0761fe92868a09201b32dd7c347a625e7bfa14a17d498326b343b0cb7674a8ee7e27af2e435f1d19171b564f20aa1f6e81e299e8849da40852782cf78c
checksum: 10/ba893ce944dc9f1acf37d123d34a3473f2e9e2faed75897ca609fcacf5e0628f3fb1e569eab3a641cffd3b1ffc5fbc294ebc7fe0d4f3c784f2d0174675527c79
languageName: node
linkType: hard
"@blocksuite/presets@npm:0.14.0-canary-202404260628-ddb1941":
version: 0.14.0-canary-202404260628-ddb1941
resolution: "@blocksuite/presets@npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/presets@npm:0.14.0-canary-202404280529-c8e5f89":
version: 0.14.0-canary-202404280529-c8e5f89
resolution: "@blocksuite/presets@npm:0.14.0-canary-202404280529-c8e5f89"
dependencies:
"@blocksuite/block-std": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/blocks": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/global": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/inline": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/store": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/block-std": "npm:0.14.0-canary-202404280529-c8e5f89"
"@blocksuite/blocks": "npm:0.14.0-canary-202404280529-c8e5f89"
"@blocksuite/global": "npm:0.14.0-canary-202404280529-c8e5f89"
"@blocksuite/inline": "npm:0.14.0-canary-202404280529-c8e5f89"
"@blocksuite/store": "npm:0.14.0-canary-202404280529-c8e5f89"
"@dotlottie/player-component": "npm:^2.7.12"
"@fal-ai/serverless-client": "npm:^0.9.3"
"@floating-ui/dom": "npm:^1.6.3"
"@toeverything/theme": "npm:^0.7.29"
lit: "npm:^3.1.3"
openai: "npm:^4.37.1"
checksum: 10/e0d998d93f5becf17f7883ea5e1af18a0c0235c997e17ca498597b5b695b53b199eb20b63b8ca924cb1e9501777c85d0393ae52c57e7bd04087beb09539a0313
checksum: 10/6f427982c25288578e052e73e7c3400f0c1fbb8354d8ab043d34da5e7ccb9d4f20a42bd85c92cdcdb607e0e1173b70da4f90b73bdce8fe7e75e1544d570bb23c
languageName: node
linkType: hard
"@blocksuite/store@npm:0.14.0-canary-202404260628-ddb1941":
version: 0.14.0-canary-202404260628-ddb1941
resolution: "@blocksuite/store@npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/store@npm:0.14.0-canary-202404280529-c8e5f89":
version: 0.14.0-canary-202404280529-c8e5f89
resolution: "@blocksuite/store@npm:0.14.0-canary-202404280529-c8e5f89"
dependencies:
"@blocksuite/global": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/inline": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/sync": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/global": "npm:0.14.0-canary-202404280529-c8e5f89"
"@blocksuite/inline": "npm:0.14.0-canary-202404280529-c8e5f89"
"@blocksuite/sync": "npm:0.14.0-canary-202404280529-c8e5f89"
"@types/flexsearch": "npm:^0.7.6"
flexsearch: "npm:0.7.43"
idb-keyval: "npm:^6.2.1"
@@ -3867,20 +3866,20 @@ __metadata:
zod: "npm:^3.22.4"
peerDependencies:
yjs: ^13
checksum: 10/fc97033a7e08922e3e9e5812df60e402c7824f048fc1cbea63e616d407e4b278549ba7465cac0c4934eafc091a461cec17fa38a99837c807627f9aed14ae3129
checksum: 10/f0ed64763e4e5277f598d64ae555b7658ed7db9ece7389b1cc5dcd80622326d44f7a7a7cb86b790762e41cfd0fd53e9a4163e53d9a44b80d5b3b8d5fc6f7d9dc
languageName: node
linkType: hard
"@blocksuite/sync@npm:0.14.0-canary-202404260628-ddb1941":
version: 0.14.0-canary-202404260628-ddb1941
resolution: "@blocksuite/sync@npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/sync@npm:0.14.0-canary-202404280529-c8e5f89":
version: 0.14.0-canary-202404280529-c8e5f89
resolution: "@blocksuite/sync@npm:0.14.0-canary-202404280529-c8e5f89"
dependencies:
"@blocksuite/global": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/global": "npm:0.14.0-canary-202404280529-c8e5f89"
idb: "npm:^8.0.0"
y-protocols: "npm:^1.0.6"
peerDependencies:
yjs: ^13
checksum: 10/749bf6cbe97bddc5d5d306f02861a517fa50fbdb162798c83b5eb0839c86572088db88e93bc97c1af767b63e37de9b04c2196e0af132aa88c30e19cbb7f19611
checksum: 10/45960ea9fc4aed445f5517539468f670e8d1eb8730ab567e0f98716a9c42aeb1ecc85717fd5fceb36d5aa40ef6d52435ce17a9f952f772d73879ee169332b2d9
languageName: node
linkType: hard
@@ -10198,14 +10197,14 @@ __metadata:
languageName: node
linkType: hard
"@pengx17/electron-forge-maker-appimage@npm:^1.2.0":
version: 1.2.0
resolution: "@pengx17/electron-forge-maker-appimage@npm:1.2.0"
"@pengx17/electron-forge-maker-appimage@npm:^1.2.1":
version: 1.2.1
resolution: "@pengx17/electron-forge-maker-appimage@npm:1.2.1"
dependencies:
"@electron-forge/maker-base": "npm:^7.3.0"
"@electron-forge/shared-types": "npm:^7.3.0"
app-builder-lib: "npm:^24.13.3"
checksum: 10/f5e8927810b5381462ec2cde8fcbbaab74b66e025e549d49707c1d855a9618c1b88bf136a4a0df9bc2b80a19ea136443115c462feb2a5b8b0311ec6c6c0ea1fa
checksum: 10/632c243dd6d0ee61d17741b212c9fd2b201ee4dc05ffc244e3d14fa0f7af368546c533612145367bdf61563d03240e3464f5a5f22028a16de454cabb8fbe010b
languageName: node
linkType: hard
@@ -14409,11 +14408,11 @@ __metadata:
"@affine/debug": "workspace:*"
"@affine/env": "workspace:*"
"@affine/templates": "workspace:*"
"@blocksuite/block-std": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/blocks": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/global": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/presets": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/store": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/block-std": "npm:0.14.0-canary-202404280529-c8e5f89"
"@blocksuite/blocks": "npm:0.14.0-canary-202404280529-c8e5f89"
"@blocksuite/global": "npm:0.14.0-canary-202404280529-c8e5f89"
"@blocksuite/presets": "npm:0.14.0-canary-202404280529-c8e5f89"
"@blocksuite/store": "npm:0.14.0-canary-202404280529-c8e5f89"
"@datastructures-js/binary-search-tree": "npm:^5.3.2"
"@testing-library/react": "npm:^15.0.0"
async-call-rpc: "npm:^6.4.0"
@@ -14464,9 +14463,9 @@ __metadata:
version: 0.0.0-use.local
resolution: "@toeverything/y-indexeddb@workspace:packages/common/y-indexeddb"
dependencies:
"@blocksuite/blocks": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/global": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/store": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/blocks": "npm:0.14.0-canary-202404280529-c8e5f89"
"@blocksuite/global": "npm:0.14.0-canary-202404280529-c8e5f89"
"@blocksuite/store": "npm:0.14.0-canary-202404280529-c8e5f89"
fake-indexeddb: "npm:^5.0.2"
idb: "npm:^8.0.0"
nanoid: "npm:^5.0.7"
@@ -22536,17 +22535,6 @@ __metadata:
languageName: node
linkType: hard
"file-type@npm:^19.0.0":
version: 19.0.0
resolution: "file-type@npm:19.0.0"
dependencies:
readable-web-to-node-stream: "npm:^3.0.2"
strtok3: "npm:^7.0.0"
token-types: "npm:^5.0.1"
checksum: 10/8befa58f769b19d4a72c214694906b83b584310575300e63c08c48f9f2cfa6cb57fb4e1d08325961938d9dde3ecc4f5737b1604ddedfd759f5a1e65e5b0ca577
languageName: node
linkType: hard
"file-uri-to-path@npm:1.0.0":
version: 1.0.0
resolution: "file-uri-to-path@npm:1.0.0"
@@ -32384,15 +32372,6 @@ __metadata:
languageName: node
linkType: hard
"readable-web-to-node-stream@npm:^3.0.2":
version: 3.0.2
resolution: "readable-web-to-node-stream@npm:3.0.2"
dependencies:
readable-stream: "npm:^3.6.0"
checksum: 10/d3a5bf9d707c01183d546a64864aa63df4d9cb835dfd2bf89ac8305e17389feef2170c4c14415a10d38f9b9bfddf829a57aaef7c53c8b40f11d499844bf8f1a4
languageName: node
linkType: hard
"readdirp@npm:~3.6.0":
version: 3.6.0
resolution: "readdirp@npm:3.6.0"
@@ -34681,16 +34660,6 @@ __metadata:
languageName: node
linkType: hard
"strtok3@npm:^7.0.0":
version: 7.0.0
resolution: "strtok3@npm:7.0.0"
dependencies:
"@tokenizer/token": "npm:^0.3.0"
peek-readable: "npm:^5.0.0"
checksum: 10/4f2269679fcfce1e9fe0600eff361ea4c687ae0a0e8d9dab6703811071cd92545cbcb32d4ace3d3aa591f777cec1a3e8aeecd5efd71ae216fd2962a7a238b4ab
languageName: node
linkType: hard
"style-loader@npm:^4.0.0":
version: 4.0.0
resolution: "style-loader@npm:4.0.0"
@@ -35327,16 +35296,6 @@ __metadata:
languageName: node
linkType: hard
"token-types@npm:^5.0.1":
version: 5.0.1
resolution: "token-types@npm:5.0.1"
dependencies:
"@tokenizer/token": "npm:^0.3.0"
ieee754: "npm:^1.2.1"
checksum: 10/0985369bbea9f53a5ccd79bb9899717b41401a813deb2c7fb1add5d0baf2f702aaf6da78f6e0ccf346d5a9f7acaa7cb5efed7d092d89d8c1e6962959e9509bc0
languageName: node
linkType: hard
"toml@npm:^3.0.0":
version: 3.0.0
resolution: "toml@npm:3.0.0"
@@ -37672,7 +37631,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "y-provider@workspace:packages/common/y-provider"
dependencies:
"@blocksuite/store": "npm:0.14.0-canary-202404260628-ddb1941"
"@blocksuite/store": "npm:0.14.0-canary-202404280529-c8e5f89"
vite: "npm:^5.1.4"
vite-plugin-dts: "npm:3.7.3"
vitest: "npm:1.4.0"