Merge branch 'canary' into beta

This commit is contained in:
LongYinan
2024-04-25 15:36:58 +08:00
951 changed files with 85077 additions and 21370 deletions
+1 -1
View File
@@ -12,4 +12,4 @@ static
web-static
public
packages/frontend/i18n/src/i18n-generated.ts
packages/frontend/templates/edgeless-templates.gen.ts
packages/frontend/templates/*.gen.ts
+1 -3
View File
@@ -48,14 +48,12 @@ const allPackages = [
'packages/frontend/i18n',
'packages/frontend/native',
'packages/frontend/templates',
'packages/frontend/workspace-impl',
'packages/common/debug',
'packages/common/env',
'packages/common/infra',
'packages/common/theme',
'packages/common/y-indexeddb',
'tools/cli',
'tests/storybook',
];
/**
@@ -234,7 +232,7 @@ const config = {
},
},
...allPackages.map(pkg => ({
files: [`${pkg}/src/**/*.ts`, `${pkg}/src/**/*.tsx`],
files: [`${pkg}/src/**/*.ts`, `${pkg}/src/**/*.tsx`, `${pkg}/**/*.mjs`],
rules: {
'@typescript-eslint/no-restricted-imports': [
'error',
+8 -2
View File
@@ -13,8 +13,10 @@ const {
R2_ACCOUNT_ID,
R2_ACCESS_KEY_ID,
R2_SECRET_ACCESS_KEY,
ENABLE_CAPTCHA,
CAPTCHA_TURNSTILE_SECRET,
COPILOT_OPENAI_API_KEY,
COPILOT_FAL_API_KEY,
COPILOT_UNSPLASH_API_KEY,
MAILER_SENDER,
MAILER_USER,
MAILER_PASSWORD,
@@ -97,8 +99,12 @@ const createHelmCommand = ({ isDryRun }) => {
`--set graphql.replicaCount=${graphqlReplicaCount}`,
`--set-string graphql.image.tag="${imageTag}"`,
`--set graphql.app.host=${host}`,
`--set graphql.app.captcha.enabled=${ENABLE_CAPTCHA}`,
`--set graphql.app.captcha.enabled=true`,
`--set-string graphql.app.captcha.turnstile.secret="${CAPTCHA_TURNSTILE_SECRET}"`,
`--set graphql.app.copilot.enabled=true`,
`--set-string graphql.app.copilot.openai.key="${COPILOT_OPENAI_API_KEY}"`,
`--set-string graphql.app.copilot.fal.key="${COPILOT_FAL_API_KEY}"`,
`--set-string graphql.app.copilot.unsplash.key="${COPILOT_UNSPLASH_API_KEY}"`,
`--set graphql.app.objectStorage.r2.enabled=true`,
`--set-string graphql.app.objectStorage.r2.accountId="${R2_ACCOUNT_ID}"`,
`--set-string graphql.app.objectStorage.r2.accessKeyId="${R2_ACCESS_KEY_ID}"`,
@@ -30,6 +30,9 @@ services:
- NODE_ENV=production
- AFFINE_ADMIN_EMAIL=${AFFINE_ADMIN_EMAIL}
- AFFINE_ADMIN_PASSWORD=${AFFINE_ADMIN_PASSWORD}
# Telemetry allows us to collect data on how you use the affine. This data will helps us improve the app and provide better features.
# Uncomment next line if you wish to quit telemetry.
# - TELEMETRY_ENABLE=false
redis:
image: redis
container_name: affine_redis
@@ -0,0 +1,11 @@
{{- if .Values.app.copilot.enabled -}}
apiVersion: v1
kind: Secret
metadata:
name: "{{ .Values.app.copilot.secretName }}"
type: Opaque
data:
openaiSecret: {{ .Values.app.copilot.openai.key | b64enc }}
falSecret: {{ .Values.app.copilot.fal.key | b64enc }}
unsplashSecret: {{ .Values.app.copilot.unsplash.key | b64enc }}
{{- end }}
@@ -148,6 +148,23 @@ spec:
name: "{{ .Values.app.captcha.secretName }}"
key: turnstileSecret
{{ end }}
{{ if .Values.app.copilot.enabled }}
- name: COPILOT_OPENAI_API_KEY
valueFrom:
secretKeyRef:
name: "{{ .Values.app.copilot.secretName }}"
key: openaiSecret
- name: COPILOT_FAL_API_KEY
valueFrom:
secretKeyRef:
name: "{{ .Values.app.copilot.secretName }}"
key: falSecret
- name: COPILOT_UNSPLASH_API_KEY
valueFrom:
secretKeyRef:
name: "{{ .Values.app.copilot.secretName }}"
key: unsplashSecret
{{ end }}
{{ if .Values.app.oauth.google.enabled }}
- name: OAUTH_GOOGLE_ENABLED
value: "true"
@@ -24,6 +24,11 @@ app:
secretName: captcha
turnstile:
secret: ''
copilot:
enable: false
secretName: copilot
openai:
key: ''
objectStorage:
r2:
enabled: false
+2
View File
@@ -35,6 +35,8 @@ graphql:
service:
type: ClusterIP
port: 3000
annotations:
cloud.google.com/backend-config: '{"default": "affine-backendconfig"}'
sync:
service:
-5
View File
@@ -29,11 +29,6 @@ mod:plugin-cli:
- any-glob-to-any-file:
- 'tools/plugin-cli/**/*'
mod:workspace-impl:
- changed-files:
- any-glob-to-any-file:
- 'packages/frontend/workspace-impl/**/*'
mod:i18n:
- changed-files:
- any-glob-to-any-file:
+1
View File
@@ -56,6 +56,7 @@ jobs:
SHOULD_REPORT_TRACE: false
PUBLIC_PATH: '/'
SELF_HOSTED: true
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
- name: Download selfhost fonts
run: node ./scripts/download-blocksuite-fonts.mjs
- name: Upload web artifact
+1 -1
View File
@@ -283,7 +283,7 @@ jobs:
env:
DISTRIBUTION: 'desktop'
- name: zip web
run: tar -czf dist.tar.gz --directory=packages/frontend/electron/dist .
run: tar -czf dist.tar.gz --directory=packages/frontend/electron/renderer/dist .
- name: Upload web artifact
uses: actions/upload-artifact@v4
with:
+5 -3
View File
@@ -14,7 +14,6 @@ on:
- internal
env:
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
MIXPANEL_TOKEN: '389c0615a69b57cca7d3fa0a4824c930'
permissions:
contents: 'write'
@@ -50,10 +49,11 @@ jobs:
TRACE_REPORT_ENDPOINT: ${{ secrets.TRACE_REPORT_ENDPOINT }}
CAPTCHA_SITE_KEY: ${{ secrets.CAPTCHA_SITE_KEY }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
SENTRY_PROJECT: 'affine-web'
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
PERFSEE_TOKEN: ${{ secrets.PERFSEE_TOKEN }}
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
- name: Upload web artifact
uses: actions/upload-artifact@v4
with:
@@ -133,8 +133,10 @@ jobs:
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
ENABLE_CAPTCHA: true
CAPTCHA_TURNSTILE_SECRET: ${{ secrets.CAPTCHA_TURNSTILE_SECRET }}
COPILOT_OPENAI_API_KEY: ${{ secrets.COPILOT_OPENAI_API_KEY }}
COPILOT_FAL_API_KEY: ${{ secrets.COPILOT_FAL_API_KEY }}
COPILOT_UNSPLASH_API_KEY: ${{ secrets.COPILOT_UNSPLASH_API_KEY }}
MAILER_SENDER: ${{ secrets.OAUTH_EMAIL_SENDER }}
MAILER_USER: ${{ secrets.OAUTH_EMAIL_LOGIN }}
MAILER_PASSWORD: ${{ secrets.OAUTH_EMAIL_PASSWORD }}
+4 -1
View File
@@ -25,4 +25,7 @@ jobs:
node-version-file: '.nvmrc'
- name: Install dependencies
run: yarn workspaces focus @affine/commitlint-config
- run: echo "${{ github.event.pull_request.title }}" | yarn workspace @affine/commitlint-config commitlint -g ./.commitlintrc.json
- name: Check PR title
env:
TITLE: ${{ github.event.pull_request.title }}
run: echo "$TITLE" | yarn workspace @affine/commitlint-config commitlint -g ./.commitlintrc.json
-51
View File
@@ -1,51 +0,0 @@
name: Publish Storybook
env:
NODE_OPTIONS: --max-old-space-size=4096
on:
workflow_dispatch:
push:
branches:
- canary
pull_request:
branches:
- canary
paths-ignore:
- README.md
- .github/**
- packages/backend/server
- packages/frontend/electron
- '!.github/workflows/publish-storybook.yml'
jobs:
publish-storybook:
name: Publish Storybook
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
# This is required to fetch all commits for chromatic
fetch-depth: 0
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
electron-install: false
- uses: chromaui/action-next@v1
with:
workingDir: tests/storybook
buildScriptName: build
exitOnceUploaded: true
onlyChanged: false
diagnostics: true
env:
CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
NODE_OPTIONS: ${{ env.NODE_OPTIONS }}
- uses: actions/upload-artifact@v4
if: always()
with:
name: chromatic-build-artifacts-${{ github.run_id }}
path: |
chromatic-diagnostics.json
**/build-storybook.log
@@ -1,51 +0,0 @@
name: Publish UI Storybook
env:
NODE_OPTIONS: --max-old-space-size=4096
on:
workflow_dispatch:
push:
branches:
- canary
pull_request:
branches:
- canary
paths-ignore:
- README.md
- .github/**
- packages/backend/server
- packages/frontend/electron
- '!.github/workflows/publish-storybook.yml'
jobs:
publish-ui-storybook:
name: Publish UI Storybook
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
# This is required to fetch all commits for chromatic
fetch-depth: 0
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
electron-install: false
- uses: chromaui/action-next@v1
with:
workingDir: packages/frontend/component
buildScriptName: build:storybook
exitOnceUploaded: true
onlyChanged: false
diagnostics: true
env:
CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_UI_PROJECT_TOKEN }}
NODE_OPTIONS: ${{ env.NODE_OPTIONS }}
- uses: actions/upload-artifact@v4
if: always()
with:
name: chromatic-build-artifacts-${{ github.run_id }}
path: |
chromatic-diagnostics.json
**/build-storybook.log
+20 -5
View File
@@ -33,7 +33,6 @@ env:
DEBUG: napi:*
APP_NAME: affine
MACOSX_DEPLOYMENT_TARGET: '10.13'
MIXPANEL_TOKEN: '389c0615a69b57cca7d3fa0a4824c930'
jobs:
before-make:
@@ -54,12 +53,13 @@ jobs:
run: yarn workspace @affine/electron generate-assets
env:
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
SENTRY_PROJECT: 'affine'
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
RELEASE_VERSION: ${{ steps.version.outputs.APP_VERSION }}
SKIP_PLUGIN_BUILD: 'true'
SKIP_NX_CACHE: 'true'
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
- name: Upload web artifact
uses: actions/upload-artifact@v4
@@ -91,9 +91,10 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
SKIP_GENERATE_ASSETS: 1
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
SENTRY_PROJECT: 'affine'
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
steps:
- uses: actions/checkout@v4
- name: Setup Version
@@ -128,6 +129,12 @@ jobs:
p12-file-base64: ${{ secrets.CERTIFICATES_P12 }}
p12-password: ${{ secrets.CERTIFICATES_P12_PASSWORD }}
- name: Install fuse on Linux (for patching AppImage)
if: ${{ matrix.spec.platform == 'linux' }}
run: |
sudo add-apt-repository universe
sudo apt install libfuse2 -y
- name: make
run: yarn workspace @affine/electron make --platform=${{ matrix.spec.platform }} --arch=${{ matrix.spec.arch }}
env:
@@ -174,9 +181,10 @@ jobs:
env:
SKIP_GENERATE_ASSETS: 1
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
SENTRY_PROJECT: 'affine'
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
steps:
- uses: actions/checkout@v4
- name: Setup Version
@@ -252,6 +260,10 @@ jobs:
- name: Setup Node.js
timeout-minutes: 10
uses: ./.github/actions/setup-node
with:
extra-flags: workspaces focus @affine/electron @affine/monorepo
hard-link-nm: false
nmHoistingLimits: workspaces
- name: Download and overwrite packaged artifacts
uses: actions/download-artifact@v4
with:
@@ -263,6 +275,9 @@ jobs:
- name: Make squirrel.windows installer
run: yarn workspace @affine/electron make-squirrel --platform=${{ matrix.spec.platform }} --arch=${{ matrix.spec.arch }}
- name: Make nsis.windows installer
run: yarn workspace @affine/electron make-nsis --platform=${{ matrix.spec.platform }} --arch=${{ matrix.spec.arch }}
- name: Zip artifacts for faster upload
run: Compress-Archive -CompressionLevel Fastest -Path packages/frontend/electron/out/${{ env.BUILD_TYPE }}/make/* -DestinationPath archive.zip
@@ -310,7 +325,7 @@ jobs:
mkdir -p builds
mv packages/frontend/electron/out/*/make/zip/win32/x64/AFFiNE*-win32-x64-*.zip ./builds/affine-${{ needs.before-make.outputs.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-windows-x64.zip
mv packages/frontend/electron/out/*/make/squirrel.windows/x64/*.exe ./builds/affine-${{ needs.before-make.outputs.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-windows-x64.exe
mv packages/frontend/electron/out/*/make/squirrel.windows/x64/*.msi ./builds/affine-${{ needs.before-make.outputs.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-windows-x64.msi
mv packages/frontend/electron/out/*/make/nsis.windows/x64/*.exe ./builds/affine-${{ needs.before-make.outputs.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-windows-x64.snis.exe
- name: Upload Artifact
uses: actions/upload-artifact@v4
+1 -1
View File
@@ -1 +1 @@
20
20.12.1
+1 -2
View File
@@ -16,8 +16,7 @@ packages/frontend/i18n/src/i18n-generated.ts
packages/frontend/graphql/src/graphql/index.ts
tests/affine-legacy/**/static
.yarnrc.yml
packages/frontend/templates/edgeless-templates.gen.ts
packages/frontend/templates/templates.gen.ts
packages/frontend/templates/*.gen.ts
packages/frontend/templates/onboarding
# auto-generated by NAPI-RS
File diff suppressed because one or more lines are too long
Generated
+384 -365
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -73,7 +73,7 @@ AFFiNE is an open-source, all-in-one workspace and an operating system for all t
**Self-host & Shape your own AFFiNE**
- You have the freedom to manage, self-host, fork and build your own AFFiNE. Plugin community and third-party blocks are coming soon. More tractions on [Blocksuite](block-suite.com). Check there to learn how to [self-host AFFiNE](https://docs.affine.pro/docs/self-host-affine-).
- You have the freedom to manage, self-host, fork and build your own AFFiNE. Plugin community and third-party blocks are coming soon. More tractions on [Blocksuite](https://blocksuite.io). Check there to learn how to [self-host AFFiNE](https://docs.affine.pro/docs/self-host-affine).
## Acknowledgement
@@ -112,7 +112,7 @@ If you have questions, you are welcome to contact us. One of the best places to
| Name | | |
| -------------------------------------------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| [@affine/component](packages/frontend/component) | AFFiNE Component Resources | [![](https://img.shields.io/codecov/c/github/toeverything/affine?style=flat-square)](https://affine-storybook.vercel.app/) |
| [@affine/component](packages/frontend/component) | AFFiNE Component Resources | ![](https://img.shields.io/codecov/c/github/toeverything/affine?style=flat-square) |
| [@toeverything/y-indexeddb](packages/common/y-indexeddb) | IndexedDB database adapter for Yjs | [![](https://img.shields.io/npm/dm/@toeverything/y-indexeddb?style=flat-square&color=eee)](https://www.npmjs.com/package/@toeverything/y-indexeddb) |
| [@toeverything/theme](packages/common/theme) | AFFiNE theme | [![](https://img.shields.io/npm/dm/@toeverything/theme?style=flat-square&color=eee)](https://www.npmjs.com/package/@toeverything/theme) |
@@ -143,7 +143,7 @@ We would like to express our gratitude to all the individuals who have already c
## Self-Host
Begin with Docker to deploy your own feature-rich, unrestricted version of AFFiNE. Our team is diligently updating to the latest version. For more information on how to self-host AFFiNE, please refer to our [documentation](https://docs.affine.pro/docs/self-host-affine-).
Begin with Docker to deploy your own feature-rich, unrestricted version of AFFiNE. Our team is diligently updating to the latest version. For more information on how to self-host AFFiNE, please refer to our [documentation](https://docs.affine.pro/docs/self-host-affine).
## Hiring
-13
View File
@@ -29,13 +29,6 @@ It includes the global constants, browser and system check.
This package should be imported at the very beginning of the entry point.
### `@affine/workspace-impl`
Current we have two workspace plugin:
- `local` for local workspace, which is the default workspace type.
- `affine` for cloud workspace, which is the workspace type for AFFiNE Cloud with OctoBase backend.
#### Design principles
- Each workspace plugin has its state and is isolated from other workspace plugins.
@@ -61,12 +54,6 @@ yarn dev
See [building desktop client app](../building-desktop-client-app.md).
### `@affine/storybook`
```shell
yarn workspace @affine/storybook storybook
```
## What's next?
- [Behind the code](./behind-the-code.md)
+1 -1
View File
@@ -9,7 +9,7 @@
"devDependencies": {
"nodemon": "^3.1.0",
"serve": "^14.2.1",
"typedoc": "^0.25.8"
"typedoc": "^0.25.13"
},
"nodemonConfig": {
"watch": [
+10
View File
@@ -0,0 +1,10 @@
{
"rules": {
"import/no-cycle": [
"error",
{
"ignoreTypes": true
}
]
}
}
+28 -31
View File
@@ -22,15 +22,13 @@
"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:storybook": "yarn nx build @affine/storybook",
"start:web-static": "yarn workspace @affine/web static-server",
"start:storybook": "yarn exec serve tests/storybook/storybook-static -l 6006",
"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",
"lint:eslint:fix": "yarn lint:eslint --fix",
"lint:prettier": "prettier --ignore-unknown --cache --check .",
"lint:prettier:fix": "prettier --ignore-unknown --cache --write .",
"lint:ox": "oxlint --import-plugin --deny-warnings -D correctness -D nursery -D prefer-array-some -D no-useless-promise-resolve-reject -D perf -A no-undef -A consistent-type-exports -A default -A named -A ban-ts-comment -A export -A no-unresolved -A no-default-export -A no-duplicates -A no-side-effects-in-initialization -A no-named-as-default -A getter-return",
"lint:ox": "oxlint -c oxlint.json --import-plugin --deny-warnings -D correctness -D nursery -D prefer-array-some -D no-useless-promise-resolve-reject -D perf -A no-undef -A consistent-type-exports -A default -A named -A ban-ts-comment -A export -A no-unresolved -A no-default-export -A no-duplicates -A no-side-effects-in-initialization -A no-named-as-default -A getter-return",
"lint": "yarn lint:eslint && yarn lint:prettier",
"lint:fix": "yarn lint:eslint:fix && yarn lint:prettier:fix",
"test": "vitest --run",
@@ -56,64 +54,63 @@
"devDependencies": {
"@affine-test/kit": "workspace:*",
"@affine/cli": "workspace:*",
"@commitlint/cli": "^19.0.0",
"@commitlint/config-conventional": "^19.0.0",
"@commitlint/cli": "^19.2.1",
"@commitlint/config-conventional": "^19.1.0",
"@faker-js/faker": "^8.4.1",
"@istanbuljs/schema": "^0.1.3",
"@magic-works/i18n-codegen": "^0.5.0",
"@nx/vite": "18.1.2",
"@playwright/test": "^1.41.2",
"@nx/vite": "18.2.4",
"@playwright/test": "^1.43.0",
"@taplo/cli": "^0.7.0",
"@testing-library/react": "^14.2.1",
"@testing-library/react": "^15.0.0",
"@toeverything/infra": "workspace:*",
"@types/affine__env": "workspace:*",
"@types/eslint": "^8.56.3",
"@types/node": "^20.11.20",
"@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^7.0.2",
"@vanilla-extract/vite-plugin": "^4.0.4",
"@vanilla-extract/webpack-plugin": "^2.3.6",
"@types/eslint": "^8.56.7",
"@types/node": "^20.12.7",
"@typescript-eslint/eslint-plugin": "^7.6.0",
"@typescript-eslint/parser": "^7.6.0",
"@vanilla-extract/vite-plugin": "^4.0.7",
"@vanilla-extract/webpack-plugin": "^2.3.7",
"@vitejs/plugin-react-swc": "^3.6.0",
"@vitest/coverage-istanbul": "1.4.0",
"@vitest/ui": "1.4.0",
"cross-env": "^7.0.3",
"electron": "^29.0.1",
"eslint": "^8.56.0",
"electron": "^30.0.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import-x": "^0.4.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-import-x": "^0.5.0",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-rxjs": "^5.0.3",
"eslint-plugin-simple-import-sort": "^12.0.0",
"eslint-plugin-sonarjs": "^0.24.0",
"eslint-plugin-unicorn": "^51.0.1",
"eslint-plugin-sonarjs": "^0.25.1",
"eslint-plugin-unicorn": "^52.0.0",
"eslint-plugin-unused-imports": "^3.1.0",
"eslint-plugin-vue": "^9.22.0",
"eslint-plugin-vue": "^9.24.1",
"fake-indexeddb": "5.0.2",
"happy-dom": "^14.0.0",
"happy-dom": "^14.7.1",
"husky": "^9.0.11",
"lint-staged": "^15.2.2",
"msw": "^2.2.1",
"nanoid": "^5.0.6",
"nx": "^18.0.4",
"msw": "^2.2.13",
"nanoid": "^5.0.7",
"nx": "^18.2.4",
"nyc": "^15.1.0",
"oxlint": "0.2.14",
"oxlint": "0.3.1",
"prettier": "^3.2.5",
"semver": "^7.6.0",
"serve": "^14.2.1",
"string-width": "^7.1.0",
"ts-node": "^10.9.2",
"typescript": "^5.3.3",
"vite": "^5.1.4",
"typescript": "^5.4.5",
"vite": "^5.2.8",
"vite-plugin-istanbul": "^6.0.0",
"vite-plugin-static-copy": "^1.0.1",
"vite-plugin-static-copy": "^1.0.2",
"vitest": "1.4.0",
"vitest-fetch-mock": "^0.2.2",
"vitest-mock-extended": "^1.3.1"
},
"packageManager": "yarn@4.1.1",
"resolutions": {
"vite": "^5.0.6",
"array-buffer-byte-length": "npm:@nolyfill/array-buffer-byte-length@latest",
"array-includes": "npm:@nolyfill/array-includes@latest",
"array.prototype.flat": "npm:@nolyfill/array.prototype.flat@latest",
@@ -169,7 +166,7 @@
"unbox-primitive": "npm:@nolyfill/unbox-primitive@latest",
"which-boxed-primitive": "npm:@nolyfill/which-boxed-primitive@latest",
"which-typed-array": "npm:@nolyfill/which-typed-array@latest",
"@reforged/maker-appimage/@electron-forge/maker-base": "7.3.0",
"@reforged/maker-appimage/@electron-forge/maker-base": "7.3.1",
"macos-alias": "npm:@napi-rs/macos-alias@0.0.4",
"fs-xattr": "npm:@napi-rs/xattr@latest",
"@radix-ui/react-dialog": "npm:@radix-ui/react-dialog@latest"
@@ -0,0 +1,16 @@
-- CreateEnum
CREATE TYPE "AiPromptRole" AS ENUM ('system', 'assistant', 'user');
-- CreateTable
CREATE TABLE "ai_prompts" (
"id" VARCHAR NOT NULL,
"name" VARCHAR(20) NOT NULL,
"idx" INTEGER NOT NULL,
"role" "AiPromptRole" NOT NULL,
"content" TEXT NOT NULL,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ai_prompts_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ai_prompts_name_idx_key" ON "ai_prompts"("name", "idx");
@@ -0,0 +1,25 @@
-- CreateTable
CREATE TABLE "ai_sessions" (
"id" VARCHAR(36) NOT NULL,
"user_id" VARCHAR NOT NULL,
"workspace_id" VARCHAR NOT NULL,
"doc_id" VARCHAR NOT NULL,
"prompt_name" VARCHAR NOT NULL,
"action" BOOLEAN NOT NULL,
"flavor" VARCHAR NOT NULL,
"model" VARCHAR NOT NULL,
"messages" JSON NOT NULL,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) NOT NULL,
CONSTRAINT "ai_sessions_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "ai_sessions" ADD CONSTRAINT "ai_sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ai_sessions" ADD CONSTRAINT "ai_sessions_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ai_sessions" ADD CONSTRAINT "ai_sessions_doc_id_workspace_id_fkey" FOREIGN KEY ("doc_id", "workspace_id") REFERENCES "snapshots"("guid", "workspace_id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,87 @@
/*
Warnings:
- You are about to drop the `ai_prompts` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `ai_sessions` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "ai_sessions" DROP CONSTRAINT "ai_sessions_doc_id_workspace_id_fkey";
-- DropForeignKey
ALTER TABLE "ai_sessions" DROP CONSTRAINT "ai_sessions_user_id_fkey";
-- DropForeignKey
ALTER TABLE "ai_sessions" DROP CONSTRAINT "ai_sessions_workspace_id_fkey";
-- DropTable
DROP TABLE "ai_prompts";
-- DropTable
DROP TABLE "ai_sessions";
-- CreateTable
CREATE TABLE "ai_prompts_messages" (
"prompt_id" INTEGER NOT NULL,
"idx" INTEGER NOT NULL,
"role" "AiPromptRole" NOT NULL,
"content" TEXT NOT NULL,
"attachments" JSON,
"params" JSON,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "ai_prompts_metadata" (
"id" SERIAL NOT NULL,
"name" VARCHAR(32) NOT NULL,
"action" VARCHAR,
"model" VARCHAR,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ai_prompts_metadata_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ai_sessions_messages" (
"id" VARCHAR(36) NOT NULL,
"session_id" VARCHAR(36) NOT NULL,
"role" "AiPromptRole" NOT NULL,
"content" TEXT NOT NULL,
"attachments" JSON,
"params" JSON,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6) NOT NULL,
CONSTRAINT "ai_sessions_messages_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ai_sessions_metadata" (
"id" VARCHAR(36) NOT NULL,
"user_id" VARCHAR(36) NOT NULL,
"workspace_id" VARCHAR(36) NOT NULL,
"doc_id" VARCHAR(36) NOT NULL,
"prompt_name" VARCHAR(32) NOT NULL,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ai_sessions_metadata_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ai_prompts_messages_prompt_id_idx_key" ON "ai_prompts_messages"("prompt_id", "idx");
-- CreateIndex
CREATE UNIQUE INDEX "ai_prompts_metadata_name_key" ON "ai_prompts_metadata"("name");
-- AddForeignKey
ALTER TABLE "ai_prompts_messages" ADD CONSTRAINT "ai_prompts_messages_prompt_id_fkey" FOREIGN KEY ("prompt_id") REFERENCES "ai_prompts_metadata"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ai_sessions_messages" ADD CONSTRAINT "ai_sessions_messages_session_id_fkey" FOREIGN KEY ("session_id") REFERENCES "ai_sessions_metadata"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ai_sessions_metadata" ADD CONSTRAINT "ai_sessions_metadata_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ai_sessions_metadata" ADD CONSTRAINT "ai_sessions_metadata_prompt_name_fkey" FOREIGN KEY ("prompt_name") REFERENCES "ai_prompts_metadata"("name") ON DELETE CASCADE ON UPDATE CASCADE;
+51 -48
View File
@@ -18,100 +18,103 @@
"predeploy": "yarn prisma migrate deploy && node --import ./scripts/register.js ./dist/data/index.js run"
},
"dependencies": {
"@apollo/server": "^4.10.0",
"@auth/prisma-adapter": "^1.4.0",
"@aws-sdk/client-s3": "^3.536.0",
"@apollo/server": "^4.10.2",
"@aws-sdk/client-s3": "^3.552.0",
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.17.0",
"@google-cloud/opentelemetry-cloud-trace-exporter": "^2.1.0",
"@google-cloud/opentelemetry-resource-util": "^2.1.0",
"@keyv/redis": "^2.8.4",
"@nestjs/apollo": "^12.1.0",
"@nestjs/common": "^10.3.3",
"@nestjs/core": "^10.3.3",
"@nestjs/common": "^10.3.7",
"@nestjs/core": "^10.3.7",
"@nestjs/event-emitter": "^2.0.4",
"@nestjs/graphql": "^12.1.1",
"@nestjs/platform-express": "^10.3.3",
"@nestjs/platform-socket.io": "^10.3.3",
"@nestjs/platform-express": "^10.3.7",
"@nestjs/platform-socket.io": "^10.3.7",
"@nestjs/schedule": "^4.0.1",
"@nestjs/serve-static": "^4.0.1",
"@nestjs/throttler": "^5.0.1",
"@nestjs/websockets": "^10.3.3",
"@node-rs/argon2": "^1.7.2",
"@node-rs/crc32": "^1.9.2",
"@node-rs/jsonwebtoken": "^0.5.0",
"@opentelemetry/api": "^1.7.0",
"@opentelemetry/core": "^1.21.0",
"@opentelemetry/exporter-prometheus": "^0.49.0",
"@opentelemetry/exporter-zipkin": "^1.21.0",
"@nestjs/serve-static": "^4.0.2",
"@nestjs/throttler": "5.0.1",
"@nestjs/websockets": "^10.3.7",
"@node-rs/argon2": "^1.8.0",
"@node-rs/crc32": "^1.10.0",
"@node-rs/jsonwebtoken": "^0.5.2",
"@opentelemetry/api": "^1.8.0",
"@opentelemetry/core": "^1.23.0",
"@opentelemetry/exporter-prometheus": "^0.50.0",
"@opentelemetry/exporter-zipkin": "^1.23.0",
"@opentelemetry/host-metrics": "^0.35.0",
"@opentelemetry/instrumentation": "^0.49.0",
"@opentelemetry/instrumentation-graphql": "^0.38.0",
"@opentelemetry/instrumentation-http": "^0.49.0",
"@opentelemetry/instrumentation-ioredis": "^0.38.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.35.0",
"@opentelemetry/instrumentation-socket.io": "^0.37.0",
"@opentelemetry/resources": "^1.21.0",
"@opentelemetry/sdk-metrics": "^1.21.0",
"@opentelemetry/sdk-node": "^0.49.0",
"@opentelemetry/sdk-trace-node": "^1.21.0",
"@opentelemetry/semantic-conventions": "^1.21.0",
"@prisma/client": "^5.10.2",
"@prisma/instrumentation": "^5.10.2",
"@socket.io/redis-adapter": "^8.2.1",
"@opentelemetry/instrumentation": "^0.50.0",
"@opentelemetry/instrumentation-graphql": "^0.39.0",
"@opentelemetry/instrumentation-http": "^0.50.0",
"@opentelemetry/instrumentation-ioredis": "^0.39.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.36.0",
"@opentelemetry/instrumentation-socket.io": "^0.38.0",
"@opentelemetry/resources": "^1.23.0",
"@opentelemetry/sdk-metrics": "^1.23.0",
"@opentelemetry/sdk-node": "^0.50.0",
"@opentelemetry/sdk-trace-node": "^1.23.0",
"@opentelemetry/semantic-conventions": "^1.23.0",
"@prisma/client": "^5.12.1",
"@prisma/instrumentation": "^5.12.1",
"@socket.io/redis-adapter": "^8.3.0",
"cookie-parser": "^1.4.6",
"dotenv": "^16.4.5",
"dotenv-cli": "^7.3.0",
"express": "^4.18.2",
"dotenv-cli": "^7.4.1",
"express": "^4.19.2",
"file-type": "^19.0.0",
"get-stream": "^9.0.0",
"get-stream": "^9.0.1",
"graphql": "^16.8.1",
"graphql-scalars": "^1.22.4",
"graphql-scalars": "^1.23.0",
"graphql-type-json": "^0.3.2",
"graphql-upload": "^16.0.2",
"ioredis": "^5.3.2",
"keyv": "^4.5.4",
"lodash-es": "^4.17.21",
"mixpanel": "^0.18.0",
"nanoid": "^5.0.6",
"mustache": "^4.2.0",
"nanoid": "^5.0.7",
"nest-commander": "^3.12.5",
"nestjs-throttler-storage-redis": "^0.4.1",
"nodemailer": "^6.9.10",
"nodemailer": "^6.9.13",
"on-headers": "^1.0.2",
"openai": "^4.33.0",
"parse-duration": "^1.1.0",
"pretty-time": "^1.1.0",
"prisma": "^5.10.2",
"prom-client": "^15.1.0",
"reflect-metadata": "^0.2.1",
"prisma": "^5.12.1",
"prom-client": "^15.1.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"semver": "^7.6.0",
"socket.io": "^4.7.4",
"stripe": "^14.18.0",
"socket.io": "^4.7.5",
"stripe": "^15.0.0",
"tiktoken": "^1.0.13",
"ts-node": "^10.9.2",
"typescript": "^5.3.3",
"typescript": "^5.4.5",
"ws": "^8.16.0",
"yjs": "^13.6.12",
"yjs": "^13.6.14",
"zod": "^3.22.4"
},
"devDependencies": {
"@affine-test/kit": "workspace:*",
"@affine/storage": "workspace:*",
"@napi-rs/image": "^1.9.1",
"@nestjs/testing": "^10.3.3",
"@types/cookie-parser": "^1.4.6",
"@nestjs/testing": "^10.3.7",
"@types/cookie-parser": "^1.4.7",
"@types/engine.io": "^3.1.10",
"@types/express": "^4.17.21",
"@types/graphql-upload": "^16.0.7",
"@types/keyv": "^4.2.0",
"@types/lodash-es": "^4.17.12",
"@types/mixpanel": "^2.14.8",
"@types/node": "^20.11.20",
"@types/mustache": "^4.2.5",
"@types/node": "^20.12.7",
"@types/nodemailer": "^6.4.14",
"@types/on-headers": "^1.0.3",
"@types/pretty-time": "^1.1.5",
"@types/sinon": "^17.0.3",
"@types/supertest": "^6.0.2",
"@types/ws": "^8.5.10",
"ava": "^6.1.1",
"ava": "^6.1.2",
"c8": "^9.1.0",
"nodemon": "^3.1.0",
"sinon": "^17.0.1",
+70
View File
@@ -30,6 +30,7 @@ model User {
pagePermissions WorkspacePageUserPermission[]
connectedAccounts ConnectedAccount[]
sessions UserSession[]
aiSessions AiSession[]
@@map("users")
}
@@ -422,6 +423,75 @@ model UserInvoice {
@@map("user_invoices")
}
enum AiPromptRole {
system
assistant
user
}
model AiPromptMessage {
promptId Int @map("prompt_id") @db.Integer
// if a group of prompts contains multiple sentences, idx specifies the order of each sentence
idx Int @db.Integer
// system/assistant/user
role AiPromptRole
// prompt content
content String @db.Text
attachments Json? @db.Json
params Json? @db.Json
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
prompt AiPrompt @relation(fields: [promptId], references: [id], onDelete: Cascade)
@@unique([promptId, idx])
@@map("ai_prompts_messages")
}
model AiPrompt {
id Int @id @default(autoincrement()) @db.Integer
name String @unique @db.VarChar(32)
// an mark identifying which view to use to display the session
// it is only used in the frontend and does not affect the backend
action String? @db.VarChar
model String? @db.VarChar
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
messages AiPromptMessage[]
sessions AiSession[]
@@map("ai_prompts_metadata")
}
model AiSessionMessage {
id String @id @default(uuid()) @db.VarChar(36)
sessionId String @map("session_id") @db.VarChar(36)
role AiPromptRole
content String @db.Text
attachments Json? @db.Json
params Json? @db.Json
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
session AiSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
@@map("ai_sessions_messages")
}
model AiSession {
id String @id @default(uuid()) @db.VarChar(36)
userId String @map("user_id") @db.VarChar(36)
workspaceId String @map("workspace_id") @db.VarChar(36)
docId String @map("doc_id") @db.VarChar(36)
promptName String @map("prompt_name") @db.VarChar(32)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
prompt AiPrompt @relation(fields: [promptName], references: [name], onDelete: Cascade)
messages AiSessionMessage[]
@@map("ai_sessions_metadata")
}
model DataMigration {
id String @id @default(uuid()) @db.VarChar(36)
name String @db.VarChar
+3 -15
View File
@@ -1,13 +1,12 @@
import { join } from 'node:path';
import { Logger, Module } from '@nestjs/common';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static';
import { get } from 'lodash-es';
import { AppController } from './app.controller';
import { AuthGuard, AuthModule } from './core/auth';
import { AuthModule } from './core/auth';
import { ADD_ENABLED_FEATURES, ServerConfigModule } from './core/config';
import { DocModule } from './core/doc';
import { FeatureModule } from './core/features';
@@ -17,7 +16,7 @@ import { SyncModule } from './core/sync';
import { UserModule } from './core/user';
import { WorkspaceModule } from './core/workspaces';
import { getOptionalModuleMetadata } from './fundamentals';
import { CacheInterceptor, CacheModule } from './fundamentals/cache';
import { CacheModule } from './fundamentals/cache';
import type { AvailablePlugins } from './fundamentals/config';
import { Config, ConfigModule } from './fundamentals/config';
import { EventModule } from './fundamentals/event';
@@ -103,16 +102,6 @@ export class AppModuleBuilder {
compile() {
@Module({
providers: [
{
provide: APP_INTERCEPTOR,
useClass: CacheInterceptor,
},
{
provide: APP_GUARD,
useClass: AuthGuard,
},
],
imports: this.modules,
controllers: this.config.isSelfhosted ? [] : [AppController],
})
@@ -135,13 +124,12 @@ function buildAppModule() {
.use(DocModule)
// sync server only
.useIf(config => config.flavor.sync, SyncModule)
.useIf(config => config.flavor.sync, WebSocketModule, SyncModule)
// graphql server only
.useIf(
config => config.flavor.graphql,
ServerConfigModule,
WebSocketModule,
GqlModule,
StorageModule,
UserModule,
+8 -1
View File
@@ -4,7 +4,12 @@ import type { NestExpressApplication } from '@nestjs/platform-express';
import cookieParser from 'cookie-parser';
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import { GlobalExceptionFilter } from './fundamentals';
import { AuthGuard } from './core/auth';
import {
CacheInterceptor,
CloudThrottlerGuard,
GlobalExceptionFilter,
} from './fundamentals';
import { SocketIoAdapter, SocketIoAdapterImpl } from './fundamentals/websocket';
import { serverTimingAndCache } from './middleware/timing';
@@ -28,6 +33,8 @@ export async function createApp() {
})
);
app.useGlobalGuards(app.get(AuthGuard), app.get(CloudThrottlerGuard));
app.useGlobalInterceptors(app.get(CacheInterceptor));
app.useGlobalFilters(new GlobalExceptionFilter(app.getHttpAdapter()));
app.use(cookieParser());
@@ -19,6 +19,9 @@ AFFiNE.ENV_MAP = {
MAILER_SECURE: ['mailer.secure', 'boolean'],
THROTTLE_TTL: ['rateLimiter.ttl', 'int'],
THROTTLE_LIMIT: ['rateLimiter.limit', 'int'],
COPILOT_OPENAI_API_KEY: 'plugins.copilot.openai.apiKey',
COPILOT_FAL_API_KEY: 'plugins.copilot.fal.apiKey',
COPILOT_UNSPLASH_API_KEY: 'plugins.copilot.unsplashKey',
REDIS_SERVER_HOST: 'plugins.redis.host',
REDIS_SERVER_PORT: ['plugins.redis.port', 'int'],
REDIS_SERVER_USER: 'plugins.redis.username',
@@ -36,4 +39,5 @@ AFFiNE.ENV_MAP = {
'featureFlags.syncClientVersionCheck',
'boolean',
],
TELEMETRY_ENABLE: ['telemetry.enabled', 'boolean'],
};
@@ -36,8 +36,16 @@ if (env.R2_OBJECT_STORAGE_ACCOUNT_ID) {
AFFiNE.storage.storages.blob.bucket = `workspace-blobs-${
AFFiNE.affine.canary ? 'canary' : 'prod'
}`;
AFFiNE.storage.storages.copilot.provider = 'cloudflare-r2';
AFFiNE.storage.storages.copilot.bucket = `workspace-copilot-${
AFFiNE.affine.canary ? 'canary' : 'prod'
}`;
}
AFFiNE.plugins.use('copilot', {
openai: {},
});
AFFiNE.plugins.use('redis');
AFFiNE.plugins.use('payment', {
stripe: {
@@ -14,7 +14,11 @@ import {
} from '@nestjs/common';
import type { Request, Response } from 'express';
import { PaymentRequiredException, URLHelper } from '../../fundamentals';
import {
PaymentRequiredException,
Throttle,
URLHelper,
} from '../../fundamentals';
import { UserService } from '../user';
import { validators } from '../utils/validators';
import { CurrentUser } from './current-user';
@@ -27,6 +31,12 @@ class SignInCredential {
password?: string;
}
class MagicLinkCredential {
email!: string;
token!: string;
}
@Throttle('strict')
@Controller('/api/auth')
export class AuthController {
constructor(
@@ -85,7 +95,7 @@ export class AuthController {
) {
const token = await this.token.createToken(TokenType.SignIn, email);
const magicLink = this.url.link('/api/auth/magic-link', {
const magicLink = this.url.link('/magic-link', {
token,
email,
redirect_uri: redirectUri,
@@ -124,20 +134,16 @@ export class AuthController {
}
@Public()
@Get('/magic-link')
@Post('/magic-link')
async magicLinkSignIn(
@Req() req: Request,
@Res() res: Response,
@Query('token') token?: string,
@Query('email') email?: string,
@Query('redirect_uri') redirectUri = this.url.home
@Body() { email, token }: MagicLinkCredential
) {
if (!token || !email) {
throw new BadRequestException('Invalid Sign-in mail Token');
throw new BadRequestException('Missing sign-in mail token');
}
email = decodeURIComponent(email);
token = decodeURIComponent(token);
validators.assertValidEmail(email);
const valid = await this.token.verifyToken(TokenType.SignIn, token, {
@@ -145,7 +151,7 @@ export class AuthController {
});
if (!valid) {
throw new BadRequestException('Invalid Sign-in mail Token');
throw new BadRequestException('Invalid sign-in mail token');
}
const user = await this.user.fulfillUser(email, {
@@ -155,9 +161,10 @@ export class AuthController {
await this.auth.setCookie(req, res, user);
return this.url.safeRedirect(res, redirectUri);
res.send({ id: user.id, email: user.email, name: user.name });
}
@Throttle('default', { limit: 1200 })
@Public()
@Get('/session')
async currentSessionUser(@CurrentUser() user?: CurrentUser) {
@@ -166,6 +173,7 @@ export class AuthController {
};
}
@Throttle('default', { limit: 1200 })
@Public()
@Get('/sessions')
async currentSessionUsers(@Req() req: Request) {
+15 -2
View File
@@ -36,7 +36,7 @@ export class AuthGuard implements CanActivate, OnModuleInit {
}
async canActivate(context: ExecutionContext) {
const { req } = getRequestResponseFromContext(context);
const { req, res } = getRequestResponseFromContext(context);
// check cookie
let sessionToken: string | undefined =
@@ -51,9 +51,22 @@ export class AuthGuard implements CanActivate, OnModuleInit {
req.headers[AuthService.authUserSeqHeaderName]
);
const user = await this.auth.getUser(sessionToken, userSeq);
const { user, expiresAt } = await this.auth.getUser(
sessionToken,
userSeq
);
if (res && user && expiresAt) {
await this.auth.refreshUserSessionIfNeeded(
req,
res,
sessionToken,
user.id,
expiresAt
);
}
if (user) {
req.sid = sessionToken;
req.user = user;
}
}
@@ -1,16 +1,18 @@
import { Module } from '@nestjs/common';
import { FeatureModule } from '../features';
import { QuotaModule } from '../quota';
import { UserModule } from '../user';
import { AuthController } from './controller';
import { AuthGuard } from './guard';
import { AuthResolver } from './resolver';
import { AuthService } from './service';
import { TokenService, TokenType } from './token';
@Module({
imports: [FeatureModule, UserModule],
providers: [AuthService, AuthResolver, TokenService],
exports: [AuthService],
imports: [FeatureModule, UserModule, QuotaModule],
providers: [AuthService, AuthResolver, TokenService, AuthGuard],
exports: [AuthService, AuthGuard],
controllers: [AuthController],
})
export class AuthModule {}
@@ -1,8 +1,4 @@
import {
BadRequestException,
ForbiddenException,
UseGuards,
} from '@nestjs/common';
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import {
Args,
Context,
@@ -16,7 +12,7 @@ import {
} from '@nestjs/graphql';
import type { Request, Response } from 'express';
import { CloudThrottlerGuard, Config, Throttle } from '../../fundamentals';
import { Config, Throttle } from '../../fundamentals';
import { UserService } from '../user';
import { UserType } from '../user/types';
import { validators } from '../utils/validators';
@@ -43,7 +39,7 @@ export class ClientTokenType {
* Sign up/in rate limit: 10 req/m
* Other rate limit: 5 req/m
*/
@UseGuards(CloudThrottlerGuard)
@Throttle('strict')
@Resolver(() => UserType)
export class AuthResolver {
constructor(
@@ -53,12 +49,6 @@ export class AuthResolver {
private readonly token: TokenService
) {}
@Throttle({
default: {
limit: 10,
ttl: 60,
},
})
@Public()
@Query(() => UserType, {
name: 'currentUser',
@@ -69,12 +59,6 @@ export class AuthResolver {
return user;
}
@Throttle({
default: {
limit: 20,
ttl: 60,
},
})
@ResolveField(() => ClientTokenType, {
name: 'token',
deprecationReason: 'use [/api/auth/authorize]',
@@ -101,12 +85,6 @@ export class AuthResolver {
}
@Public()
@Throttle({
default: {
limit: 10,
ttl: 60,
},
})
@Mutation(() => UserType)
async signUp(
@Context() ctx: { req: Request; res: Response },
@@ -122,12 +100,6 @@ export class AuthResolver {
}
@Public()
@Throttle({
default: {
limit: 10,
ttl: 60,
},
})
@Mutation(() => UserType)
async signIn(
@Context() ctx: { req: Request; res: Response },
@@ -141,12 +113,6 @@ export class AuthResolver {
return user;
}
@Throttle({
default: {
limit: 5,
ttl: 60,
},
})
@Mutation(() => UserType)
async changePassword(
@CurrentUser() user: CurrentUser,
@@ -172,12 +138,6 @@ export class AuthResolver {
return user;
}
@Throttle({
default: {
limit: 5,
ttl: 60,
},
})
@Mutation(() => UserType)
async changeEmail(
@CurrentUser() user: CurrentUser,
@@ -202,12 +162,6 @@ export class AuthResolver {
return user;
}
@Throttle({
default: {
limit: 5,
ttl: 60,
},
})
@Mutation(() => Boolean)
async sendChangePasswordEmail(
@CurrentUser() user: CurrentUser,
@@ -235,12 +189,6 @@ export class AuthResolver {
return !res.rejected.length;
}
@Throttle({
default: {
limit: 5,
ttl: 60,
},
})
@Mutation(() => Boolean)
async sendSetPasswordEmail(
@CurrentUser() user: CurrentUser,
@@ -273,12 +221,6 @@ export class AuthResolver {
// 4. user open confirm email page from new email
// 5. user click confirm button
// 6. send notification email
@Throttle({
default: {
limit: 5,
ttl: 60,
},
})
@Mutation(() => Boolean)
async sendChangeEmail(
@CurrentUser() user: CurrentUser,
@@ -299,12 +241,6 @@ export class AuthResolver {
return !res.rejected.length;
}
@Throttle({
default: {
limit: 5,
ttl: 60,
},
})
@Mutation(() => Boolean)
async sendVerifyChangeEmail(
@CurrentUser() user: CurrentUser,
@@ -347,12 +283,6 @@ export class AuthResolver {
return !res.rejected.length;
}
@Throttle({
default: {
limit: 5,
ttl: 60,
},
})
@Mutation(() => Boolean)
async sendVerifyEmail(
@CurrentUser() user: CurrentUser,
@@ -367,12 +297,6 @@ export class AuthResolver {
return !res.rejected.length;
}
@Throttle({
default: {
limit: 5,
ttl: 60,
},
})
@Mutation(() => Boolean)
async verifyEmail(
@CurrentUser() user: CurrentUser,
@@ -11,6 +11,8 @@ import { assign, omit } from 'lodash-es';
import { Config, CryptoHelper, MailService } from '../../fundamentals';
import { FeatureManagementService } from '../features/management';
import { QuotaService } from '../quota/service';
import { QuotaType } from '../quota/types';
import { UserService } from '../user/service';
import type { CurrentUser } from './current-user';
@@ -68,15 +70,28 @@ export class AuthService implements OnApplicationBootstrap {
private readonly db: PrismaClient,
private readonly mailer: MailService,
private readonly feature: FeatureManagementService,
private readonly quota: QuotaService,
private readonly user: UserService,
private readonly crypto: CryptoHelper
) {}
async onApplicationBootstrap() {
if (this.config.node.dev) {
await this.signUp('Dev User', 'dev@affine.pro', 'dev').catch(() => {
try {
const [email, name, pwd] = ['dev@affine.pro', 'Dev User', 'dev'];
let devUser = await this.user.findUserByEmail(email);
if (!devUser) {
devUser = await this.user.createUser({
email,
name,
password: await this.crypto.encryptPassword(pwd),
});
}
await this.quota.switchUserQuota(devUser.id, QuotaType.ProPlanV1);
await this.feature.addCopilot(devUser.id);
} catch (e) {
// ignore
});
}
}
}
@@ -131,24 +146,27 @@ export class AuthService implements OnApplicationBootstrap {
return sessionUser(user);
}
async getUser(token: string, seq = 0): Promise<CurrentUser | null> {
async getUser(
token: string,
seq = 0
): Promise<{ user: CurrentUser | null; expiresAt: Date | null }> {
const session = await this.getSession(token);
// no such session
if (!session) {
return null;
return { user: null, expiresAt: null };
}
const userSession = session.userSessions.at(seq);
// no such user session
if (!userSession) {
return null;
return { user: null, expiresAt: null };
}
// user session expired
if (userSession.expiresAt && userSession.expiresAt <= new Date()) {
return null;
return { user: null, expiresAt: null };
}
const user = await this.db.user.findUnique({
@@ -156,10 +174,10 @@ export class AuthService implements OnApplicationBootstrap {
});
if (!user) {
return null;
return { user: null, expiresAt: null };
}
return sessionUser(user);
return { user: sessionUser(user), expiresAt: userSession.expiresAt };
}
async getUserList(token: string) {
@@ -255,6 +273,43 @@ export class AuthService implements OnApplicationBootstrap {
});
}
async refreshUserSessionIfNeeded(
_req: Request,
res: Response,
sessionId: string,
userId: string,
expiresAt: Date,
ttr = this.config.auth.session.ttr
): Promise<boolean> {
if (expiresAt && expiresAt.getTime() - Date.now() > ttr * 1000) {
// no need to refresh
return false;
}
const newExpiresAt = new Date(
Date.now() + this.config.auth.session.ttl * 1000
);
await this.db.userSession.update({
where: {
sessionId_userId: {
sessionId,
userId,
},
},
data: {
expiresAt: newExpiresAt,
},
});
res.cookie(AuthService.sessionCookieName, sessionId, {
expires: newExpiresAt,
...this.cookieOptions,
});
return true;
}
async createUserSession(
user: { id: string },
existingSession?: string,
@@ -70,14 +70,17 @@ export class TokenService {
!expired && (!record.credential || record.credential === credential);
if ((expired || valid) && !keep) {
await this.db.verificationToken.delete({
const deleted = await this.db.verificationToken.deleteMany({
where: {
type_token: {
token,
type,
},
token,
type,
},
});
// already deleted, means token has been used
if (!deleted.count) {
return null;
}
}
return valid ? record : null;
@@ -5,6 +5,7 @@ import { DeploymentType } from '../fundamentals';
import { Public } from './auth';
export enum ServerFeature {
Copilot = 'copilot',
Payment = 'payment',
OAuth = 'oauth',
}
@@ -66,6 +67,9 @@ export class ServerConfigType {
description: 'credentials requirement',
})
credentialsRequirement!: CredentialsRequirementType;
@Field({ description: 'enable telemetry' })
enableTelemetry!: boolean;
}
export class ServerConfigResolver {
@@ -87,6 +91,7 @@ export class ServerConfigResolver {
credentialsRequirement: {
password: AFFiNE.auth.password,
},
enableTelemetry: AFFiNE.telemetry.enabled,
};
}
}
@@ -54,10 +54,35 @@ export class UnlimitedWorkspaceFeatureConfig extends FeatureConfig {
}
}
export class UnlimitedCopilotFeatureConfig extends FeatureConfig {
override config!: Feature & { feature: FeatureType.UnlimitedCopilot };
constructor(data: any) {
super(data);
if (this.config.feature !== FeatureType.UnlimitedCopilot) {
throw new Error('Invalid feature config: type is not AIEarlyAccess');
}
}
}
export class AIEarlyAccessFeatureConfig extends FeatureConfig {
override config!: Feature & { feature: FeatureType.AIEarlyAccess };
constructor(data: any) {
super(data);
if (this.config.feature !== FeatureType.AIEarlyAccess) {
throw new Error('Invalid feature config: type is not AIEarlyAccess');
}
}
}
const FeatureConfigMap = {
[FeatureType.Copilot]: CopilotFeatureConfig,
[FeatureType.EarlyAccess]: EarlyAccessFeatureConfig,
[FeatureType.AIEarlyAccess]: AIEarlyAccessFeatureConfig,
[FeatureType.UnlimitedWorkspace]: UnlimitedWorkspaceFeatureConfig,
[FeatureType.UnlimitedCopilot]: UnlimitedCopilotFeatureConfig,
};
export type FeatureConfigType<F extends FeatureType> = InstanceType<
@@ -1,6 +1,6 @@
import { Module } from '@nestjs/common';
import { FeatureManagementService } from './management';
import { EarlyAccessType, FeatureManagementService } from './management';
import { FeatureService } from './service';
/**
@@ -15,6 +15,11 @@ import { FeatureService } from './service';
})
export class FeatureModule {}
export { type CommonFeature, commonFeatureSchema } from './types';
export { FeatureKind, Features, FeatureType } from './types';
export { FeatureManagementService, FeatureService };
export {
type CommonFeature,
commonFeatureSchema,
FeatureKind,
Features,
FeatureType,
} from './types';
export { EarlyAccessType, FeatureManagementService, FeatureService };
@@ -7,6 +7,11 @@ import { FeatureType } from './types';
const STAFF = ['@toeverything.info'];
export enum EarlyAccessType {
App = 'app',
AI = 'ai',
}
@Injectable()
export class FeatureManagementService {
protected logger = new Logger(FeatureManagementService.name);
@@ -30,25 +35,43 @@ export class FeatureManagementService {
}
// ======== Early Access ========
async addEarlyAccess(userId: string) {
async addEarlyAccess(
userId: string,
type: EarlyAccessType = EarlyAccessType.App
) {
return this.feature.addUserFeature(
userId,
FeatureType.EarlyAccess,
2,
type === EarlyAccessType.App
? FeatureType.EarlyAccess
: FeatureType.AIEarlyAccess,
'Early access user'
);
}
async removeEarlyAccess(userId: string) {
return this.feature.removeUserFeature(userId, FeatureType.EarlyAccess);
async removeEarlyAccess(
userId: string,
type: EarlyAccessType = EarlyAccessType.App
) {
return this.feature.removeUserFeature(
userId,
type === EarlyAccessType.App
? FeatureType.EarlyAccess
: FeatureType.AIEarlyAccess
);
}
async listEarlyAccess() {
return this.feature.listFeatureUsers(FeatureType.EarlyAccess);
async listEarlyAccess(type: EarlyAccessType = EarlyAccessType.App) {
return this.feature.listFeatureUsers(
type === EarlyAccessType.App
? FeatureType.EarlyAccess
: FeatureType.AIEarlyAccess
);
}
async isEarlyAccessUser(email: string) {
async isEarlyAccessUser(
email: string,
type: EarlyAccessType = EarlyAccessType.App
) {
const user = await this.prisma.user.findFirst({
where: {
email: {
@@ -57,9 +80,15 @@ export class FeatureManagementService {
},
},
});
if (user) {
const canEarlyAccess = await this.feature
.hasUserFeature(user.id, FeatureType.EarlyAccess)
.hasUserFeature(
user.id,
type === EarlyAccessType.App
? FeatureType.EarlyAccess
: FeatureType.AIEarlyAccess
)
.catch(() => false);
return canEarlyAccess;
@@ -68,14 +97,43 @@ export class FeatureManagementService {
}
/// check early access by email
async canEarlyAccess(email: string) {
async canEarlyAccess(
email: string,
type: EarlyAccessType = EarlyAccessType.App
) {
if (this.config.featureFlags.earlyAccessPreview && !this.isStaff(email)) {
return this.isEarlyAccessUser(email);
return this.isEarlyAccessUser(email, type);
} else {
return true;
}
}
// ======== CopilotFeature ========
async addCopilot(userId: string, reason = 'Copilot plan user') {
return this.feature.addUserFeature(
userId,
FeatureType.UnlimitedCopilot,
reason
);
}
async removeCopilot(userId: string) {
return this.feature.removeUserFeature(userId, FeatureType.UnlimitedCopilot);
}
async isCopilotUser(userId: string) {
return await this.feature.hasUserFeature(
userId,
FeatureType.UnlimitedCopilot
);
}
// ======== User Feature ========
async getActivatedUserFeatures(userId: string): Promise<FeatureType[]> {
const features = await this.feature.getActivatedUserFeatures(userId);
return features.map(f => f.feature.name);
}
// ======== Workspace Feature ========
async addWorkspaceFeatures(
workspaceId: string,
@@ -115,10 +173,4 @@ export class FeatureManagementService {
async listFeatureWorkspaces(feature: FeatureType) {
return this.feature.listFeatureWorkspaces(feature);
}
async getUserFeatures(userId: string): Promise<FeatureType[]> {
return (await this.feature.getUserFeatures(userId)).map(
f => f.feature.name
);
}
}
@@ -59,7 +59,6 @@ export class FeatureService {
async addUserFeature(
userId: string,
feature: FeatureType,
version: number,
reason: string,
expiredAt?: Date | string
) {
@@ -77,9 +76,21 @@ export class FeatureService {
createdAt: 'desc',
},
});
if (latestFlag) {
return latestFlag.id;
} else {
const latestVersion = await tx.features
.aggregate({
where: { feature },
_max: { version: true },
})
.then(r => r._max.version);
if (!latestVersion) {
throw new Error(`Feature ${feature} not found`);
}
return tx.userFeatures
.create({
data: {
@@ -95,7 +106,7 @@ export class FeatureService {
connect: {
feature_version: {
feature,
version,
version: latestVersion,
},
type: FeatureKind.Feature,
},
@@ -157,6 +168,33 @@ export class FeatureService {
return configs.filter(feature => !!feature.feature);
}
async getActivatedUserFeatures(userId: string) {
const features = await this.prisma.userFeatures.findMany({
where: {
user: { id: userId },
feature: { type: FeatureKind.Feature },
activated: true,
OR: [{ expiredAt: null }, { expiredAt: { gt: new Date() } }],
},
select: {
activated: true,
reason: true,
createdAt: true,
expiredAt: true,
featureId: true,
},
});
const configs = await Promise.all(
features.map(async feature => ({
...feature,
feature: await getFeature(this.prisma, feature.featureId),
}))
);
return configs.filter(feature => !!feature.feature);
}
async listFeatureUsers(feature: FeatureType) {
return this.prisma.userFeatures
.findMany({
@@ -193,6 +231,7 @@ export class FeatureService {
feature,
type: FeatureKind.Feature,
},
OR: [{ expiredAt: null }, { expiredAt: { gt: new Date() } }],
},
})
.then(count => count > 0);
@@ -1,8 +1,12 @@
import { registerEnumType } from '@nestjs/graphql';
export enum FeatureType {
Copilot = 'copilot',
// user feature
EarlyAccess = 'early_access',
AIEarlyAccess = 'ai_early_access',
UnlimitedCopilot = 'unlimited_copilot',
// workspace feature
Copilot = 'copilot',
UnlimitedWorkspace = 'unlimited_workspace',
}
@@ -9,3 +9,8 @@ export const featureEarlyAccess = z.object({
whitelist: z.string().array(),
}),
});
export const featureAIEarlyAccess = z.object({
feature: z.literal(FeatureType.AIEarlyAccess),
configs: z.object({}),
});
@@ -2,7 +2,8 @@ import { z } from 'zod';
import { FeatureType } from './common';
import { featureCopilot } from './copilot';
import { featureEarlyAccess } from './early-access';
import { featureAIEarlyAccess, featureEarlyAccess } from './early-access';
import { featureUnlimitedCopilot } from './unlimited-copilot';
import { featureUnlimitedWorkspace } from './unlimited-workspace';
/// ======== common schema ========
@@ -52,6 +53,18 @@ export const Features: Feature[] = [
version: 1,
configs: {},
},
{
feature: FeatureType.UnlimitedCopilot,
type: FeatureKind.Feature,
version: 1,
configs: {},
},
{
feature: FeatureType.AIEarlyAccess,
type: FeatureKind.Feature,
version: 1,
configs: {},
},
];
/// ======== schema infer ========
@@ -64,7 +77,9 @@ export const FeatureSchema = commonFeatureSchema
z.discriminatedUnion('feature', [
featureCopilot,
featureEarlyAccess,
featureAIEarlyAccess,
featureUnlimitedWorkspace,
featureUnlimitedCopilot,
])
);
@@ -0,0 +1,8 @@
import { z } from 'zod';
import { FeatureType } from './common';
export const featureUnlimitedCopilot = z.object({
feature: z.literal(FeatureType.UnlimitedCopilot),
configs: z.object({}),
});
@@ -20,5 +20,5 @@ import { QuotaManagementService } from './storage';
export class QuotaModule {}
export { QuotaManagementService, QuotaService };
export { Quota_FreePlanV1_1, Quota_ProPlanV1, Quotas } from './schema';
export { Quota_FreePlanV1_1, Quota_ProPlanV1 } from './schema';
export { QuotaQueryType, QuotaType } from './types';
@@ -79,6 +79,10 @@ export class QuotaConfig {
return this.config.configs.memberLimit;
}
get copilotActionLimit() {
return this.config.configs.copilotActionLimit || undefined;
}
get humanReadable() {
return {
name: this.config.configs.name,
@@ -86,6 +90,9 @@ export class QuotaConfig {
storageQuota: formatSize(this.storageQuota),
historyPeriod: formatDate(this.historyPeriod),
memberLimit: this.memberLimit.toString(),
copilotActionLimit: this.copilotActionLimit
? `${this.copilotActionLimit} times`
: 'Unlimited',
};
}
}
@@ -93,14 +93,85 @@ export const Quotas: Quota[] = [
memberLimit: 3,
},
},
{
feature: QuotaType.FreePlanV1,
type: FeatureKind.Quota,
version: 4,
configs: {
// quota name
name: 'Free',
// single blob limit 10MB
blobLimit: 10 * OneMB,
// server limit will larger then client to handle a edge case:
// when a user downgrades from pro to free, he can still continue
// to upload previously added files that exceed the free limit
// NOTE: this is a product decision, may change in future
businessBlobLimit: 100 * OneMB,
// total blob limit 10GB
storageQuota: 10 * OneGB,
// history period of validity 7 days
historyPeriod: 7 * OneDay,
// member limit 3
memberLimit: 3,
// copilot action limit 10
copilotActionLimit: 10,
},
},
{
feature: QuotaType.ProPlanV1,
type: FeatureKind.Quota,
version: 2,
configs: {
// quota name
name: 'Pro',
// single blob limit 100MB
blobLimit: 100 * OneMB,
// total blob limit 100GB
storageQuota: 100 * OneGB,
// history period of validity 30 days
historyPeriod: 30 * OneDay,
// member limit 10
memberLimit: 10,
// copilot action limit 10
copilotActionLimit: 10,
},
},
{
feature: QuotaType.RestrictedPlanV1,
type: FeatureKind.Quota,
version: 2,
configs: {
// quota name
name: 'Restricted',
// single blob limit 10MB
blobLimit: OneMB,
// total blob limit 1GB
storageQuota: 10 * OneMB,
// history period of validity 30 days
historyPeriod: 30 * OneDay,
// member limit 10
memberLimit: 10,
// copilot action limit 10
copilotActionLimit: 10,
},
},
];
export function getLatestQuota(type: QuotaType) {
const quota = Quotas.filter(f => f.feature === type);
quota.sort((a, b) => b.version - a.version);
return quota[0];
}
export const FreePlan = getLatestQuota(QuotaType.FreePlanV1);
export const ProPlan = getLatestQuota(QuotaType.ProPlanV1);
export const Quota_FreePlanV1_1 = {
feature: Quotas[4].feature,
version: Quotas[4].version,
feature: Quotas[5].feature,
version: Quotas[5].version,
};
export const Quota_ProPlanV1 = {
feature: Quotas[1].feature,
version: Quotas[1].version,
feature: Quotas[6].feature,
version: Quotas[6].version,
};
@@ -3,13 +3,17 @@ import { PrismaClient } from '@prisma/client';
import type { EventPayload } from '../../fundamentals';
import { OnEvent, PrismaTransaction } from '../../fundamentals';
import { FeatureKind } from '../features';
import { SubscriptionPlan } from '../../plugins/payment/types';
import { FeatureKind, FeatureManagementService } from '../features';
import { QuotaConfig } from './quota';
import { QuotaType } from './types';
@Injectable()
export class QuotaService {
constructor(private readonly prisma: PrismaClient) {}
constructor(
private readonly prisma: PrismaClient,
private readonly feature: FeatureManagementService
) {}
// get activated user quota
async getUserQuota(userId: string) {
@@ -159,22 +163,42 @@ export class QuotaService {
@OnEvent('user.subscription.activated')
async onSubscriptionUpdated({
userId,
plan,
}: EventPayload<'user.subscription.activated'>) {
await this.switchUserQuota(
userId,
QuotaType.ProPlanV1,
'subscription activated'
);
switch (plan) {
case SubscriptionPlan.AI:
await this.feature.addCopilot(userId, 'subscription activated');
break;
case SubscriptionPlan.Pro:
await this.switchUserQuota(
userId,
QuotaType.ProPlanV1,
'subscription activated'
);
break;
default:
break;
}
}
@OnEvent('user.subscription.canceled')
async onSubscriptionCanceled(
userId: EventPayload<'user.subscription.canceled'>
) {
await this.switchUserQuota(
userId,
QuotaType.FreePlanV1,
'subscription canceled'
);
async onSubscriptionCanceled({
userId,
plan,
}: EventPayload<'user.subscription.canceled'>) {
switch (plan) {
case SubscriptionPlan.AI:
await this.feature.removeCopilot(userId);
break;
case SubscriptionPlan.Pro:
await this.switchUserQuota(
userId,
QuotaType.FreePlanV1,
'subscription canceled'
);
break;
default:
break;
}
}
}
@@ -7,7 +7,10 @@ import { OneGB } from './constant';
import { QuotaService } from './service';
import { formatSize, QuotaQueryType } from './types';
type QuotaBusinessType = QuotaQueryType & { businessBlobLimit: number };
type QuotaBusinessType = QuotaQueryType & {
businessBlobLimit: number;
unlimited: boolean;
};
@Injectable()
export class QuotaManagementService {
@@ -33,6 +36,7 @@ export class QuotaManagementService {
storageQuota: quota.feature.storageQuota,
historyPeriod: quota.feature.historyPeriod,
memberLimit: quota.feature.memberLimit,
copilotActionLimit: quota.feature.copilotActionLimit,
};
}
@@ -58,6 +62,52 @@ export class QuotaManagementService {
}, 0);
}
private generateQuotaCalculator(
quota: number,
blobLimit: number,
usedSize: number,
unlimited = false
) {
const checkExceeded = (recvSize: number) => {
const total = usedSize + recvSize;
// only skip total storage check if workspace has unlimited feature
if (total > quota && !unlimited) {
this.logger.log(`storage size limit exceeded: ${total} > ${quota}`);
return true;
} else if (recvSize > blobLimit) {
this.logger.log(`blob size limit exceeded: ${recvSize} > ${blobLimit}`);
return true;
} else {
return false;
}
};
return checkExceeded;
}
async getQuotaCalculator(userId: string) {
const quota = await this.getUserQuota(userId);
const { storageQuota, businessBlobLimit } = quota;
const usedSize = await this.getUserUsage(userId);
return this.generateQuotaCalculator(
storageQuota,
businessBlobLimit,
usedSize
);
}
async getQuotaCalculatorByWorkspace(workspaceId: string) {
const { storageQuota, usedSize, businessBlobLimit, unlimited } =
await this.getWorkspaceUsage(workspaceId);
return this.generateQuotaCalculator(
storageQuota,
businessBlobLimit,
usedSize,
unlimited
);
}
// get workspace's owner quota and total size of used
// quota was apply to owner's account
async getWorkspaceUsage(workspaceId: string): Promise<QuotaBusinessType> {
@@ -72,11 +122,18 @@ export class QuotaManagementService {
historyPeriod,
memberLimit,
storageQuota,
copilotActionLimit,
humanReadable,
},
} = await this.quota.getUserQuota(owner.id);
// get all workspaces size of owner used
const usedSize = await this.getUserUsage(owner.id);
// relax restrictions if workspace has unlimited feature
// todo(@darkskygit): need a mechanism to allow feature as a middleware to edit quota
const unlimited = await this.feature.hasWorkspaceFeature(
workspaceId,
FeatureType.UnlimitedWorkspace
);
const quota = {
name,
@@ -85,17 +142,13 @@ export class QuotaManagementService {
historyPeriod,
memberLimit,
storageQuota,
copilotActionLimit,
humanReadable,
usedSize,
unlimited,
};
// relax restrictions if workspace has unlimited feature
// todo(@darkskygit): need a mechanism to allow feature as a middleware to edit quota
const unlimited = await this.feature.hasWorkspaceFeature(
workspaceId,
FeatureType.UnlimitedWorkspace
);
if (unlimited) {
if (quota.unlimited) {
return this.mergeUnlimitedQuota(quota);
}
@@ -34,6 +34,7 @@ const quotaPlan = z.object({
historyPeriod: z.number().positive().int(),
memberLimit: z.number().positive().int(),
businessBlobLimit: z.number().positive().int().nullish(),
copilotActionLimit: z.number().positive().int().nullish(),
}),
});
@@ -65,6 +66,9 @@ export class HumanReadableQuotaType {
@Field(() => String)
memberLimit!: string;
@Field(() => String, { nullable: true })
copilotActionLimit?: string;
}
@ObjectType()
@@ -84,6 +88,9 @@ export class QuotaQueryType {
@Field(() => SafeIntResolver)
storageQuota!: number;
@Field(() => SafeIntResolver, { nullable: true })
copilotActionLimit?: number;
@Field(() => HumanReadableQuotaType)
humanReadable!: HumanReadableQuotaType;
@@ -1,22 +1,24 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import {
BadRequestException,
ForbiddenException,
UseGuards,
} from '@nestjs/common';
import { Args, Context, Int, Mutation, Query, Resolver } from '@nestjs/graphql';
Args,
Context,
Int,
Mutation,
Query,
registerEnumType,
Resolver,
} from '@nestjs/graphql';
import { CloudThrottlerGuard, Throttle } from '../../fundamentals';
import { CurrentUser } from '../auth/current-user';
import { sessionUser } from '../auth/service';
import { FeatureManagementService } from '../features';
import { EarlyAccessType, FeatureManagementService } from '../features';
import { UserService } from './service';
import { UserType } from './types';
/**
* User resolver
* All op rate limit: 10 req/m
*/
@UseGuards(CloudThrottlerGuard)
registerEnumType(EarlyAccessType, {
name: 'EarlyAccessType',
});
@Resolver(() => UserType)
export class UserManagementResolver {
constructor(
@@ -24,37 +26,26 @@ export class UserManagementResolver {
private readonly feature: FeatureManagementService
) {}
@Throttle({
default: {
limit: 10,
ttl: 60,
},
})
@Mutation(() => Int)
async addToEarlyAccess(
@CurrentUser() currentUser: CurrentUser,
@Args('email') email: string
@Args('email') email: string,
@Args({ name: 'type', type: () => EarlyAccessType }) type: EarlyAccessType
): Promise<number> {
if (!this.feature.isStaff(currentUser.email)) {
throw new ForbiddenException('You are not allowed to do this');
}
const user = await this.users.findUserByEmail(email);
if (user) {
return this.feature.addEarlyAccess(user.id);
return this.feature.addEarlyAccess(user.id, type);
} else {
const user = await this.users.createAnonymousUser(email, {
registered: false,
});
return this.feature.addEarlyAccess(user.id);
return this.feature.addEarlyAccess(user.id, type);
}
}
@Throttle({
default: {
limit: 10,
ttl: 60,
},
})
@Mutation(() => Int)
async removeEarlyAccess(
@CurrentUser() currentUser: CurrentUser,
@@ -70,12 +61,6 @@ export class UserManagementResolver {
return this.feature.removeEarlyAccess(user.id);
}
@Throttle({
default: {
limit: 10,
ttl: 60,
},
})
@Query(() => [UserType])
async earlyAccessUsers(
@Context() ctx: { isAdminQuery: boolean },
@@ -1,4 +1,4 @@
import { BadRequestException, UseGuards } from '@nestjs/common';
import { BadRequestException } from '@nestjs/common';
import {
Args,
Int,
@@ -14,7 +14,6 @@ import { isNil, omitBy } from 'lodash-es';
import type { FileUpload } from '../../fundamentals';
import {
CloudThrottlerGuard,
EventEmitter,
PaymentRequiredException,
Throttle,
@@ -35,11 +34,6 @@ import {
UserType,
} from './types';
/**
* User resolver
* All op rate limit: 10 req/m
*/
@UseGuards(CloudThrottlerGuard)
@Resolver(() => UserType)
export class UserResolver {
constructor(
@@ -51,12 +45,7 @@ export class UserResolver {
private readonly event: EventEmitter
) {}
@Throttle({
default: {
limit: 10,
ttl: 60,
},
})
@Throttle('strict')
@Query(() => UserOrLimitedUser, {
name: 'user',
description: 'Get user by email',
@@ -90,7 +79,6 @@ export class UserResolver {
};
}
@Throttle({ default: { limit: 10, ttl: 60 } })
@ResolveField(() => UserQuotaType, { name: 'quota', nullable: true })
async getQuota(@CurrentUser() me: User) {
const quota = await this.quota.getUserQuota(me.id);
@@ -98,7 +86,6 @@ export class UserResolver {
return quota.feature;
}
@Throttle({ default: { limit: 10, ttl: 60 } })
@ResolveField(() => Int, {
name: 'invoiceCount',
description: 'Get user invoice count',
@@ -109,21 +96,14 @@ export class UserResolver {
});
}
@Throttle({ default: { limit: 10, ttl: 60 } })
@ResolveField(() => [FeatureType], {
name: 'features',
description: 'Enabled features of a user',
})
async userFeatures(@CurrentUser() user: CurrentUser) {
return this.feature.getUserFeatures(user.id);
return this.feature.getActivatedUserFeatures(user.id);
}
@Throttle({
default: {
limit: 10,
ttl: 60,
},
})
@Mutation(() => UserType, {
name: 'uploadAvatar',
description: 'Upload user avatar',
@@ -153,12 +133,6 @@ export class UserResolver {
});
}
@Throttle({
default: {
limit: 10,
ttl: 60,
},
})
@Mutation(() => UserType, {
name: 'updateProfile',
})
@@ -180,12 +154,6 @@ export class UserResolver {
);
}
@Throttle({
default: {
limit: 10,
ttl: 60,
},
})
@Mutation(() => RemoveAvatar, {
name: 'removeAvatar',
description: 'Remove user avatar',
@@ -201,12 +169,6 @@ export class UserResolver {
return { success: true };
}
@Throttle({
default: {
limit: 10,
ttl: 60,
},
})
@Mutation(() => DeleteAccount)
async deleteAccount(
@CurrentUser() user: CurrentUser
@@ -36,10 +36,17 @@ export class WorkspacesController {
@Get('/:id/blobs/:name')
@CallTimer('controllers', 'workspace_get_blob')
async blob(
@CurrentUser() user: CurrentUser | undefined,
@Param('id') workspaceId: string,
@Param('name') name: string,
@Res() res: Response
) {
// if workspace is public or have any public page, then allow to access
// otherwise, check permission
if (!(await this.permission.tryCheckWorkspace(workspaceId, user?.id))) {
throw new ForbiddenException('Permission denied');
}
const { body, metadata } = await this.storage.get(workspaceId, name);
if (!body) {
@@ -109,11 +116,6 @@ export class WorkspacesController {
}
res.setHeader('content-type', 'application/octet-stream');
res.setHeader(
'last-modified',
new Date(binResponse.timestamp).toUTCString()
);
res.setHeader('cache-control', 'private, max-age=2592000');
res.send(binResponse.binary);
}
@@ -1,4 +1,4 @@
import { ForbiddenException, UseGuards } from '@nestjs/common';
import { ForbiddenException } from '@nestjs/common';
import {
Args,
Int,
@@ -9,13 +9,11 @@ import {
Resolver,
} from '@nestjs/graphql';
import { CloudThrottlerGuard, Throttle } from '../../fundamentals';
import { CurrentUser } from '../auth';
import { FeatureManagementService, FeatureType } from '../features';
import { PermissionService } from './permission';
import { WorkspaceType } from './types';
@UseGuards(CloudThrottlerGuard)
@Resolver(() => WorkspaceType)
export class WorkspaceManagementResolver {
constructor(
@@ -23,12 +21,6 @@ export class WorkspaceManagementResolver {
private readonly permission: PermissionService
) {}
@Throttle({
default: {
limit: 10,
ttl: 60,
},
})
@Mutation(() => Int)
async addWorkspaceFeature(
@CurrentUser() currentUser: CurrentUser,
@@ -42,12 +34,6 @@ export class WorkspaceManagementResolver {
return this.feature.addWorkspaceFeatures(workspaceId, feature);
}
@Throttle({
default: {
limit: 10,
ttl: 60,
},
})
@Mutation(() => Int)
async removeWorkspaceFeature(
@CurrentUser() currentUser: CurrentUser,
@@ -61,12 +47,6 @@ export class WorkspaceManagementResolver {
return this.feature.removeWorkspaceFeature(workspaceId, feature);
}
@Throttle({
default: {
limit: 10,
ttl: 60,
},
})
@Query(() => [WorkspaceType])
async listWorkspaceFeatures(
@CurrentUser() user: CurrentUser,
@@ -117,12 +97,7 @@ export class WorkspaceManagementResolver {
async availableFeatures(
@CurrentUser() user: CurrentUser
): Promise<FeatureType[]> {
const isEarlyAccessUser = await this.feature.isEarlyAccessUser(user.email);
if (isEarlyAccessUser) {
return [FeatureType.Copilot];
} else {
return [];
}
return await this.feature.getActivatedUserFeatures(user.id);
}
@ResolveField(() => [FeatureType], {
@@ -26,6 +26,22 @@ export class PermissionService {
return data?.type as Permission;
}
/**
* check whether a workspace exists and has any one can access it
* @param workspaceId workspace id
* @returns
*/
async hasWorkspace(workspaceId: string) {
return await this.prisma.workspaceUserPermission
.count({
where: {
workspaceId,
accepted: true,
},
})
.then(count => count > 0);
}
async getOwnedWorkspaces(userId: string) {
return this.prisma.workspaceUserPermission
.findMany({
@@ -65,10 +81,22 @@ export class PermissionService {
});
}
/**
* check if a doc binary is accessible by a user
*/
async isAccessible(ws: string, id: string, user?: string): Promise<boolean> {
// workspace
if (ws === id) {
return this.tryCheckWorkspace(ws, user, Permission.Read);
// if workspace is public or have any public page, then allow to access
const [isPublicWorkspace, publicPages] = await Promise.all([
this.tryCheckWorkspace(ws, user, Permission.Read),
await this.prisma.workspacePage.count({
where: {
workspaceId: ws,
public: true,
},
}),
]);
return isPublicWorkspace || publicPages > 0;
}
return this.tryCheckPage(ws, id, user);
@@ -96,6 +124,23 @@ export class PermissionService {
return count !== 0;
}
/**
* only check permission if the workspace is a cloud workspace
* @param workspaceId workspace id
* @param userId user id, check if is a public workspace if not provided
* @param permission default is read
*/
async checkCloudWorkspace(
workspaceId: string,
userId?: string,
permission: Permission = Permission.Read
) {
const hasWorkspace = await this.hasWorkspace(workspaceId);
if (hasWorkspace) {
await this.checkWorkspace(workspaceId, userId, permission);
}
}
async checkWorkspace(
ws: string,
user?: string,
@@ -122,21 +167,6 @@ export class PermissionService {
if (count > 0) {
return true;
}
const publicPage = await this.prisma.workspacePage.findFirst({
select: {
pageId: true,
},
where: {
workspaceId: ws,
public: true,
},
});
// has any public pages
if (publicPage) {
return true;
}
}
if (user) {
@@ -263,6 +293,25 @@ export class PermissionService {
/// End regin: workspace permission
/// Start regin: page permission
/**
* only check permission if the workspace is a cloud workspace
* @param workspaceId workspace id
* @param pageId page id aka doc id
* @param userId user id, check if is a public page if not provided
* @param permission default is read
*/
async checkCloudPagePermission(
workspaceId: string,
pageId: string,
userId?: string,
permission = Permission.Read
) {
const hasWorkspace = await this.hasWorkspace(workspaceId);
if (hasWorkspace) {
await this.checkPagePermission(workspaceId, pageId, userId, permission);
}
}
async checkPagePermission(
ws: string,
page: string,
@@ -1,9 +1,4 @@
import {
ForbiddenException,
Logger,
PayloadTooLargeException,
UseGuards,
} from '@nestjs/common';
import { Logger, PayloadTooLargeException, UseGuards } from '@nestjs/common';
import {
Args,
Int,
@@ -23,7 +18,6 @@ import {
PreventCache,
} from '../../../fundamentals';
import { CurrentUser } from '../../auth';
import { FeatureManagementService, FeatureType } from '../../features';
import { QuotaManagementService } from '../../quota';
import { WorkspaceBlobStorage } from '../../storage';
import { PermissionService } from '../permission';
@@ -35,7 +29,6 @@ export class WorkspaceBlobResolver {
logger = new Logger(WorkspaceBlobResolver.name);
constructor(
private readonly permissions: PermissionService,
private readonly feature: FeatureManagementService,
private readonly quota: QuotaManagementService,
private readonly storage: WorkspaceBlobStorage
) {}
@@ -130,34 +123,8 @@ export class WorkspaceBlobResolver {
Permission.Write
);
const { storageQuota, usedSize, businessBlobLimit } =
await this.quota.getWorkspaceUsage(workspaceId);
const unlimited = await this.feature.hasWorkspaceFeature(
workspaceId,
FeatureType.UnlimitedWorkspace
);
const checkExceeded = (recvSize: number) => {
if (!storageQuota) {
throw new ForbiddenException('Cannot find user quota.');
}
const total = usedSize + recvSize;
// only skip total storage check if workspace has unlimited feature
if (total > storageQuota && !unlimited) {
this.logger.log(
`storage size limit exceeded: ${total} > ${storageQuota}`
);
return true;
} else if (recvSize > businessBlobLimit) {
this.logger.log(
`blob size limit exceeded: ${recvSize} > ${businessBlobLimit}`
);
return true;
} else {
return false;
}
};
const checkExceeded =
await this.quota.getQuotaCalculatorByWorkspace(workspaceId);
if (checkExceeded(0)) {
throw new PayloadTooLargeException(
@@ -1,4 +1,3 @@
import { UseGuards } from '@nestjs/common';
import {
Args,
Field,
@@ -12,7 +11,6 @@ import {
} from '@nestjs/graphql';
import type { SnapshotHistory } from '@prisma/client';
import { CloudThrottlerGuard } from '../../../fundamentals';
import { CurrentUser } from '../../auth';
import { DocHistoryManager } from '../../doc';
import { DocID } from '../../utils/doc';
@@ -31,7 +29,6 @@ class DocHistoryType implements Partial<SnapshotHistory> {
timestamp!: Date;
}
@UseGuards(CloudThrottlerGuard)
@Resolver(() => WorkspaceType)
export class DocHistoryResolver {
constructor(
@@ -1,4 +1,4 @@
import { BadRequestException, UseGuards } from '@nestjs/common';
import { BadRequestException } from '@nestjs/common';
import {
Args,
Field,
@@ -12,7 +12,6 @@ import {
import type { WorkspacePage as PrismaWorkspacePage } from '@prisma/client';
import { PrismaClient } from '@prisma/client';
import { CloudThrottlerGuard } from '../../../fundamentals';
import { CurrentUser } from '../../auth';
import { DocID } from '../../utils/doc';
import { PermissionService, PublicPageMode } from '../permission';
@@ -38,7 +37,6 @@ class WorkspacePage implements Partial<PrismaWorkspacePage> {
public!: boolean;
}
@UseGuards(CloudThrottlerGuard)
@Resolver(() => WorkspaceType)
export class PagePermissionResolver {
constructor(
@@ -78,12 +76,30 @@ export class PagePermissionResolver {
});
}
@ResolveField(() => WorkspacePage, {
description: 'Get public page of a workspace by page id.',
complexity: 2,
nullable: true,
})
async publicPage(
@Parent() workspace: WorkspaceType,
@Args('pageId') pageId: string
) {
return this.prisma.workspacePage.findFirst({
where: {
workspaceId: workspace.id,
pageId,
public: true,
},
});
}
/**
* @deprecated
*/
@Mutation(() => Boolean, {
name: 'sharePage',
deprecationReason: 'renamed to publicPage',
deprecationReason: 'renamed to publishPage',
})
async deprecatedSharePage(
@CurrentUser() user: CurrentUser,
@@ -4,7 +4,6 @@ import {
Logger,
NotFoundException,
PayloadTooLargeException,
UseGuards,
} from '@nestjs/common';
import {
Args,
@@ -22,7 +21,6 @@ import { applyUpdate, Doc } from 'yjs';
import type { FileUpload } from '../../../fundamentals';
import {
CloudThrottlerGuard,
EventEmitter,
MailService,
MutexService,
@@ -48,7 +46,6 @@ import { defaultWorkspaceAvatar } from '../utils';
* Public apis rate limit: 10 req/m
* Other rate limit: 120 req/m
*/
@UseGuards(CloudThrottlerGuard)
@Resolver(() => WorkspaceType)
export class WorkspaceResolver {
private readonly logger = new Logger(WorkspaceResolver.name);
@@ -191,28 +188,6 @@ export class WorkspaceResolver {
});
}
@Throttle({
default: {
limit: 10,
ttl: 30,
},
})
@Public()
@Query(() => WorkspaceType, {
description: 'Get public workspace by id',
})
async publicWorkspace(@Args('id') id: string) {
const workspace = await this.prisma.workspace.findUnique({
where: { id },
});
if (workspace?.public) {
return workspace;
}
throw new NotFoundException("Workspace doesn't exist");
}
@Query(() => WorkspaceType, {
description: 'Get workspace by id',
})
@@ -422,15 +397,10 @@ export class WorkspaceResolver {
}
}
@Throttle({
default: {
limit: 10,
ttl: 30,
},
})
@Throttle('strict')
@Public()
@Query(() => InvitationType, {
description: 'Update workspace',
description: 'send workspace invitation',
})
async getInviteInfo(@Args('inviteId') inviteId: string) {
const workspaceId = await this.prisma.workspaceUserPermission
@@ -1,6 +1,6 @@
import { PrismaClient } from '@prisma/client';
import { Quotas } from '../../core/quota';
import { Quotas } from '../../core/quota/schema';
import { upgradeQuotaVersion } from './utils/user-quotas';
export class NewFreePlan1705395933447 {
@@ -1,6 +1,6 @@
import { PrismaClient } from '@prisma/client';
import { Quotas } from '../../core/quota';
import { Quotas } from '../../core/quota/schema';
import { upgradeQuotaVersion } from './utils/user-quotas';
export class BusinessBlobLimit1706513866287 {
@@ -0,0 +1,13 @@
import { PrismaClient } from '@prisma/client';
import { refreshPrompts } from './utils/prompts';
export class Prompts1712068777394 {
// do the migration
static async up(db: PrismaClient) {
await refreshPrompts(db);
}
// revert the migration
static async down(_db: PrismaClient) {}
}
@@ -0,0 +1,19 @@
import { PrismaClient } from '@prisma/client';
import { QuotaType } from '../../core/quota/types';
import { upgradeLatestQuotaVersion } from './utils/user-quotas';
export class RefreshFreePlan1712224382221 {
// do the migration
static async up(db: PrismaClient) {
// free plan 1.1
await upgradeLatestQuotaVersion(
db,
QuotaType.FreePlanV1,
'free plan 1.1 migration'
);
}
// revert the migration
static async down(_db: PrismaClient) {}
}
@@ -0,0 +1,23 @@
import { PrismaClient } from '@prisma/client';
import { QuotaType } from '../../core/quota/types';
import { upgradeLatestQuotaVersion } from './utils/user-quotas';
export class CopilotFeature1713164714634 {
// do the migration
static async up(db: PrismaClient) {
await upgradeLatestQuotaVersion(
db,
QuotaType.ProPlanV1,
'pro plan 1.1 migration'
);
await upgradeLatestQuotaVersion(
db,
QuotaType.RestrictedPlanV1,
'restricted plan 1.1 migration'
);
}
// revert the migration
static async down(_db: PrismaClient) {}
}
@@ -0,0 +1,14 @@
import { PrismaClient } from '@prisma/client';
import { FeatureType } from '../../core/features';
import { upsertLatestFeatureVersion } from './utils/user-features';
export class AiEarlyAccess1713176777814 {
// do the migration
static async up(db: PrismaClient) {
await upsertLatestFeatureVersion(db, FeatureType.AIEarlyAccess);
}
// revert the migration
static async down(_db: PrismaClient) {}
}
@@ -0,0 +1,13 @@
import { PrismaClient } from '@prisma/client';
import { refreshPrompts } from './utils/prompts';
export class RefreshPrompt1713185798895 {
// do the migration
static async up(db: PrismaClient) {
await refreshPrompts(db);
}
// revert the migration
static async down(_db: PrismaClient) {}
}
@@ -0,0 +1,14 @@
import { PrismaClient } from '@prisma/client';
import { FeatureType } from '../../core/features';
import { upsertLatestFeatureVersion } from './utils/user-features';
export class UnlimitedCopilot1713285638427 {
// do the migration
static async up(db: PrismaClient) {
await upsertLatestFeatureVersion(db, FeatureType.UnlimitedCopilot);
}
// revert the migration
static async down(_db: PrismaClient) {}
}
@@ -0,0 +1,13 @@
import { PrismaClient } from '@prisma/client';
import { refreshPrompts } from './utils/prompts';
export class UpdatePrompt1713522040090 {
// do the migration
static async up(db: PrismaClient) {
await refreshPrompts(db);
}
// revert the migration
static async down(_db: PrismaClient) {}
}
@@ -0,0 +1,13 @@
import { PrismaClient } from '@prisma/client';
import { refreshPrompts } from './utils/prompts';
export class UpdatePrompts1713777617122 {
// do the migration
static async up(db: PrismaClient) {
await refreshPrompts(db);
}
// revert the migration
static async down(_db: PrismaClient) {}
}
@@ -0,0 +1,13 @@
import { PrismaClient } from '@prisma/client';
import { refreshPrompts } from './utils/prompts';
export class UpdatePrompt1713864641056 {
// do the migration
static async up(db: PrismaClient) {
await refreshPrompts(db);
}
// revert the migration
static async down(_db: PrismaClient) {}
}
@@ -0,0 +1,13 @@
import { PrismaClient } from '@prisma/client';
import { refreshPrompts } from './utils/prompts';
export class UpdatePrompts1714021969665 {
// do the migration
static async up(db: PrismaClient) {
await refreshPrompts(db);
}
// revert the migration
static async down(_db: PrismaClient) {}
}
@@ -0,0 +1,629 @@
import { AiPromptRole, PrismaClient } from '@prisma/client';
type PromptMessage = {
role: AiPromptRole;
content: string;
params?: Record<string, string | string[]>;
};
type Prompt = {
name: string;
action?: string;
model: string;
messages: PromptMessage[];
};
export const prompts: Prompt[] = [
{
name: 'debug:chat:gpt4',
model: 'gpt-4-turbo-preview',
messages: [
{
role: 'system',
content:
'You are AFFiNE AI, a professional and humor copilot within AFFiNE. You are powered by latest GPT model from OpenAI and AFFiNE. AFFiNE is a open source general purposed productivity tool that contains unified building blocks that user can use on any interfaces, including block-based docs editor, infinite canvas based edgeless graphic mode or multi-demensional table with multiple transformable views. Your mission is always try the very best to assist user to use AFFiNE to write docs, draw diagrams or plan things with these abilities. You always think step-by-step and describe your plan for what to build with well-structured clear markdown, written out in great detail. Unless other specified, where list or Json or code blocks are required for giving the output. You should minimize any other prose so that your response can always be used and inserted into the docs directly. You are able to access to API of AFFiNE to finish your job. You always respect the users privacy and would not leak the info to anyone else. AFFiNE is made by Toeverything .Ltd, a company registered in Singapore with a diversed and international team. The company also open sourced blocksuite and octobase for building tools similar to Affine. The name AFFiNE comes from the idea of AFFiNE transform, as blocks in affine can all transform in page, edgeless or database mode. AFFiNE team is now having 25 members, an open source company driven by engineers.',
},
],
},
{
name: 'chat:gpt4',
model: 'gpt-4-vision-preview',
messages: [
{
role: 'system',
content:
'You are AFFiNE AI, a professional and humor copilot within AFFiNE. You are powered by latest GPT model from OpenAI and AFFiNE. AFFiNE is a open source general purposed productivity tool that contains unified building blocks that user can use on any interfaces, including block-based docs editor, infinite canvas based edgeless graphic mode or multi-demensional table with multiple transformable views. Your mission is always try the very best to assist user to use AFFiNE to write docs, draw diagrams or plan things with these abilities. You always think step-by-step and describe your plan for what to build with well-structured clear markdown, written out in great detail. Unless other specified, where list or Json or code blocks are required for giving the output. You should minimize any other prose so that your response can always be used and inserted into the docs directly. You are able to access to API of AFFiNE to finish your job. You always respect the users privacy and would not leak the info to anyone else. AFFiNE is made by Toeverything .Ltd, a company registered in Singapore with a diversed and international team. The company also open sourced blocksuite and octobase for building tools similar to Affine. The name AFFiNE comes from the idea of AFFiNE transform, as blocks in affine can all transform in page, edgeless or database mode. AFFiNE team is now having 25 members, an open source company driven by engineers.',
},
],
},
{
name: 'debug:action:gpt4',
action: 'text',
model: 'gpt-4-turbo-preview',
messages: [],
},
{
name: 'debug:action:vision4',
action: 'text',
model: 'gpt-4-vision-preview',
messages: [],
},
{
name: 'debug:action:dalle3',
action: 'image',
model: 'dall-e-3',
messages: [],
},
{
name: 'debug:action:fal-sd15',
action: 'image',
model: 'lcm-sd15-i2i',
messages: [],
},
{
name: 'debug:action:fal-sdturbo',
action: 'image',
model: 'fast-turbo-diffusion',
messages: [],
},
{
name: 'Summary',
action: 'Summary',
model: 'gpt-4-turbo-preview',
messages: [
{
role: 'assistant',
content: `Summarize the key points from the following content in a clear and concise manner, suitable for a reader who is seeking a quick understanding of the original content. Ensure to capture the main ideas and any significant details without unnecessary elaboration:
""""
{{content}}
""""`,
},
],
},
{
name: 'Summary the webpage',
action: 'Summary the webpage',
model: 'gpt-4-turbo-preview',
messages: [
{
role: 'assistant',
content:
'Summarize the insights from the following webpage content:\n\nFirst, provide a brief summary of the webpage content below. Then, list the insights derived from it, one by one.\n\n{{#links}}\n- {{.}}\n{{/links}}',
},
],
},
{
name: 'Explain this',
action: 'Explain this',
model: 'gpt-4-turbo-preview',
messages: [
{
role: 'assistant',
content: `Please analyze the following content and provide a brief summary and more detailed insights, with the insights listed in the form of an outline:
""""
{{content}}
""""
You can refer to this template:
""""
### Summary
your summary content here
### Insights
- Insight 1
- Insight 2
- Insight 3
""""`,
},
],
},
{
name: 'Explain this image',
action: 'Explain this image',
model: 'gpt-4-vision-preview',
messages: [
{
role: 'assistant',
content:
'Describe the scene captured in this image, focusing on the details, colors, emotions, and any interactions between subjects or objects present.\n\n{{image}}',
},
],
},
{
name: 'Explain this code',
action: 'Explain this code',
model: 'gpt-4-turbo-preview',
messages: [
{
role: 'assistant',
content:
'Analyze and explain the functionality of the following code snippet, highlighting its purpose, the logic behind its operations, and its potential output:\n\n{{code}}',
},
],
},
{
name: 'Translate to',
action: 'Translate',
model: 'gpt-4-turbo-preview',
messages: [
{
role: 'assistant',
content: `You are a translation expert, please translate the following content into {{language}}, and only perform the translation action, keeping the translated content in the same format as the original content:
""""
{{content}}
""""`,
params: {
language: [
'English',
'Spanish',
'German',
'French',
'Italian',
'Simplified Chinese',
'Traditional Chinese',
'Japanese',
'Russian',
'Korean',
],
},
},
],
},
{
name: 'Write an article about this',
action: 'Write an article about this',
model: 'gpt-4-turbo-preview',
messages: [
{
role: 'assistant',
content: `You are a good editor.
Please write an article based on the following content with reference to the rules given, and finally send only the written article to us
""""
{{content}}
""""
Rules to follow
1. Title: Craft an engaging and relevant title for the article that encapsulates the main theme.
2. Introduction: Start with an introductory paragraph that provides an overview of the topic and piques the readers interest.
3. Main Content:
Include at least three key points about the subject matter that are informative and backed by credible sources.
For each key point, provide analysis or insights that contribute to a deeper understanding of the topic.
Make sure to maintain a flow and connection between the points to ensure the article is cohesive.
4. Conclusion: Write a concluding paragraph that summarizes the main points and offers a final thought or call to action for the readers.
5. Tone: The article should be written in a professional yet accessible tone, appropriate for an educated audience interested in the topic.`,
},
],
},
{
name: 'Write a twitter about this',
action: 'Write a twitter about this',
model: 'gpt-4-turbo-preview',
messages: [
{
role: 'assistant',
content: `You are a social media strategist with a flair for crafting engaging tweets. Please write a tweet based on the following content. The tweet must be concise, not exceeding 280 characters, and should be designed to capture attention and encourage sharing. Make sure it includes relevant hashtags and, if applicable, a call-to-action:
""""
{{content}}
""""`,
},
],
},
{
name: 'Write a poem about this',
action: 'Write a poem about this',
model: 'gpt-4-turbo-preview',
messages: [
{
role: 'assistant',
content: `You are an accomplished poet tasked with the creation of vivid and evocative verse. Please write a poem incorporating the following content into its narrative. Your poem should have a clear theme, employ rich imagery, and convey deep emotions. Make sure to structure the poem with attention to rhythm, meter, and where appropriate, rhyme scheme. Provide a title that encapsulates the essence of your poem:
""""
{{content}}
""""`,
},
],
},
{
name: 'Write a blog post about this',
action: 'Write a blog post about this',
model: 'gpt-4-turbo-preview',
messages: [
{
role: 'assistant',
content: `You are a creative blog writer specializing in producing captivating and informative content. Your task is to write a blog post based on the following content. The blog post should be between 500-700 words, engaging, and well-structured, with an inviting introduction that hooks the reader, concise and informative body paragraphs, and a compelling conclusion that encourages readers to engage with the content, whether it's through commenting, sharing, or exploring the topics further.
Please ensure the blog post is optimized for SEO with relevant keywords, includes at least 2-3 subheadings for better readability, and whenever possible, provides actionable insights or takeaways for the reader. Integrate a friendly and approachable tone throughout the post that reflects the voice of someone knowledgeable yet relatable.
Here is the content you need to base your blog post on:
""""
{{content}}
""""`,
},
],
},
{
name: 'Write outline',
action: 'Write outline',
model: 'gpt-4-turbo-preview',
messages: [
{
role: 'assistant',
content:
'Write an outline based on the following content, organizing the main points, subtopics, and structure:\n\n{{content}}',
},
],
},
{
name: 'Change tone to',
action: 'Change tone',
model: 'gpt-4-turbo-preview',
messages: [
{
role: 'assistant',
content: `You are an editor, please rewrite the following content in a {{tone}} tone. It is essential to retain the core meaning of the original content and send us only the rewritten version.
""""
{{content}}
""""`,
params: {
tone: [
'professional',
'informal',
'friendly',
'critical',
'humorous',
],
},
},
],
},
{
name: 'Brainstorm ideas about this',
action: 'Brainstorm ideas about this',
model: 'gpt-4-turbo-preview',
messages: [
{
role: 'assistant',
content: `You are an innovative thinker and brainstorming expert skilled at generating creative ideas. Your task is to help brainstorm various concepts, strategies, and approaches based on the following content. I am looking for original and actionable ideas that can be implemented. Please present your suggestions in a bulleted points format to clearly outline the different ideas. Ensure that each point is focused on potential development or implementation of the concept presented in the content provided. Heres the content for your brainstorming session:
""""
{{content}}
""""
Based on the information above, please provide a list of brainstormed ideas in the following format:
""""
- Idea 1: [Brief explanation]
- Idea 2: [Brief explanation]
- Idea 3: [Brief explanation]
- []
""""
Remember, the focus is on creativity and practicality. Submit a range of diverse ideas that explore different angles and aspects of the content. `,
},
],
},
{
name: 'Brainstorm mindmap',
action: 'Brainstorm mindmap',
model: 'gpt-4-turbo-preview',
messages: [
{
role: 'assistant',
content:
'Use the nested unordered list syntax without other extra text style in Markdown to create a structure similar to a mind map without any unnecessary plain text description. Analyze the following questions or topics: \n\n{{content}}',
},
],
},
{
name: 'Expand mind map',
action: 'Expand mind map',
model: 'gpt-4-turbo-preview',
messages: [
{
role: 'assistant',
content: `An existing mind map is displayed as a markdown list:
{{mindmap}}.
Please expand the node {{content}}", adding more essential details and subtopics to the existing mind map in the same markdown list format. Only output the expand part without the original mind map. No need to include any additional text or explanation`,
},
],
},
{
name: 'Improve writing for it',
action: 'Improve writing for it',
model: 'gpt-4-turbo-preview',
messages: [
{
role: 'assistant',
content: `You are an editor
Please rewrite the following content to enhance its clarity, coherence, and overall quality, ensuring that the message is effectively communicated and free of any grammatical errors. Provide a refined version that maintains the original intent but exhibits improved structure and readability
""""
{{content}}
""""`,
},
],
},
{
name: 'Improve grammar for it',
action: 'Improve grammar for it',
model: 'gpt-4-turbo-preview',
messages: [
{
role: 'assistant',
content:
'Please correct the grammar in the following content to ensure that it is free from any grammatical errors, maintaining proper sentence structure, correct tense usage, and accurate punctuation. Ensure that the final content is grammatically sound while preserving the original message:\n\n{{content}}',
},
],
},
{
name: 'Fix spelling for it',
action: 'Fix spelling for it',
model: 'gpt-4-turbo-preview',
messages: [
{
role: 'assistant',
content: `Please carefully check the following content, and correct all the spelling errors found, only carry out this operation. The standard for correcting errors is, Ensure that each word is spelled correctly, adhering to standard {{language}} spelling conventions, The content's meaning should remain unchanged, and retain the original format of the content. Finally, return the corrected content
""""
{{content}}
""""`,
params: {
language: [
'English',
'Spanish',
'German',
'French',
'Italian',
'Simplified Chinese',
'Traditional Chinese',
'Japanese',
'Russian',
'Korean',
],
},
},
],
},
{
name: 'Find action items from it',
action: 'Find action items from it',
model: 'gpt-4-turbo-preview',
messages: [
{
role: 'assistant',
content: `Please extract the items that can be used as tasks from the following content, and send them to me in the format provided by the template. The extracted items should cover as much of this content as possible:
""""
{{content}}
""""
If there are no items that can be used as to-do tasks, please reply with the following message:
""""
The current content does not have any items that can be listed as to-dos, please check again.
""""
If there are items in the content that can be used as to-do tasks, please refer to the template below:
""""
[] Todo 1
[] Todo 2
[] Todo 3
""""`,
},
],
},
{
name: 'Check code error',
action: 'Check code error',
model: 'gpt-4-turbo-preview',
messages: [
{
role: 'assistant',
content:
'Review the following code snippet for any syntax errors and list them individually:\n\n{{content}}',
},
],
},
{
name: 'Create a presentation',
action: 'Create a presentation',
model: 'gpt-4-turbo-preview',
messages: [
{
role: 'assistant',
content:
'I want to write a PPT, that has many pages, each page has 1 to 4 sections,\neach section has a title of no more than 30 words and no more than 500 words of content,\nbut also need some keywords that match the content of the paragraph used to generate images,\nTry to have a different number of section per page\nThe first page is the cover, which generates a general title (no more than 4 words) and description based on the topic\nthis is a template:\n- page name\n - title\n - keywords\n - description\n- page name\n - section name\n - keywords\n - content\n - section name\n - keywords\n - content\n- page name\n - section name\n - keywords\n - content\n - section name\n - keywords\n - content\n - section name\n - keywords\n - content\n- page name\n - section name\n - keywords\n - content\n - section name\n - keywords\n - content\n - section name\n - keywords\n - content\n - section name\n - keywords\n - content\n- page name\n - section name\n - keywords\n - content\n\n\nplease help me to write this ppt, do not output any content that does not belong to the ppt content itself outside of the content, Directly output the title content keywords without prefix like Title:xxx, Content: xxx, Keywords: xxx\nThe PPT is based on the following topics:\n\n{{content}}',
},
],
},
{
name: 'Create headings',
action: 'Create headings',
model: 'gpt-4-turbo-preview',
messages: [
{
role: 'assistant',
content: `You are an editor.
Please generate a title for the following content, no more than 20 words, and output in H1 format
""""
{{content}}
""""
The output format can refer to this template
""""
# Title content
""""`,
},
],
},
{
name: 'Make it real',
action: 'Make it real',
model: 'gpt-4-vision-preview',
messages: [
{
role: 'system',
content: `You are an expert web developer who specializes in building working website prototypes from low-fidelity wireframes.
Your job is to accept low-fidelity wireframes, then create a working prototype using HTML, CSS, and JavaScript, and finally send back the results.
The results should be a single HTML file.
Use tailwind to style the website.
Put any additional CSS styles in a style tag and any JavaScript in a script tag.
Use unpkg or skypack to import any required dependencies.
Use Google fonts to pull in any open source fonts you require.
If you have any images, load them from Unsplash or use solid colored rectangles.
The wireframes may include flow charts, diagrams, labels, arrows, sticky notes, and other features that should inform your work.
If there are screenshots or images, use them to inform the colors, fonts, and layout of your website.
Use your best judgement to determine whether what you see should be part of the user interface, or else is just an annotation.
Use what you know about applications and user experience to fill in any implicit business logic in the wireframes. Flesh it out, make it real!
The user may also provide you with the html of a previous design that they want you to iterate from.
In the wireframe, the previous design's html will appear as a white rectangle.
Use their notes, together with the previous design, to inform your next result.
Sometimes it's hard for you to read the writing in the wireframes.
For this reason, all text from the wireframes will be provided to you as a list of strings, separated by newlines.
Use the provided list of text from the wireframes as a reference if any text is hard to read.
You love your designers and want them to be happy. Incorporating their feedback and notes and producing working websites makes them happy.
When sent new wireframes, respond ONLY with the contents of the html file.
`,
},
],
},
{
name: 'Make it longer',
action: 'Make it longer',
model: 'gpt-4-turbo-preview',
messages: [
{
role: 'assistant',
content: `You are an editor, skilled in elaborating and adding detail to given texts without altering their core meaning.
Commands:
1. Carefully read the following content.
2. Maintain the original message or story.
3. Enhance the content by adding descriptive language, relevant details, and any necessary explanations to make it longer.
4. Ensure that the content remains coherent and the flow is natural.
5. Avoid repetitive or redundant information that does not contribute meaningful content or insight.
6. Use creative and engaging language to enrich the content and capture the readers interest.
7. Keep the expansion within a reasonable length to avoid over-elaboration.
Following content
""""
{{content}}
""""
Output: Generate a new version of the provided content that is longer in length due to the added details and descriptions. The expanded content should convey the same message as the original, but with more depth and richness to give the reader a fuller understanding or a more vivid picture of the topic discussed.`,
},
],
},
{
name: 'Make it shorter',
action: 'Make it shorter',
model: 'gpt-4-turbo-preview',
messages: [
{
role: 'assistant',
content: `You are a skilled editor with a talent for conciseness. Your task is to shorten the provided text without sacrificing its core meaning, ensuring the essence of the message remains clear and strong.
Commands:
1. Read the Following content carefully.
2. Identify the key points and main message within the content.
3. Rewrite the content in a more concise form, ensuring you preserve its essential meaning and main points.
4. Avoid using unnecessary words or phrases that do not contribute to the core message.
5. Ensure readability is maintained, with proper grammar and punctuation.
6. Present the shortened version as the final polished content.
Following content
""""
{{content}}
""""
Finally, you should present the final, shortened content as your response. Make sure it is a clear, well-structured version of the original, maintaining the integrity of the main ideas and information.`,
},
],
},
{
name: 'Continue writing',
action: 'Continue writing',
model: 'gpt-4-turbo-preview',
messages: [
{
role: 'assistant',
content: `You are an accomplished ghostwriter known for your ability to seamlessly continue narratives in the voice and style of the original author. You are tasked with extending a given story, maintaining the established tone, characters, and plot direction. Please read the following content carefully and continue writing the story. Your continuation should feel like an uninterrupted extension of the provided text. Aim for a smooth narrative flow and authenticity to the original context. Heres the content you need to continue:
""""
{{content}}
""""
When you craft your continuation, remember to:
- Immerse yourself in the role of the characters, ensuring their actions and dialogue remain true to their established personalities.
- Adhere to the pre-existing plot points, building upon them in a way that feels organic and plausible within the storys universe.
- Maintain the voice and style of the original text, making your writing indistinguishable from the initial content.
- Provide a natural progression of the story that adds depth and interest, guiding the reader to the next phase of the plot.
- Ensure your writing is compelling and keeps the reader eager to read on.
Finally, please only send us the content of your continuation.`,
},
],
},
];
export async function refreshPrompts(db: PrismaClient) {
await db.$transaction(async tx => {
for (const prompt of prompts) {
await tx.aiPrompt.upsert({
create: {
name: prompt.name,
action: prompt.action,
model: prompt.model,
messages: {
create: prompt.messages.map((message, idx) => ({
idx,
role: message.role,
content: message.content,
params: message.params,
})),
},
},
where: { name: prompt.name },
update: {
action: prompt.action,
model: prompt.model,
messages: {
deleteMany: {},
create: prompt.messages.map((message, idx) => ({
idx,
role: message.role,
content: message.content,
params: message.params,
})),
},
},
});
}
});
}
@@ -1,7 +1,8 @@
import { PrismaClient } from '@prisma/client';
import { FeatureKind } from '../../../core/features';
import { Quota } from '../../../core/quota/types';
import { getLatestQuota } from '../../../core/quota/schema';
import { Quota, QuotaType } from '../../../core/quota/types';
import { upsertFeature } from './user-features';
export async function upgradeQuotaVersion(
@@ -20,10 +21,10 @@ export async function upgradeQuotaVersion(
});
// find all users that have old free plan
const userIds = await db.user.findMany({
const userIds = await tx.user.findMany({
where: {
features: {
every: {
some: {
feature: {
type: FeatureKind.Quota,
feature: quota.feature,
@@ -63,3 +64,20 @@ export async function upgradeQuotaVersion(
});
});
}
export async function upsertLatestQuotaVersion(
db: PrismaClient,
type: QuotaType
) {
const latestQuota = getLatestQuota(type);
await upsertFeature(db, latestQuota);
}
export async function upgradeLatestQuotaVersion(
db: PrismaClient,
type: QuotaType,
reason: string
) {
const latestQuota = getLatestQuota(type);
await upgradeQuotaVersion(db, latestQuota, reason);
}
+3 -2
View File
@@ -1,11 +1,12 @@
import { Global, Module } from '@nestjs/common';
import { Cache, SessionCache } from './instances';
import { CacheInterceptor } from './interceptor';
@Global()
@Module({
providers: [Cache, SessionCache],
exports: [Cache, SessionCache],
providers: [Cache, SessionCache, CacheInterceptor],
exports: [Cache, SessionCache, CacheInterceptor],
})
export class CacheModule {}
export { Cache, SessionCache };
@@ -2,7 +2,6 @@ import type { ApolloDriverConfig } from '@nestjs/apollo';
import SMTPTransport from 'nodemailer/lib/smtp-transport';
import type { LeafPaths } from '../utils/types';
import { EnvConfigType } from './env';
import type { AFFiNEStorageConfig } from './storage';
declare global {
@@ -13,6 +12,7 @@ declare global {
}
}
export type EnvConfigType = 'string' | 'int' | 'float' | 'boolean';
export type ServerFlavor = 'allinone' | 'graphql' | 'sync';
export type AFFINE_ENV = 'dev' | 'beta' | 'production';
export type NODE_ENV = 'development' | 'test' | 'production';
@@ -240,6 +240,13 @@ export interface AFFiNEConfig {
* @default 15 days
*/
ttl: number;
/**
* Application auth time to refresh in seconds
*
* @default 7 days
*/
ttr: number;
};
/**
@@ -153,6 +153,7 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
},
session: {
ttl: 15 * ONE_DAY_IN_SEC,
ttr: 7 * ONE_DAY_IN_SEC,
},
accessToken: {
ttl: 7 * ONE_DAY_IN_SEC,
@@ -188,7 +189,7 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
enabled: false,
},
telemetry: {
enabled: isSelfhosted && !process.env.DISABLE_SERVER_TELEMETRY,
enabled: isSelfhosted,
token: '389c0615a69b57cca7d3fa0a4824c930',
},
plugins: {
@@ -1,8 +1,7 @@
import { set } from 'lodash-es';
import type { AFFiNEConfig } from './def';
import type { AFFiNEConfig, EnvConfigType } from './def';
export type EnvConfigType = 'string' | 'int' | 'float' | 'boolean';
/**
* parse number value from environment variables
*/
@@ -19,6 +19,7 @@ export type StorageConfig<Ext = unknown> = {
export interface StoragesConfig {
avatar: StorageConfig<{ publicLinkFactory: (key: string) => string }>;
blob: StorageConfig;
copilot: StorageConfig;
}
export interface AFFiNEStorageConfig {
@@ -51,6 +52,10 @@ export function getDefaultAFFiNEStorageConfig(): AFFiNEStorageConfig {
provider: 'fs',
bucket: 'blobs',
},
copilot: {
provider: 'fs',
bucket: 'copilot',
},
},
};
}
@@ -27,7 +27,7 @@ export {
export type { PrismaTransaction } from './prisma';
export * from './storage';
export { type StorageProvider, StorageProviderFactory } from './storage';
export { AuthThrottlerGuard, CloudThrottlerGuard, Throttle } from './throttler';
export { CloudThrottlerGuard, Throttle } from './throttler';
export {
getRequestFromHost,
getRequestResponseFromContext,
@@ -0,0 +1,38 @@
import { applyDecorators, SetMetadata } from '@nestjs/common';
import { SkipThrottle, Throttle as RawThrottle } from '@nestjs/throttler';
export type Throttlers = 'default' | 'strict';
export const THROTTLER_PROTECTED = 'affine_throttler:protected';
/**
* Choose what throttler to use
*
* If a Controller or Query do not protected behind a Throttler,
* it will never be rate limited.
*
* - Ease: 120 calls within 60 seconds
* - Strict: 10 calls within 60 seconds
*
* @example
*
* \@Throttle()
* \@Throttle('strict')
*
* // the config call be override by the second parameter,
* // and the call count will be calculated separately
* \@Throttle('default', { limit: 10, ttl: 10 })
*
*/
export function Throttle(
type: Throttlers = 'default',
override: { limit?: number; ttl?: number } = {}
): MethodDecorator & ClassDecorator {
return applyDecorators(
SetMetadata(THROTTLER_PROTECTED, type),
RawThrottle({
[type]: override,
})
);
}
export { SkipThrottle };
@@ -1,15 +1,20 @@
import { ExecutionContext, Global, Injectable, Module } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import {
Throttle,
InjectThrottlerOptions,
InjectThrottlerStorage,
ThrottlerGuard,
ThrottlerModule,
ThrottlerModuleOptions,
type ThrottlerModuleOptions,
ThrottlerOptions,
ThrottlerOptionsFactory,
ThrottlerStorageService,
} from '@nestjs/throttler';
import type { Request } from 'express';
import { Config } from '../config';
import { getRequestResponseFromContext } from '../utils/request';
import { THROTTLER_PROTECTED, Throttlers } from './decorators';
@Injectable()
export class ThrottlerStorage extends ThrottlerStorageService {}
@@ -25,13 +30,16 @@ class CustomOptionsFactory implements ThrottlerOptionsFactory {
const options: ThrottlerModuleOptions = {
throttlers: [
{
name: 'default',
ttl: this.config.rateLimiter.ttl * 1000,
limit: this.config.rateLimiter.limit,
},
{
name: 'strict',
ttl: this.config.rateLimiter.ttl * 1000,
limit: 20,
},
],
skipIf: () => {
return !this.config.node.prod || this.config.affine.canary;
},
storage: this.storage,
};
@@ -39,6 +47,132 @@ class CustomOptionsFactory implements ThrottlerOptionsFactory {
}
}
@Injectable()
export class CloudThrottlerGuard extends ThrottlerGuard {
constructor(
@InjectThrottlerOptions() options: ThrottlerModuleOptions,
@InjectThrottlerStorage() storageService: ThrottlerStorage,
reflector: Reflector,
private readonly config: Config
) {
super(options, storageService, reflector);
}
override getRequestResponse(context: ExecutionContext) {
return getRequestResponseFromContext(context) as any;
}
override getTracker(req: Request): Promise<string> {
return Promise.resolve(
// ↓ prefer session id if available
`throttler:${req.sid ?? req.get('CF-Connecting-IP') ?? req.get('CF-ray') ?? req.ip}`
// ^ throttler prefix make the key in store recognizable
);
}
override generateKey(
context: ExecutionContext,
tracker: string,
throttler: string
) {
if (tracker.endsWith(';custom')) {
return `${tracker};${throttler}:${context.getClass().name}.${context.getHandler().name}`;
}
return `${tracker};${throttler}`;
}
override async handleRequest(
context: ExecutionContext,
limit: number,
ttl: number,
throttlerOptions: ThrottlerOptions
) {
// give it 'default' if no throttler is specified,
// so the unauthenticated users visits will always hit default throttler
// authenticated users will directly bypass unprotected APIs in [CloudThrottlerGuard.canActivate]
const throttler = this.getSpecifiedThrottler(context) ?? 'default';
// by pass unmatched throttlers
if (throttlerOptions.name !== throttler) {
return true;
}
const { req, res } = this.getRequestResponse(context);
const ignoreUserAgents =
throttlerOptions.ignoreUserAgents ?? this.commonOptions.ignoreUserAgents;
if (Array.isArray(ignoreUserAgents)) {
for (const pattern of ignoreUserAgents) {
const ua = req.headers['user-agent'];
if (ua && pattern.test(ua)) {
return true;
}
}
}
let tracker = await this.getTracker(req);
if (this.config.node.dev) {
limit = Number.MAX_SAFE_INTEGER;
} else {
// custom limit or ttl APIs will be treated standalone
if (limit !== throttlerOptions.limit || ttl !== throttlerOptions.ttl) {
tracker += ';custom';
}
}
const key = this.generateKey(
context,
tracker,
throttlerOptions.name ?? 'default'
);
const { timeToExpire, totalHits } = await this.storageService.increment(
key,
ttl
);
if (totalHits > limit) {
res.header('Retry-After', timeToExpire.toString());
await this.throwThrottlingException(context, {
limit,
ttl,
key,
tracker,
totalHits,
timeToExpire,
});
}
res.header(`${this.headerPrefix}-Limit`, limit.toString());
res.header(
`${this.headerPrefix}-Remaining`,
(limit - totalHits).toString()
);
res.header(`${this.headerPrefix}-Reset`, timeToExpire.toString());
return true;
}
override async canActivate(context: ExecutionContext): Promise<boolean> {
const { req } = this.getRequestResponse(context);
const throttler = this.getSpecifiedThrottler(context);
// if user is logged in, bypass non-protected handlers
if (!throttler && req.user) {
return true;
}
return super.canActivate(context);
}
getSpecifiedThrottler(context: ExecutionContext) {
return this.reflector.getAllAndOverride<Throttlers | undefined>(
THROTTLER_PROTECTED,
[context.getHandler(), context.getClass()]
);
}
}
@Global()
@Module({
imports: [
@@ -46,46 +180,9 @@ class CustomOptionsFactory implements ThrottlerOptionsFactory {
useClass: CustomOptionsFactory,
}),
],
providers: [ThrottlerStorage],
exports: [ThrottlerStorage],
providers: [ThrottlerStorage, CloudThrottlerGuard],
exports: [ThrottlerStorage, CloudThrottlerGuard],
})
export class RateLimiterModule {}
@Injectable()
export class CloudThrottlerGuard extends ThrottlerGuard {
override getRequestResponse(context: ExecutionContext) {
return getRequestResponseFromContext(context) as any;
}
protected override getTracker(req: Record<string, any>): Promise<string> {
return Promise.resolve(
req?.get('CF-Connecting-IP') ?? req?.get('CF-ray') ?? req?.ip
);
}
}
@Injectable()
export class AuthThrottlerGuard extends CloudThrottlerGuard {
override async handleRequest(
context: ExecutionContext,
limit: number,
ttl: number
): Promise<boolean> {
const { req } = this.getRequestResponse(context);
if (req?.url === '/api/auth/session') {
// relax throttle for session auto renew
return super.handleRequest(context, limit * 20, ttl, {
ttl: ttl * 20,
limit: limit * 20,
});
}
return super.handleRequest(context, limit, ttl, {
ttl,
limit,
});
}
}
export { Throttle };
export * from './decorators';
@@ -26,7 +26,7 @@ export function getRequestResponseFromHost(host: ArgumentsHost) {
const ws = host.switchToWs();
const req = ws.getClient<Socket>().client.conn.request as Request;
const cookieStr = req?.headers?.cookie;
const cookieStr = req?.headers?.cookie ?? '';
// patch cookies to match auth guard logic
if (typeof cookieStr === 'string') {
req.cookies = cookieStr.split(';').reduce(
+1
View File
@@ -1,6 +1,7 @@
declare namespace Express {
interface Request {
user?: import('./core/auth/current-user').CurrentUser;
sid?: string;
}
}
@@ -1,3 +1,4 @@
import { CopilotConfig } from './copilot';
import { GCloudConfig } from './gcloud/config';
import { OAuthConfig } from './oauth';
import { PaymentConfig } from './payment';
@@ -6,6 +7,7 @@ import { R2StorageConfig, S3StorageConfig } from './storage';
declare module '../fundamentals/config' {
interface PluginsConfig {
readonly copilot: CopilotConfig;
readonly payment: PaymentConfig;
readonly redis: RedisOptions;
readonly gcloud: GCloudConfig;
@@ -0,0 +1,352 @@
import {
BadRequestException,
Controller,
Get,
HttpException,
InternalServerErrorException,
Logger,
NotFoundException,
Param,
Query,
Req,
Res,
Sse,
} from '@nestjs/common';
import type { Request, Response } from 'express';
import {
catchError,
concatMap,
connect,
EMPTY,
from,
map,
merge,
mergeMap,
Observable,
of,
switchMap,
toArray,
} from 'rxjs';
import { Public } from '../../core/auth';
import { CurrentUser } from '../../core/auth/current-user';
import { Config } from '../../fundamentals';
import { CopilotProviderService } from './providers';
import { ChatSession, ChatSessionService } from './session';
import { CopilotStorage } from './storage';
import { CopilotCapability } from './types';
export interface ChatEvent {
type: 'attachment' | 'message' | 'error';
id?: string;
data: string;
}
@Controller('/api/copilot')
export class CopilotController {
private readonly logger = new Logger(CopilotController.name);
constructor(
private readonly config: Config,
private readonly chatSession: ChatSessionService,
private readonly provider: CopilotProviderService,
private readonly storage: CopilotStorage
) {}
private async hasAttachment(sessionId: string, messageId: string) {
const session = await this.chatSession.get(sessionId);
if (!session) {
throw new BadRequestException('Session not found');
}
const message = await session.getMessageById(messageId);
if (Array.isArray(message.attachments) && message.attachments.length) {
return true;
}
return false;
}
private async appendSessionMessage(
sessionId: string,
messageId: string
): Promise<ChatSession> {
const session = await this.chatSession.get(sessionId);
if (!session) {
throw new BadRequestException('Session not found');
}
await session.pushByMessageId(messageId);
return session;
}
private getSignal(req: Request) {
const controller = new AbortController();
req.on('close', () => controller.abort());
return controller.signal;
}
private handleError(err: any) {
if (err instanceof Error) {
const ret = {
message: err.message,
status: (err as any).status,
};
if (err instanceof HttpException) {
ret.status = err.getStatus();
}
}
return err;
}
@Get('/chat/:sessionId')
async chat(
@CurrentUser() user: CurrentUser,
@Req() req: Request,
@Param('sessionId') sessionId: string,
@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 provider = this.provider.getProviderByCapability(
CopilotCapability.TextToText,
model
);
if (!provider) {
throw new InternalServerErrorException('No provider available');
}
const session = await this.appendSessionMessage(sessionId, messageId);
try {
delete params.messageId;
const content = await provider.generateText(
session.finish(params),
session.model,
{
signal: this.getSignal(req),
user: user.id,
}
);
session.push({
role: 'assistant',
content,
createdAt: new Date(),
});
await session.save();
return content;
} catch (e: any) {
throw new InternalServerErrorException(
e.message || "Couldn't generate text"
);
}
}
@Sse('/chat/:sessionId/stream')
async chatStream(
@CurrentUser() user: CurrentUser,
@Req() req: Request,
@Param('sessionId') sessionId: string,
@Query('messageId') messageId: string,
@Query() params: Record<string, string>
): Promise<Observable<ChatEvent>> {
try {
await this.chatSession.checkQuota(user.id);
} 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')
async chatImagesStream(
@CurrentUser() user: CurrentUser,
@Req() req: Request,
@Param('sessionId') sessionId: string,
@Query('messageId') messageId: string,
@Query() params: Record<string, string>
): Promise<Observable<ChatEvent>> {
try {
await this.chatSession.checkQuota(user.id);
} 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')
async unsplashPhotos(
@Req() req: Request,
@Res() res: Response,
@Query() params: Record<string, string>
) {
const { unsplashKey } = this.config.plugins.copilot || {};
if (!unsplashKey) {
throw new InternalServerErrorException('Unsplash key is not configured');
}
const query = new URLSearchParams(params);
const response = await fetch(
`https://api.unsplash.com/search/photos?${query}`,
{
headers: { Authorization: `Client-ID ${unsplashKey}` },
signal: this.getSignal(req),
}
);
res.set({
'Content-Type': response.headers.get('Content-Type'),
'Content-Length': response.headers.get('Content-Length'),
'X-Ratelimit-Limit': response.headers.get('X-Ratelimit-Limit'),
'X-Ratelimit-Remaining': response.headers.get('X-Ratelimit-Remaining'),
});
res.status(response.status).send(await response.json());
}
@Public()
@Get('/blob/:userId/:workspaceId/:key')
async getBlob(
@Res() res: Response,
@Param('userId') userId: string,
@Param('workspaceId') workspaceId: string,
@Param('key') key: string
) {
const { body, metadata } = await this.storage.get(userId, workspaceId, key);
if (!body) {
throw new NotFoundException(
`Blob not found in ${userId}'s workspace ${workspaceId}: ${key}`
);
}
// metadata should always exists if body is not null
if (metadata) {
res.setHeader('content-type', metadata.contentType);
res.setHeader('last-modified', metadata.lastModified.toUTCString());
res.setHeader('content-length', metadata.contentLength);
} else {
this.logger.warn(`Blob ${workspaceId}/${key} has no metadata`);
}
res.setHeader('cache-control', 'public, max-age=2592000, immutable');
body.pipe(res);
}
}
@@ -0,0 +1,47 @@
import { ServerFeature } from '../../core/config';
import { FeatureModule } from '../../core/features';
import { QuotaModule } from '../../core/quota';
import { PermissionService } from '../../core/workspaces/permission';
import { Plugin } from '../registry';
import { CopilotController } from './controller';
import { ChatMessageCache } from './message';
import { PromptService } from './prompt';
import {
assertProvidersConfigs,
CopilotProviderService,
FalProvider,
OpenAIProvider,
registerCopilotProvider,
} from './providers';
import { CopilotResolver, UserCopilotResolver } from './resolver';
import { ChatSessionService } from './session';
import { CopilotStorage } from './storage';
registerCopilotProvider(FalProvider);
registerCopilotProvider(OpenAIProvider);
@Plugin({
name: 'copilot',
imports: [FeatureModule, QuotaModule],
providers: [
PermissionService,
ChatSessionService,
CopilotResolver,
ChatMessageCache,
UserCopilotResolver,
PromptService,
CopilotProviderService,
CopilotStorage,
],
controllers: [CopilotController],
contributesTo: ServerFeature.Copilot,
if: config => {
if (config.flavor.graphql) {
return assertProvidersConfigs(config);
}
return false;
},
})
export class CopilotModule {}
export type { CopilotConfig } from './types';
@@ -0,0 +1,35 @@
import { randomUUID } from 'node:crypto';
import { Injectable, Logger } from '@nestjs/common';
import { SessionCache } from '../../fundamentals';
import { SubmittedMessage, SubmittedMessageSchema } from './types';
const CHAT_MESSAGE_KEY = 'chat-message';
const CHAT_MESSAGE_TTL = 3600 * 1 * 1000; // 1 hours
@Injectable()
export class ChatMessageCache {
private readonly logger = new Logger(ChatMessageCache.name);
constructor(private readonly cache: SessionCache) {}
async get(id: string): Promise<SubmittedMessage | undefined> {
return await this.cache.get(`${CHAT_MESSAGE_KEY}:${id}`);
}
async set(message: SubmittedMessage): Promise<string | undefined> {
try {
const parsed = SubmittedMessageSchema.safeParse(message);
if (parsed.success) {
const id = randomUUID();
await this.cache.set(`${CHAT_MESSAGE_KEY}:${id}`, parsed.data, {
ttl: CHAT_MESSAGE_TTL,
});
return id;
}
} catch (e: any) {
this.logger.error(`Failed to get chat message from cache: ${e.message}`);
}
return undefined;
}
}
@@ -0,0 +1,226 @@
import { Injectable } from '@nestjs/common';
import { AiPrompt, PrismaClient } from '@prisma/client';
import Mustache from 'mustache';
import { Tiktoken } from 'tiktoken';
import {
getTokenEncoder,
PromptMessage,
PromptMessageSchema,
PromptParams,
} from './types';
// disable escaping
Mustache.escape = (text: string) => text;
function extractMustacheParams(template: string) {
const regex = /\{\{\s*([^{}]+)\s*\}\}/g;
const params = [];
let match;
while ((match = regex.exec(template)) !== null) {
params.push(match[1]);
}
return Array.from(new Set(params));
}
export class ChatPrompt {
public readonly encoder?: Tiktoken;
private readonly promptTokenSize: number;
private readonly templateParamKeys: string[] = [];
private readonly templateParams: PromptParams = {};
static createFromPrompt(
options: Omit<AiPrompt, 'id' | 'createdAt'> & {
messages: PromptMessage[];
}
) {
return new ChatPrompt(
options.name,
options.action || undefined,
options.model || undefined,
options.messages
);
}
constructor(
public readonly name: string,
public readonly action: string | undefined,
public readonly model: string | undefined,
private readonly messages: PromptMessage[]
) {
this.encoder = getTokenEncoder(model);
this.promptTokenSize =
this.encoder?.encode_ordinary(messages.map(m => m.content).join('') || '')
.length || 0;
this.templateParamKeys = extractMustacheParams(
messages.map(m => m.content).join('')
);
this.templateParams = messages.reduce(
(acc, m) => Object.assign(acc, m.params),
{} as PromptParams
);
}
/**
* get prompt token size
*/
get tokens() {
return this.promptTokenSize;
}
/**
* get prompt param keys in template
*/
get paramKeys() {
return this.templateParamKeys.slice();
}
/**
* get prompt params
*/
get params() {
return { ...this.templateParams };
}
encode(message: string) {
return this.encoder?.encode_ordinary(message).length || 0;
}
private checkParams(params: PromptParams) {
const selfParams = this.templateParams;
for (const key of Object.keys(selfParams)) {
const options = selfParams[key];
const income = params[key];
if (
typeof income !== 'string' ||
(Array.isArray(options) && !options.includes(income))
) {
throw new Error(`Invalid param: ${key}`);
}
}
}
/**
* render prompt messages with params
* @param params record of params, e.g. { name: 'Alice' }
* @returns e.g. [{ role: 'system', content: 'Hello, {{name}}' }] => [{ role: 'system', content: 'Hello, Alice' }]
*/
finish(params: PromptParams): PromptMessage[] {
this.checkParams(params);
return this.messages.map(({ content, params: _, ...rest }) => ({
...rest,
params,
content: Mustache.render(content, params),
}));
}
free() {
this.encoder?.free();
}
}
@Injectable()
export class PromptService {
private readonly cache = new Map<string, ChatPrompt>();
constructor(private readonly db: PrismaClient) {}
/**
* list prompt names
* @returns prompt names
*/
async list() {
return this.db.aiPrompt
.findMany({ select: { name: true } })
.then(prompts => Array.from(new Set(prompts.map(p => p.name))));
}
/**
* get prompt messages by prompt name
* @param name prompt name
* @returns prompt messages
*/
async get(name: string): Promise<ChatPrompt | null> {
const cached = this.cache.get(name);
if (cached) return cached;
const prompt = await this.db.aiPrompt.findUnique({
where: {
name,
},
select: {
name: true,
action: true,
model: true,
messages: {
select: {
role: true,
content: true,
params: true,
},
orderBy: {
idx: 'asc',
},
},
},
});
const messages = PromptMessageSchema.array().safeParse(prompt?.messages);
if (prompt && messages.success) {
const chatPrompt = ChatPrompt.createFromPrompt({
...prompt,
messages: messages.data,
});
this.cache.set(name, chatPrompt);
return chatPrompt;
}
return null;
}
async set(name: string, messages: PromptMessage[]) {
return await this.db.aiPrompt
.create({
data: {
name,
messages: {
create: messages.map((m, idx) => ({
idx,
...m,
attachments: m.attachments || undefined,
params: m.params || undefined,
})),
},
},
})
.then(ret => ret.id);
}
async update(name: string, messages: PromptMessage[]) {
const { id } = await this.db.aiPrompt.update({
where: { name },
data: {
messages: {
// cleanup old messages
deleteMany: {},
create: messages.map((m, idx) => ({
idx,
...m,
attachments: m.attachments || undefined,
params: m.params || undefined,
})),
},
},
});
this.cache.delete(name);
return id;
}
async delete(name: string) {
const { id } = await this.db.aiPrompt.delete({ where: { name } });
this.cache.delete(name);
return id;
}
}
@@ -0,0 +1,109 @@
import assert from 'node:assert';
import {
CopilotCapability,
CopilotImageToImageProvider,
CopilotProviderType,
CopilotTextToImageProvider,
PromptMessage,
} from '../types';
export type FalConfig = {
apiKey: string;
};
export type FalResponse = {
detail: Array<{ msg: string }>;
images: Array<{ url: string }>;
};
export class FalProvider
implements CopilotTextToImageProvider, CopilotImageToImageProvider
{
static readonly type = CopilotProviderType.FAL;
static readonly capabilities = [
CopilotCapability.TextToImage,
CopilotCapability.ImageToImage,
];
readonly availableModels = [
// text to image
'fast-turbo-diffusion',
// image to image
'lcm-sd15-i2i',
];
constructor(private readonly config: FalConfig) {
assert(FalProvider.assetsConfig(config));
}
static assetsConfig(config: FalConfig) {
return !!config.apiKey;
}
getCapabilities(): CopilotCapability[] {
return FalProvider.capabilities;
}
isModelAvailable(model: string): boolean {
return this.availableModels.includes(model);
}
// ====== image to image ======
async generateImages(
messages: PromptMessage[],
model: string = this.availableModels[0],
options: {
signal?: AbortSignal;
user?: string;
} = {}
): Promise<Array<string>> {
const { content, attachments } = messages.pop() || {};
if (!this.availableModels.includes(model)) {
throw new Error(`Invalid model: ${model}`);
}
// prompt attachments require at least one
if (!content && (!Array.isArray(attachments) || !attachments.length)) {
throw new Error('Prompt or Attachments is empty');
}
const data = (await fetch(`https://fal.run/fal-ai/${model}`, {
method: 'POST',
headers: {
Authorization: `key ${this.config.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
image_url: attachments?.[0],
prompt: content,
sync_mode: true,
seed: 42,
enable_safety_checks: false,
}),
signal: options.signal,
}).then(res => res.json())) as FalResponse;
if (!data.images?.length) {
const error = data.detail?.[0]?.msg;
throw new Error(
error ? `Invalid message: ${error}` : 'No images generated'
);
}
return data.images?.map(image => image.url) || [];
}
async *generateImagesStream(
messages: PromptMessage[],
model: string = this.availableModels[0],
options: {
signal?: AbortSignal;
user?: string;
} = {}
): AsyncIterable<string> {
const ret = await this.generateImages(messages, model, options);
for (const url of ret) {
yield url;
}
}
}
@@ -0,0 +1,157 @@
import assert from 'node:assert';
import { Injectable, Logger } from '@nestjs/common';
import { Config } from '../../../fundamentals';
import {
CapabilityToCopilotProvider,
CopilotCapability,
CopilotConfig,
CopilotProvider,
CopilotProviderType,
} from '../types';
type CopilotProviderConfig = CopilotConfig[keyof CopilotConfig];
interface CopilotProviderDefinition<C extends CopilotProviderConfig> {
// constructor signature
new (config: C): CopilotProvider;
// type of the provider
readonly type: CopilotProviderType;
// capabilities of the provider, like text to text, text to image, etc.
readonly capabilities: CopilotCapability[];
// asserts that the config is valid for this provider
assetsConfig(config: C): boolean;
}
// registered provider factory
const COPILOT_PROVIDER = new Map<
CopilotProviderType,
(config: Config, logger: Logger) => CopilotProvider
>();
// map of capabilities to providers
const PROVIDER_CAPABILITY_MAP = new Map<
CopilotCapability,
CopilotProviderType[]
>();
// config assertions for providers
const ASSERT_CONFIG = new Map<CopilotProviderType, (config: Config) => void>();
export function registerCopilotProvider<
C extends CopilotProviderConfig = CopilotProviderConfig,
>(provider: CopilotProviderDefinition<C>) {
const type = provider.type;
const factory = (config: Config, logger: Logger) => {
const providerConfig = config.plugins.copilot?.[type];
if (!provider.assetsConfig(providerConfig as C)) {
throw new Error(
`Invalid configuration for copilot provider ${type}: ${providerConfig}`
);
}
const instance = new provider(providerConfig as C);
logger.log(
`Copilot provider ${type} registered, capabilities: ${provider.capabilities.join(', ')}`
);
return instance;
};
// register the provider
COPILOT_PROVIDER.set(type, factory);
// register the provider capabilities
for (const capability of provider.capabilities) {
const providers = PROVIDER_CAPABILITY_MAP.get(capability) || [];
if (!providers.includes(type)) {
providers.push(type);
}
PROVIDER_CAPABILITY_MAP.set(capability, providers);
}
// register the provider config assertion
ASSERT_CONFIG.set(type, (config: Config) => {
assert(config.plugins.copilot);
const providerConfig = config.plugins.copilot[type];
if (!providerConfig) return false;
return provider.assetsConfig(providerConfig as C);
});
}
/// Asserts that the config is valid for any registered providers
export function assertProvidersConfigs(config: Config) {
return (
Array.from(ASSERT_CONFIG.values()).findIndex(assertConfig =>
assertConfig(config)
) !== -1
);
}
@Injectable()
export class CopilotProviderService {
private readonly logger = new Logger(CopilotProviderService.name);
constructor(private readonly config: Config) {}
private readonly cachedProviders = new Map<
CopilotProviderType,
CopilotProvider
>();
private create(provider: CopilotProviderType): CopilotProvider {
assert(this.config.plugins.copilot);
const providerFactory = COPILOT_PROVIDER.get(provider);
if (!providerFactory) {
throw new Error(`Unknown copilot provider type: ${provider}`);
}
return providerFactory(this.config, this.logger);
}
getProvider(provider: CopilotProviderType): CopilotProvider {
if (!this.cachedProviders.has(provider)) {
this.cachedProviders.set(provider, this.create(provider));
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return this.cachedProviders.get(provider)!;
}
getProviderByCapability<C extends CopilotCapability>(
capability: C,
model?: string,
prefer?: CopilotProviderType
): CapabilityToCopilotProvider[C] | null {
const providers = PROVIDER_CAPABILITY_MAP.get(capability);
if (Array.isArray(providers) && providers.length) {
let selectedProvider: CopilotProviderType | undefined = prefer;
let currentIndex = -1;
if (!selectedProvider) {
currentIndex = 0;
selectedProvider = providers[currentIndex];
}
while (selectedProvider) {
// find first provider that supports the capability and model
if (providers.includes(selectedProvider)) {
const provider = this.getProvider(selectedProvider);
if (provider.getCapabilities().includes(capability)) {
if (model) {
if (provider.isModelAvailable(model)) {
return provider as CapabilityToCopilotProvider[C];
}
} else {
return provider as CapabilityToCopilotProvider[C];
}
}
}
currentIndex += 1;
selectedProvider = providers[currentIndex];
}
}
return null;
}
}
export { FalProvider } from './fal';
export { OpenAIProvider } from './openai';
@@ -0,0 +1,270 @@
import assert from 'node:assert';
import { ClientOptions, OpenAI } from 'openai';
import {
ChatMessageRole,
CopilotCapability,
CopilotImageToTextProvider,
CopilotProviderType,
CopilotTextToEmbeddingProvider,
CopilotTextToImageProvider,
CopilotTextToTextProvider,
PromptMessage,
} from '../types';
const DEFAULT_DIMENSIONS = 256;
const SIMPLE_IMAGE_URL_REGEX = /^(https?:\/\/|data:image\/)/;
export class OpenAIProvider
implements
CopilotTextToTextProvider,
CopilotTextToEmbeddingProvider,
CopilotTextToImageProvider,
CopilotImageToTextProvider
{
static readonly type = CopilotProviderType.OpenAI;
static readonly capabilities = [
CopilotCapability.TextToText,
CopilotCapability.TextToEmbedding,
CopilotCapability.TextToImage,
CopilotCapability.ImageToText,
];
readonly availableModels = [
// text to text
'gpt-4-vision-preview',
'gpt-4-turbo-preview',
'gpt-3.5-turbo',
// embeddings
'text-embedding-3-large',
'text-embedding-3-small',
'text-embedding-ada-002',
// moderation
'text-moderation-latest',
'text-moderation-stable',
// text to image
'dall-e-3',
];
private readonly instance: OpenAI;
constructor(config: ClientOptions) {
assert(OpenAIProvider.assetsConfig(config));
this.instance = new OpenAI(config);
}
static assetsConfig(config: ClientOptions) {
return !!config.apiKey;
}
getCapabilities(): CopilotCapability[] {
return OpenAIProvider.capabilities;
}
isModelAvailable(model: string): boolean {
return this.availableModels.includes(model);
}
private chatToGPTMessage(
messages: PromptMessage[]
): OpenAI.Chat.Completions.ChatCompletionMessageParam[] {
// filter redundant fields
return messages.map(({ role, content, attachments }) => {
if (Array.isArray(attachments)) {
const contents = [
{ type: 'text', text: content },
...attachments
.filter(url => SIMPLE_IMAGE_URL_REGEX.test(url))
.map(url => ({
type: 'image_url',
image_url: { url, detail: 'high' },
})),
];
return {
role,
content: contents,
} as OpenAI.Chat.Completions.ChatCompletionMessageParam;
} else {
return { role, content };
}
});
}
private checkParams({
messages,
embeddings,
model,
}: {
messages?: PromptMessage[];
embeddings?: string[];
model: string;
}) {
if (!this.availableModels.includes(model)) {
throw new Error(`Invalid model: ${model}`);
}
if (Array.isArray(messages) && messages.length > 0) {
if (
messages.some(
m =>
// check non-object
typeof m !== 'object' ||
!m ||
// check content
typeof m.content !== 'string' ||
// content and attachments must exist at least one
((!m.content || !m.content.trim()) &&
(!Array.isArray(m.attachments) || !m.attachments.length))
)
) {
throw new Error('Empty message content');
}
if (
messages.some(
m =>
typeof m.role !== 'string' ||
!m.role ||
!ChatMessageRole.includes(m.role)
)
) {
throw new Error('Invalid message role');
}
} else if (
Array.isArray(embeddings) &&
embeddings.some(e => typeof e !== 'string' || !e || !e.trim())
) {
throw new Error('Invalid embedding');
}
}
// ====== text to text ======
async generateText(
messages: PromptMessage[],
model: string = 'gpt-3.5-turbo',
options: {
temperature?: number;
maxTokens?: number;
signal?: AbortSignal;
user?: string;
} = {}
): Promise<string> {
this.checkParams({ messages, model });
const result = await this.instance.chat.completions.create(
{
messages: this.chatToGPTMessage(messages),
model: model,
temperature: options.temperature || 0,
max_tokens: options.maxTokens || 4096,
user: options.user,
},
{ signal: options.signal }
);
const { content } = result.choices[0].message;
if (!content) {
throw new Error('Failed to generate text');
}
return content;
}
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 = await this.instance.chat.completions.create(
{
stream: true,
messages: this.chatToGPTMessage(messages),
model: model,
temperature: options.temperature || 0,
max_tokens: options.maxTokens || 4096,
user: options.user,
},
{
signal: options.signal,
}
);
for await (const message of result) {
const content = message.choices[0].delta.content;
if (content) {
yield content;
if (options.signal?.aborted) {
result.controller.abort();
break;
}
}
}
}
// ====== text to embedding ======
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 });
const result = await this.instance.embeddings.create({
model: model,
input: messages,
dimensions: options.dimensions || DEFAULT_DIMENSIONS,
user: options.user,
});
return result.data.map(e => e.embedding);
}
// ====== text to image ======
async generateImages(
messages: PromptMessage[],
model: string = 'dall-e-3',
options: {
signal?: AbortSignal;
user?: string;
} = {}
): Promise<Array<string>> {
const { content: prompt } = messages.pop() || {};
if (!prompt) {
throw new Error('Prompt is required');
}
const result = await this.instance.images.generate(
{
prompt,
model,
response_format: 'url',
user: options.user,
},
{ signal: options.signal }
);
return result.data.map(image => image.url).filter((v): v is string => !!v);
}
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;
}
}
}
@@ -0,0 +1,326 @@
import { createHash } from 'node:crypto';
import { BadRequestException, Logger } from '@nestjs/common';
import {
Args,
Field,
ID,
InputType,
Mutation,
ObjectType,
Parent,
registerEnumType,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { GraphQLJSON, SafeIntResolver } from 'graphql-scalars';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { CurrentUser } from '../../core/auth';
import { UserType } from '../../core/user';
import { PermissionService } from '../../core/workspaces/permission';
import {
FileUpload,
MutexService,
TooManyRequestsException,
} from '../../fundamentals';
import { ChatSessionService } from './session';
import { CopilotStorage } from './storage';
import {
AvailableModels,
type ChatHistory,
type ChatMessage,
type ListHistoriesOptions,
SubmittedMessage,
} from './types';
registerEnumType(AvailableModels, { name: 'CopilotModel' });
export const COPILOT_LOCKER = 'copilot';
// ================== Input Types ==================
@InputType()
class CreateChatSessionInput {
@Field(() => String)
workspaceId!: string;
@Field(() => String)
docId!: string;
@Field(() => String, {
description: 'The prompt name to use for the session',
})
promptName!: string;
}
@InputType()
class CreateChatMessageInput implements Omit<SubmittedMessage, 'content'> {
@Field(() => String)
sessionId!: string;
@Field(() => String, { nullable: true })
content!: string | undefined;
@Field(() => [String], { nullable: true })
attachments!: string[] | undefined;
@Field(() => [GraphQLUpload], { nullable: true })
blobs!: Promise<FileUpload>[] | undefined;
@Field(() => GraphQLJSON, { nullable: true })
params!: Record<string, string> | undefined;
}
@InputType()
class QueryChatHistoriesInput implements Partial<ListHistoriesOptions> {
@Field(() => Boolean, { nullable: true })
action: boolean | undefined;
@Field(() => Number, { nullable: true })
limit: number | undefined;
@Field(() => Number, { nullable: true })
skip: number | undefined;
@Field(() => String, { nullable: true })
sessionId: string | undefined;
}
// ================== Return Types ==================
@ObjectType('ChatMessage')
class ChatMessageType implements Partial<ChatMessage> {
@Field(() => String)
role!: 'system' | 'assistant' | 'user';
@Field(() => String)
content!: string;
@Field(() => [String], { nullable: true })
attachments!: string[];
@Field(() => GraphQLJSON, { nullable: true })
params!: Record<string, string> | undefined;
@Field(() => Date)
createdAt!: Date;
}
@ObjectType('CopilotHistories')
class CopilotHistoriesType implements Partial<ChatHistory> {
@Field(() => String)
sessionId!: string;
@Field(() => String, {
description: 'An mark identifying which view to use to display the session',
nullable: true,
})
action!: string | undefined;
@Field(() => Number, {
description: 'The number of tokens used in the session',
})
tokens!: number;
@Field(() => [ChatMessageType])
messages!: ChatMessageType[];
@Field(() => Date)
createdAt!: Date;
}
@ObjectType('CopilotQuota')
class CopilotQuotaType {
@Field(() => SafeIntResolver, { nullable: true })
limit?: number;
@Field(() => SafeIntResolver)
used!: number;
}
// ================== Resolver ==================
@ObjectType('Copilot')
export class CopilotType {
@Field(() => ID, { nullable: true })
workspaceId!: string | undefined;
}
@Resolver(() => CopilotType)
export class CopilotResolver {
private readonly logger = new Logger(CopilotResolver.name);
constructor(
private readonly permissions: PermissionService,
private readonly mutex: MutexService,
private readonly chatSession: ChatSessionService,
private readonly storage: CopilotStorage
) {}
@ResolveField(() => CopilotQuotaType, {
name: 'quota',
description: 'Get the quota of the user in the workspace',
complexity: 2,
})
async getQuota(@CurrentUser() user: CurrentUser) {
return await this.chatSession.getQuota(user.id);
}
@ResolveField(() => [String], {
description: 'Get the session list of chats in the workspace',
complexity: 2,
})
async chats(
@Parent() copilot: CopilotType,
@CurrentUser() user: CurrentUser
) {
if (!copilot.workspaceId) return [];
await this.permissions.checkCloudWorkspace(copilot.workspaceId, user.id);
return await this.chatSession.listSessions(user.id, copilot.workspaceId);
}
@ResolveField(() => [String], {
description: 'Get the session list of actions in the workspace',
complexity: 2,
})
async actions(
@Parent() copilot: CopilotType,
@CurrentUser() user: CurrentUser
) {
if (!copilot.workspaceId) return [];
await this.permissions.checkCloudWorkspace(copilot.workspaceId, user.id);
return await this.chatSession.listSessions(user.id, copilot.workspaceId, {
action: true,
});
}
@ResolveField(() => [CopilotHistoriesType], {})
async histories(
@Parent() copilot: CopilotType,
@CurrentUser() user: CurrentUser,
@Args('docId', { nullable: true }) docId?: string,
@Args({
name: 'options',
type: () => QueryChatHistoriesInput,
nullable: true,
})
options?: QueryChatHistoriesInput
) {
const workspaceId = copilot.workspaceId;
if (!workspaceId) {
return [];
} else if (docId) {
await this.permissions.checkCloudPagePermission(
workspaceId,
docId,
user.id
);
} else {
await this.permissions.checkCloudWorkspace(workspaceId, user.id);
}
const histories = await this.chatSession.listHistories(
user.id,
workspaceId,
docId,
options,
true
);
return histories.map(h => ({
...h,
// filter out empty messages
messages: h.messages.filter(m => m.content || m.attachments?.length),
}));
}
@Mutation(() => String, {
description: 'Create a chat session',
})
async createCopilotSession(
@CurrentUser() user: CurrentUser,
@Args({ name: 'options', type: () => CreateChatSessionInput })
options: CreateChatSessionInput
) {
await this.permissions.checkCloudPagePermission(
options.workspaceId,
options.docId,
user.id
);
const lockFlag = `${COPILOT_LOCKER}:session:${user.id}:${options.workspaceId}`;
await using lock = await this.mutex.lock(lockFlag);
if (!lock) {
return new TooManyRequestsException('Server is busy');
}
await this.chatSession.checkQuota(user.id);
const session = await this.chatSession.create({
...options,
userId: user.id,
});
return session;
}
@Mutation(() => String, {
description: 'Create a chat message',
})
async createCopilotMessage(
@CurrentUser() user: CurrentUser,
@Args({ name: 'options', type: () => CreateChatMessageInput })
options: CreateChatMessageInput
) {
const lockFlag = `${COPILOT_LOCKER}:message:${user?.id}:${options.sessionId}`;
await using lock = await this.mutex.lock(lockFlag);
if (!lock) {
return new TooManyRequestsException('Server is busy');
}
const session = await this.chatSession.get(options.sessionId);
if (!session) return new BadRequestException('Session not found');
if (options.blobs) {
options.attachments = options.attachments || [];
const { workspaceId } = session.config;
const blobs = await Promise.all(options.blobs);
delete options.blobs;
for (const blob of blobs) {
const uploaded = await this.storage.handleUpload(user.id, blob);
const filename = createHash('sha256')
.update(uploaded.buffer)
.digest('base64url');
const link = await this.storage.put(
user.id,
workspaceId,
filename,
uploaded.buffer
);
options.attachments.push(link);
}
}
try {
return await this.chatSession.createMessage(options);
} catch (e: any) {
this.logger.error(`Failed to create chat message: ${e.message}`);
throw new Error('Failed to create chat message');
}
}
}
@Resolver(() => UserType)
export class UserCopilotResolver {
constructor(private readonly permissions: PermissionService) {}
@ResolveField(() => CopilotType)
async copilot(
@CurrentUser() user: CurrentUser,
@Args('workspaceId', { nullable: true }) workspaceId?: string
) {
if (workspaceId) {
await this.permissions.checkCloudWorkspace(workspaceId, user.id);
}
return { workspaceId };
}
}
@@ -0,0 +1,457 @@
import { randomUUID } from 'node:crypto';
import { Injectable, Logger } from '@nestjs/common';
import { AiPromptRole, PrismaClient } from '@prisma/client';
import { FeatureManagementService } from '../../core/features';
import { QuotaService } from '../../core/quota';
import { PaymentRequiredException } from '../../fundamentals';
import { ChatMessageCache } from './message';
import { ChatPrompt, PromptService } from './prompt';
import {
AvailableModel,
ChatHistory,
ChatMessage,
ChatMessageSchema,
ChatSessionOptions,
ChatSessionState,
getTokenEncoder,
ListHistoriesOptions,
PromptMessage,
PromptParams,
SubmittedMessage,
} from './types';
export class ChatSession implements AsyncDisposable {
constructor(
private readonly messageCache: ChatMessageCache,
private readonly state: ChatSessionState,
private readonly dispose?: (state: ChatSessionState) => Promise<void>,
private readonly maxTokenSize = 3840
) {}
get model() {
return this.state.prompt.model;
}
get config() {
const {
sessionId,
userId,
workspaceId,
docId,
prompt: { name: promptName },
} = this.state;
return { sessionId, userId, workspaceId, docId, promptName };
}
push(message: ChatMessage) {
if (
this.state.prompt.action &&
this.state.messages.length > 0 &&
message.role === 'user'
) {
throw new Error('Action has been taken, no more messages allowed');
}
this.state.messages.push(message);
}
async getMessageById(messageId: string) {
const message = await this.messageCache.get(messageId);
if (!message || message.sessionId !== this.state.sessionId) {
throw new Error(`Message not found: ${messageId}`);
}
return message;
}
async pushByMessageId(messageId: string) {
const message = await this.messageCache.get(messageId);
if (!message || message.sessionId !== this.state.sessionId) {
throw new Error(`Message not found: ${messageId}`);
}
this.push({
role: 'user',
content: message.content || '',
attachments: message.attachments,
params: message.params,
createdAt: new Date(),
});
}
pop() {
this.state.messages.pop();
}
private takeMessages(): ChatMessage[] {
if (this.state.prompt.action) {
const messages = this.state.messages;
return messages.slice(messages.length - 1);
}
const ret = [];
const messages = this.state.messages.slice();
let size = this.state.prompt.tokens;
while (messages.length) {
const message = messages.pop();
if (!message) break;
size += this.state.prompt.encode(message.content);
if (size > this.maxTokenSize) {
break;
}
ret.push(message);
}
ret.reverse();
return ret;
}
finish(params: PromptParams): PromptMessage[] {
const messages = this.takeMessages();
return [
...this.state.prompt.finish(
Object.keys(params).length ? params : messages[0]?.params || {}
),
...messages.filter(m => m.content || m.attachments?.length),
];
}
async save() {
await this.dispose?.(this.state);
}
async [Symbol.asyncDispose]() {
this.state.prompt.free();
await this.save?.();
}
}
@Injectable()
export class ChatSessionService {
private readonly logger = new Logger(ChatSessionService.name);
constructor(
private readonly db: PrismaClient,
private readonly feature: FeatureManagementService,
private readonly quota: QuotaService,
private readonly messageCache: ChatMessageCache,
private readonly prompt: PromptService
) {}
private async setSession(state: ChatSessionState): Promise<string> {
return await this.db.$transaction(async tx => {
let sessionId = state.sessionId;
// find existing session if session is chat session
if (!state.prompt.action) {
const { id } =
(await tx.aiSession.findFirst({
where: {
userId: state.userId,
workspaceId: state.workspaceId,
docId: state.docId,
prompt: { action: { equals: null } },
},
select: { id: true },
})) || {};
if (id) sessionId = id;
}
const messages = state.messages.map(m => ({
...m,
attachments: m.attachments || undefined,
params: m.params || undefined,
}));
await tx.aiSession.upsert({
where: {
id: sessionId,
userId: state.userId,
},
update: {
messages: {
// skip delete old messages if no new messages
deleteMany: messages.length ? {} : undefined,
create: messages,
},
},
create: {
id: sessionId,
workspaceId: state.workspaceId,
docId: state.docId,
messages: {
create: messages,
},
// connect
user: { connect: { id: state.userId } },
prompt: { connect: { name: state.prompt.name } },
},
});
return sessionId;
});
}
private async getSession(
sessionId: string
): Promise<ChatSessionState | undefined> {
return await this.db.aiSession
.findUnique({
where: { id: sessionId },
select: {
id: true,
userId: true,
workspaceId: true,
docId: true,
messages: {
select: {
role: true,
content: true,
createdAt: true,
},
orderBy: {
createdAt: 'asc',
},
},
prompt: {
select: {
name: true,
action: true,
model: true,
messages: {
select: {
role: true,
content: true,
createdAt: true,
},
orderBy: {
idx: 'asc',
},
},
},
},
},
})
.then(async session => {
if (!session) return;
const messages = ChatMessageSchema.array().safeParse(session.messages);
return {
sessionId: session.id,
userId: session.userId,
workspaceId: session.workspaceId,
docId: session.docId,
prompt: ChatPrompt.createFromPrompt(session.prompt),
messages: messages.success ? messages.data : [],
};
});
}
private calculateTokenSize(
messages: PromptMessage[],
model: AvailableModel
): number {
const encoder = getTokenEncoder(model);
return messages
.map(m => encoder?.encode_ordinary(m.content).length || 0)
.reduce((total, length) => total + length, 0);
}
private async countUserActions(userId: string): Promise<number> {
return await this.db.aiSession.count({
where: { userId, prompt: { action: { not: null } } },
});
}
private async countUserChats(userId: string): Promise<number> {
const chats = await this.db.aiSession.findMany({
where: { userId, prompt: { action: null } },
select: {
_count: {
select: { messages: { where: { role: AiPromptRole.user } } },
},
},
});
return chats.reduce((prev, chat) => prev + chat._count.messages, 0);
}
async listSessions(
userId: string,
workspaceId: string,
options?: { docId?: string; action?: boolean }
): Promise<string[]> {
return await this.db.aiSession
.findMany({
where: {
userId,
workspaceId,
docId: workspaceId === options?.docId ? undefined : options?.docId,
prompt: {
action: options?.action ? { not: null } : null,
},
},
select: { id: true },
})
.then(sessions => sessions.map(({ id }) => id));
}
async listHistories(
userId: string,
workspaceId?: string,
docId?: string,
options?: ListHistoriesOptions,
withPrompt = false
): Promise<ChatHistory[]> {
return await this.db.aiSession
.findMany({
where: {
userId,
workspaceId: workspaceId,
docId: workspaceId === docId ? undefined : docId,
prompt: {
action: options?.action ? { not: null } : null,
},
id: options?.sessionId ? { equals: options.sessionId } : undefined,
},
select: {
id: true,
promptName: true,
createdAt: true,
messages: {
select: {
role: true,
content: true,
attachments: true,
params: true,
createdAt: true,
},
orderBy: {
createdAt: 'asc',
},
},
},
take: options?.limit,
skip: options?.skip,
orderBy: { createdAt: 'desc' },
})
.then(sessions =>
Promise.all(
sessions.map(async ({ id, promptName, messages, createdAt }) => {
try {
const ret = ChatMessageSchema.array().safeParse(messages);
if (ret.success) {
const prompt = await this.prompt.get(promptName);
if (!prompt) {
throw new Error(`Prompt not found: ${promptName}`);
}
const tokens = this.calculateTokenSize(
ret.data,
prompt.model as AvailableModel
);
// render system prompt
const preload = withPrompt
? prompt
.finish(ret.data[0]?.params || {})
.filter(({ role }) => role !== 'system')
: [];
// `createdAt` is required for history sorting in frontend, let's fake the creating time of prompt messages
(preload as ChatMessage[]).forEach((msg, i) => {
msg.createdAt = new Date(
createdAt.getTime() - preload.length - i - 1
);
});
return {
sessionId: id,
action: prompt.action || undefined,
tokens,
createdAt,
messages: preload.concat(ret.data),
};
} else {
this.logger.error(
`Unexpected message schema: ${JSON.stringify(ret.error)}`
);
}
} catch (e) {
this.logger.error('Unexpected error in listHistories', e);
}
return undefined;
})
)
)
.then(histories =>
histories.filter((v): v is NonNullable<typeof v> => !!v)
);
}
async getQuota(userId: string) {
const isCopilotUser = await this.feature.isCopilotUser(userId);
let limit: number | undefined;
if (!isCopilotUser) {
const quota = await this.quota.getUserQuota(userId);
limit = quota.feature.copilotActionLimit;
}
const actions = await this.countUserActions(userId);
const chats = await this.countUserChats(userId);
return { limit, used: actions + chats };
}
async checkQuota(userId: string) {
const { limit, used } = await this.getQuota(userId);
if (limit && Number.isFinite(limit) && used >= limit) {
throw new PaymentRequiredException(
`You have reached the limit of actions in this workspace, please upgrade your plan.`
);
}
}
async create(options: ChatSessionOptions): Promise<string> {
const sessionId = randomUUID();
const prompt = await this.prompt.get(options.promptName);
if (!prompt) {
this.logger.error(`Prompt not found: ${options.promptName}`);
throw new Error('Prompt not found');
}
return await this.setSession({
...options,
sessionId,
prompt,
messages: [],
});
}
async createMessage(message: SubmittedMessage): Promise<string | undefined> {
return await this.messageCache.set(message);
}
/**
* usage:
* ``` typescript
* {
* // allocate a session, can be reused chat in about 12 hours with same session
* await using session = await session.get(sessionId);
* session.push(message);
* copilot.generateText(session.finish(), model);
* }
* // session will be disposed after the block
* @param sessionId session id
* @returns
*/
async get(sessionId: string): Promise<ChatSession | null> {
const state = await this.getSession(sessionId);
if (state) {
return new ChatSession(this.messageCache, state, async state => {
await this.setSession(state);
});
}
return null;
}
}
@@ -0,0 +1,95 @@
import { createHash } from 'node:crypto';
import { Injectable, PayloadTooLargeException } from '@nestjs/common';
import { QuotaManagementService } from '../../core/quota';
import {
type BlobInputType,
Config,
type FileUpload,
type StorageProvider,
StorageProviderFactory,
} from '../../fundamentals';
@Injectable()
export class CopilotStorage {
public readonly provider: StorageProvider;
constructor(
private readonly config: Config,
private readonly storageFactory: StorageProviderFactory,
private readonly quota: QuotaManagementService
) {
this.provider = this.storageFactory.create('copilot');
}
async put(
userId: string,
workspaceId: string,
key: string,
blob: BlobInputType
) {
const name = `${userId}/${workspaceId}/${key}`;
await this.provider.put(name, blob);
if (this.config.node.dev) {
// return image base64url for dev environment
return `data:image/png;base64,${blob.toString('base64')}`;
}
return `${this.config.baseUrl}/api/copilot/blob/${name}`;
}
async get(userId: string, workspaceId: string, key: string) {
return this.provider.get(`${userId}/${workspaceId}/${key}`);
}
async delete(userId: string, workspaceId: string, key: string) {
return this.provider.delete(`${userId}/${workspaceId}/${key}`);
}
async handleUpload(userId: string, blob: FileUpload) {
const checkExceeded = await this.quota.getQuotaCalculator(userId);
if (checkExceeded(0)) {
throw new PayloadTooLargeException(
'Storage or blob size limit exceeded.'
);
}
const buffer = await new Promise<Buffer>((resolve, reject) => {
const stream = blob.createReadStream();
const chunks: Uint8Array[] = [];
stream.on('data', chunk => {
chunks.push(chunk);
// check size after receive each chunk to avoid unnecessary memory usage
const bufferSize = chunks.reduce((acc, cur) => acc + cur.length, 0);
if (checkExceeded(bufferSize)) {
reject(
new PayloadTooLargeException('Storage or blob size limit exceeded.')
);
}
});
stream.on('error', reject);
stream.on('end', () => {
const buffer = Buffer.concat(chunks);
if (checkExceeded(buffer.length)) {
reject(new PayloadTooLargeException('Storage limit exceeded.'));
} else {
resolve(buffer);
}
});
});
return {
buffer,
filename: blob.filename,
};
}
async handleRemoteLink(userId: string, workspaceId: string, link: string) {
const response = await fetch(link);
const buffer = new Uint8Array(await response.arrayBuffer());
const filename = createHash('sha256').update(buffer).digest('base64url');
return this.put(userId, workspaceId, filename, Buffer.from(buffer));
}
}
@@ -0,0 +1,250 @@
import { AiPromptRole } from '@prisma/client';
import type { ClientOptions as OpenAIClientOptions } from 'openai';
import {
encoding_for_model,
get_encoding,
Tiktoken,
TiktokenModel,
} from 'tiktoken';
import { z } from 'zod';
import type { ChatPrompt } from './prompt';
import type { FalConfig } from './providers/fal';
export interface CopilotConfig {
openai: OpenAIClientOptions;
fal: FalConfig;
unsplashKey: string;
}
export enum AvailableModels {
// text to text
Gpt4VisionPreview = 'gpt-4-vision-preview',
Gpt4TurboPreview = 'gpt-4-turbo-preview',
Gpt35Turbo = 'gpt-3.5-turbo',
// embeddings
TextEmbedding3Large = 'text-embedding-3-large',
TextEmbedding3Small = 'text-embedding-3-small',
TextEmbeddingAda002 = 'text-embedding-ada-002',
// moderation
TextModerationLatest = 'text-moderation-latest',
TextModerationStable = 'text-moderation-stable',
// text to image
DallE3 = 'dall-e-3',
}
export type AvailableModel = keyof typeof AvailableModels;
export function getTokenEncoder(model?: string | null): Tiktoken | undefined {
if (!model) return undefined;
const modelStr = AvailableModels[model as AvailableModel];
if (!modelStr) return undefined;
if (modelStr.startsWith('gpt')) {
return encoding_for_model(modelStr as TiktokenModel);
} else if (modelStr.startsWith('dall')) {
// dalle don't need to calc the token
return undefined;
} else {
return get_encoding('cl100k_base');
}
}
// ======== ChatMessage ========
export const ChatMessageRole = Object.values(AiPromptRole) as [
'system',
'assistant',
'user',
];
const PureMessageSchema = z.object({
content: z.string(),
attachments: z.array(z.string()).optional().nullable(),
params: z
.record(z.union([z.string(), z.array(z.string())]))
.optional()
.nullable(),
});
export const PromptMessageSchema = PureMessageSchema.extend({
role: z.enum(ChatMessageRole),
}).strict();
export type PromptMessage = z.infer<typeof PromptMessageSchema>;
export type PromptParams = NonNullable<PromptMessage['params']>;
export const ChatMessageSchema = PromptMessageSchema.extend({
createdAt: z.date(),
}).strict();
export type ChatMessage = z.infer<typeof ChatMessageSchema>;
export const SubmittedMessageSchema = PureMessageSchema.extend({
sessionId: z.string(),
content: z.string().optional(),
}).strict();
export type SubmittedMessage = z.infer<typeof SubmittedMessageSchema>;
export const ChatHistorySchema = z
.object({
sessionId: z.string(),
action: z.string().optional(),
tokens: z.number(),
messages: z.array(PromptMessageSchema.or(ChatMessageSchema)),
createdAt: z.date(),
})
.strict();
export type ChatHistory = z.infer<typeof ChatHistorySchema>;
// ======== Chat Session ========
export interface ChatSessionOptions {
// connect ids
userId: string;
workspaceId: string;
docId: string;
promptName: string;
}
export interface ChatSessionState
extends Omit<ChatSessionOptions, 'promptName'> {
// connect ids
sessionId: string;
// states
prompt: ChatPrompt;
messages: ChatMessage[];
}
export type ListHistoriesOptions = {
action: boolean | undefined;
limit: number | undefined;
skip: number | undefined;
sessionId: string | undefined;
};
// ======== Provider Interface ========
export enum CopilotProviderType {
FAL = 'fal',
OpenAI = 'openai',
}
export enum CopilotCapability {
TextToText = 'text-to-text',
TextToEmbedding = 'text-to-embedding',
TextToImage = 'text-to-image',
ImageToImage = 'image-to-image',
ImageToText = 'image-to-text',
}
export interface CopilotProvider {
getCapabilities(): CopilotCapability[];
isModelAvailable(model: string): boolean;
}
export interface CopilotTextToTextProvider extends CopilotProvider {
generateText(
messages: PromptMessage[],
model?: string,
options?: {
temperature?: number;
maxTokens?: number;
signal?: AbortSignal;
user?: string;
}
): Promise<string>;
generateTextStream(
messages: PromptMessage[],
model?: string,
options?: {
temperature?: number;
maxTokens?: number;
signal?: AbortSignal;
user?: string;
}
): AsyncIterable<string>;
}
export interface CopilotTextToEmbeddingProvider extends CopilotProvider {
generateEmbedding(
messages: string[] | string,
model: string,
options: {
dimensions: number;
signal?: AbortSignal;
user?: string;
}
): Promise<number[][]>;
}
export interface CopilotTextToImageProvider extends CopilotProvider {
generateImages(
messages: PromptMessage[],
model: string,
options: {
signal?: AbortSignal;
user?: string;
}
): Promise<Array<string>>;
generateImagesStream(
messages: PromptMessage[],
model?: string,
options?: {
signal?: AbortSignal;
user?: string;
}
): AsyncIterable<string>;
}
export interface CopilotImageToTextProvider extends CopilotProvider {
generateText(
messages: PromptMessage[],
model: string,
options: {
temperature?: number;
maxTokens?: number;
signal?: AbortSignal;
user?: string;
}
): Promise<string>;
generateTextStream(
messages: PromptMessage[],
model: string,
options: {
temperature?: number;
maxTokens?: number;
signal?: AbortSignal;
user?: string;
}
): AsyncIterable<string>;
}
export interface CopilotImageToImageProvider extends CopilotProvider {
generateImages(
messages: PromptMessage[],
model: string,
options: {
signal?: AbortSignal;
user?: string;
}
): Promise<Array<string>>;
generateImagesStream(
messages: PromptMessage[],
model?: string,
options?: {
signal?: AbortSignal;
user?: string;
}
): AsyncIterable<string>;
}
export type CapabilityToCopilotProvider = {
[CopilotCapability.TextToText]: CopilotTextToTextProvider;
[CopilotCapability.TextToEmbedding]: CopilotTextToEmbeddingProvider;
[CopilotCapability.TextToImage]: CopilotTextToImageProvider;
[CopilotCapability.ImageToText]: CopilotImageToTextProvider;
[CopilotCapability.ImageToImage]: CopilotImageToImageProvider;
};

Some files were not shown because too many files have changed in this diff Show More