mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-03-23 15:50:43 +08:00
Compare commits
1 Commits
v0.24.0-be
...
use-jemall
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1d7011047 |
@@ -219,41 +219,6 @@
|
||||
"type": "boolean",
|
||||
"description": "Whether ignore email server's TSL certification verification. Enable it for self-signed certificates.\n@default false\n@environment `MAILER_IGNORE_TLS`",
|
||||
"default": false
|
||||
},
|
||||
"fallbackDomains": {
|
||||
"type": "array",
|
||||
"description": "The emails from these domains are always sent using the fallback SMTP server.\n@default []",
|
||||
"default": []
|
||||
},
|
||||
"fallbackSMTP.host": {
|
||||
"type": "string",
|
||||
"description": "Host of the email server (e.g. smtp.gmail.com)\n@default \"\"",
|
||||
"default": ""
|
||||
},
|
||||
"fallbackSMTP.port": {
|
||||
"type": "number",
|
||||
"description": "Port of the email server (they commonly are 25, 465 or 587)\n@default 465",
|
||||
"default": 465
|
||||
},
|
||||
"fallbackSMTP.username": {
|
||||
"type": "string",
|
||||
"description": "Username used to authenticate the email server\n@default \"\"",
|
||||
"default": ""
|
||||
},
|
||||
"fallbackSMTP.password": {
|
||||
"type": "string",
|
||||
"description": "Password used to authenticate the email server\n@default \"\"",
|
||||
"default": ""
|
||||
},
|
||||
"fallbackSMTP.sender": {
|
||||
"type": "string",
|
||||
"description": "Sender of all the emails (e.g. \"AFFiNE Team <noreply@affine.pro>\")\n@default \"\"",
|
||||
"default": ""
|
||||
},
|
||||
"fallbackSMTP.ignoreTLS": {
|
||||
"type": "boolean",
|
||||
"description": "Whether ignore email server's TSL certification verification. Enable it for self-signed certificates.\n@default false",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -664,34 +629,14 @@
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to enable the copilot plugin. <br> Document: <a href=\"https://docs.affine.pro/self-host-affine/administer/ai\" target=\"_blank\">https://docs.affine.pro/self-host-affine/administer/ai</a>\n@default false",
|
||||
"description": "Whether to enable the copilot plugin.\n@default false",
|
||||
"default": false
|
||||
},
|
||||
"scenarios": {
|
||||
"type": "object",
|
||||
"description": "Use custom models in scenarios and override default settings.\n@default {\"override_enabled\":false,\"scenarios\":{\"audio_transcribing\":\"gemini-2.5-flash\",\"chat\":\"claude-sonnet-4@20250514\",\"embedding\":\"gemini-embedding-001\",\"image\":\"gpt-image-1\",\"rerank\":\"gpt-4.1\",\"coding\":\"claude-sonnet-4@20250514\",\"complex_text_generation\":\"gpt-4o-2024-08-06\",\"quick_decision_making\":\"gpt-4.1-mini\",\"quick_text_generation\":\"gemini-2.5-flash\",\"polish_and_summarize\":\"gemini-2.5-flash\"}}",
|
||||
"default": {
|
||||
"override_enabled": false,
|
||||
"scenarios": {
|
||||
"audio_transcribing": "gemini-2.5-flash",
|
||||
"chat": "claude-sonnet-4@20250514",
|
||||
"embedding": "gemini-embedding-001",
|
||||
"image": "gpt-image-1",
|
||||
"rerank": "gpt-4.1",
|
||||
"coding": "claude-sonnet-4@20250514",
|
||||
"complex_text_generation": "gpt-4o-2024-08-06",
|
||||
"quick_decision_making": "gpt-4.1-mini",
|
||||
"quick_text_generation": "gemini-2.5-flash",
|
||||
"polish_and_summarize": "gemini-2.5-flash"
|
||||
}
|
||||
}
|
||||
},
|
||||
"providers.openai": {
|
||||
"type": "object",
|
||||
"description": "The config for the openai provider.\n@default {\"apiKey\":\"\",\"baseURL\":\"https://api.openai.com/v1\"}\n@link https://github.com/openai/openai-node",
|
||||
"description": "The config for the openai provider.\n@default {\"apiKey\":\"\"}\n@link https://github.com/openai/openai-node",
|
||||
"default": {
|
||||
"apiKey": "",
|
||||
"baseURL": "https://api.openai.com/v1"
|
||||
"apiKey": ""
|
||||
}
|
||||
},
|
||||
"providers.fal": {
|
||||
@@ -703,10 +648,9 @@
|
||||
},
|
||||
"providers.gemini": {
|
||||
"type": "object",
|
||||
"description": "The config for the gemini provider.\n@default {\"apiKey\":\"\",\"baseURL\":\"https://generativelanguage.googleapis.com/v1beta\"}",
|
||||
"description": "The config for the gemini provider.\n@default {\"apiKey\":\"\"}",
|
||||
"default": {
|
||||
"apiKey": "",
|
||||
"baseURL": "https://generativelanguage.googleapis.com/v1beta"
|
||||
"apiKey": ""
|
||||
}
|
||||
},
|
||||
"providers.geminiVertex": {
|
||||
@@ -753,10 +697,9 @@
|
||||
},
|
||||
"providers.anthropic": {
|
||||
"type": "object",
|
||||
"description": "The config for the anthropic provider.\n@default {\"apiKey\":\"\",\"baseURL\":\"https://api.anthropic.com/v1\"}",
|
||||
"description": "The config for the anthropic provider.\n@default {\"apiKey\":\"\"}",
|
||||
"default": {
|
||||
"apiKey": "",
|
||||
"baseURL": "https://api.anthropic.com/v1"
|
||||
"apiKey": ""
|
||||
}
|
||||
},
|
||||
"providers.anthropicVertex": {
|
||||
|
||||
30
.github/actions/deploy/deploy.mjs
vendored
30
.github/actions/deploy/deploy.mjs
vendored
@@ -29,25 +29,25 @@ const isInternal = buildType === 'internal';
|
||||
|
||||
const replicaConfig = {
|
||||
stable: {
|
||||
web: 2,
|
||||
graphql: Number(process.env.PRODUCTION_GRAPHQL_REPLICA) || 2,
|
||||
sync: Number(process.env.PRODUCTION_SYNC_REPLICA) || 2,
|
||||
renderer: Number(process.env.PRODUCTION_RENDERER_REPLICA) || 2,
|
||||
doc: Number(process.env.PRODUCTION_DOC_REPLICA) || 2,
|
||||
web: 3,
|
||||
graphql: Number(process.env.PRODUCTION_GRAPHQL_REPLICA) || 3,
|
||||
sync: Number(process.env.PRODUCTION_SYNC_REPLICA) || 3,
|
||||
renderer: Number(process.env.PRODUCTION_RENDERER_REPLICA) || 3,
|
||||
doc: Number(process.env.PRODUCTION_DOC_REPLICA) || 3,
|
||||
},
|
||||
beta: {
|
||||
web: 1,
|
||||
graphql: Number(process.env.BETA_GRAPHQL_REPLICA) || 1,
|
||||
sync: Number(process.env.BETA_SYNC_REPLICA) || 1,
|
||||
renderer: Number(process.env.BETA_RENDERER_REPLICA) || 1,
|
||||
doc: Number(process.env.BETA_DOC_REPLICA) || 1,
|
||||
web: 2,
|
||||
graphql: Number(process.env.BETA_GRAPHQL_REPLICA) || 2,
|
||||
sync: Number(process.env.BETA_SYNC_REPLICA) || 2,
|
||||
renderer: Number(process.env.BETA_RENDERER_REPLICA) || 2,
|
||||
doc: Number(process.env.BETA_DOC_REPLICA) || 2,
|
||||
},
|
||||
canary: {
|
||||
web: 1,
|
||||
graphql: 1,
|
||||
sync: 1,
|
||||
renderer: 1,
|
||||
doc: 1,
|
||||
web: 2,
|
||||
graphql: 2,
|
||||
sync: 2,
|
||||
renderer: 2,
|
||||
doc: 2,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
6
.github/actions/setup-version/action.yml
vendored
6
.github/actions/setup-version/action.yml
vendored
@@ -4,15 +4,9 @@ inputs:
|
||||
app-version:
|
||||
description: 'App Version'
|
||||
required: true
|
||||
ios-app-version:
|
||||
description: 'iOS App Store Version (Optional, use App version if empty)'
|
||||
required: false
|
||||
type: string
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: 'Write Version'
|
||||
shell: bash
|
||||
env:
|
||||
IOS_APP_VERSION: ${{ inputs.ios-app-version }}
|
||||
run: ./scripts/set-version.sh ${{ inputs.app-version }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
replicaCount: 2
|
||||
replicaCount: 3
|
||||
enabled: false
|
||||
database:
|
||||
connectionName: ""
|
||||
@@ -33,11 +33,8 @@ service:
|
||||
|
||||
resources:
|
||||
limits:
|
||||
memory: "1Gi"
|
||||
cpu: "1"
|
||||
requests:
|
||||
memory: "512Mi"
|
||||
cpu: "100m"
|
||||
memory: "4Gi"
|
||||
cpu: "2"
|
||||
|
||||
volumes: []
|
||||
volumeMounts: []
|
||||
|
||||
2
.github/workflows/release-desktop.yml
vendored
2
.github/workflows/release-desktop.yml
vendored
@@ -465,7 +465,7 @@ jobs:
|
||||
name: ${{ env.RELEASE_VERSION }}
|
||||
draft: ${{ inputs.build-type == 'stable' }}
|
||||
prerelease: ${{ inputs.build-type != 'stable' }}
|
||||
tag_name: v${{ env.RELEASE_VERSION}}
|
||||
tag_name: ${{ env.RELEASE_VERSION}}
|
||||
files: |
|
||||
./release/*
|
||||
./release/.env.example
|
||||
|
||||
8
.github/workflows/release-mobile.yml
vendored
8
.github/workflows/release-mobile.yml
vendored
@@ -12,9 +12,6 @@ on:
|
||||
build-type:
|
||||
type: string
|
||||
required: true
|
||||
ios-app-version:
|
||||
type: string
|
||||
required: false
|
||||
|
||||
env:
|
||||
BUILD_TYPE: ${{ inputs.build-type }}
|
||||
@@ -81,7 +78,7 @@ jobs:
|
||||
path: packages/frontend/apps/android/dist
|
||||
|
||||
ios:
|
||||
runs-on: 'macos-15'
|
||||
runs-on: ${{ github.ref_name == 'canary' && 'macos-latest' || 'blaze/macos-14' }}
|
||||
needs:
|
||||
- build-ios-web
|
||||
steps:
|
||||
@@ -90,7 +87,6 @@ jobs:
|
||||
uses: ./.github/actions/setup-version
|
||||
with:
|
||||
app-version: ${{ inputs.app-version }}
|
||||
ios-app-version: ${{ inputs.ios-app-version }}
|
||||
- name: 'Update Code Sign Identity'
|
||||
shell: bash
|
||||
run: ./packages/frontend/apps/ios/update_code_sign_identity.sh
|
||||
@@ -110,7 +106,7 @@ jobs:
|
||||
enableScripts: false
|
||||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: 16.4
|
||||
xcode-version: 16.2
|
||||
- name: Install Swiftformat
|
||||
run: brew install swiftformat
|
||||
- name: Cap sync
|
||||
|
||||
7
.github/workflows/release.yml
vendored
7
.github/workflows/release.yml
vendored
@@ -21,10 +21,6 @@ on:
|
||||
required: true
|
||||
type: boolean
|
||||
default: false
|
||||
ios-app-version:
|
||||
description: 'iOS App Store Version (Optional, use tag version if empty)'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -34,7 +30,6 @@ permissions:
|
||||
packages: write
|
||||
security-events: write
|
||||
attestations: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
@@ -75,7 +70,6 @@ jobs:
|
||||
with:
|
||||
secret: ${{ secrets.GITHUB_TOKEN }}
|
||||
approvers: forehalo,fengmk2
|
||||
minimum-approvals: 1
|
||||
fail-on-denial: true
|
||||
issue-title: Please confirm to release docker image
|
||||
issue-body: |
|
||||
@@ -123,4 +117,3 @@ jobs:
|
||||
build-type: ${{ needs.prepare.outputs.BUILD_TYPE }}
|
||||
app-version: ${{ needs.prepare.outputs.APP_VERSION }}
|
||||
git-short-hash: ${{ needs.prepare.outputs.GIT_SHORT_HASH }}
|
||||
ios-app-version: ${{ inputs.ios-app-version }}
|
||||
|
||||
2
.github/workflows/windows-signer.yml
vendored
2
.github/workflows/windows-signer.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
shell: cmd
|
||||
run: |
|
||||
cd ${{ env.ARCHIVE_DIR }}/out
|
||||
signtool sign /tr http://timestamp.globalsign.com/tsa/r6advanced1 /td sha256 /fd sha256 /a ${{ inputs.files }}
|
||||
signtool sign /tr http://timestamp.sectigo.com /td sha256 /fd sha256 /a ${{ inputs.files }}
|
||||
- name: zip file
|
||||
shell: cmd
|
||||
run: |
|
||||
|
||||
@@ -266,7 +266,6 @@
|
||||
"./components/toggle-button": "./src/components/toggle-button.ts",
|
||||
"./components/toggle-switch": "./src/components/toggle-switch.ts",
|
||||
"./components/toolbar": "./src/components/toolbar.ts",
|
||||
"./components/tooltip": "./src/components/tooltip.ts",
|
||||
"./components/view-dropdown-menu": "./src/components/view-dropdown-menu.ts",
|
||||
"./components/tooltip-content-with-shortcut": "./src/components/tooltip-content-with-shortcut.ts",
|
||||
"./components/resource": "./src/components/resource.ts",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from '@blocksuite/affine-components/tooltip';
|
||||
@@ -39,13 +39,6 @@ export class CodeBlockHighlighter extends LifeCycleWatcher {
|
||||
private readonly _loadTheme = async (
|
||||
highlighter: HighlighterCore
|
||||
): Promise<void> => {
|
||||
// It is possible that by the time the highlighter is ready all instances
|
||||
// have already been unmounted. In that case there is no need to load
|
||||
// themes or update state.
|
||||
if (CodeBlockHighlighter._refCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = this.std.getOptional(CodeBlockConfigExtension.identifier);
|
||||
const darkTheme = config?.theme?.dark ?? CODE_BLOCK_DEFAULT_DARK_THEME;
|
||||
const lightTheme = config?.theme?.light ?? CODE_BLOCK_DEFAULT_LIGHT_THEME;
|
||||
@@ -85,27 +78,14 @@ export class CodeBlockHighlighter extends LifeCycleWatcher {
|
||||
override unmounted(): void {
|
||||
CodeBlockHighlighter._refCount--;
|
||||
|
||||
// Dispose the shared highlighter **after** any in-flight creation finishes.
|
||||
if (CodeBlockHighlighter._refCount !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const doDispose = (highlighter: HighlighterCore | null) => {
|
||||
if (highlighter) {
|
||||
highlighter.dispose();
|
||||
}
|
||||
// Only dispose the shared highlighter when no instances are using it
|
||||
if (
|
||||
CodeBlockHighlighter._refCount === 0 &&
|
||||
CodeBlockHighlighter._sharedHighlighter
|
||||
) {
|
||||
CodeBlockHighlighter._sharedHighlighter.dispose();
|
||||
CodeBlockHighlighter._sharedHighlighter = null;
|
||||
CodeBlockHighlighter._highlighterPromise = null;
|
||||
};
|
||||
|
||||
if (CodeBlockHighlighter._sharedHighlighter) {
|
||||
// Highlighter already created – dispose immediately.
|
||||
doDispose(CodeBlockHighlighter._sharedHighlighter);
|
||||
} else if (CodeBlockHighlighter._highlighterPromise) {
|
||||
// Highlighter still being created – wait for it, then dispose.
|
||||
CodeBlockHighlighter._highlighterPromise
|
||||
.then(doDispose)
|
||||
.catch(console.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,10 +164,8 @@ export class DatabaseBlockDataSource extends DataSourceBase {
|
||||
readonly$: ReadonlySignal<boolean> = computed(() => {
|
||||
return (
|
||||
this._model.store.readonly ||
|
||||
(IS_MOBILE &&
|
||||
!this._model.store.provider
|
||||
.get(FeatureFlagService)
|
||||
.getFlag('enable_mobile_database_editing'))
|
||||
// TODO(@L-Sun): use block level readonly
|
||||
IS_MOBILE
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
BlockElementCommentManager,
|
||||
CommentProviderIdentifier,
|
||||
DocModeProvider,
|
||||
FeatureFlagService,
|
||||
NotificationProvider,
|
||||
type TelemetryEventMap,
|
||||
TelemetryProvider,
|
||||
@@ -35,7 +34,6 @@ import {
|
||||
uniMap,
|
||||
} from '@blocksuite/data-view';
|
||||
import { widgetPresets } from '@blocksuite/data-view/widget-presets';
|
||||
import { IS_MOBILE } from '@blocksuite/global/env';
|
||||
import { Rect } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
CommentIcon,
|
||||
@@ -50,7 +48,6 @@ import { autoUpdate } from '@floating-ui/dom';
|
||||
import { computed, signal } from '@preact/signals-core';
|
||||
import { html, nothing } from 'lit';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { popSideDetail } from './components/layout.js';
|
||||
import { DatabaseConfigExtension } from './config.js';
|
||||
@@ -352,7 +349,6 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent<DatabaseBloc
|
||||
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
|
||||
this.classList.add(databaseBlockStyles);
|
||||
this.listenFullWidthChange();
|
||||
this.handleMobileEditing();
|
||||
}
|
||||
|
||||
listenFullWidthChange() {
|
||||
@@ -368,40 +364,6 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent<DatabaseBloc
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
handleMobileEditing() {
|
||||
if (!IS_MOBILE) return;
|
||||
|
||||
const handler = () => {
|
||||
if (
|
||||
!this.std
|
||||
.get(FeatureFlagService)
|
||||
.getFlag('enable_mobile_database_editing')
|
||||
) {
|
||||
const notification = this.std.getOptional(NotificationProvider);
|
||||
if (notification) {
|
||||
notification.notify({
|
||||
title: html`<div
|
||||
style=${styleMap({
|
||||
whiteSpace: 'wrap',
|
||||
})}
|
||||
>
|
||||
Mobile database editing is not supported yet. You can open it in
|
||||
experimental features, or edit it in desktop mode.
|
||||
</div>`,
|
||||
accent: 'warning',
|
||||
});
|
||||
}
|
||||
this.removeEventListener('click', handler);
|
||||
}
|
||||
};
|
||||
|
||||
this.addEventListener('click', handler);
|
||||
this.disposables.add(() => {
|
||||
this.removeEventListener('click', handler);
|
||||
});
|
||||
}
|
||||
|
||||
private readonly dataViewRootLogic = lazy(
|
||||
() =>
|
||||
new DataViewRootUILogic({
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ImageBlockModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
ActionPlacement,
|
||||
blockCommentToolbarButton,
|
||||
type ToolbarModuleConfig,
|
||||
ToolbarModuleExtension,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
@@ -50,10 +49,6 @@ const builtinToolbarConfig = {
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c.comment',
|
||||
...blockCommentToolbarButton,
|
||||
},
|
||||
{
|
||||
placement: ActionPlacement.More,
|
||||
id: 'a.clipboard',
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
getPrevContentBlock,
|
||||
matchModels,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { IS_MOBILE } from '@blocksuite/global/env';
|
||||
import { BlockSelection, type EditorHost } from '@blocksuite/std';
|
||||
import type { BlockModel, Text } from '@blocksuite/store';
|
||||
|
||||
@@ -92,17 +91,10 @@ export function mergeWithPrev(editorHost: EditorHost, model: BlockModel) {
|
||||
...EMBED_BLOCK_MODEL_LIST,
|
||||
])
|
||||
) {
|
||||
// due to create a block selection will clear text selection, which lead
|
||||
// the virtual keyboard to be auto closed on mobile. This behavior breaks
|
||||
// the user experience.
|
||||
if (!IS_MOBILE) {
|
||||
const selection = editorHost.selection.create(BlockSelection, {
|
||||
blockId: prevBlock.id,
|
||||
});
|
||||
editorHost.selection.setGroup('note', [selection]);
|
||||
} else {
|
||||
doc.deleteBlock(prevBlock);
|
||||
}
|
||||
const selection = editorHost.selection.create(BlockSelection, {
|
||||
blockId: prevBlock.id,
|
||||
});
|
||||
editorHost.selection.setGroup('note', [selection]);
|
||||
|
||||
if (model.text?.length === 0) {
|
||||
doc.deleteBlock(model, {
|
||||
|
||||
@@ -634,9 +634,9 @@ export class EdgelessPageKeyboardManager extends PageKeyboardManager {
|
||||
|
||||
const movedElements = new Set([
|
||||
...selectedElements,
|
||||
...selectedElements.flatMap(el =>
|
||||
isGfxGroupCompatibleModel(el) ? el.descendantElements : []
|
||||
),
|
||||
...selectedElements
|
||||
.map(el => (isGfxGroupCompatibleModel(el) ? el.descendantElements : []))
|
||||
.flat(),
|
||||
]);
|
||||
|
||||
movedElements.forEach(element => {
|
||||
|
||||
@@ -4,6 +4,6 @@ export * from './clipboard/command';
|
||||
export * from './edgeless-root-block.js';
|
||||
export { EdgelessRootService } from './edgeless-root-service.js';
|
||||
export * from './utils/clipboard-utils.js';
|
||||
export { getElementProps, sortEdgelessElements } from './utils/clone-utils.js';
|
||||
export { sortEdgelessElements } from './utils/clone-utils.js';
|
||||
export { isCanvasElement } from './utils/query.js';
|
||||
export { EDGELESS_BLOCK_CHILD_PADDING } from '@blocksuite/affine-shared/consts';
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
} from '@blocksuite/affine-shared/commands';
|
||||
import {
|
||||
ActionPlacement,
|
||||
blockCommentToolbarButton,
|
||||
type ToolbarModuleConfig,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { CaptionIcon, CopyIcon, DeleteIcon } from '@blocksuite/icons/lit';
|
||||
@@ -62,10 +61,6 @@ export const surfaceRefToolbarModuleConfig: ToolbarModuleConfig = {
|
||||
surfaceRefBlock.captionElement.show();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'e.comment',
|
||||
...blockCommentToolbarButton,
|
||||
},
|
||||
{
|
||||
id: 'a.clipboard',
|
||||
placement: ActionPlacement.More,
|
||||
|
||||
@@ -73,8 +73,7 @@
|
||||
"./edgeless-line-styles-panel": "./src/edgeless-line-styles-panel/index.ts",
|
||||
"./edgeless-shape-color-picker": "./src/edgeless-shape-color-picker/index.ts",
|
||||
"./open-doc-dropdown-menu": "./src/open-doc-dropdown-menu/index.ts",
|
||||
"./slider": "./src/slider/index.ts",
|
||||
"./tooltip": "./src/tooltip/index.ts"
|
||||
"./slider": "./src/slider/index.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
|
||||
@@ -85,8 +85,6 @@ export class MenuSubMenu extends MenuFocusable {
|
||||
.catch(err => console.error(err));
|
||||
});
|
||||
this.menu.openSubMenu(menu);
|
||||
// in case that the menu is not closed, but the component is removed,
|
||||
this.disposables.add(unsub);
|
||||
}
|
||||
|
||||
protected override render(): unknown {
|
||||
|
||||
@@ -18,7 +18,6 @@ export const LoadingIcon = ({
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
style="fill: none;"
|
||||
>
|
||||
<style>
|
||||
.spinner {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { effects as tooltipEffects } from '../tooltip/effect.js';
|
||||
import { EditorIconButton } from './icon-button.js';
|
||||
import {
|
||||
EditorMenuAction,
|
||||
@@ -7,6 +6,7 @@ import {
|
||||
} from './menu-button.js';
|
||||
import { EditorToolbarSeparator } from './separator.js';
|
||||
import { EditorToolbar } from './toolbar.js';
|
||||
import { Tooltip } from './tooltip.js';
|
||||
|
||||
export { EditorChevronDown } from './chevron-down.js';
|
||||
export { ToolbarMoreMenuConfigExtension } from './config.js';
|
||||
@@ -20,6 +20,7 @@ export { MenuContext } from './menu-context.js';
|
||||
export { EditorToolbarSeparator } from './separator.js';
|
||||
export { darkToolbarStyles, lightToolbarStyles } from './styles.js';
|
||||
export { EditorToolbar } from './toolbar.js';
|
||||
export { Tooltip } from './tooltip.js';
|
||||
export type {
|
||||
AdvancedMenuItem,
|
||||
FatMenuItems,
|
||||
@@ -37,12 +38,11 @@ export {
|
||||
} from './utils.js';
|
||||
|
||||
export function effects() {
|
||||
tooltipEffects();
|
||||
|
||||
customElements.define('editor-toolbar-separator', EditorToolbarSeparator);
|
||||
customElements.define('editor-toolbar', EditorToolbar);
|
||||
customElements.define('editor-icon-button', EditorIconButton);
|
||||
customElements.define('editor-menu-button', EditorMenuButton);
|
||||
customElements.define('editor-menu-content', EditorMenuContent);
|
||||
customElements.define('editor-menu-action', EditorMenuAction);
|
||||
customElements.define('affine-tooltip', Tooltip);
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { Tooltip } from './tooltip.js';
|
||||
|
||||
export function effects() {
|
||||
if (!customElements.get('affine-tooltip')) {
|
||||
customElements.define('affine-tooltip', Tooltip);
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { effects } from './effect.js';
|
||||
export { Tooltip } from './tooltip.js';
|
||||
@@ -65,7 +65,7 @@ export abstract class DataViewUILogicBase<
|
||||
return handler(context);
|
||||
});
|
||||
}
|
||||
setSelection(selection?: Selection) {
|
||||
setSelection(selection?: Selection): void {
|
||||
this.root.setSelection(selection);
|
||||
}
|
||||
|
||||
|
||||
@@ -73,9 +73,7 @@ export class MobileKanbanCell extends SignalWatcher(
|
||||
if (this.view.readonly$.value) {
|
||||
return;
|
||||
}
|
||||
const setSelection = this.kanbanViewLogic.setSelection.bind(
|
||||
this.kanbanViewLogic
|
||||
);
|
||||
const setSelection = this.kanbanViewLogic.setSelection;
|
||||
const viewId = this.kanbanViewLogic.view.id;
|
||||
if (setSelection && viewId) {
|
||||
if (editing && this.cell?.beforeEnterEditMode() === false) {
|
||||
|
||||
@@ -86,9 +86,6 @@ export class MobileKanbanViewUILogic extends DataViewUILogicBase<
|
||||
}
|
||||
|
||||
renderAddGroup = () => {
|
||||
if (this.readonly) {
|
||||
return;
|
||||
}
|
||||
const addGroup = this.groupManager.addGroup;
|
||||
if (!addGroup) {
|
||||
return;
|
||||
|
||||
@@ -68,9 +68,7 @@ export class MobileTableCell extends SignalWatcher(
|
||||
if (this.view.readonly$.value) {
|
||||
return;
|
||||
}
|
||||
const setSelection = this.tableViewLogic.setSelection.bind(
|
||||
this.tableViewLogic
|
||||
);
|
||||
const setSelection = this.tableViewLogic.setSelection;
|
||||
const viewId = this.tableViewLogic.view.id;
|
||||
if (setSelection && viewId) {
|
||||
if (editing && this.cell?.beforeEnterEditMode() === false) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { IS_IOS } from '@blocksuite/global/env';
|
||||
import { css } from '@emotion/css';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
|
||||
@@ -11,7 +10,7 @@ export const mobileTableViewWrapper = css({
|
||||
* See https://github.com/toeverything/AFFiNE/pull/12203
|
||||
* and https://github.com/toeverything/blocksuite/pull/8784
|
||||
*/
|
||||
overflowX: IS_IOS ? 'hidden' : undefined,
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'hidden',
|
||||
});
|
||||
|
||||
|
||||
@@ -88,9 +88,6 @@ export class FilterBar extends SignalWatcher(ShadowlessElement) {
|
||||
};
|
||||
|
||||
private readonly addFilter = (e: MouseEvent) => {
|
||||
if (this.dataViewLogic.root.config.dataSource.readonly$.peek()) {
|
||||
return;
|
||||
}
|
||||
const element = popupTargetFromElement(e.target as HTMLElement);
|
||||
popCreateFilter(element, {
|
||||
vars: this.vars,
|
||||
|
||||
@@ -68,5 +68,5 @@ export function getHeadingBlocksFromDoc(
|
||||
ignoreEmpty = false
|
||||
) {
|
||||
const notes = getNotesFromStore(store, modes);
|
||||
return notes.flatMap(note => getHeadingBlocksFromNote(note, ignoreEmpty));
|
||||
return notes.map(note => getHeadingBlocksFromNote(note, ignoreEmpty)).flat();
|
||||
}
|
||||
|
||||
@@ -116,7 +116,6 @@ export class EdgelessTemplateButton extends EdgelessToolbarToolMixin(
|
||||
`;
|
||||
|
||||
private _cleanup: (() => void) | null = null;
|
||||
private _autoUpdateCleanup: (() => void) | null = null;
|
||||
|
||||
private _prevTool: ToolOptionWithType | null = null;
|
||||
|
||||
@@ -129,11 +128,6 @@ export class EdgelessTemplateButton extends EdgelessToolbarToolMixin(
|
||||
return [TemplateCard1[theme], TemplateCard2[theme], TemplateCard3[theme]];
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.disposables.add(() => this._autoUpdateCleanup?.());
|
||||
}
|
||||
|
||||
private _closePanel() {
|
||||
if (this._openedPanel) {
|
||||
this._openedPanel.remove();
|
||||
@@ -181,8 +175,8 @@ export class EdgelessTemplateButton extends EdgelessToolbarToolMixin(
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const arrowEl = panel.renderRoot.querySelector('.arrow') as HTMLElement;
|
||||
this._autoUpdateCleanup?.();
|
||||
this._autoUpdateCleanup = autoUpdate(this, panel, () => {
|
||||
|
||||
autoUpdate(this, panel, () => {
|
||||
computePosition(this, panel, {
|
||||
placement: 'top',
|
||||
middleware: [offset(20), arrow({ element: arrowEl }), shift()],
|
||||
|
||||
@@ -103,52 +103,54 @@ export class InlineCommentManager extends LifeCycleWatcher {
|
||||
id: CommentId,
|
||||
selections: BaseSelection[]
|
||||
) => {
|
||||
const needCommentTexts = selections.flatMap(selection => {
|
||||
if (!selection.is(TextSelection)) return [];
|
||||
const [_, { selectedBlocks }] = this.std.command
|
||||
.chain()
|
||||
.pipe(getSelectedBlocksCommand, {
|
||||
textSelection: selection,
|
||||
})
|
||||
.run();
|
||||
const needCommentTexts = selections
|
||||
.map(selection => {
|
||||
if (!selection.is(TextSelection)) return [];
|
||||
const [_, { selectedBlocks }] = this.std.command
|
||||
.chain()
|
||||
.pipe(getSelectedBlocksCommand, {
|
||||
textSelection: selection,
|
||||
})
|
||||
.run();
|
||||
|
||||
if (!selectedBlocks) return [];
|
||||
if (!selectedBlocks) return [];
|
||||
|
||||
type MakeRequired<T, K extends keyof T> = T & {
|
||||
[key in K]: NonNullable<T[key]>;
|
||||
};
|
||||
type MakeRequired<T, K extends keyof T> = T & {
|
||||
[key in K]: NonNullable<T[key]>;
|
||||
};
|
||||
|
||||
return selectedBlocks
|
||||
.map(
|
||||
({ model }) =>
|
||||
[model, getInlineEditorByModel(this.std, model)] as const
|
||||
)
|
||||
.filter(
|
||||
(
|
||||
pair
|
||||
): pair is [MakeRequired<BlockModel, 'text'>, AffineInlineEditor] =>
|
||||
!!pair[0].text && !!pair[1]
|
||||
)
|
||||
.map(([model, inlineEditor]) => {
|
||||
let from: TextRangePoint;
|
||||
let to: TextRangePoint | null;
|
||||
if (model.id === selection.from.blockId) {
|
||||
from = selection.from;
|
||||
to = null;
|
||||
} else if (model.id === selection.to?.blockId) {
|
||||
from = selection.to;
|
||||
to = null;
|
||||
} else {
|
||||
from = {
|
||||
blockId: model.id,
|
||||
index: 0,
|
||||
length: model.text.yText.length,
|
||||
};
|
||||
to = null;
|
||||
}
|
||||
return [new TextSelection({ from, to }), inlineEditor] as const;
|
||||
});
|
||||
});
|
||||
return selectedBlocks
|
||||
.map(
|
||||
({ model }) =>
|
||||
[model, getInlineEditorByModel(this.std, model)] as const
|
||||
)
|
||||
.filter(
|
||||
(
|
||||
pair
|
||||
): pair is [MakeRequired<BlockModel, 'text'>, AffineInlineEditor] =>
|
||||
!!pair[0].text && !!pair[1]
|
||||
)
|
||||
.map(([model, inlineEditor]) => {
|
||||
let from: TextRangePoint;
|
||||
let to: TextRangePoint | null;
|
||||
if (model.id === selection.from.blockId) {
|
||||
from = selection.from;
|
||||
to = null;
|
||||
} else if (model.id === selection.to?.blockId) {
|
||||
from = selection.to;
|
||||
to = null;
|
||||
} else {
|
||||
from = {
|
||||
blockId: model.id,
|
||||
index: 0,
|
||||
length: model.text.yText.length,
|
||||
};
|
||||
to = null;
|
||||
}
|
||||
return [new TextSelection({ from, to }), inlineEditor] as const;
|
||||
});
|
||||
})
|
||||
.flat();
|
||||
|
||||
if (needCommentTexts.length === 0) return;
|
||||
|
||||
|
||||
@@ -22,11 +22,8 @@ import { isEqual } from 'lodash-es';
|
||||
})
|
||||
export class InlineComment extends WithDisposable(ShadowlessElement) {
|
||||
static override styles = css`
|
||||
inline-comment {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
inline-comment.unresolved {
|
||||
display: inline-block;
|
||||
background-color: ${unsafeCSSVarV2('block/comment/highlightDefault')};
|
||||
border-bottom: 2px solid
|
||||
${unsafeCSSVarV2('block/comment/highlightUnderline')};
|
||||
|
||||
@@ -150,9 +150,6 @@ export class AffineReference extends WithDisposable(ShadowlessElement) {
|
||||
|
||||
readonly open = (event?: Partial<DocLinkClickedEvent>) => {
|
||||
if (!this.config.interactable) return;
|
||||
if (event?.event?.button === 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.std.getOptional(RefNodeSlotsProvider)?.docLinkClicked.next({
|
||||
...this.referenceInfo,
|
||||
|
||||
@@ -64,7 +64,7 @@ export const blockCommentToolbarButton: Omit<ToolbarAction, 'id'> = {
|
||||
|
||||
// may be hover on a block or element, in this case
|
||||
// the selection is empty, so we need to get the current model
|
||||
if (model) {
|
||||
if (model && selections.length === 0) {
|
||||
if (model instanceof BlockModel) {
|
||||
commentProvider.addComment([
|
||||
new BlockSelection({
|
||||
|
||||
@@ -15,7 +15,6 @@ export interface BlockSuiteFlags {
|
||||
enable_shape_shadow_blur: boolean;
|
||||
enable_mobile_keyboard_toolbar: boolean;
|
||||
enable_mobile_linked_doc_menu: boolean;
|
||||
enable_mobile_database_editing: boolean;
|
||||
enable_block_meta: boolean;
|
||||
enable_callout: boolean;
|
||||
enable_edgeless_scribbled_style: boolean;
|
||||
@@ -42,7 +41,6 @@ export class FeatureFlagService extends StoreExtension {
|
||||
enable_mobile_keyboard_toolbar: false,
|
||||
enable_mobile_linked_doc_menu: false,
|
||||
enable_block_meta: true,
|
||||
enable_mobile_database_editing: false,
|
||||
enable_callout: false,
|
||||
enable_edgeless_scribbled_style: false,
|
||||
enable_table_virtual_scroll: false,
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { Signal } from '@preact/signals-core';
|
||||
import type { AffineUserInfo } from './types';
|
||||
|
||||
export interface UserService {
|
||||
currentUserInfo$: Signal<AffineUserInfo | null>;
|
||||
userInfo$(id: string): Signal<AffineUserInfo | null>;
|
||||
isLoading$(id: string): Signal<boolean>;
|
||||
error$(id: string): Signal<string | null>; // user friendly error string
|
||||
|
||||
@@ -4,14 +4,6 @@ import type { ReadonlySignal } from '@preact/signals-core';
|
||||
export interface VirtualKeyboardProvider {
|
||||
readonly visible$: ReadonlySignal<boolean>;
|
||||
readonly height$: ReadonlySignal<number>;
|
||||
/**
|
||||
* The static height of the keyboard, it should record the last non-zero height of virtual keyboard
|
||||
*/
|
||||
readonly staticHeight$: ReadonlySignal<number>;
|
||||
/**
|
||||
* The safe area of the app tab, it will be used when the keyboard is open or closed
|
||||
*/
|
||||
readonly appTabSafeArea$: ReadonlySignal<string>;
|
||||
}
|
||||
|
||||
export interface VirtualKeyboardProviderWithAction
|
||||
|
||||
@@ -11,12 +11,14 @@ export function getSelectedRect(selected: GfxModel[]): DOMRect {
|
||||
return new DOMRect();
|
||||
}
|
||||
|
||||
const lockedElementsByFrame = selected.flatMap(selectable => {
|
||||
if (selectable instanceof FrameBlockModel && selectable.isLocked()) {
|
||||
return selectable.descendantElements;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
const lockedElementsByFrame = selected
|
||||
.map(selectable => {
|
||||
if (selectable instanceof FrameBlockModel && selectable.isLocked()) {
|
||||
return selectable.descendantElements;
|
||||
}
|
||||
return [];
|
||||
})
|
||||
.flat();
|
||||
|
||||
selected = [...new Set([...selected, ...lockedElementsByFrame])];
|
||||
|
||||
|
||||
@@ -114,7 +114,6 @@ export class PreviewHelper {
|
||||
});
|
||||
|
||||
let width: number = 500;
|
||||
// oxlint-disable-next-line no-unassigned-vars
|
||||
let height;
|
||||
|
||||
const noteBlock = this.widget.host.querySelector('affine-note');
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
"@blocksuite/affine-block-paragraph": "workspace:*",
|
||||
"@blocksuite/affine-block-surface": "workspace:*",
|
||||
"@blocksuite/affine-block-surface-ref": "workspace:*",
|
||||
"@blocksuite/affine-block-table": "workspace:*",
|
||||
"@blocksuite/affine-components": "workspace:*",
|
||||
"@blocksuite/affine-ext-loader": "workspace:*",
|
||||
"@blocksuite/affine-fragment-doc-title": "workspace:*",
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
} from '@blocksuite/affine-block-paragraph';
|
||||
import { DefaultTool, getSurfaceBlock } from '@blocksuite/affine-block-surface';
|
||||
import { insertSurfaceRefBlockCommand } from '@blocksuite/affine-block-surface-ref';
|
||||
import { insertTableBlockCommand } from '@blocksuite/affine-block-table';
|
||||
import { toggleEmbedCardCreateModal } from '@blocksuite/affine-components/embed-card-modal';
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import { insertInlineLatex } from '@blocksuite/affine-inline-latex';
|
||||
@@ -41,20 +40,14 @@ import {
|
||||
deleteSelectedModelsCommand,
|
||||
draftSelectedModelsCommand,
|
||||
duplicateSelectedModelsCommand,
|
||||
focusBlockEnd,
|
||||
getBlockSelectionsCommand,
|
||||
getSelectedModelsCommand,
|
||||
getTextSelectionCommand,
|
||||
} from '@blocksuite/affine-shared/commands';
|
||||
import { REFERENCE_NODE } from '@blocksuite/affine-shared/consts';
|
||||
import {
|
||||
FeatureFlagService,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import type { AffineTextStyleAttributes } from '@blocksuite/affine-shared/types';
|
||||
import {
|
||||
createDefaultDoc,
|
||||
isInsideBlockByFlavour,
|
||||
openSingleFileWith,
|
||||
type Signal,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
@@ -94,7 +87,6 @@ import {
|
||||
RedoIcon,
|
||||
RightTabIcon,
|
||||
StrikeThroughIcon,
|
||||
TableIcon,
|
||||
TeXIcon,
|
||||
TextIcon,
|
||||
TodayIcon,
|
||||
@@ -168,6 +160,10 @@ export type KeyboardSubToolbarConfig = {
|
||||
export type KeyboardToolbarContext = {
|
||||
std: BlockStdScope;
|
||||
rootComponent: BlockComponent;
|
||||
/**
|
||||
* Close tool bar, and blur the focus if blur is true, default is false
|
||||
*/
|
||||
closeToolbar: (blur?: boolean) => void;
|
||||
/**
|
||||
* Close current tool panel and show virtual keyboard
|
||||
*/
|
||||
@@ -262,62 +258,6 @@ const textToolActionItems: KeyboardToolbarActionItem[] = [
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Table',
|
||||
icon: TableIcon(),
|
||||
showWhen: ({ std, rootComponent: { model } }) =>
|
||||
std.store.schema.flavourSchemaMap.has('affine:table') &&
|
||||
!isInsideBlockByFlavour(std.store, model, 'affine:edgeless-text'),
|
||||
action: ({ std }) => {
|
||||
std.command
|
||||
.chain()
|
||||
.pipe(getSelectedModelsCommand)
|
||||
.pipe(insertTableBlockCommand, {
|
||||
place: 'after',
|
||||
removeEmptyLine: true,
|
||||
})
|
||||
.pipe(({ insertedTableBlockId }) => {
|
||||
if (insertedTableBlockId) {
|
||||
const telemetry = std.getOptional(TelemetryProvider);
|
||||
telemetry?.track('BlockCreated', {
|
||||
blockType: 'affine:table',
|
||||
});
|
||||
}
|
||||
})
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Callout',
|
||||
icon: FontIcon(),
|
||||
showWhen: ({ std, rootComponent: { model } }) => {
|
||||
return (
|
||||
std.get(FeatureFlagService).getFlag('enable_callout') &&
|
||||
!isInsideBlockByFlavour(model.store, model, 'affine:edgeless-text')
|
||||
);
|
||||
},
|
||||
action: ({ rootComponent: { model }, std }) => {
|
||||
const { store } = model;
|
||||
const parent = store.getParent(model);
|
||||
if (!parent) return;
|
||||
|
||||
const index = parent.children.indexOf(model);
|
||||
if (index === -1) return;
|
||||
const calloutId = store.addBlock('affine:callout', {}, parent, index + 1);
|
||||
if (!calloutId) return;
|
||||
const paragraphId = store.addBlock('affine:paragraph', {}, calloutId);
|
||||
if (!paragraphId) return;
|
||||
std.host.updateComplete
|
||||
.then(() => {
|
||||
const paragraph = std.view.getBlock(paragraphId);
|
||||
if (!paragraph) return;
|
||||
std.command.exec(focusBlockEnd, {
|
||||
focusBlock: paragraph,
|
||||
});
|
||||
})
|
||||
.catch(console.error);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const listToolActionItems: KeyboardToolbarActionItem[] = [
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
requiredProperties,
|
||||
ShadowlessElement,
|
||||
} from '@blocksuite/std';
|
||||
import { html, nothing } from 'lit';
|
||||
import { html, nothing, type PropertyValues } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
@@ -71,13 +71,22 @@ export class AffineKeyboardToolPanel extends SignalWatcher(
|
||||
.map(group => (typeof group === 'function' ? group(this.context) : group))
|
||||
.filter((group): group is KeyboardToolPanelGroup => group !== null);
|
||||
|
||||
return html`<div class="affine-keyboard-tool-panel-container">
|
||||
${repeat(
|
||||
groups,
|
||||
group => group.name,
|
||||
group => this._renderGroup(group)
|
||||
)}
|
||||
</div>`;
|
||||
return repeat(
|
||||
groups,
|
||||
group => group.name,
|
||||
group => this._renderGroup(group)
|
||||
);
|
||||
}
|
||||
|
||||
protected override willUpdate(changedProperties: PropertyValues<this>) {
|
||||
if (changedProperties.has('height')) {
|
||||
this.style.height = `${this.height}px`;
|
||||
if (this.height === 0) {
|
||||
this.style.padding = '0';
|
||||
} else {
|
||||
this.style.padding = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
@@ -85,4 +94,7 @@ export class AffineKeyboardToolPanel extends SignalWatcher(
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor context!: KeyboardToolbarContext;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor height = 0;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
requiredProperties,
|
||||
ShadowlessElement,
|
||||
} from '@blocksuite/std';
|
||||
import { effect, type Signal, signal } from '@preact/signals-core';
|
||||
import { effect, type Signal, signal, untracked } from '@preact/signals-core';
|
||||
import { html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
KeyboardToolbarItem,
|
||||
KeyboardToolPanelConfig,
|
||||
} from './config';
|
||||
import { PositionController } from './position-controller';
|
||||
import { keyboardToolbarStyles } from './styles';
|
||||
import {
|
||||
isKeyboardSubToolBarConfig,
|
||||
@@ -40,7 +41,10 @@ export class AffineKeyboardToolbar extends SignalWatcher(
|
||||
) {
|
||||
static override styles = keyboardToolbarStyles;
|
||||
|
||||
private readonly _expanded$ = signal(false);
|
||||
/** This field records the panel static height same as the virtual keyboard height */
|
||||
panelHeight$ = signal(0);
|
||||
|
||||
positionController = new PositionController(this);
|
||||
|
||||
get std() {
|
||||
return this.rootComponent.std;
|
||||
@@ -50,31 +54,9 @@ export class AffineKeyboardToolbar extends SignalWatcher(
|
||||
return this._currentPanelIndex$.value !== -1;
|
||||
}
|
||||
|
||||
private get panelHeight() {
|
||||
return this._expanded$.value
|
||||
? `${
|
||||
this.keyboard.staticHeight$.value !== 0
|
||||
? this.keyboard.staticHeight$.value
|
||||
: 330
|
||||
}px`
|
||||
: this.keyboard.appTabSafeArea$.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent flickering during keyboard opening
|
||||
*/
|
||||
private _resetPanelIndexTimeoutId: ReturnType<typeof setTimeout> | null =
|
||||
null;
|
||||
private readonly _closeToolPanel = () => {
|
||||
this._currentPanelIndex$.value = -1;
|
||||
if (!this.keyboard.visible$.peek()) this.keyboard.show();
|
||||
|
||||
if (this._resetPanelIndexTimeoutId) {
|
||||
clearTimeout(this._resetPanelIndexTimeoutId);
|
||||
this._resetPanelIndexTimeoutId = null;
|
||||
}
|
||||
this._resetPanelIndexTimeoutId = setTimeout(() => {
|
||||
this._currentPanelIndex$.value = -1;
|
||||
}, 100);
|
||||
};
|
||||
|
||||
private readonly _currentPanelIndex$ = signal(-1);
|
||||
@@ -101,10 +83,6 @@ export class AffineKeyboardToolbar extends SignalWatcher(
|
||||
if (this._currentPanelIndex$.value === index) {
|
||||
this._closeToolPanel();
|
||||
} else {
|
||||
if (this._resetPanelIndexTimeoutId) {
|
||||
clearTimeout(this._resetPanelIndexTimeoutId);
|
||||
this._resetPanelIndexTimeoutId = null;
|
||||
}
|
||||
this._currentPanelIndex$.value = index;
|
||||
this.keyboard.hide();
|
||||
this._scrollCurrentBlockIntoView();
|
||||
@@ -145,6 +123,9 @@ export class AffineKeyboardToolbar extends SignalWatcher(
|
||||
return {
|
||||
std: this.std,
|
||||
rootComponent: this.rootComponent,
|
||||
closeToolbar: (blur = false) => {
|
||||
this.close(blur);
|
||||
},
|
||||
closeToolPanel: () => {
|
||||
this._closeToolPanel();
|
||||
},
|
||||
@@ -221,7 +202,7 @@ export class AffineKeyboardToolbar extends SignalWatcher(
|
||||
}
|
||||
|
||||
private _renderItems() {
|
||||
if (!this.std.event.active$.value)
|
||||
if (document.activeElement !== this.rootComponent)
|
||||
return html`<div class="item-container"></div>`;
|
||||
|
||||
const goPrevToolbarAction = when(
|
||||
@@ -245,15 +226,7 @@ export class AffineKeyboardToolbar extends SignalWatcher(
|
||||
<icon-button
|
||||
size="36px"
|
||||
@click=${() => {
|
||||
if (this.keyboard.staticHeight$.value === 0) {
|
||||
this._closeToolPanel();
|
||||
return;
|
||||
}
|
||||
if (this.keyboard.visible$.peek()) {
|
||||
this.keyboard.hide();
|
||||
} else {
|
||||
this.keyboard.show();
|
||||
}
|
||||
this.close(true);
|
||||
}}
|
||||
>
|
||||
${KeyboardIcon()}
|
||||
@@ -264,23 +237,6 @@ export class AffineKeyboardToolbar extends SignalWatcher(
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
// There are two cases that `_expanded$` will be true:
|
||||
// 1. when virtual keyboard is opened, the panel need to be expanded and overlapped by the keyboard,
|
||||
// so that the toolbar will be on the top of the keyboard.
|
||||
// 2. the panel is opened, whether the keyboard is closed or not exists (e.g. a physical keyboard connected)
|
||||
//
|
||||
// There is one case that `_expanded$` will be false:
|
||||
// 1. the panel is closed, and the keyboard is closed, the toolbar will be rendered at the bottom of the viewport
|
||||
this._disposables.add(
|
||||
effect(() => {
|
||||
if (this.keyboard.visible$.value || this.panelOpened) {
|
||||
this._expanded$.value = true;
|
||||
} else {
|
||||
this._expanded$.value = false;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// prevent editor blur when click item in toolbar
|
||||
this.disposables.addFromEvent(this, 'pointerdown', e => {
|
||||
e.preventDefault();
|
||||
@@ -304,17 +260,15 @@ export class AffineKeyboardToolbar extends SignalWatcher(
|
||||
if (this.keyboard.visible$.value) {
|
||||
this._closeToolPanel();
|
||||
}
|
||||
// when keyboard is closed and the panel is not opened, we need to close the toolbar,
|
||||
// this usually happens when user close keyboard from system side
|
||||
else if (this.hasUpdated && untracked(() => !this.panelOpened)) {
|
||||
this.close(true);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this._watchAutoShow();
|
||||
|
||||
this.disposables.add(() => {
|
||||
if (this._resetPanelIndexTimeoutId) {
|
||||
clearTimeout(this._resetPanelIndexTimeoutId);
|
||||
this._resetPanelIndexTimeoutId = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _watchAutoShow() {
|
||||
@@ -377,10 +331,7 @@ export class AffineKeyboardToolbar extends SignalWatcher(
|
||||
<affine-keyboard-tool-panel
|
||||
.config=${this._currentPanelConfig}
|
||||
.context=${this._context}
|
||||
style=${styleMap({
|
||||
height: this.panelHeight,
|
||||
paddingBottom: this.keyboard.appTabSafeArea$.value,
|
||||
})}
|
||||
.height=${this.panelHeight$.value}
|
||||
></affine-keyboard-tool-panel>
|
||||
`;
|
||||
}
|
||||
@@ -388,6 +339,9 @@ export class AffineKeyboardToolbar extends SignalWatcher(
|
||||
@property({ attribute: false })
|
||||
accessor keyboard!: VirtualKeyboardProviderWithAction;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor close: (blur: boolean) => void = () => {};
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor config!: KeyboardToolbarConfig;
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { type VirtualKeyboardProvider } from '@blocksuite/affine-shared/services';
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import type { BlockStdScope, ShadowlessElement } from '@blocksuite/std';
|
||||
import { effect, type Signal } from '@preact/signals-core';
|
||||
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
||||
|
||||
/**
|
||||
* This controller is used to control the keyboard toolbar position
|
||||
*/
|
||||
export class PositionController implements ReactiveController {
|
||||
private readonly _disposables = new DisposableGroup();
|
||||
|
||||
host: ReactiveControllerHost &
|
||||
ShadowlessElement & {
|
||||
std: BlockStdScope;
|
||||
panelHeight$: Signal<number>;
|
||||
keyboard: VirtualKeyboardProvider;
|
||||
panelOpened: boolean;
|
||||
};
|
||||
|
||||
constructor(host: PositionController['host']) {
|
||||
(this.host = host).addController(this);
|
||||
}
|
||||
|
||||
hostConnected() {
|
||||
const { keyboard } = this.host;
|
||||
|
||||
this._disposables.add(
|
||||
effect(() => {
|
||||
if (keyboard.visible$.value) {
|
||||
this.host.panelHeight$.value = keyboard.height$.value;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.host.style.bottom = '0px';
|
||||
}
|
||||
|
||||
hostDisconnected() {
|
||||
this._disposables.dispose();
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ export const keyboardToolbarStyles = css`
|
||||
position: fixed;
|
||||
display: block;
|
||||
width: 100vw;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.keyboard-toolbar {
|
||||
@@ -61,18 +60,14 @@ export const keyboardToolbarStyles = css`
|
||||
|
||||
export const keyboardToolPanelStyles = css`
|
||||
affine-keyboard-tool-panel {
|
||||
display: block;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
background-color: ${unsafeCSSVarV2('layer/background/primary')};
|
||||
}
|
||||
|
||||
.affine-keyboard-tool-panel-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
padding: 16px 4px 8px 8px;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
background-color: ${unsafeCSSVarV2('layer/background/primary')};
|
||||
}
|
||||
|
||||
${scrollbarStyle('affine-keyboard-tool-panel')}
|
||||
|
||||
@@ -20,6 +20,18 @@ import {
|
||||
export const AFFINE_KEYBOARD_TOOLBAR_WIDGET = 'affine-keyboard-toolbar-widget';
|
||||
|
||||
export class AffineKeyboardToolbarWidget extends WidgetComponent<RootBlockModel> {
|
||||
private readonly _close = (blur: boolean) => {
|
||||
if (blur) {
|
||||
if (document.activeElement === this._docTitle?.inlineEditorContainer) {
|
||||
this._docTitle?.inlineEditor?.setInlineRange(null);
|
||||
this._docTitle?.inlineEditor?.eventSource?.blur();
|
||||
} else if (document.activeElement === this.block?.rootComponent) {
|
||||
this.std.selection.clear();
|
||||
}
|
||||
}
|
||||
this._show$.value = false;
|
||||
};
|
||||
|
||||
private readonly _show$ = signal(false);
|
||||
|
||||
private _initialInputMode: string = '';
|
||||
@@ -61,26 +73,29 @@ export class AffineKeyboardToolbarWidget extends WidgetComponent<RootBlockModel>
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
this._show$.value = this.std.event.active$.value;
|
||||
})
|
||||
);
|
||||
|
||||
const rootComponent = this.block?.rootComponent;
|
||||
if (rootComponent && this.keyboard.fallback) {
|
||||
this._initialInputMode = rootComponent.inputMode;
|
||||
this.disposables.add(() => {
|
||||
rootComponent.inputMode = this._initialInputMode;
|
||||
if (rootComponent) {
|
||||
this.disposables.addFromEvent(rootComponent, 'focus', () => {
|
||||
this._show$.value = true;
|
||||
});
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
// recover input mode when keyboard toolbar is hidden
|
||||
if (!this._show$.value) {
|
||||
rootComponent.inputMode = this._initialInputMode;
|
||||
}
|
||||
})
|
||||
);
|
||||
this.disposables.addFromEvent(rootComponent, 'blur', () => {
|
||||
this._show$.value = false;
|
||||
});
|
||||
|
||||
if (this.keyboard.fallback) {
|
||||
this._initialInputMode = rootComponent.inputMode;
|
||||
this.disposables.add(() => {
|
||||
rootComponent.inputMode = this._initialInputMode;
|
||||
});
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
// recover input mode when keyboard toolbar is hidden
|
||||
if (!this._show$.value) {
|
||||
rootComponent.inputMode = this._initialInputMode;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (this._docTitle) {
|
||||
@@ -114,6 +129,7 @@ export class AffineKeyboardToolbarWidget extends WidgetComponent<RootBlockModel>
|
||||
.keyboard=${this.keyboard}
|
||||
.config=${this.config}
|
||||
.rootComponent=${this.block.rootComponent}
|
||||
.close=${this._close}
|
||||
></affine-keyboard-toolbar>`}
|
||||
></blocksuite-portal>`;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
{ "path": "../../blocks/paragraph" },
|
||||
{ "path": "../../blocks/surface" },
|
||||
{ "path": "../../blocks/surface-ref" },
|
||||
{ "path": "../../blocks/table" },
|
||||
{ "path": "../../components" },
|
||||
{ "path": "../../ext-loader" },
|
||||
{ "path": "../../fragments/doc-title" },
|
||||
|
||||
@@ -113,9 +113,11 @@ export class LinkedDocPopover extends SignalWatcher(
|
||||
}
|
||||
|
||||
private get _flattenActionList() {
|
||||
return this._actionGroup.flatMap(group =>
|
||||
group.items.map(item => ({ ...item, groupName: group.name }))
|
||||
);
|
||||
return this._actionGroup
|
||||
.map(group =>
|
||||
group.items.map(item => ({ ...item, groupName: group.name }))
|
||||
)
|
||||
.flat();
|
||||
}
|
||||
|
||||
private get _query() {
|
||||
@@ -341,18 +343,7 @@ export class LinkedDocPopover extends SignalWatcher(
|
||||
override willUpdate() {
|
||||
if (!this.hasUpdated) {
|
||||
const updatePosition = throttle(() => {
|
||||
this._position = getPopperPosition(
|
||||
{
|
||||
getBoundingClientRect: () => {
|
||||
return {
|
||||
...this.getBoundingClientRect(),
|
||||
// Workaround: the width of the popover is zero when it is not rendered
|
||||
width: 280,
|
||||
};
|
||||
},
|
||||
},
|
||||
this.context.startNativeRange
|
||||
);
|
||||
this._position = getPopperPosition(this, this.context.startNativeRange);
|
||||
}, 10);
|
||||
|
||||
this.disposables.addFromEvent(window, 'resize', updatePosition);
|
||||
|
||||
@@ -142,13 +142,15 @@ export class SlashMenu extends WithDisposable(LitElement) {
|
||||
// We search first and second layer
|
||||
if (this._filteredItems.length !== 0 && depth >= 1) break;
|
||||
|
||||
queue = queue.flatMap(item => {
|
||||
if (isSubMenuItem(item)) {
|
||||
return item.subMenu;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
queue = queue
|
||||
.map<typeof queue>(item => {
|
||||
if (isSubMenuItem(item)) {
|
||||
return item.subMenu;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
})
|
||||
.flat();
|
||||
|
||||
depth++;
|
||||
}
|
||||
|
||||
@@ -418,9 +418,9 @@ export class AffineToolbarWidget extends WidgetComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
const elementIds = selections.flatMap(s =>
|
||||
s.editing || s.inoperable ? [] : s.elements
|
||||
);
|
||||
const elementIds = selections
|
||||
.map(s => (s.editing || s.inoperable ? [] : s.elements))
|
||||
.flat();
|
||||
const count = elementIds.length;
|
||||
const activated = context.activated && Boolean(count);
|
||||
|
||||
|
||||
@@ -229,7 +229,8 @@ export function renderToolbar(
|
||||
? module.config.when(context)
|
||||
: (module.config.when ?? true)
|
||||
)
|
||||
.flatMap(module => module.config.actions);
|
||||
.map<ToolbarActions>(module => module.config.actions)
|
||||
.flat();
|
||||
|
||||
const combined = combine(actions, context);
|
||||
|
||||
|
||||
@@ -30,9 +30,9 @@ function inlineTextStyles(
|
||||
}
|
||||
|
||||
return styleMap({
|
||||
'font-weight': props.bold ? 'bold' : 'inherit',
|
||||
'font-style': props.italic ? 'italic' : 'inherit',
|
||||
'text-decoration': textDecorations.length > 0 ? textDecorations : 'inherit',
|
||||
'font-weight': props.bold ? 'bold' : 'normal',
|
||||
'font-style': props.italic ? 'italic' : 'normal',
|
||||
'text-decoration': textDecorations.length > 0 ? textDecorations : 'none',
|
||||
...inlineCodeStyle,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -159,7 +159,6 @@
|
||||
}
|
||||
],
|
||||
"unicorn/prefer-array-some": "error",
|
||||
"unicorn/prefer-array-flat-map": "off",
|
||||
"unicorn/no-useless-promise-resolve-reject": "error",
|
||||
"unicorn/no-unnecessary-await": "error",
|
||||
"unicorn/no-useless-fallback-in-spread": "error",
|
||||
|
||||
@@ -135,7 +135,6 @@
|
||||
"object.fromentries": "npm:@nolyfill/object.fromentries@^1",
|
||||
"object.hasown": "npm:@nolyfill/object.hasown@^1",
|
||||
"object.values": "npm:@nolyfill/object.values@^1",
|
||||
"on-headers": "npm:on-headers@^1.1.0",
|
||||
"reflect.getprototypeof": "npm:@nolyfill/reflect.getprototypeof@^1",
|
||||
"regexp.prototype.flags": "npm:@nolyfill/regexp.prototype.flags@^1",
|
||||
"safe-array-concat": "npm:@nolyfill/safe-array-concat@^1",
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
-- CreateTable
|
||||
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- The primary key for the `ai_workspace_embeddings` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
- The primary key for the `ai_workspace_file_embeddings` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
|
||||
*/
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'ai_workspace_embeddings') AND
|
||||
EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'ai_workspace_file_embeddings') THEN
|
||||
CREATE TABLE "ai_workspace_blob_embeddings" (
|
||||
"workspace_id" VARCHAR NOT NULL,
|
||||
"blob_id" VARCHAR NOT NULL,
|
||||
"chunk" INTEGER NOT NULL,
|
||||
"content" VARCHAR NOT NULL,
|
||||
"embedding" vector(1024) NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ai_workspace_blob_embeddings_pkey" PRIMARY KEY ("workspace_id","blob_id","chunk")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ai_workspace_blob_embeddings_idx" ON "ai_workspace_blob_embeddings"
|
||||
USING hnsw (embedding vector_cosine_ops);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ai_workspace_blob_embeddings"
|
||||
ADD CONSTRAINT "ai_workspace_blob_embeddings_workspace_id_blob_id_fkey"
|
||||
FOREIGN KEY ("workspace_id", "blob_id")
|
||||
REFERENCES "blobs"("workspace_id", "key")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
@@ -1,20 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "access_tokens" (
|
||||
"id" VARCHAR NOT NULL,
|
||||
"name" VARCHAR NOT NULL,
|
||||
"token" VARCHAR NOT NULL,
|
||||
"user_id" VARCHAR NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expires_at" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "access_tokens_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "access_tokens_token_key" ON "access_tokens"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "access_tokens_user_id_idx" ON "access_tokens"("user_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "access_tokens" ADD CONSTRAINT "access_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -1,5 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "workspaces" ADD COLUMN "last_check_embeddings" TIMESTAMPTZ(3) NOT NULL DEFAULT '1970-01-01 00:00:00 +00:00';
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "workspaces_last_check_embeddings_idx" ON "workspaces"("last_check_embeddings");
|
||||
@@ -40,7 +40,6 @@
|
||||
"@fal-ai/serverless-client": "^0.15.0",
|
||||
"@google-cloud/opentelemetry-cloud-trace-exporter": "^2.4.1",
|
||||
"@google-cloud/opentelemetry-resource-util": "^2.4.0",
|
||||
"@modelcontextprotocol/sdk": "^1.16.0",
|
||||
"@nestjs-cls/transactional": "^2.6.1",
|
||||
"@nestjs-cls/transactional-adapter-prisma": "^1.2.19",
|
||||
"@nestjs/apollo": "^13.0.4",
|
||||
@@ -86,7 +85,6 @@
|
||||
"express": "^5.0.1",
|
||||
"fast-xml-parser": "^5.0.0",
|
||||
"get-stream": "^9.0.1",
|
||||
"google-auth-library": "^10.2.0",
|
||||
"graphql": "^16.9.0",
|
||||
"graphql-scalars": "^1.24.0",
|
||||
"graphql-upload": "^17.0.0",
|
||||
@@ -105,7 +103,7 @@
|
||||
"nest-winston": "^1.9.7",
|
||||
"nestjs-cls": "^6.0.0",
|
||||
"nodemailer": "^7.0.0",
|
||||
"on-headers": "^1.1.0",
|
||||
"on-headers": "^1.0.2",
|
||||
"piscina": "^5.0.0-alpha.0",
|
||||
"prisma": "^6.6.0",
|
||||
"react": "19.1.0",
|
||||
|
||||
@@ -49,7 +49,6 @@ model User {
|
||||
comments Comment[]
|
||||
replies Reply[]
|
||||
commentAttachments CommentAttachment[] @relation("createdCommentAttachments")
|
||||
AccessToken AccessToken[]
|
||||
|
||||
@@index([email])
|
||||
@@map("users")
|
||||
@@ -111,18 +110,17 @@ model VerificationToken {
|
||||
|
||||
model Workspace {
|
||||
// NOTE: manually set this column type to identity in migration file
|
||||
sid Int @unique @default(autoincrement())
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
public Boolean
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
sid Int @unique @default(autoincrement())
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
public Boolean
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
// workspace level feature flags
|
||||
enableAi Boolean @default(true) @map("enable_ai")
|
||||
enableUrlPreview Boolean @default(false) @map("enable_url_preview")
|
||||
enableDocEmbedding Boolean @default(true) @map("enable_doc_embedding")
|
||||
name String? @db.VarChar
|
||||
avatarKey String? @map("avatar_key") @db.VarChar
|
||||
indexed Boolean @default(false)
|
||||
lastCheckEmbeddings DateTime @default("1970-01-01T00:00:00-00:00") @map("last_check_embeddings") @db.Timestamptz(3)
|
||||
enableAi Boolean @default(true) @map("enable_ai")
|
||||
enableUrlPreview Boolean @default(false) @map("enable_url_preview")
|
||||
enableDocEmbedding Boolean @default(true) @map("enable_doc_embedding")
|
||||
name String? @db.VarChar
|
||||
avatarKey String? @map("avatar_key") @db.VarChar
|
||||
indexed Boolean @default(false)
|
||||
|
||||
features WorkspaceFeature[]
|
||||
docs WorkspaceDoc[]
|
||||
@@ -134,7 +132,6 @@ model Workspace {
|
||||
comments Comment[]
|
||||
commentAttachments CommentAttachment[]
|
||||
|
||||
@@index([lastCheckEmbeddings])
|
||||
@@map("workspaces")
|
||||
}
|
||||
|
||||
@@ -571,23 +568,6 @@ model AiWorkspaceFileEmbedding {
|
||||
@@map("ai_workspace_file_embeddings")
|
||||
}
|
||||
|
||||
model AiWorkspaceBlobEmbedding {
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
blobId String @map("blob_id") @db.VarChar
|
||||
// a file can be divided into multiple chunks and embedded separately.
|
||||
chunk Int @db.Integer
|
||||
content String @db.VarChar
|
||||
embedding Unsupported("vector(1024)")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
|
||||
blob Blob @relation(fields: [workspaceId, blobId], references: [workspaceId, key], onDelete: Cascade)
|
||||
|
||||
@@id([workspaceId, blobId, chunk])
|
||||
@@index([embedding], map: "ai_workspace_blob_embeddings_idx")
|
||||
@@map("ai_workspace_blob_embeddings")
|
||||
}
|
||||
|
||||
enum AiJobStatus {
|
||||
pending
|
||||
running
|
||||
@@ -827,8 +807,7 @@ model Blob {
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
|
||||
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
AiWorkspaceBlobEmbedding AiWorkspaceBlobEmbedding[]
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([workspaceId, key])
|
||||
@@map("blobs")
|
||||
@@ -952,17 +931,3 @@ model CommentAttachment {
|
||||
@@id([workspaceId, docId, key])
|
||||
@@map("comment_attachments")
|
||||
}
|
||||
|
||||
model AccessToken {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
name String @db.VarChar
|
||||
token String @unique @db.VarChar
|
||||
userId String @map("user_id") @db.VarChar
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
expiresAt DateTime? @map("expires_at") @db.Timestamptz(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@map("access_tokens")
|
||||
}
|
||||
|
||||
@@ -372,75 +372,3 @@ Generated by [AVA](https://avajs.dev).
|
||||
[assistant]: Quantum computing uses quantum mechanics principles.`,
|
||||
promptName: 'Summary as title',
|
||||
}
|
||||
|
||||
## should handle copilot cron jobs correctly
|
||||
|
||||
> daily job scheduling calls
|
||||
|
||||
[
|
||||
{
|
||||
args: [
|
||||
'copilot.session.cleanupEmptySessions',
|
||||
{},
|
||||
{
|
||||
jobId: 'daily-copilot-cleanup-empty-sessions',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
args: [
|
||||
'copilot.session.generateMissingTitles',
|
||||
{},
|
||||
{
|
||||
jobId: 'daily-copilot-generate-missing-titles',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
args: [
|
||||
'copilot.workspace.cleanupTrashedDocEmbeddings',
|
||||
{},
|
||||
{
|
||||
jobId: 'daily-copilot-cleanup-trashed-doc-embeddings',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
> cleanup empty sessions calls
|
||||
|
||||
[
|
||||
{
|
||||
args: [
|
||||
'Date',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
> title generation calls
|
||||
|
||||
{
|
||||
jobCalls: [
|
||||
{
|
||||
args: [
|
||||
'copilot.session.generateTitle',
|
||||
{
|
||||
sessionId: 'session1',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
args: [
|
||||
'copilot.session.generateTitle',
|
||||
{
|
||||
sessionId: 'session2',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
modelCalls: [
|
||||
{
|
||||
args: [],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -96,21 +96,6 @@ test('should be able to visit private api if signed in', async t => {
|
||||
t.is(res.body.user.id, u1.id);
|
||||
});
|
||||
|
||||
test('should be able to visit private api with access token', async t => {
|
||||
const models = t.context.app.get(Models);
|
||||
const token = await models.accessToken.create({
|
||||
userId: u1.id,
|
||||
name: 'test',
|
||||
});
|
||||
|
||||
const res = await request(server)
|
||||
.get('/private')
|
||||
.set('Authorization', `Bearer ${token.token}`)
|
||||
.expect(HttpStatus.OK);
|
||||
|
||||
t.is(res.body.user.id, u1.id);
|
||||
});
|
||||
|
||||
test('should be able to parse session cookie', async t => {
|
||||
const spy = Sinon.spy(auth, 'getUserSession');
|
||||
await request(server)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type { ExecutionContext, TestFn } from 'ava';
|
||||
import ava from 'ava';
|
||||
import { z } from 'zod';
|
||||
@@ -7,7 +5,6 @@ import { z } from 'zod';
|
||||
import { ServerFeature, ServerService } from '../core';
|
||||
import { AuthService } from '../core/auth';
|
||||
import { QuotaModule } from '../core/quota';
|
||||
import { Models } from '../models';
|
||||
import { CopilotModule } from '../plugins/copilot';
|
||||
import { prompts, PromptService } from '../plugins/copilot/prompt';
|
||||
import {
|
||||
@@ -33,8 +30,6 @@ import { TestAssets } from './utils/copilot';
|
||||
type Tester = {
|
||||
auth: AuthService;
|
||||
module: TestingModule;
|
||||
models: Models;
|
||||
service: ServerService;
|
||||
prompt: PromptService;
|
||||
factory: CopilotProviderFactory;
|
||||
workflow: CopilotWorkflowService;
|
||||
@@ -71,15 +66,12 @@ test.serial.before(async t => {
|
||||
isCopilotConfigured = service.features.includes(ServerFeature.Copilot);
|
||||
|
||||
const auth = module.get(AuthService);
|
||||
const models = module.get(Models);
|
||||
const prompt = module.get(PromptService);
|
||||
const factory = module.get(CopilotProviderFactory);
|
||||
const workflow = module.get(CopilotWorkflowService);
|
||||
|
||||
t.context.module = module;
|
||||
t.context.auth = auth;
|
||||
t.context.service = service;
|
||||
t.context.models = models;
|
||||
t.context.prompt = prompt;
|
||||
t.context.factory = factory;
|
||||
t.context.workflow = workflow;
|
||||
@@ -92,7 +84,7 @@ test.serial.before(async t => {
|
||||
});
|
||||
|
||||
test.serial.before(async t => {
|
||||
const { prompt, executors, models, service } = t.context;
|
||||
const { prompt, executors } = t.context;
|
||||
|
||||
executors.image.register();
|
||||
executors.text.register();
|
||||
@@ -106,28 +98,6 @@ test.serial.before(async t => {
|
||||
for (const p of prompts) {
|
||||
await prompt.set(p.name, p.model, p.messages, p.config);
|
||||
}
|
||||
|
||||
const user = await models.user.create({
|
||||
email: `${randomUUID()}@affine.pro`,
|
||||
});
|
||||
await service.updateConfig(user.id, [
|
||||
{
|
||||
module: 'copilot',
|
||||
key: 'scenarios',
|
||||
value: {
|
||||
enabled: true,
|
||||
scenarios: {
|
||||
image: 'flux-1/schnell',
|
||||
rerank: 'gpt-4.1-mini',
|
||||
complex_text_generation: 'gpt-4.1-mini',
|
||||
coding: 'gpt-4.1-mini',
|
||||
quick_decision_making: 'gpt-4.1-mini',
|
||||
quick_text_generation: 'gpt-4.1-mini',
|
||||
polish_and_summarize: 'gemini-2.5-flash',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test.after(async t => {
|
||||
@@ -237,7 +207,6 @@ const retry = async (
|
||||
try {
|
||||
await callback(t);
|
||||
} catch (e) {
|
||||
console.error(`Error during ${action}:`, e);
|
||||
t.log(`Error during ${action}:`, e);
|
||||
throw e;
|
||||
}
|
||||
@@ -381,10 +350,10 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
|
||||
params: {
|
||||
files: [
|
||||
{
|
||||
blobId: 'todo_md',
|
||||
fileName: 'todo.md',
|
||||
fileType: 'text/markdown',
|
||||
fileContent: TestAssets.TODO,
|
||||
blobId: 'euclidean_distance',
|
||||
fileName: 'euclidean_distance.rs',
|
||||
fileType: 'text/rust',
|
||||
fileContent: TestAssets.Code,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -414,12 +383,12 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
|
||||
role: 'user' as const,
|
||||
content: 'what is ssot',
|
||||
params: {
|
||||
docs: [
|
||||
files: [
|
||||
{
|
||||
docId: 'SSOT',
|
||||
docTitle: 'Single source of truth - Wikipedia',
|
||||
blobId: 'SSOT',
|
||||
fileName: 'Single source of truth - Wikipedia',
|
||||
fileType: 'text/markdown',
|
||||
docContent: TestAssets.SSOT,
|
||||
fileContent: TestAssets.SSOT,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -506,7 +475,6 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
|
||||
},
|
||||
},
|
||||
],
|
||||
config: { model: 'gemini-2.5-pro' },
|
||||
verifier: (t: ExecutionContext<Tester>, result: string) => {
|
||||
t.notThrows(() => {
|
||||
TranscriptionResponseSchema.parse(JSON.parse(result));
|
||||
@@ -515,34 +483,6 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
|
||||
type: 'structured' as const,
|
||||
prefer: CopilotProviderType.Gemini,
|
||||
},
|
||||
{
|
||||
promptName: ['Conversation Summary'],
|
||||
messages: [
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: '',
|
||||
params: {
|
||||
messages: [
|
||||
{ role: 'user', content: 'what is single source of truth?' },
|
||||
{ role: 'assistant', content: TestAssets.SSOT },
|
||||
],
|
||||
focus: 'technical decisions',
|
||||
length: 'comprehensive',
|
||||
},
|
||||
},
|
||||
],
|
||||
verifier: (t: ExecutionContext<Tester>, result: string) => {
|
||||
assertNotWrappedInCodeBlock(t, result);
|
||||
const cleared = result.toLowerCase();
|
||||
t.assert(
|
||||
cleared.includes('single source of truth') ||
|
||||
/single.*source/.test(cleared) ||
|
||||
cleared.includes('ssot'),
|
||||
'should include original keyword'
|
||||
);
|
||||
},
|
||||
type: 'text' as const,
|
||||
},
|
||||
{
|
||||
promptName: [
|
||||
'Summary',
|
||||
@@ -560,8 +500,9 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
|
||||
'Create headings',
|
||||
'Make it longer',
|
||||
'Make it shorter',
|
||||
'Section Edit',
|
||||
'Continue writing',
|
||||
'Chat With AFFiNE AI',
|
||||
'Search With AFFiNE AI',
|
||||
],
|
||||
messages: [{ role: 'user' as const, content: TestAssets.SSOT }],
|
||||
verifier: (t: ExecutionContext<Tester>, result: string) => {
|
||||
@@ -576,18 +517,9 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
|
||||
},
|
||||
type: 'text' as const,
|
||||
},
|
||||
{
|
||||
promptName: ['Continue writing'],
|
||||
messages: [{ role: 'user' as const, content: TestAssets.AFFiNE }],
|
||||
verifier: (t: ExecutionContext<Tester>, result: string) => {
|
||||
assertNotWrappedInCodeBlock(t, result);
|
||||
t.assert(result.length > 0, 'should not be empty');
|
||||
},
|
||||
type: 'text' as const,
|
||||
},
|
||||
{
|
||||
promptName: ['Brainstorm ideas about this', 'Brainstorm mindmap'],
|
||||
messages: [{ role: 'user' as const, content: TestAssets.AFFiNE }],
|
||||
messages: [{ role: 'user' as const, content: TestAssets.SSOT }],
|
||||
verifier: (t: ExecutionContext<Tester>, result: string) => {
|
||||
assertNotWrappedInCodeBlock(t, result);
|
||||
t.assert(checkMDList(result), 'should be a markdown list');
|
||||
@@ -684,7 +616,20 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
|
||||
type: 'image' as const,
|
||||
},
|
||||
{
|
||||
promptName: ['Generate image'],
|
||||
promptName: ['debug:action:dalle3'],
|
||||
messages: [
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: 'Panda',
|
||||
},
|
||||
],
|
||||
verifier: (t: ExecutionContext<Tester>, link: string) => {
|
||||
t.truthy(checkUrl(link), 'should be a valid url');
|
||||
},
|
||||
type: 'image' as const,
|
||||
},
|
||||
{
|
||||
promptName: ['debug:action:gpt-image-1'],
|
||||
messages: [
|
||||
{
|
||||
role: 'user' as const,
|
||||
@@ -723,16 +668,15 @@ for (const {
|
||||
t.truthy(provider, 'should have provider');
|
||||
await retry(`action: ${promptName}`, t, async t => {
|
||||
const finalConfig = Object.assign({}, prompt.config, config);
|
||||
const modelId = finalConfig.model || prompt.model;
|
||||
|
||||
switch (type) {
|
||||
case 'text': {
|
||||
const result = await provider.text(
|
||||
{ modelId },
|
||||
{ modelId: prompt.model },
|
||||
[
|
||||
...prompt.finish(
|
||||
messages.reduce(
|
||||
// @ts-expect-error params not typed
|
||||
// @ts-expect-error
|
||||
(acc, m) => Object.assign(acc, m.params),
|
||||
{}
|
||||
)
|
||||
@@ -747,7 +691,7 @@ for (const {
|
||||
}
|
||||
case 'structured': {
|
||||
const result = await provider.structure(
|
||||
{ modelId },
|
||||
{ modelId: prompt.model },
|
||||
[
|
||||
...prompt.finish(
|
||||
messages.reduce(
|
||||
@@ -766,7 +710,7 @@ for (const {
|
||||
case 'object': {
|
||||
const streamObjects: StreamObject[] = [];
|
||||
for await (const chunk of provider.streamObject(
|
||||
{ modelId },
|
||||
{ modelId: prompt.model },
|
||||
[
|
||||
...prompt.finish(
|
||||
messages.reduce(
|
||||
@@ -798,11 +742,11 @@ for (const {
|
||||
});
|
||||
}
|
||||
const stream = provider.streamImages(
|
||||
{ modelId },
|
||||
{ modelId: prompt.model },
|
||||
[
|
||||
...prompt.finish(
|
||||
finalMessage.reduce(
|
||||
// @ts-expect-error params not typed
|
||||
// @ts-expect-error
|
||||
(acc, m) => Object.assign(acc, m.params),
|
||||
params
|
||||
)
|
||||
|
||||
@@ -111,7 +111,7 @@ test.before(async t => {
|
||||
m.overrideProvider(OpenAIProvider).useClass(MockCopilotProvider);
|
||||
m.overrideProvider(GeminiGenerativeProvider).useClass(
|
||||
class MockGenerativeProvider extends MockCopilotProvider {
|
||||
// @ts-expect-error type not typed
|
||||
// @ts-expect-error
|
||||
override type: CopilotProviderType = CopilotProviderType.Gemini;
|
||||
}
|
||||
);
|
||||
@@ -290,7 +290,6 @@ test('should fork session correctly', async t => {
|
||||
|
||||
const assertForkSession = async (
|
||||
workspaceId: string,
|
||||
docId: string,
|
||||
sessionId: string,
|
||||
lastMessageId: string | undefined,
|
||||
error: string,
|
||||
@@ -301,7 +300,13 @@ test('should fork session correctly', async t => {
|
||||
}
|
||||
) =>
|
||||
await asserter(
|
||||
forkCopilotSession(app, workspaceId, docId, sessionId, lastMessageId)
|
||||
forkCopilotSession(
|
||||
app,
|
||||
workspaceId,
|
||||
randomUUID(),
|
||||
sessionId,
|
||||
lastMessageId
|
||||
)
|
||||
);
|
||||
|
||||
// prepare session
|
||||
@@ -325,7 +330,6 @@ test('should fork session correctly', async t => {
|
||||
// should be able to fork session
|
||||
forkedSessionId = await assertForkSession(
|
||||
id,
|
||||
docId,
|
||||
sessionId,
|
||||
latestMessageId!,
|
||||
'should be able to fork session with cloud workspace that user can access'
|
||||
@@ -336,7 +340,6 @@ test('should fork session correctly', async t => {
|
||||
{
|
||||
forkedSessionId = await assertForkSession(
|
||||
id,
|
||||
docId,
|
||||
sessionId,
|
||||
undefined,
|
||||
'should be able to fork session without latestMessageId'
|
||||
@@ -345,25 +348,18 @@ test('should fork session correctly', async t => {
|
||||
|
||||
// should not be able to fork session with wrong latestMessageId
|
||||
{
|
||||
await assertForkSession(
|
||||
id,
|
||||
docId,
|
||||
sessionId,
|
||||
'wrong-message-id',
|
||||
'',
|
||||
async x => {
|
||||
await t.throwsAsync(
|
||||
x,
|
||||
{ instanceOf: Error },
|
||||
'should not able to fork session with wrong latestMessageId'
|
||||
);
|
||||
}
|
||||
);
|
||||
await assertForkSession(id, sessionId, 'wrong-message-id', '', async x => {
|
||||
await t.throwsAsync(
|
||||
x,
|
||||
{ instanceOf: Error },
|
||||
'should not able to fork session with wrong latestMessageId'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
const u2 = await app.signupV1();
|
||||
await assertForkSession(id, docId, sessionId, randomUUID(), '', async x => {
|
||||
await assertForkSession(id, sessionId, randomUUID(), '', async x => {
|
||||
await t.throwsAsync(
|
||||
x,
|
||||
{ instanceOf: Error },
|
||||
@@ -375,7 +371,7 @@ test('should fork session correctly', async t => {
|
||||
const inviteId = await inviteUser(app, id, u2.email);
|
||||
await app.switchUser(u2);
|
||||
await acceptInviteById(app, id, inviteId, false);
|
||||
await assertForkSession(id, docId, sessionId, randomUUID(), '', async x => {
|
||||
await assertForkSession(id, sessionId, randomUUID(), '', async x => {
|
||||
await t.throwsAsync(
|
||||
x,
|
||||
{ instanceOf: Error },
|
||||
@@ -393,7 +389,6 @@ test('should fork session correctly', async t => {
|
||||
await app.switchUser(u2);
|
||||
await assertForkSession(
|
||||
id,
|
||||
docId,
|
||||
forkedSessionId,
|
||||
latestMessageId!,
|
||||
'should able to fork a forked session created by other user'
|
||||
@@ -461,29 +456,6 @@ test('should create message correctly', async t => {
|
||||
sessionId,
|
||||
undefined,
|
||||
undefined,
|
||||
new File([new Uint8Array(pngData)], '1.png', { type: 'image/png' })
|
||||
);
|
||||
t.truthy(messageId, 'should be able to create message with blob');
|
||||
}
|
||||
|
||||
// with attachments
|
||||
{
|
||||
const { id } = await createWorkspace(app);
|
||||
const sessionId = await createCopilotSession(
|
||||
app,
|
||||
id,
|
||||
randomUUID(),
|
||||
textPromptName
|
||||
);
|
||||
const smallestPng =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII';
|
||||
const pngData = await fetch(smallestPng).then(res => res.arrayBuffer());
|
||||
const messageId = await createCopilotMessage(
|
||||
app,
|
||||
sessionId,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
[new File([new Uint8Array(pngData)], '1.png', { type: 'image/png' })]
|
||||
);
|
||||
t.truthy(messageId, 'should be able to create message with blobs');
|
||||
|
||||
@@ -11,7 +11,6 @@ import { EventBus, JobQueue } from '../base';
|
||||
import { ConfigModule } from '../base/config';
|
||||
import { AuthService } from '../core/auth';
|
||||
import { QuotaModule } from '../core/quota';
|
||||
import { StorageModule, WorkspaceBlobStorage } from '../core/storage';
|
||||
import {
|
||||
ContextCategories,
|
||||
CopilotSessionModel,
|
||||
@@ -19,7 +18,6 @@ import {
|
||||
} from '../models';
|
||||
import { CopilotModule } from '../plugins/copilot';
|
||||
import { CopilotContextService } from '../plugins/copilot/context';
|
||||
import { CopilotCronJobs } from '../plugins/copilot/cron';
|
||||
import {
|
||||
CopilotEmbeddingJob,
|
||||
MockEmbeddingClient,
|
||||
@@ -69,7 +67,6 @@ type Context = {
|
||||
db: PrismaClient;
|
||||
event: EventBus;
|
||||
workspace: WorkspaceModel;
|
||||
workspaceStorage: WorkspaceBlobStorage;
|
||||
copilotSession: CopilotSessionModel;
|
||||
context: CopilotContextService;
|
||||
prompt: PromptService;
|
||||
@@ -80,7 +77,6 @@ type Context = {
|
||||
jobs: CopilotEmbeddingJob;
|
||||
storage: CopilotStorage;
|
||||
workflow: CopilotWorkflowService;
|
||||
cronJobs: CopilotCronJobs;
|
||||
executors: {
|
||||
image: CopilotChatImageExecutor;
|
||||
text: CopilotChatTextExecutor;
|
||||
@@ -116,7 +112,6 @@ test.before(async t => {
|
||||
},
|
||||
}),
|
||||
QuotaModule,
|
||||
StorageModule,
|
||||
CopilotModule,
|
||||
],
|
||||
tapModule: builder => {
|
||||
@@ -130,7 +125,6 @@ test.before(async t => {
|
||||
const db = module.get(PrismaClient);
|
||||
const event = module.get(EventBus);
|
||||
const workspace = module.get(WorkspaceModel);
|
||||
const workspaceStorage = module.get(WorkspaceBlobStorage);
|
||||
const copilotSession = module.get(CopilotSessionModel);
|
||||
const prompt = module.get(PromptService);
|
||||
const factory = module.get(CopilotProviderFactory);
|
||||
@@ -143,14 +137,12 @@ test.before(async t => {
|
||||
const jobs = module.get(CopilotEmbeddingJob);
|
||||
const transcript = module.get(CopilotTranscriptionService);
|
||||
const workspaceEmbedding = module.get(CopilotWorkspaceService);
|
||||
const cronJobs = module.get(CopilotCronJobs);
|
||||
|
||||
t.context.module = module;
|
||||
t.context.auth = auth;
|
||||
t.context.db = db;
|
||||
t.context.event = event;
|
||||
t.context.workspace = workspace;
|
||||
t.context.workspaceStorage = workspaceStorage;
|
||||
t.context.copilotSession = copilotSession;
|
||||
t.context.prompt = prompt;
|
||||
t.context.factory = factory;
|
||||
@@ -161,7 +153,6 @@ test.before(async t => {
|
||||
t.context.jobs = jobs;
|
||||
t.context.transcript = transcript;
|
||||
t.context.workspaceEmbedding = workspaceEmbedding;
|
||||
t.context.cronJobs = cronJobs;
|
||||
|
||||
t.context.executors = {
|
||||
image: module.get(CopilotChatImageExecutor),
|
||||
@@ -211,9 +202,7 @@ test('should be able to manage prompt', async t => {
|
||||
'should have two messages'
|
||||
);
|
||||
|
||||
await prompt.update(promptName, {
|
||||
messages: [{ role: 'system', content: 'hello' }],
|
||||
});
|
||||
await prompt.update(promptName, [{ role: 'system', content: 'hello' }]);
|
||||
t.is(
|
||||
(await prompt.get(promptName))!.finish({}).length,
|
||||
1,
|
||||
@@ -372,7 +361,7 @@ test('should be able to update chat session prompt', async t => {
|
||||
// Update the session
|
||||
const updatedSessionId = await session.update({
|
||||
sessionId,
|
||||
promptName: 'Chat With AFFiNE AI',
|
||||
promptName: 'Search With AFFiNE AI',
|
||||
userId,
|
||||
});
|
||||
t.is(updatedSessionId, sessionId, 'should update session with same id');
|
||||
@@ -382,7 +371,7 @@ test('should be able to update chat session prompt', async t => {
|
||||
t.truthy(updatedSession, 'should retrieve updated session');
|
||||
t.is(
|
||||
updatedSession?.config.promptName,
|
||||
'Chat With AFFiNE AI',
|
||||
'Search With AFFiNE AI',
|
||||
'should have updated prompt name'
|
||||
);
|
||||
});
|
||||
@@ -411,7 +400,7 @@ test('should be able to fork chat session', async t => {
|
||||
|
||||
// fork session
|
||||
const s1 = (await session.get(sessionId))!;
|
||||
// @ts-expect-error find maybe return undefined
|
||||
// @ts-expect-error
|
||||
const latestMessageId = s1.finish({}).find(m => m.role === 'assistant')!.id;
|
||||
const forkedSessionId1 = await session.fork({
|
||||
userId,
|
||||
@@ -1527,25 +1516,14 @@ test('TextStreamParser should process a sequence of message chunks', t => {
|
||||
|
||||
// ==================== context ====================
|
||||
test('should be able to manage context', async t => {
|
||||
const {
|
||||
context,
|
||||
event,
|
||||
jobs,
|
||||
prompt,
|
||||
session,
|
||||
storage,
|
||||
workspace,
|
||||
workspaceStorage,
|
||||
} = t.context;
|
||||
|
||||
const ws = await workspace.create(userId);
|
||||
const { context, prompt, session, event, jobs, storage } = t.context;
|
||||
|
||||
await prompt.set(promptName, 'model', [
|
||||
{ role: 'system', content: 'hello {{word}}' },
|
||||
]);
|
||||
const chatSession = await session.create({
|
||||
docId: 'test',
|
||||
workspaceId: ws.id,
|
||||
workspaceId: 'test',
|
||||
userId,
|
||||
promptName,
|
||||
pinned: false,
|
||||
@@ -1626,24 +1604,6 @@ test('should be able to manage context', async t => {
|
||||
t.is(result[0].fileId, file.id, 'should match file id');
|
||||
}
|
||||
|
||||
// blob record
|
||||
{
|
||||
const blobId = 'test-blob';
|
||||
await workspaceStorage.put(session.workspaceId, blobId, buffer);
|
||||
|
||||
await jobs.embedPendingBlob({ workspaceId: session.workspaceId, blobId });
|
||||
|
||||
const result = await t.context.context.matchWorkspaceBlobs(
|
||||
session.workspaceId,
|
||||
'test',
|
||||
1,
|
||||
undefined,
|
||||
1
|
||||
);
|
||||
t.is(result.length, 1, 'should match blob embedding');
|
||||
t.is(result[0].blobId, blobId, 'should match blob id');
|
||||
}
|
||||
|
||||
// doc record
|
||||
|
||||
const addDoc = async () => {
|
||||
@@ -1971,71 +1931,3 @@ test('should handle generateSessionTitle correctly under various conditions', as
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle copilot cron jobs correctly', async t => {
|
||||
const { cronJobs, copilotSession } = t.context;
|
||||
|
||||
// mock calls
|
||||
const mockCleanupResult = { removed: 2, cleaned: 3 };
|
||||
const mockSessions = [
|
||||
{ id: 'session1', _count: { messages: 1 } },
|
||||
{ id: 'session2', _count: { messages: 2 } },
|
||||
];
|
||||
const cleanupStub = Sinon.stub(
|
||||
copilotSession,
|
||||
'cleanupEmptySessions'
|
||||
).resolves(mockCleanupResult);
|
||||
const toBeGenerateStub = Sinon.stub(
|
||||
copilotSession,
|
||||
'toBeGenerateTitle'
|
||||
).resolves(mockSessions);
|
||||
const jobAddStub = Sinon.stub(cronJobs['jobs'], 'add').resolves();
|
||||
|
||||
// daily cleanup job scheduling
|
||||
{
|
||||
await cronJobs.dailyCleanupJob();
|
||||
t.snapshot(
|
||||
jobAddStub.getCalls().map(call => ({
|
||||
args: call.args,
|
||||
})),
|
||||
'daily job scheduling calls'
|
||||
);
|
||||
|
||||
jobAddStub.reset();
|
||||
cleanupStub.reset();
|
||||
toBeGenerateStub.reset();
|
||||
}
|
||||
|
||||
// cleanup empty sessions
|
||||
{
|
||||
// mock
|
||||
cleanupStub.resolves(mockCleanupResult);
|
||||
toBeGenerateStub.resolves(mockSessions);
|
||||
|
||||
await cronJobs.cleanupEmptySessions();
|
||||
t.snapshot(
|
||||
cleanupStub.getCalls().map(call => ({
|
||||
args: call.args.map(arg => (arg instanceof Date ? 'Date' : arg)), // Replace Date with string for stable snapshot
|
||||
})),
|
||||
'cleanup empty sessions calls'
|
||||
);
|
||||
}
|
||||
|
||||
// generate missing titles
|
||||
await cronJobs.generateMissingTitles();
|
||||
t.snapshot(
|
||||
{
|
||||
modelCalls: toBeGenerateStub.getCalls().map(call => ({
|
||||
args: call.args,
|
||||
})),
|
||||
jobCalls: jobAddStub.getCalls().map(call => ({
|
||||
args: call.args,
|
||||
})),
|
||||
},
|
||||
'title generation calls'
|
||||
);
|
||||
|
||||
cleanupStub.restore();
|
||||
toBeGenerateStub.restore();
|
||||
jobAddStub.restore();
|
||||
});
|
||||
|
||||
@@ -13,45 +13,74 @@ Generated by [AVA](https://avajs.dev).
|
||||
␊
|
||||
␊
|
||||
␊
|
||||
␊
|
||||
␊
|
||||
# You own your data, with no compromises␊
|
||||
␊
|
||||
␊
|
||||
## Local-first & Real-time collaborative␊
|
||||
␊
|
||||
␊
|
||||
We love the idea proposed by Ink & Switch in the famous article about you owning your data, despite the cloud. Furthermore, AFFiNE is the first all-in-one workspace that keeps your data ownership with no compromises on real-time collaboration and editing experience.␊
|
||||
␊
|
||||
␊
|
||||
AFFiNE is a local-first application upon CRDTs with real-time collaboration support. Your data is always stored locally while multiple nodes remain synced in real-time.␊
|
||||
␊
|
||||
␊
|
||||
␊
|
||||
␊
|
||||
␊
|
||||
### Blocks that assemble your next docs, tasks kanban or whiteboard␊
|
||||
␊
|
||||
␊
|
||||
There is a large overlap of their atomic "building blocks" between these apps. They are neither open source nor have a plugin system like VS Code for contributors to customize. We want to have something that contains all the features we love and goes one step further.␊
|
||||
␊
|
||||
␊
|
||||
We are building AFFiNE to be a fundamental open source platform that contains all the building blocks for docs, task management and visual collaboration, hoping you can shape your next workflow with us that can make your life better and also connect others, too.␊
|
||||
␊
|
||||
␊
|
||||
If you want to learn more about the product design of AFFiNE, here goes the concepts:␊
|
||||
␊
|
||||
␊
|
||||
To Shape, not to adapt. AFFiNE is built for individuals & teams who care about their data, who refuse vendor lock-in, and who want to have control over their essential tools.␊
|
||||
␊
|
||||
␊
|
||||
## A true canvas for blocks in any form␊
|
||||
␊
|
||||
␊
|
||||
[Many editor apps](http://notion.so) claimed to be a canvas for productivity. Since _the Mother of All Demos,_ Douglas Engelbart, a creative and programable digital workspace has been a pursuit and an ultimate mission for generations of tool makers.␊
|
||||
␊
|
||||
␊
|
||||
␊
|
||||
␊
|
||||
␊
|
||||
"We shape our tools and thereafter our tools shape us”. A lot of pioneers have inspired us a long the way, e.g.:␊
|
||||
␊
|
||||
␊
|
||||
* Quip & Notion with their great concept of "everything is a block"␊
|
||||
␊
|
||||
␊
|
||||
* Trello with their Kanban␊
|
||||
␊
|
||||
␊
|
||||
* Airtable & Miro with their no-code programable datasheets␊
|
||||
␊
|
||||
␊
|
||||
* Miro & Whimiscal with their edgeless visual whiteboard␊
|
||||
␊
|
||||
␊
|
||||
* Remnote & Capacities with their object-based tag system␊
|
||||
␊
|
||||
␊
|
||||
For more details, please refer to our [RoadMap](https://docs.affine.pro/docs/core-concepts/roadmap)␊
|
||||
␊
|
||||
␊
|
||||
## Self Host␊
|
||||
␊
|
||||
␊
|
||||
Self host AFFiNE␊
|
||||
␊
|
||||
␊
|
||||
||Title|Tag|␊
|
||||
|---|---|---|␊
|
||||
|Affine Development|Affine Development|<span data-affine-option data-value="AxSe-53xjX" data-option-color="var(--affine-tag-pink)">AFFiNE</span>|␊
|
||||
@@ -62,12 +91,16 @@ Generated by [AVA](https://avajs.dev).
|
||||
|Miro & Whimiscal with their edgeless visual whiteboard|Miro & Whimiscal with their edgeless visual whiteboard|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|␊
|
||||
|Remnote & Capacities with their object-based tag system|Remnote & Capacities with their object-based tag system||␊
|
||||
␊
|
||||
␊
|
||||
## Affine Development␊
|
||||
␊
|
||||
␊
|
||||
For developer or installation guides, please go to [AFFiNE Development](https://docs.affine.pro/docs/development/quick-start)␊
|
||||
␊
|
||||
␊
|
||||
␊
|
||||
␊
|
||||
␊
|
||||
`,
|
||||
title: 'Write, Draw, Plan all at Once.',
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -99,56 +99,3 @@ e2e(
|
||||
t.is(result2.workspace.doc.public, true);
|
||||
}
|
||||
);
|
||||
|
||||
e2e('should get doc with title and summary', async t => {
|
||||
const owner = await app.signup();
|
||||
|
||||
const workspace = await app.create(Mockers.Workspace, {
|
||||
owner: { id: owner.id },
|
||||
});
|
||||
|
||||
const docSnapshot = await app.create(Mockers.DocSnapshot, {
|
||||
workspaceId: workspace.id,
|
||||
user: owner,
|
||||
});
|
||||
const doc = await app.create(Mockers.DocMeta, {
|
||||
workspaceId: workspace.id,
|
||||
docId: docSnapshot.id,
|
||||
title: 'doc1',
|
||||
summary: 'summary1',
|
||||
});
|
||||
|
||||
const result = await app.gql({
|
||||
query: getWorkspacePageByIdQuery,
|
||||
variables: { workspaceId: workspace.id, pageId: doc.docId },
|
||||
});
|
||||
|
||||
t.is(result.workspace.doc.title, doc.title);
|
||||
t.is(result.workspace.doc.summary, doc.summary);
|
||||
});
|
||||
|
||||
e2e('should get doc with title and null summary', async t => {
|
||||
const owner = await app.signup();
|
||||
|
||||
const workspace = await app.create(Mockers.Workspace, {
|
||||
owner: { id: owner.id },
|
||||
});
|
||||
|
||||
const docSnapshot = await app.create(Mockers.DocSnapshot, {
|
||||
workspaceId: workspace.id,
|
||||
user: owner,
|
||||
});
|
||||
const doc = await app.create(Mockers.DocMeta, {
|
||||
workspaceId: workspace.id,
|
||||
docId: docSnapshot.id,
|
||||
title: 'doc1',
|
||||
});
|
||||
|
||||
const result = await app.gql({
|
||||
query: getWorkspacePageByIdQuery,
|
||||
variables: { workspaceId: workspace.id, pageId: doc.docId },
|
||||
});
|
||||
|
||||
t.is(result.workspace.doc.title, doc.title);
|
||||
t.is(result.workspace.doc.summary, null);
|
||||
});
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { AccessToken } from '@prisma/client';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { Mocker } from './factory';
|
||||
|
||||
export type MockAccessTokenInput = Omit<
|
||||
Prisma.AccessTokenUncheckedCreateInput,
|
||||
'token'
|
||||
>;
|
||||
|
||||
export type MockedAccessToken = AccessToken;
|
||||
|
||||
export class MockAccessToken extends Mocker<
|
||||
MockAccessTokenInput,
|
||||
MockedAccessToken
|
||||
> {
|
||||
override async create(input: MockAccessTokenInput) {
|
||||
return await this.db.accessToken.create({
|
||||
data: {
|
||||
...input,
|
||||
name: input.name ?? faker.lorem.word(),
|
||||
token: 'ut_' + faker.string.hexadecimal({ length: 37 }),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -111,19 +111,6 @@ export class MockCopilotProvider extends OpenAIProvider {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'gemini-2.5-pro',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text, ModelInputType.Image],
|
||||
output: [
|
||||
ModelOutputType.Text,
|
||||
ModelOutputType.Object,
|
||||
ModelOutputType.Structured,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
override async text(
|
||||
|
||||
@@ -4,7 +4,6 @@ export * from './user.mock';
|
||||
export * from './workspace.mock';
|
||||
export * from './workspace-user.mock';
|
||||
|
||||
import { MockAccessToken } from './access-token.mock';
|
||||
import { MockCopilotProvider } from './copilot.mock';
|
||||
import { MockDocMeta } from './doc-meta.mock';
|
||||
import { MockDocSnapshot } from './doc-snapshot.mock';
|
||||
@@ -27,7 +26,6 @@ export const Mockers = {
|
||||
DocMeta: MockDocMeta,
|
||||
DocSnapshot: MockDocSnapshot,
|
||||
DocUser: MockDocUser,
|
||||
AccessToken: MockAccessToken,
|
||||
};
|
||||
|
||||
export { MockCopilotProvider, MockEventBus, MockJobQueue, MockMailer };
|
||||
|
||||
@@ -565,65 +565,3 @@ Generated by [AVA](https://avajs.dev).
|
||||
workspaceSessionExists: true,
|
||||
},
|
||||
}
|
||||
|
||||
## should cleanup empty sessions correctly
|
||||
|
||||
> cleanup empty sessions results
|
||||
|
||||
{
|
||||
cleanupResult: {
|
||||
cleaned: 0,
|
||||
removed: 0,
|
||||
},
|
||||
remainingSessions: [
|
||||
{
|
||||
deleted: false,
|
||||
pinned: false,
|
||||
type: 'zeroCost',
|
||||
},
|
||||
{
|
||||
deleted: false,
|
||||
pinned: false,
|
||||
type: 'zeroCost',
|
||||
},
|
||||
{
|
||||
deleted: false,
|
||||
pinned: false,
|
||||
type: 'noMessages',
|
||||
},
|
||||
{
|
||||
deleted: false,
|
||||
pinned: false,
|
||||
type: 'noMessages',
|
||||
},
|
||||
{
|
||||
deleted: false,
|
||||
pinned: false,
|
||||
type: 'recent',
|
||||
},
|
||||
{
|
||||
deleted: false,
|
||||
pinned: false,
|
||||
type: 'withMessages',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
## should get sessions for title generation correctly
|
||||
|
||||
> sessions for title generation results
|
||||
|
||||
{
|
||||
onlyValidSessionsReturned: true,
|
||||
sessions: [
|
||||
{
|
||||
assistantMessageCount: 1,
|
||||
isValid: true,
|
||||
},
|
||||
{
|
||||
assistantMessageCount: 2,
|
||||
isValid: true,
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -74,17 +74,6 @@ Generated by [AVA](https://avajs.dev).
|
||||
},
|
||||
]
|
||||
|
||||
> should match workspace blob embedding
|
||||
|
||||
[
|
||||
{
|
||||
blobId: 'blob-test',
|
||||
chunk: 0,
|
||||
content: 'blob content',
|
||||
distance: 0,
|
||||
},
|
||||
]
|
||||
|
||||
> should find docs to embed
|
||||
|
||||
1
|
||||
@@ -100,19 +89,3 @@ Generated by [AVA](https://avajs.dev).
|
||||
> should not find docs to embed
|
||||
|
||||
0
|
||||
|
||||
## should filter outdated doc id style in embedding status
|
||||
|
||||
> should include modern doc format
|
||||
|
||||
{
|
||||
embedded: 0,
|
||||
total: 1,
|
||||
}
|
||||
|
||||
> should count docs after filtering outdated
|
||||
|
||||
{
|
||||
embedded: 1,
|
||||
total: 1,
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -89,14 +89,13 @@ test('should get null for non-exist job', async t => {
|
||||
|
||||
test('should update context', async t => {
|
||||
const { id: contextId } = await t.context.copilotContext.create(sessionId);
|
||||
const config = (await t.context.copilotContext.getConfig(contextId))!;
|
||||
t.assert(config, 'should get context config');
|
||||
const config = await t.context.copilotContext.getConfig(contextId);
|
||||
|
||||
const doc = {
|
||||
id: docId,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
config.docs.push(doc);
|
||||
config?.docs.push(doc);
|
||||
await t.context.copilotContext.update(contextId, { config });
|
||||
|
||||
const config1 = await t.context.copilotContext.getConfig(contextId);
|
||||
@@ -165,14 +164,11 @@ test('should insert embedding by doc id', async t => {
|
||||
);
|
||||
|
||||
{
|
||||
const ret = await t.context.copilotContext.listWorkspaceDocEmbedding(
|
||||
const ret = await t.context.copilotContext.hasWorkspaceEmbedding(
|
||||
workspace.id,
|
||||
[docId]
|
||||
);
|
||||
t.true(
|
||||
ret.includes(docId),
|
||||
'should return doc id when embedding is inserted'
|
||||
);
|
||||
t.true(ret.has(docId), 'should return doc id when embedding is inserted');
|
||||
}
|
||||
|
||||
{
|
||||
@@ -321,8 +317,8 @@ test('should merge doc status correctly', async t => {
|
||||
|
||||
const hasEmbeddingStub = Sinon.stub(
|
||||
t.context.copilotContext,
|
||||
'listWorkspaceDocEmbedding'
|
||||
).resolves([]);
|
||||
'hasWorkspaceEmbedding'
|
||||
).resolves(new Set<string>());
|
||||
|
||||
const stubResult = await t.context.copilotContext.mergeDocStatus(
|
||||
workspace.id,
|
||||
|
||||
@@ -917,178 +917,3 @@ test('should handle fork and session attachment operations', async t => {
|
||||
'attach and detach operation results'
|
||||
);
|
||||
});
|
||||
|
||||
test('should cleanup empty sessions correctly', async t => {
|
||||
const { copilotSession, db } = t.context;
|
||||
await createTestPrompts(copilotSession, db);
|
||||
|
||||
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
||||
|
||||
// should be deleted
|
||||
const neverUsedSessionIds: string[] = [randomUUID(), randomUUID()];
|
||||
await Promise.all(
|
||||
neverUsedSessionIds.map(async id => {
|
||||
await createTestSession(t, { sessionId: id });
|
||||
await db.aiSession.update({
|
||||
where: { id },
|
||||
data: { messageCost: 0, updatedAt: oneDayAgo },
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// should be marked as deleted
|
||||
const emptySessionIds: string[] = [randomUUID(), randomUUID()];
|
||||
await Promise.all(
|
||||
emptySessionIds.map(async id => {
|
||||
await createTestSession(t, { sessionId: id });
|
||||
await db.aiSession.update({
|
||||
where: { id },
|
||||
data: { messageCost: 100, updatedAt: oneDayAgo },
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// should not be affected
|
||||
const recentSessionId = randomUUID();
|
||||
await createTestSession(t, { sessionId: recentSessionId });
|
||||
await db.aiSession.update({
|
||||
where: { id: recentSessionId },
|
||||
data: { messageCost: 0, updatedAt: twoHoursAgo },
|
||||
});
|
||||
|
||||
// Create session with messages (should not be affected)
|
||||
const sessionWithMsgId = randomUUID();
|
||||
await createSessionWithMessages(
|
||||
t,
|
||||
{ sessionId: sessionWithMsgId },
|
||||
'test message'
|
||||
);
|
||||
|
||||
const result = await copilotSession.cleanupEmptySessions(oneDayAgo);
|
||||
|
||||
const remainingSessions = await db.aiSession.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: [
|
||||
...neverUsedSessionIds,
|
||||
...emptySessionIds,
|
||||
recentSessionId,
|
||||
sessionWithMsgId,
|
||||
],
|
||||
},
|
||||
},
|
||||
select: { id: true, deletedAt: true, pinned: true },
|
||||
});
|
||||
|
||||
t.snapshot(
|
||||
{
|
||||
cleanupResult: result,
|
||||
remainingSessions: remainingSessions.map(s => ({
|
||||
deleted: !!s.deletedAt,
|
||||
pinned: s.pinned,
|
||||
type: neverUsedSessionIds.includes(s.id)
|
||||
? 'zeroCost'
|
||||
: emptySessionIds.includes(s.id)
|
||||
? 'noMessages'
|
||||
: s.id === recentSessionId
|
||||
? 'recent'
|
||||
: 'withMessages',
|
||||
})),
|
||||
},
|
||||
'cleanup empty sessions results'
|
||||
);
|
||||
});
|
||||
|
||||
test('should get sessions for title generation correctly', async t => {
|
||||
const { copilotSession, db } = t.context;
|
||||
await createTestPrompts(copilotSession, db);
|
||||
|
||||
// create valid sessions with messages
|
||||
const sessionIds: string[] = [randomUUID(), randomUUID()];
|
||||
await Promise.all(
|
||||
sessionIds.map(async (id, index) => {
|
||||
await createTestSession(t, { sessionId: id });
|
||||
await db.aiSession.update({
|
||||
where: { id },
|
||||
data: {
|
||||
updatedAt: new Date(Date.now() - index * 1000),
|
||||
messages: {
|
||||
create: Array.from({ length: index + 1 }, (_, i) => ({
|
||||
role: 'assistant',
|
||||
content: `assistant message ${i}`,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// create excluded sessions
|
||||
const excludedSessions = [
|
||||
{
|
||||
reason: 'hasTitle',
|
||||
setupFn: async (id: string) => {
|
||||
await createTestSession(t, { sessionId: id });
|
||||
await db.aiSession.update({
|
||||
where: { id },
|
||||
data: { title: 'Existing Title' },
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
reason: 'isDeleted',
|
||||
setupFn: async (id: string) => {
|
||||
await createTestSession(t, { sessionId: id });
|
||||
await db.aiSession.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
reason: 'noMessages',
|
||||
setupFn: async (id: string) => {
|
||||
await createTestSession(t, { sessionId: id });
|
||||
},
|
||||
},
|
||||
{
|
||||
reason: 'isAction',
|
||||
setupFn: async (id: string) => {
|
||||
await createTestSession(t, {
|
||||
sessionId: id,
|
||||
promptName: TEST_PROMPTS.ACTION,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
reason: 'noAssistantMessages',
|
||||
setupFn: async (id: string) => {
|
||||
await createTestSession(t, { sessionId: id });
|
||||
await db.aiSessionMessage.create({
|
||||
data: { sessionId: id, role: 'user', content: 'User message only' },
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await Promise.all(
|
||||
excludedSessions.map(async session => {
|
||||
await session.setupFn(randomUUID());
|
||||
})
|
||||
);
|
||||
|
||||
const result = await copilotSession.toBeGenerateTitle();
|
||||
|
||||
t.snapshot(
|
||||
{
|
||||
total: result.length,
|
||||
sessions: result.map(s => ({
|
||||
assistantMessageCount: s._count.messages,
|
||||
isValid: sessionIds.includes(s.id),
|
||||
})),
|
||||
onlyValidSessionsReturned: result.every(s => sessionIds.includes(s.id)),
|
||||
},
|
||||
'sessions for title generation results'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -145,52 +145,6 @@ test('should insert and search embedding', async t => {
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
await t.context.db.blob.create({
|
||||
data: {
|
||||
workspaceId: workspace.id,
|
||||
key: 'blob-test',
|
||||
mime: 'text/plain',
|
||||
size: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const blobId = 'blob-test';
|
||||
await t.context.copilotWorkspace.insertBlobEmbeddings(
|
||||
workspace.id,
|
||||
blobId,
|
||||
[
|
||||
{
|
||||
index: 0,
|
||||
content: 'blob content',
|
||||
embedding: Array.from({ length: 1024 }, () => 1),
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
{
|
||||
const ret = await t.context.copilotWorkspace.matchBlobEmbedding(
|
||||
workspace.id,
|
||||
Array.from({ length: 1024 }, () => 0.9),
|
||||
1,
|
||||
1
|
||||
);
|
||||
t.snapshot(cleanObject(ret), 'should match workspace blob embedding');
|
||||
}
|
||||
|
||||
await t.context.copilotWorkspace.removeBlob(workspace.id, blobId);
|
||||
|
||||
{
|
||||
const ret = await t.context.copilotWorkspace.matchBlobEmbedding(
|
||||
workspace.id,
|
||||
Array.from({ length: 1024 }, () => 0.9),
|
||||
1,
|
||||
1
|
||||
);
|
||||
t.deepEqual(ret, [], 'should not match after removal');
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const docId = randomUUID();
|
||||
await t.context.doc.upsert({
|
||||
@@ -260,21 +214,6 @@ test('should insert and search embedding', async t => {
|
||||
);
|
||||
t.false(results.includes(docId), 'docs containing `$` should be excluded');
|
||||
}
|
||||
|
||||
{
|
||||
const docId = 'empty_doc';
|
||||
await t.context.doc.upsert({
|
||||
spaceId: workspace.id,
|
||||
docId: docId,
|
||||
blob: Uint8Array.from([0, 0]),
|
||||
timestamp: Date.now(),
|
||||
editorId: user.id,
|
||||
});
|
||||
const results = await t.context.copilotWorkspace.findDocsToEmbed(
|
||||
workspace.id
|
||||
);
|
||||
t.false(results.includes(docId), 'empty documents should be excluded');
|
||||
}
|
||||
});
|
||||
|
||||
test('should check need to be embedded', async t => {
|
||||
@@ -352,50 +291,3 @@ test('should check embedding table', async t => {
|
||||
// t.false(ret, 'should return false when embedding table is not available');
|
||||
// }
|
||||
});
|
||||
|
||||
test('should filter outdated doc id style in embedding status', async t => {
|
||||
const docId = randomUUID();
|
||||
const outdatedDocId = `${workspace.id}:space:${docId}`;
|
||||
|
||||
await t.context.doc.upsert({
|
||||
spaceId: workspace.id,
|
||||
docId,
|
||||
blob: Uint8Array.from([1, 2, 3]),
|
||||
timestamp: Date.now(),
|
||||
editorId: user.id,
|
||||
});
|
||||
|
||||
await t.context.doc.upsert({
|
||||
spaceId: workspace.id,
|
||||
docId: outdatedDocId,
|
||||
blob: Uint8Array.from([1, 2, 3]),
|
||||
timestamp: Date.now(),
|
||||
editorId: user.id,
|
||||
});
|
||||
|
||||
{
|
||||
const status = await t.context.copilotWorkspace.getEmbeddingStatus(
|
||||
workspace.id
|
||||
);
|
||||
t.snapshot(status, 'should include modern doc format');
|
||||
}
|
||||
|
||||
{
|
||||
await t.context.copilotContext.insertWorkspaceEmbedding(
|
||||
workspace.id,
|
||||
docId,
|
||||
[
|
||||
{
|
||||
index: 0,
|
||||
content: 'content',
|
||||
embedding: Array.from({ length: 1024 }, () => 1),
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
const status = await t.context.copilotWorkspace.getEmbeddingStatus(
|
||||
workspace.id
|
||||
);
|
||||
t.snapshot(status, 'should count docs after filtering outdated');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -669,10 +669,7 @@ test('should get doc info', async t => {
|
||||
};
|
||||
|
||||
await t.context.doc.upsert(snapshot);
|
||||
await t.context.doc.upsertMeta(workspace.id, docId, {
|
||||
title: 'test title',
|
||||
summary: 'test summary',
|
||||
});
|
||||
await t.context.doc.upsertMeta(workspace.id, docId);
|
||||
|
||||
const docInfo = await t.context.doc.getDocInfo(workspace.id, docId);
|
||||
|
||||
@@ -682,8 +679,6 @@ test('should get doc info', async t => {
|
||||
updatedAt: new Date(snapshot.timestamp),
|
||||
creatorId: user.id,
|
||||
lastUpdaterId: user.id,
|
||||
title: 'test title',
|
||||
summary: 'test summary',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ test('should not switch user quota if the new quota is the same as the current o
|
||||
});
|
||||
|
||||
test('should use pro plan as free for selfhost instance', async t => {
|
||||
// @ts-expect-error DEPLOYMENT_TYPE is readonly
|
||||
// @ts-expect-error
|
||||
env.DEPLOYMENT_TYPE = 'selfhosted';
|
||||
await using module = await createTestingModule();
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -66,7 +66,7 @@ export async function createTestingModule(
|
||||
// setting up
|
||||
let imports = moduleDef.imports ?? [buildAppModule(globalThis.env)];
|
||||
imports =
|
||||
// @ts-expect-error ignore the type error
|
||||
// @ts-expect-error
|
||||
imports[0].module?.name === 'AppModule'
|
||||
? imports
|
||||
: dedupeModules([
|
||||
|
||||
@@ -28,7 +28,6 @@ import { RedisModule } from './base/redis';
|
||||
import { StorageProviderModule } from './base/storage';
|
||||
import { RateLimiterModule } from './base/throttler';
|
||||
import { WebSocketModule } from './base/websocket';
|
||||
import { AccessTokenModule } from './core/access-token';
|
||||
import { AuthModule } from './core/auth';
|
||||
import { CommentModule } from './core/comment';
|
||||
import { ServerConfigModule, ServerConfigResolverModule } from './core/config';
|
||||
@@ -37,7 +36,6 @@ import { DocRendererModule } from './core/doc-renderer';
|
||||
import { DocServiceModule } from './core/doc-service';
|
||||
import { FeatureModule } from './core/features';
|
||||
import { MailModule } from './core/mail';
|
||||
import { MonitorModule } from './core/monitor';
|
||||
import { NotificationModule } from './core/notification';
|
||||
import { PermissionModule } from './core/permission';
|
||||
import { QuotaModule } from './core/quota';
|
||||
@@ -114,8 +112,6 @@ export const FunctionalityModules = [
|
||||
WebSocketModule,
|
||||
JobModule.forRoot(),
|
||||
ModelsModule,
|
||||
ScheduleModule.forRoot(),
|
||||
MonitorModule,
|
||||
];
|
||||
|
||||
export class AppModuleBuilder {
|
||||
@@ -155,8 +151,12 @@ export function buildAppModule(env: Env) {
|
||||
// basic
|
||||
.use(...FunctionalityModules)
|
||||
|
||||
// enable indexer module on graphql server and doc service
|
||||
.useIf(() => env.flavors.graphql || env.flavors.doc, IndexerModule)
|
||||
// enable schedule module on graphql server and doc service
|
||||
.useIf(
|
||||
() => env.flavors.graphql || env.flavors.doc,
|
||||
ScheduleModule.forRoot(),
|
||||
IndexerModule
|
||||
)
|
||||
|
||||
// auth
|
||||
.use(UserModule, AuthModule, PermissionModule)
|
||||
@@ -188,8 +188,7 @@ export function buildAppModule(env: Env) {
|
||||
CaptchaModule,
|
||||
OAuthModule,
|
||||
CustomerIoModule,
|
||||
CommentModule,
|
||||
AccessTokenModule
|
||||
CommentModule
|
||||
)
|
||||
// doc service only
|
||||
.useIf(() => env.flavors.doc, DocServiceModule)
|
||||
|
||||
@@ -653,19 +653,12 @@ export const USER_FRIENDLY_ERRORS = {
|
||||
},
|
||||
no_copilot_provider_available: {
|
||||
type: 'internal_server_error',
|
||||
args: { modelId: 'string' },
|
||||
message: ({ modelId }) => `No copilot provider available: ${modelId}`,
|
||||
message: `No copilot provider available.`,
|
||||
},
|
||||
copilot_failed_to_generate_text: {
|
||||
type: 'internal_server_error',
|
||||
message: `Failed to generate text.`,
|
||||
},
|
||||
copilot_failed_to_generate_embedding: {
|
||||
type: 'internal_server_error',
|
||||
args: { provider: 'string', message: 'string' },
|
||||
message: ({ provider, message }) =>
|
||||
`Failed to generate embedding with ${provider}: ${message}`,
|
||||
},
|
||||
copilot_failed_to_create_message: {
|
||||
type: 'internal_server_error',
|
||||
message: `Failed to create chat message.`,
|
||||
|
||||
@@ -668,14 +668,10 @@ export class CopilotSessionDeleted extends UserFriendlyError {
|
||||
super('action_forbidden', 'copilot_session_deleted', message);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class NoCopilotProviderAvailableDataType {
|
||||
@Field() modelId!: string
|
||||
}
|
||||
|
||||
export class NoCopilotProviderAvailable extends UserFriendlyError {
|
||||
constructor(args: NoCopilotProviderAvailableDataType, message?: string | ((args: NoCopilotProviderAvailableDataType) => string)) {
|
||||
super('internal_server_error', 'no_copilot_provider_available', message, args);
|
||||
constructor(message?: string) {
|
||||
super('internal_server_error', 'no_copilot_provider_available', message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -684,17 +680,6 @@ export class CopilotFailedToGenerateText extends UserFriendlyError {
|
||||
super('internal_server_error', 'copilot_failed_to_generate_text', message);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class CopilotFailedToGenerateEmbeddingDataType {
|
||||
@Field() provider!: string
|
||||
@Field() message!: string
|
||||
}
|
||||
|
||||
export class CopilotFailedToGenerateEmbedding extends UserFriendlyError {
|
||||
constructor(args: CopilotFailedToGenerateEmbeddingDataType, message?: string | ((args: CopilotFailedToGenerateEmbeddingDataType) => string)) {
|
||||
super('internal_server_error', 'copilot_failed_to_generate_embedding', message, args);
|
||||
}
|
||||
}
|
||||
|
||||
export class CopilotFailedToCreateMessage extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
@@ -1194,7 +1179,6 @@ export enum ErrorNames {
|
||||
COPILOT_SESSION_DELETED,
|
||||
NO_COPILOT_PROVIDER_AVAILABLE,
|
||||
COPILOT_FAILED_TO_GENERATE_TEXT,
|
||||
COPILOT_FAILED_TO_GENERATE_EMBEDDING,
|
||||
COPILOT_FAILED_TO_CREATE_MESSAGE,
|
||||
UNSPLASH_IS_NOT_CONFIGURED,
|
||||
COPILOT_ACTION_TAKEN,
|
||||
@@ -1255,5 +1239,5 @@ registerEnumType(ErrorNames, {
|
||||
export const ErrorDataUnionType = createUnionType({
|
||||
name: 'ErrorDataUnion',
|
||||
types: () =>
|
||||
[GraphqlBadRequestDataType, HttpRequestErrorDataType, QueryTooLongDataType, ValidationErrorDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, InvalidOauthCallbackCodeDataType, MissingOauthQueryParameterDataType, InvalidOauthResponseDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, DocUpdateBlockedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, NoMoreSeatDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, NoCopilotProviderAvailableDataType, CopilotFailedToGenerateEmbeddingDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderNotSupportedDataType, CopilotProviderSideErrorDataType, CopilotInvalidContextDataType, CopilotContextFileNotSupportedDataType, CopilotFailedToModifyContextDataType, CopilotFailedToMatchContextDataType, CopilotFailedToMatchGlobalContextDataType, CopilotFailedToAddWorkspaceFileEmbeddingDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseToActivateDataType, InvalidLicenseUpdateParamsDataType, UnsupportedClientVersionDataType, MentionUserDocAccessDeniedDataType, InvalidAppConfigDataType, InvalidAppConfigInputDataType, InvalidSearchProviderRequestDataType, InvalidIndexerInputDataType] as const,
|
||||
[GraphqlBadRequestDataType, HttpRequestErrorDataType, QueryTooLongDataType, ValidationErrorDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, InvalidOauthCallbackCodeDataType, MissingOauthQueryParameterDataType, InvalidOauthResponseDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, DocUpdateBlockedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, NoMoreSeatDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderNotSupportedDataType, CopilotProviderSideErrorDataType, CopilotInvalidContextDataType, CopilotContextFileNotSupportedDataType, CopilotFailedToModifyContextDataType, CopilotFailedToMatchContextDataType, CopilotFailedToMatchGlobalContextDataType, CopilotFailedToAddWorkspaceFileEmbeddingDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseToActivateDataType, InvalidLicenseUpdateParamsDataType, UnsupportedClientVersionDataType, MentionUserDocAccessDeniedDataType, InvalidAppConfigDataType, InvalidAppConfigInputDataType, InvalidSearchProviderRequestDataType, InvalidIndexerInputDataType] as const,
|
||||
});
|
||||
|
||||
@@ -59,9 +59,7 @@ export type KnownMetricScopes =
|
||||
| 'mail'
|
||||
| 'ai'
|
||||
| 'event'
|
||||
| 'queue'
|
||||
| 'storage'
|
||||
| 'process';
|
||||
| 'queue';
|
||||
|
||||
const metricCreators: MetricCreators = {
|
||||
counter(meter: Meter, name: string, opts?: MetricOptions) {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
|
||||
import { defer as rxjsDefer, retry } from 'rxjs';
|
||||
|
||||
export class RetryablePromise<T> extends Promise<T> {
|
||||
@@ -50,7 +48,3 @@ export function defer(dispose: () => Promise<void>) {
|
||||
[Symbol.asyncDispose]: dispose,
|
||||
};
|
||||
}
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return setTimeout(ms);
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AccessTokenResolver } from './resolver';
|
||||
|
||||
@Module({
|
||||
providers: [AccessTokenResolver],
|
||||
})
|
||||
export class AccessTokenModule {}
|
||||
@@ -1,73 +0,0 @@
|
||||
import {
|
||||
Args,
|
||||
Field,
|
||||
InputType,
|
||||
Mutation,
|
||||
ObjectType,
|
||||
Query,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
|
||||
import { Models } from '../../models';
|
||||
import { CurrentUser } from '../auth/session';
|
||||
|
||||
@ObjectType()
|
||||
class AccessToken {
|
||||
@Field()
|
||||
id!: string;
|
||||
|
||||
@Field()
|
||||
name!: string;
|
||||
|
||||
@Field()
|
||||
createdAt!: Date;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
expiresAt!: Date | null;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
class RevealedAccessToken extends AccessToken {
|
||||
@Field()
|
||||
token!: string;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
class GenerateAccessTokenInput {
|
||||
@Field()
|
||||
name!: string;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
expiresAt!: Date | null;
|
||||
}
|
||||
|
||||
@Resolver(() => AccessToken)
|
||||
export class AccessTokenResolver {
|
||||
constructor(private readonly models: Models) {}
|
||||
|
||||
@Query(() => [AccessToken])
|
||||
async accessTokens(@CurrentUser() user: CurrentUser): Promise<AccessToken[]> {
|
||||
return await this.models.accessToken.list(user.id);
|
||||
}
|
||||
|
||||
@Mutation(() => RevealedAccessToken)
|
||||
async generateUserAccessToken(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('input') input: GenerateAccessTokenInput
|
||||
): Promise<RevealedAccessToken> {
|
||||
return await this.models.accessToken.create({
|
||||
userId: user.id,
|
||||
name: input.name,
|
||||
expiresAt: input.expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async revokeUserAccessToken(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('id') id: string
|
||||
): Promise<boolean> {
|
||||
await this.models.accessToken.revoke(id, user.id);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from '../../base';
|
||||
import { WEBSOCKET_OPTIONS } from '../../base/websocket';
|
||||
import { AuthService } from './service';
|
||||
import { Session, TokenSession } from './session';
|
||||
import { Session } from './session';
|
||||
|
||||
const PUBLIC_ENTRYPOINT_SYMBOL = Symbol('public');
|
||||
const INTERNAL_ENTRYPOINT_SYMBOL = Symbol('internal');
|
||||
@@ -56,7 +56,10 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
throw new AccessDenied('Invalid internal request');
|
||||
}
|
||||
|
||||
const authedUser = await this.signIn(req, res);
|
||||
const userSession = await this.signIn(req, res);
|
||||
if (res && userSession && userSession.expiresAt) {
|
||||
await this.auth.refreshUserSessionIfNeeded(res, userSession);
|
||||
}
|
||||
|
||||
// api is public
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(
|
||||
@@ -68,29 +71,14 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!authedUser) {
|
||||
if (!userSession) {
|
||||
throw new AuthenticationRequired();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async signIn(
|
||||
req: Request,
|
||||
res?: Response
|
||||
): Promise<Session | TokenSession | null> {
|
||||
const userSession = await this.signInWithCookie(req, res);
|
||||
if (userSession) {
|
||||
return userSession;
|
||||
}
|
||||
|
||||
return await this.signInWithAccessToken(req);
|
||||
}
|
||||
|
||||
async signInWithCookie(
|
||||
req: Request,
|
||||
res?: Response
|
||||
): Promise<Session | null> {
|
||||
async signIn(req: Request, res?: Response): Promise<Session | null> {
|
||||
if (req.session) {
|
||||
return req.session;
|
||||
}
|
||||
@@ -99,10 +87,6 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
const userSession = await this.auth.getUserSessionFromRequest(req, res);
|
||||
|
||||
if (userSession) {
|
||||
if (res) {
|
||||
await this.auth.refreshUserSessionIfNeeded(res, userSession.session);
|
||||
}
|
||||
|
||||
req.session = {
|
||||
...userSession.session,
|
||||
user: userSession.user,
|
||||
@@ -113,25 +97,6 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async signInWithAccessToken(req: Request): Promise<TokenSession | null> {
|
||||
if (req.token) {
|
||||
return req.token;
|
||||
}
|
||||
|
||||
const tokenSession = await this.auth.getTokenSessionFromRequest(req);
|
||||
|
||||
if (tokenSession) {
|
||||
req.token = {
|
||||
...tokenSession.token,
|
||||
user: tokenSession.user,
|
||||
};
|
||||
|
||||
return req.token;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -264,36 +264,6 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
return session;
|
||||
}
|
||||
|
||||
async getTokenSessionFromRequest(req: Request) {
|
||||
const tokenHeader = req.headers.authorization;
|
||||
if (!tokenHeader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenValue = extractTokenFromHeader(tokenHeader);
|
||||
|
||||
if (!tokenValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = await this.models.accessToken.getByToken(tokenValue);
|
||||
|
||||
if (token) {
|
||||
const user = await this.models.user.get(token.userId);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
user: sessionUser(user),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async changePassword(
|
||||
id: string,
|
||||
newPassword: string
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ExecutionContext } from '@nestjs/common';
|
||||
import { createParamDecorator } from '@nestjs/common';
|
||||
import { AccessToken } from '@prisma/client';
|
||||
|
||||
import { getRequestResponseFromContext } from '../../base';
|
||||
import type { User, UserSession } from '../../models';
|
||||
@@ -41,8 +40,7 @@ import type { User, UserSession } from '../../models';
|
||||
// oxlint-disable-next-line no-redeclare
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_: unknown, context: ExecutionContext) => {
|
||||
const req = getRequestResponseFromContext(context).req;
|
||||
return req.session?.user ?? req.token?.user;
|
||||
return getRequestResponseFromContext(context).req.session?.user;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -63,7 +61,3 @@ export const Session = createParamDecorator(
|
||||
export type Session = UserSession & {
|
||||
user: CurrentUser;
|
||||
};
|
||||
|
||||
export type TokenSession = AccessToken & {
|
||||
user: CurrentUser;
|
||||
};
|
||||
|
||||
@@ -99,7 +99,7 @@ export class ServerService implements OnApplicationBootstrap {
|
||||
}
|
||||
});
|
||||
this.configFactory.override(overrides);
|
||||
await this.event.emitAsync('config.changed', { updates: overrides });
|
||||
this.event.emit('config.changed', { updates: overrides });
|
||||
this.event.broadcast('config.changed.broadcast', { updates: overrides });
|
||||
return overrides;
|
||||
}
|
||||
|
||||
@@ -13,45 +13,74 @@ Generated by [AVA](https://avajs.dev).
|
||||
␊
|
||||
␊
|
||||
␊
|
||||
␊
|
||||
␊
|
||||
# You own your data, with no compromises␊
|
||||
␊
|
||||
␊
|
||||
## Local-first & Real-time collaborative␊
|
||||
␊
|
||||
␊
|
||||
We love the idea proposed by Ink & Switch in the famous article about you owning your data, despite the cloud. Furthermore, AFFiNE is the first all-in-one workspace that keeps your data ownership with no compromises on real-time collaboration and editing experience.␊
|
||||
␊
|
||||
␊
|
||||
AFFiNE is a local-first application upon CRDTs with real-time collaboration support. Your data is always stored locally while multiple nodes remain synced in real-time.␊
|
||||
␊
|
||||
␊
|
||||
␊
|
||||
␊
|
||||
␊
|
||||
### Blocks that assemble your next docs, tasks kanban or whiteboard␊
|
||||
␊
|
||||
␊
|
||||
There is a large overlap of their atomic "building blocks" between these apps. They are neither open source nor have a plugin system like VS Code for contributors to customize. We want to have something that contains all the features we love and goes one step further.␊
|
||||
␊
|
||||
␊
|
||||
We are building AFFiNE to be a fundamental open source platform that contains all the building blocks for docs, task management and visual collaboration, hoping you can shape your next workflow with us that can make your life better and also connect others, too.␊
|
||||
␊
|
||||
␊
|
||||
If you want to learn more about the product design of AFFiNE, here goes the concepts:␊
|
||||
␊
|
||||
␊
|
||||
To Shape, not to adapt. AFFiNE is built for individuals & teams who care about their data, who refuse vendor lock-in, and who want to have control over their essential tools.␊
|
||||
␊
|
||||
␊
|
||||
## A true canvas for blocks in any form␊
|
||||
␊
|
||||
␊
|
||||
[Many editor apps](http://notion.so) claimed to be a canvas for productivity. Since _the Mother of All Demos,_ Douglas Engelbart, a creative and programable digital workspace has been a pursuit and an ultimate mission for generations of tool makers.␊
|
||||
␊
|
||||
␊
|
||||
␊
|
||||
␊
|
||||
␊
|
||||
"We shape our tools and thereafter our tools shape us”. A lot of pioneers have inspired us a long the way, e.g.:␊
|
||||
␊
|
||||
␊
|
||||
* Quip & Notion with their great concept of "everything is a block"␊
|
||||
␊
|
||||
␊
|
||||
* Trello with their Kanban␊
|
||||
␊
|
||||
␊
|
||||
* Airtable & Miro with their no-code programable datasheets␊
|
||||
␊
|
||||
␊
|
||||
* Miro & Whimiscal with their edgeless visual whiteboard␊
|
||||
␊
|
||||
␊
|
||||
* Remnote & Capacities with their object-based tag system␊
|
||||
␊
|
||||
␊
|
||||
For more details, please refer to our [RoadMap](https://docs.affine.pro/docs/core-concepts/roadmap)␊
|
||||
␊
|
||||
␊
|
||||
## Self Host␊
|
||||
␊
|
||||
␊
|
||||
Self host AFFiNE␊
|
||||
␊
|
||||
␊
|
||||
||Title|Tag|␊
|
||||
|---|---|---|␊
|
||||
|Affine Development|Affine Development|<span data-affine-option data-value="AxSe-53xjX" data-option-color="var(--affine-tag-pink)">AFFiNE</span>|␊
|
||||
@@ -62,12 +91,16 @@ Generated by [AVA](https://avajs.dev).
|
||||
|Miro & Whimiscal with their edgeless visual whiteboard|Miro & Whimiscal with their edgeless visual whiteboard|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|␊
|
||||
|Remnote & Capacities with their object-based tag system|Remnote & Capacities with their object-based tag system||␊
|
||||
␊
|
||||
␊
|
||||
## Affine Development␊
|
||||
␊
|
||||
␊
|
||||
For developer or installation guides, please go to [AFFiNE Development](https://docs.affine.pro/docs/development/quick-start)␊
|
||||
␊
|
||||
␊
|
||||
␊
|
||||
␊
|
||||
␊
|
||||
`,
|
||||
title: 'Write, Draw, Plan all at Once.',
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -13,45 +13,74 @@ Generated by [AVA](https://avajs.dev).
|
||||
␊
|
||||
␊
|
||||
␊
|
||||
␊
|
||||
␊
|
||||
# You own your data, with no compromises␊
|
||||
␊
|
||||
␊
|
||||
## Local-first & Real-time collaborative␊
|
||||
␊
|
||||
␊
|
||||
We love the idea proposed by Ink & Switch in the famous article about you owning your data, despite the cloud. Furthermore, AFFiNE is the first all-in-one workspace that keeps your data ownership with no compromises on real-time collaboration and editing experience.␊
|
||||
␊
|
||||
␊
|
||||
AFFiNE is a local-first application upon CRDTs with real-time collaboration support. Your data is always stored locally while multiple nodes remain synced in real-time.␊
|
||||
␊
|
||||
␊
|
||||
␊
|
||||
␊
|
||||
␊
|
||||
### Blocks that assemble your next docs, tasks kanban or whiteboard␊
|
||||
␊
|
||||
␊
|
||||
There is a large overlap of their atomic "building blocks" between these apps. They are neither open source nor have a plugin system like VS Code for contributors to customize. We want to have something that contains all the features we love and goes one step further.␊
|
||||
␊
|
||||
␊
|
||||
We are building AFFiNE to be a fundamental open source platform that contains all the building blocks for docs, task management and visual collaboration, hoping you can shape your next workflow with us that can make your life better and also connect others, too.␊
|
||||
␊
|
||||
␊
|
||||
If you want to learn more about the product design of AFFiNE, here goes the concepts:␊
|
||||
␊
|
||||
␊
|
||||
To Shape, not to adapt. AFFiNE is built for individuals & teams who care about their data, who refuse vendor lock-in, and who want to have control over their essential tools.␊
|
||||
␊
|
||||
␊
|
||||
## A true canvas for blocks in any form␊
|
||||
␊
|
||||
␊
|
||||
[Many editor apps](http://notion.so) claimed to be a canvas for productivity. Since _the Mother of All Demos,_ Douglas Engelbart, a creative and programable digital workspace has been a pursuit and an ultimate mission for generations of tool makers.␊
|
||||
␊
|
||||
␊
|
||||
␊
|
||||
␊
|
||||
␊
|
||||
"We shape our tools and thereafter our tools shape us”. A lot of pioneers have inspired us a long the way, e.g.:␊
|
||||
␊
|
||||
␊
|
||||
* Quip & Notion with their great concept of "everything is a block"␊
|
||||
␊
|
||||
␊
|
||||
* Trello with their Kanban␊
|
||||
␊
|
||||
␊
|
||||
* Airtable & Miro with their no-code programable datasheets␊
|
||||
␊
|
||||
␊
|
||||
* Miro & Whimiscal with their edgeless visual whiteboard␊
|
||||
␊
|
||||
␊
|
||||
* Remnote & Capacities with their object-based tag system␊
|
||||
␊
|
||||
␊
|
||||
For more details, please refer to our [RoadMap](https://docs.affine.pro/docs/core-concepts/roadmap)␊
|
||||
␊
|
||||
␊
|
||||
## Self Host␊
|
||||
␊
|
||||
␊
|
||||
Self host AFFiNE␊
|
||||
␊
|
||||
␊
|
||||
||Title|Tag|␊
|
||||
|---|---|---|␊
|
||||
|Affine Development|Affine Development|<span data-affine-option data-value="AxSe-53xjX" data-option-color="var(--affine-tag-pink)">AFFiNE</span>|␊
|
||||
@@ -62,12 +91,16 @@ Generated by [AVA](https://avajs.dev).
|
||||
|Miro & Whimiscal with their edgeless visual whiteboard|Miro & Whimiscal with their edgeless visual whiteboard|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|␊
|
||||
|Remnote & Capacities with their object-based tag system|Remnote & Capacities with their object-based tag system||␊
|
||||
␊
|
||||
␊
|
||||
## Affine Development␊
|
||||
␊
|
||||
␊
|
||||
For developer or installation guides, please go to [AFFiNE Development](https://docs.affine.pro/docs/development/quick-start)␊
|
||||
␊
|
||||
␊
|
||||
␊
|
||||
␊
|
||||
␊
|
||||
`,
|
||||
title: 'Write, Draw, Plan all at Once.',
|
||||
}
|
||||
|
||||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user