mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-03 02:30:36 +08:00
Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac76e5b949 | |||
| 0bc1005b96 | |||
| 34a3c83d84 | |||
| fd717af3db | |||
| 039976ee6d | |||
| e158e11608 | |||
| 18faaa38a0 | |||
| e2156ea135 | |||
| 795bfb2f95 | |||
| 0710da15c6 | |||
| 693ae9c834 | |||
| 9d38f79395 | |||
| 680f3b3006 | |||
| fbf234f9fa | |||
| e9ede5213e | |||
| aea6f81937 | |||
| 66c2bf3151 | |||
| aa052096c1 | |||
| c2f3018eb7 | |||
| dd9d8adbf8 | |||
| 7e0de251cb | |||
| 5c73fc9767 | |||
| a0c22b7d06 | |||
| 072557eba1 | |||
| fda7e9008d | |||
| 678dc15365 | |||
| ef99c376ec | |||
| 65f679c4f0 | |||
| 125564b7d2 | |||
| aa20e7ba66 | |||
| 01e8458075 | |||
| 0d9f6770bf | |||
| 5ef81ba74b | |||
| 4ffa3b5ccc | |||
| 07b9b4fb8d | |||
| f7461dd3d9 | |||
| 343c717930 | |||
| bc1bd59f7b | |||
| c7afc880e6 | |||
| 3cfb0a43af | |||
| 4005f40b16 | |||
| 5fd7dfc8aa | |||
| 009288dee2 | |||
| 52a9c86219 | |||
| af7fefd59a | |||
| 94cf32ead2 | |||
| ffbd21e42a | |||
| c54ccda881 | |||
| 747b11b128 | |||
| bc3b41378d | |||
| a6c78dbcce | |||
| 542c8e2c1d | |||
| 21c758b6d6 | |||
| 9677bdf50d | |||
| 713f926247 | |||
| 99a7b7f676 | |||
| 44ef06de36 | |||
| e735ada758 | |||
| 40ccb7642c | |||
| f303ec14df | |||
| 531fbf0eed | |||
| 6ffa60c501 | |||
| 46acf9aa4f | |||
| d398aa9a71 | |||
| 36d58cd6c5 | |||
| d2a73b6d4e | |||
| 0fcb4cb0fe | |||
| 7a93db4d12 | |||
| c31504baaf | |||
| 76eedf3b76 |
@@ -18,11 +18,19 @@ services:
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
mailhog:
|
||||
image: mailhog/mailhog:latest
|
||||
# https://mailpit.axllent.org/docs/install/docker/
|
||||
mailpit:
|
||||
image: axllent/mailpit:latest
|
||||
ports:
|
||||
- 1025:1025
|
||||
- 8025:8025
|
||||
environment:
|
||||
MP_MAX_MESSAGES: 5000
|
||||
MP_DATABASE: /data/mailpit.db
|
||||
MP_SMTP_AUTH_ACCEPT_ANY: 1
|
||||
MP_SMTP_AUTH_ALLOW_INSECURE: 1
|
||||
volumes:
|
||||
- mailpit_data:/data
|
||||
|
||||
# https://manual.manticoresearch.com/Starting_the_server/Docker
|
||||
manticoresearch:
|
||||
@@ -87,4 +95,5 @@ networks:
|
||||
volumes:
|
||||
postgres_data:
|
||||
manticoresearch_data:
|
||||
mailpit_data:
|
||||
elasticsearch_data:
|
||||
|
||||
@@ -664,21 +664,34 @@
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to enable the copilot plugin.\n@default false",
|
||||
"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",
|
||||
"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-5-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-5-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\":\"\",\"fallback\":{\"text\":\"\",\"structured\":\"\",\"image\":\"\",\"embedding\":\"\"}}\n@link https://github.com/openai/openai-node",
|
||||
"description": "The config for the openai provider.\n@default {\"apiKey\":\"\",\"baseURL\":\"https://api.openai.com/v1\"}\n@link https://github.com/openai/openai-node",
|
||||
"default": {
|
||||
"apiKey": "",
|
||||
"baseUrl": "",
|
||||
"fallback": {
|
||||
"text": "",
|
||||
"structured": "",
|
||||
"image": "",
|
||||
"embedding": ""
|
||||
}
|
||||
"baseURL": "https://api.openai.com/v1"
|
||||
}
|
||||
},
|
||||
"providers.fal": {
|
||||
@@ -690,21 +703,15 @@
|
||||
},
|
||||
"providers.gemini": {
|
||||
"type": "object",
|
||||
"description": "The config for the gemini provider.\n@default {\"apiKey\":\"\",\"baseUrl\":\"\",\"fallback\":{\"text\":\"\",\"structured\":\"\",\"image\":\"\",\"embedding\":\"\"}}",
|
||||
"description": "The config for the gemini provider.\n@default {\"apiKey\":\"\",\"baseURL\":\"https://generativelanguage.googleapis.com/v1beta\"}",
|
||||
"default": {
|
||||
"apiKey": "",
|
||||
"baseUrl": "",
|
||||
"fallback": {
|
||||
"text": "",
|
||||
"structured": "",
|
||||
"image": "",
|
||||
"embedding": ""
|
||||
}
|
||||
"baseURL": "https://generativelanguage.googleapis.com/v1beta"
|
||||
}
|
||||
},
|
||||
"providers.geminiVertex": {
|
||||
"type": "object",
|
||||
"description": "The config for the google vertex provider.\n@default {\"baseURL\":\"\",\"fallback\":{\"text\":\"\",\"structured\":\"\",\"image\":\"\",\"embedding\":\"\"}}",
|
||||
"description": "The config for the google vertex provider.\n@default {}",
|
||||
"properties": {
|
||||
"location": {
|
||||
"type": "string",
|
||||
@@ -735,39 +742,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"baseURL": "",
|
||||
"fallback": {
|
||||
"text": "",
|
||||
"structured": "",
|
||||
"image": "",
|
||||
"embedding": ""
|
||||
}
|
||||
}
|
||||
"default": {}
|
||||
},
|
||||
"providers.perplexity": {
|
||||
"type": "object",
|
||||
"description": "The config for the perplexity provider.\n@default {\"apiKey\":\"\",\"fallback\":{\"text\":\"\"}}",
|
||||
"description": "The config for the perplexity provider.\n@default {\"apiKey\":\"\"}",
|
||||
"default": {
|
||||
"apiKey": "",
|
||||
"fallback": {
|
||||
"text": ""
|
||||
}
|
||||
"apiKey": ""
|
||||
}
|
||||
},
|
||||
"providers.anthropic": {
|
||||
"type": "object",
|
||||
"description": "The config for the anthropic provider.\n@default {\"apiKey\":\"\",\"fallback\":{\"text\":\"\"}}",
|
||||
"description": "The config for the anthropic provider.\n@default {\"apiKey\":\"\",\"baseURL\":\"https://api.anthropic.com/v1\"}",
|
||||
"default": {
|
||||
"apiKey": "",
|
||||
"fallback": {
|
||||
"text": ""
|
||||
}
|
||||
"baseURL": "https://api.anthropic.com/v1"
|
||||
}
|
||||
},
|
||||
"providers.anthropicVertex": {
|
||||
"type": "object",
|
||||
"description": "The config for the google vertex provider.\n@default {\"baseURL\":\"\",\"fallback\":{\"text\":\"\"}}",
|
||||
"description": "The config for the google vertex provider.\n@default {}",
|
||||
"properties": {
|
||||
"location": {
|
||||
"type": "string",
|
||||
@@ -798,12 +792,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"baseURL": "",
|
||||
"fallback": {
|
||||
"text": ""
|
||||
}
|
||||
}
|
||||
"default": {}
|
||||
},
|
||||
"providers.morph": {
|
||||
"type": "object",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
replicaCount: 3
|
||||
replicaCount: 2
|
||||
enabled: false
|
||||
database:
|
||||
connectionName: ""
|
||||
@@ -33,8 +33,11 @@ service:
|
||||
|
||||
resources:
|
||||
limits:
|
||||
memory: "4Gi"
|
||||
cpu: "2"
|
||||
memory: "1Gi"
|
||||
cpu: "1"
|
||||
requests:
|
||||
memory: "512Mi"
|
||||
cpu: "100m"
|
||||
|
||||
volumes: []
|
||||
volumeMounts: []
|
||||
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
name: Wait for approval
|
||||
with:
|
||||
secret: ${{ secrets.GITHUB_TOKEN }}
|
||||
approvers: forehalo,fengmk2
|
||||
approvers: forehalo,fengmk2,darkskygit
|
||||
minimum-approvals: 1
|
||||
fail-on-denial: true
|
||||
issue-title: Please confirm to release docker image
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
Tag: ghcr.io/toeverything/affine:${{ needs.prepare.outputs.BUILD_TYPE }}
|
||||
|
||||
> comment with "approve", "approved", "lgtm", "yes" to approve
|
||||
> comment with "deny", "deny", "no" to deny
|
||||
> comment with "deny", "denied", "no" to deny
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
**/node_modules
|
||||
.yarn
|
||||
.github/helm
|
||||
.git
|
||||
.vscode
|
||||
.yarnrc.yml
|
||||
.docker
|
||||
|
||||
Generated
+12
-12
@@ -93,7 +93,7 @@ dependencies = [
|
||||
"symphonia",
|
||||
"thiserror 2.0.12",
|
||||
"uuid",
|
||||
"windows 0.61.1",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
@@ -1691,7 +1691,7 @@ dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"rustversion",
|
||||
"windows 0.61.1",
|
||||
"windows 0.61.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2284,7 +2284,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-targets 0.48.5",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4732,9 +4732,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter"
|
||||
version = "0.25.5"
|
||||
version = "0.25.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac5fff5c47490dfdf473b5228039bfacad9d765d9b6939d26bf7cc064c1c7822"
|
||||
checksum = "6d7b8994f367f16e6fa14b5aebbcb350de5d7cbea82dc5b00ae997dd71680dd2"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"regex",
|
||||
@@ -4842,9 +4842,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-scala"
|
||||
version = "0.23.4"
|
||||
version = "0.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "efde5e68b4736e9eac17bfa296c6f104a26bffab363b365eb898c40a63c15d2f"
|
||||
checksum = "7516aeb3d1f40ede8e3045b163e86993b3434514dd06c34c0b75e782d9a0b251"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
@@ -5334,7 +5334,7 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
||||
dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5365,9 +5365,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.61.1"
|
||||
version = "0.61.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5ee8f3d025738cb02bad7868bbb5f8a6327501e870bf51f1b455b0a2454a419"
|
||||
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
|
||||
dependencies = [
|
||||
"windows-collections",
|
||||
"windows-core 0.61.2",
|
||||
@@ -5477,9 +5477,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.1.1"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38"
|
||||
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
|
||||
|
||||
[[package]]
|
||||
name = "windows-numerics"
|
||||
|
||||
+1
-1
@@ -93,7 +93,7 @@ tree-sitter-javascript = { version = "0.23" }
|
||||
tree-sitter-kotlin-ng = { version = "1.1" }
|
||||
tree-sitter-python = { version = "0.23" }
|
||||
tree-sitter-rust = { version = "0.24" }
|
||||
tree-sitter-scala = { version = "0.23" }
|
||||
tree-sitter-scala = { version = "0.24" }
|
||||
tree-sitter-typescript = { version = "0.23" }
|
||||
uniffi = "0.29"
|
||||
url = { version = "2.5" }
|
||||
|
||||
@@ -164,8 +164,10 @@ export class DatabaseBlockDataSource extends DataSourceBase {
|
||||
readonly$: ReadonlySignal<boolean> = computed(() => {
|
||||
return (
|
||||
this._model.store.readonly ||
|
||||
// TODO(@L-Sun): use block level readonly
|
||||
IS_MOBILE
|
||||
(IS_MOBILE &&
|
||||
!this._model.store.provider
|
||||
.get(FeatureFlagService)
|
||||
.getFlag('enable_mobile_database_editing'))
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
BlockElementCommentManager,
|
||||
CommentProviderIdentifier,
|
||||
DocModeProvider,
|
||||
FeatureFlagService,
|
||||
NotificationProvider,
|
||||
type TelemetryEventMap,
|
||||
TelemetryProvider,
|
||||
@@ -34,6 +35,7 @@ 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,
|
||||
@@ -48,6 +50,7 @@ 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';
|
||||
@@ -349,6 +352,7 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent<DatabaseBloc
|
||||
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
|
||||
this.classList.add(databaseBlockStyles);
|
||||
this.listenFullWidthChange();
|
||||
this.handleMobileEditing();
|
||||
}
|
||||
|
||||
listenFullWidthChange() {
|
||||
@@ -364,6 +368,41 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent<DatabaseBloc
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
handleMobileEditing() {
|
||||
if (!IS_MOBILE) return;
|
||||
|
||||
let notifyClosed = true;
|
||||
const handler = () => {
|
||||
if (
|
||||
!this.std
|
||||
.get(FeatureFlagService)
|
||||
.getFlag('enable_mobile_database_editing')
|
||||
) {
|
||||
const notification = this.std.getOptional(NotificationProvider);
|
||||
if (notification && notifyClosed) {
|
||||
notifyClosed = false;
|
||||
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',
|
||||
onClose: () => {
|
||||
notifyClosed = true;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.disposables.addFromEvent(this, 'click', handler);
|
||||
}
|
||||
|
||||
private readonly dataViewRootLogic = lazy(
|
||||
() =>
|
||||
new DataViewRootUILogic({
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
getPrevContentBlock,
|
||||
matchModels,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { IS_MOBILE } from '@blocksuite/global/env';
|
||||
import { IS_ANDROID, IS_MOBILE } from '@blocksuite/global/env';
|
||||
import { BlockSelection, type EditorHost } from '@blocksuite/std';
|
||||
import type { BlockModel, Text } from '@blocksuite/store';
|
||||
|
||||
@@ -79,6 +79,28 @@ export function mergeWithPrev(editorHost: EditorHost, model: BlockModel) {
|
||||
index: lengthBeforeJoin,
|
||||
length: 0,
|
||||
}).catch(console.error);
|
||||
|
||||
// due to some IME like Microsoft Swift IME on Android will reset range after join text,
|
||||
// for example:
|
||||
//
|
||||
// $ZERO_WIDTH_FOR_EMPTY_LINE <--- p1
|
||||
// |aaa <--- p2
|
||||
//
|
||||
// after pressing backspace, during beforeinput event, the native range is (p1, 1) -> (p2, 0)
|
||||
// and after browser and IME handle the event, the native range is (p1, 1) -> (p1, 1)
|
||||
//
|
||||
// a|aa <--- p1
|
||||
//
|
||||
// so we need to set range again after join text.
|
||||
if (IS_ANDROID) {
|
||||
setTimeout(() => {
|
||||
asyncSetInlineRange(editorHost.std, prevBlock, {
|
||||
index: lengthBeforeJoin,
|
||||
length: 0,
|
||||
}).catch(console.error);
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
getBoundWithRotation,
|
||||
intersects,
|
||||
} from '@blocksuite/global/gfx';
|
||||
import type { BlockStdScope } from '@blocksuite/std';
|
||||
import { type BlockStdScope, SurfaceSelection } from '@blocksuite/std';
|
||||
import type {
|
||||
GfxCompatibleInterface,
|
||||
GridManager,
|
||||
@@ -298,7 +298,10 @@ export class DomRenderer {
|
||||
viewportBounds,
|
||||
zoom
|
||||
);
|
||||
Object.assign(domElement.style, geometricStyles);
|
||||
const zIndexStyle = {
|
||||
'z-index': this.layerManager.getZIndex(elementModel),
|
||||
};
|
||||
Object.assign(domElement.style, geometricStyles, zIndexStyle);
|
||||
Object.assign(domElement.style, PLACEHOLDER_RESET_STYLES);
|
||||
|
||||
// Clear classes specific to shapes, if applicable
|
||||
@@ -335,7 +338,10 @@ export class DomRenderer {
|
||||
zoom
|
||||
);
|
||||
const opacityStyle = getOpacity(elementModel);
|
||||
Object.assign(domElement.style, geometricStyles, opacityStyle);
|
||||
const zIndexStyle = {
|
||||
'z-index': this.layerManager.getZIndex(elementModel),
|
||||
};
|
||||
Object.assign(domElement.style, geometricStyles, opacityStyle, zIndexStyle);
|
||||
|
||||
this._renderElement(elementModel, domElement);
|
||||
}
|
||||
@@ -384,6 +390,36 @@ export class DomRenderer {
|
||||
this.refresh();
|
||||
})
|
||||
);
|
||||
|
||||
// Workaround for the group rendering reactive update when selection changed
|
||||
let lastSet = new Set<string>();
|
||||
this._disposables.add(
|
||||
this.std.selection.filter$(SurfaceSelection).subscribe(selections => {
|
||||
const groupRelatedSelection = new Set(
|
||||
selections.flatMap(s =>
|
||||
s.elements.flatMap(e => {
|
||||
const element = surfaceModel.getElementById(e);
|
||||
if (
|
||||
element &&
|
||||
(element.type === 'group' || element.groups.length !== 0)
|
||||
) {
|
||||
return [element.id, ...element.groups.map(g => g.id)];
|
||||
}
|
||||
return [];
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
if (lastSet.symmetricDifference(groupRelatedSelection).size !== 0) {
|
||||
lastSet.union(groupRelatedSelection).forEach(g => {
|
||||
this._markElementDirty(g, UpdateType.ELEMENT_UPDATED);
|
||||
});
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
lastSet = groupRelatedSelection;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
addOverlay = (overlay: Overlay) => {
|
||||
|
||||
@@ -65,7 +65,7 @@ export abstract class DataViewUILogicBase<
|
||||
return handler(context);
|
||||
});
|
||||
}
|
||||
setSelection(selection?: Selection): void {
|
||||
setSelection(selection?: Selection) {
|
||||
this.root.setSelection(selection);
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,9 @@ export class MobileKanbanCell extends SignalWatcher(
|
||||
if (this.view.readonly$.value) {
|
||||
return;
|
||||
}
|
||||
const setSelection = this.kanbanViewLogic.setSelection;
|
||||
const setSelection = this.kanbanViewLogic.setSelection.bind(
|
||||
this.kanbanViewLogic
|
||||
);
|
||||
const viewId = this.kanbanViewLogic.view.id;
|
||||
if (setSelection && viewId) {
|
||||
if (editing && this.cell?.beforeEnterEditMode() === false) {
|
||||
@@ -101,12 +103,12 @@ export class MobileKanbanCell extends SignalWatcher(
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
const isEditing = this.isSelectionEditing$.value;
|
||||
if (isEditing) {
|
||||
if (isEditing && !this.isEditing$.peek()) {
|
||||
this.isEditing$.value = true;
|
||||
requestAnimationFrame(() => {
|
||||
this._cell.value?.afterEnterEditingMode();
|
||||
});
|
||||
} else {
|
||||
} else if (!isEditing && this.isEditing$.peek()) {
|
||||
this._cell.value?.beforeExitEditingMode();
|
||||
this.isEditing$.value = false;
|
||||
}
|
||||
|
||||
@@ -86,6 +86,9 @@ export class MobileKanbanViewUILogic extends DataViewUILogicBase<
|
||||
}
|
||||
|
||||
renderAddGroup = () => {
|
||||
if (this.readonly) {
|
||||
return;
|
||||
}
|
||||
const addGroup = this.groupManager.addGroup;
|
||||
if (!addGroup) {
|
||||
return;
|
||||
|
||||
@@ -68,7 +68,9 @@ export class MobileTableCell extends SignalWatcher(
|
||||
if (this.view.readonly$.value) {
|
||||
return;
|
||||
}
|
||||
const setSelection = this.tableViewLogic.setSelection;
|
||||
const setSelection = this.tableViewLogic.setSelection.bind(
|
||||
this.tableViewLogic
|
||||
);
|
||||
const viewId = this.tableViewLogic.view.id;
|
||||
if (setSelection && viewId) {
|
||||
if (editing && this.cell?.beforeEnterEditMode() === false) {
|
||||
@@ -103,13 +105,13 @@ export class MobileTableCell extends SignalWatcher(
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
const isEditing = this.isSelectionEditing$.value;
|
||||
if (isEditing) {
|
||||
if (isEditing && !this.isEditing$.peek()) {
|
||||
this.isEditing$.value = true;
|
||||
const cell = this._cell.value;
|
||||
requestAnimationFrame(() => {
|
||||
cell?.afterEnterEditingMode();
|
||||
});
|
||||
} else {
|
||||
} else if (!isEditing && this.isEditing$.peek()) {
|
||||
this._cell.value?.beforeExitEditingMode();
|
||||
this.isEditing$.value = false;
|
||||
}
|
||||
|
||||
@@ -5,12 +5,6 @@ export const mobileTableViewWrapper = css({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
paddingBottom: '4px',
|
||||
/**
|
||||
* Disable horizontal scrolling to prevent crashes on iOS Safari
|
||||
* See https://github.com/toeverything/AFFiNE/pull/12203
|
||||
* and https://github.com/toeverything/blocksuite/pull/8784
|
||||
*/
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'hidden',
|
||||
});
|
||||
|
||||
|
||||
@@ -88,6 +88,9 @@ 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,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * from './adapter';
|
||||
export * from './brush-tool';
|
||||
export * from './element-renderer';
|
||||
export * from './eraser-tool';
|
||||
export * from './highlighter-tool';
|
||||
export * from './renderer';
|
||||
export * from './toolbar/configs';
|
||||
export * from './toolbar/senior-tool';
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
DomElementRendererExtension,
|
||||
type DomRenderer,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import type { BrushElementModel } from '@blocksuite/affine-model';
|
||||
import { DefaultTheme } from '@blocksuite/affine-model';
|
||||
|
||||
export const BrushDomRendererExtension = DomElementRendererExtension(
|
||||
'brush',
|
||||
(
|
||||
model: BrushElementModel,
|
||||
domElement: HTMLElement,
|
||||
renderer: DomRenderer
|
||||
) => {
|
||||
const { zoom } = renderer.viewport;
|
||||
const [, , w, h] = model.deserializedXYWH;
|
||||
|
||||
// Early return if invalid dimensions
|
||||
if (w <= 0 || h <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Early return if no commands
|
||||
if (!model.commands) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear previous content
|
||||
domElement.innerHTML = '';
|
||||
|
||||
// Get color value
|
||||
const color = renderer.getColorValue(model.color, DefaultTheme.black, true);
|
||||
|
||||
// Create SVG element
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.style.position = 'absolute';
|
||||
svg.style.left = '0';
|
||||
svg.style.top = '0';
|
||||
svg.style.width = `${w * zoom}px`;
|
||||
svg.style.height = `${h * zoom}px`;
|
||||
svg.style.overflow = 'visible';
|
||||
svg.style.pointerEvents = 'none';
|
||||
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
|
||||
|
||||
// Apply rotation transform
|
||||
if (model.rotate !== 0) {
|
||||
svg.style.transform = `rotate(${model.rotate}deg)`;
|
||||
svg.style.transformOrigin = 'center';
|
||||
}
|
||||
|
||||
// Create path element for the brush stroke
|
||||
const pathElement = document.createElementNS(
|
||||
'http://www.w3.org/2000/svg',
|
||||
'path'
|
||||
);
|
||||
pathElement.setAttribute('d', model.commands);
|
||||
pathElement.setAttribute('fill', color);
|
||||
pathElement.setAttribute('stroke', 'none');
|
||||
|
||||
svg.append(pathElement);
|
||||
domElement.replaceChildren(svg);
|
||||
|
||||
// Set element size and position
|
||||
domElement.style.width = `${w * zoom}px`;
|
||||
domElement.style.height = `${h * zoom}px`;
|
||||
domElement.style.overflow = 'visible';
|
||||
domElement.style.pointerEvents = 'none';
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
DomElementRendererExtension,
|
||||
type DomRenderer,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import type { HighlighterElementModel } from '@blocksuite/affine-model';
|
||||
import { DefaultTheme } from '@blocksuite/affine-model';
|
||||
|
||||
export const HighlighterDomRendererExtension = DomElementRendererExtension(
|
||||
'highlighter',
|
||||
(
|
||||
model: HighlighterElementModel,
|
||||
domElement: HTMLElement,
|
||||
renderer: DomRenderer
|
||||
) => {
|
||||
const { zoom } = renderer.viewport;
|
||||
const [, , w, h] = model.deserializedXYWH;
|
||||
|
||||
// Early return if invalid dimensions
|
||||
if (w <= 0 || h <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Early return if no commands
|
||||
if (!model.commands) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear previous content
|
||||
domElement.innerHTML = '';
|
||||
|
||||
// Get color value
|
||||
const color = renderer.getColorValue(
|
||||
model.color,
|
||||
DefaultTheme.hightlighterColor,
|
||||
true
|
||||
);
|
||||
|
||||
// Create SVG element
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.style.position = 'absolute';
|
||||
svg.style.left = '0';
|
||||
svg.style.top = '0';
|
||||
svg.style.width = `${w * zoom}px`;
|
||||
svg.style.height = `${h * zoom}px`;
|
||||
svg.style.overflow = 'visible';
|
||||
svg.style.pointerEvents = 'none';
|
||||
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
|
||||
|
||||
// Apply rotation transform
|
||||
if (model.rotate !== 0) {
|
||||
svg.style.transform = `rotate(${model.rotate}deg)`;
|
||||
svg.style.transformOrigin = 'center';
|
||||
}
|
||||
|
||||
// Create path element for the highlighter stroke
|
||||
const pathElement = document.createElementNS(
|
||||
'http://www.w3.org/2000/svg',
|
||||
'path'
|
||||
);
|
||||
pathElement.setAttribute('d', model.commands);
|
||||
pathElement.setAttribute('fill', color);
|
||||
pathElement.setAttribute('stroke', 'none');
|
||||
|
||||
svg.append(pathElement);
|
||||
domElement.replaceChildren(svg);
|
||||
|
||||
// Set element size and position
|
||||
domElement.style.width = `${w * zoom}px`;
|
||||
domElement.style.height = `${h * zoom}px`;
|
||||
domElement.style.overflow = 'visible';
|
||||
domElement.style.pointerEvents = 'none';
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
export { BrushDomRendererExtension } from './brush';
|
||||
export { HighlighterDomRendererExtension } from './highlighter';
|
||||
@@ -0,0 +1,2 @@
|
||||
export { BrushElementRendererExtension } from './brush';
|
||||
export { HighlighterElementRendererExtension } from './highlighter';
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './dom';
|
||||
export * from './element';
|
||||
@@ -5,9 +5,14 @@ import {
|
||||
|
||||
import { BrushTool } from './brush-tool';
|
||||
import { effects } from './effects';
|
||||
import { BrushElementRendererExtension } from './element-renderer';
|
||||
import { EraserTool } from './eraser-tool';
|
||||
import { HighlighterTool } from './highlighter-tool';
|
||||
import {
|
||||
BrushDomRendererExtension,
|
||||
BrushElementRendererExtension,
|
||||
HighlighterDomRendererExtension,
|
||||
HighlighterElementRendererExtension,
|
||||
} from './renderer';
|
||||
import {
|
||||
brushToolbarExtension,
|
||||
highlighterToolbarExtension,
|
||||
@@ -30,6 +35,9 @@ export class BrushViewExtension extends ViewExtensionProvider {
|
||||
context.register(HighlighterTool);
|
||||
|
||||
context.register(BrushElementRendererExtension);
|
||||
context.register(BrushDomRendererExtension);
|
||||
context.register(HighlighterElementRendererExtension);
|
||||
context.register(HighlighterDomRendererExtension);
|
||||
|
||||
context.register(brushToolbarExtension);
|
||||
context.register(highlighterToolbarExtension);
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { DomElementRendererExtension } from '@blocksuite/affine-block-surface';
|
||||
|
||||
import { connectorDomRenderer } from './connector-dom/index.js';
|
||||
|
||||
/**
|
||||
* Extension to register the DOM-based renderer for 'connector' elements.
|
||||
*/
|
||||
export const ConnectorDomRendererExtension = DomElementRendererExtension(
|
||||
'connector',
|
||||
connectorDomRenderer
|
||||
);
|
||||
@@ -1,9 +1,8 @@
|
||||
export * from './adapter';
|
||||
export * from './connector-manager';
|
||||
export * from './connector-tool';
|
||||
export * from './element-renderer';
|
||||
export { ConnectorDomRendererExtension } from './element-renderer/connector-dom';
|
||||
export * from './element-transform';
|
||||
export * from './renderer';
|
||||
export * from './text';
|
||||
export * from './toolbar/config';
|
||||
export * from './toolbar/quick-tool';
|
||||
|
||||
+26
-11
@@ -1,14 +1,18 @@
|
||||
import type { DomRenderer } from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
DomElementRendererExtension,
|
||||
type DomRenderer,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
type ConnectorElementModel,
|
||||
ConnectorMode,
|
||||
DefaultTheme,
|
||||
type LocalConnectorElementModel,
|
||||
type PointStyle,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { PointLocation, SVGPathBuilder } from '@blocksuite/global/gfx';
|
||||
|
||||
import { isConnectorWithLabel } from '../../connector-manager.js';
|
||||
import { DEFAULT_ARROW_SIZE } from '../utils.js';
|
||||
import { isConnectorWithLabel } from '../connector-manager';
|
||||
import { DEFAULT_ARROW_SIZE } from './utils';
|
||||
|
||||
interface PathBounds {
|
||||
minX: number;
|
||||
@@ -221,8 +225,8 @@ function renderConnectorLabel(
|
||||
* @param element - The HTMLElement to apply the connector's styles to.
|
||||
* @param renderer - The main DOMRenderer instance, providing access to viewport and color utilities.
|
||||
*/
|
||||
export const connectorDomRenderer = (
|
||||
model: ConnectorElementModel,
|
||||
export const connectorBaseDomRenderer = (
|
||||
model: ConnectorElementModel | LocalConnectorElementModel,
|
||||
element: HTMLElement,
|
||||
renderer: DomRenderer
|
||||
): void => {
|
||||
@@ -358,10 +362,21 @@ export const connectorDomRenderer = (
|
||||
element.style.height = `${model.h * zoom}px`;
|
||||
element.style.overflow = 'visible';
|
||||
element.style.pointerEvents = 'none';
|
||||
|
||||
// Set z-index for layering
|
||||
element.style.zIndex = renderer.layerManager.getZIndex(model).toString();
|
||||
|
||||
// Render label if present
|
||||
renderConnectorLabel(model, element, renderer, zoom);
|
||||
};
|
||||
|
||||
export const connectorDomRenderer = (
|
||||
model: ConnectorElementModel,
|
||||
element: HTMLElement,
|
||||
renderer: DomRenderer
|
||||
): void => {
|
||||
connectorBaseDomRenderer(model, element, renderer);
|
||||
renderConnectorLabel(model, element, renderer, renderer.viewport.zoom);
|
||||
};
|
||||
|
||||
/**
|
||||
* Extension to register the DOM-based renderer for 'connector' elements.
|
||||
*/
|
||||
export const ConnectorDomRendererExtension = DomElementRendererExtension(
|
||||
'connector',
|
||||
connectorDomRenderer
|
||||
);
|
||||
+2
-2
@@ -25,7 +25,7 @@ import {
|
||||
} from '@blocksuite/global/gfx';
|
||||
import { deltaInsertsToChunks } from '@blocksuite/std/inline';
|
||||
|
||||
import { isConnectorWithLabel } from '../connector-manager.js';
|
||||
import { isConnectorWithLabel } from '../connector-manager';
|
||||
import {
|
||||
DEFAULT_ARROW_SIZE,
|
||||
getArrowOptions,
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
renderCircle,
|
||||
renderDiamond,
|
||||
renderTriangle,
|
||||
} from './utils.js';
|
||||
} from './utils';
|
||||
|
||||
export const connector: ElementRenderer<
|
||||
ConnectorElementModel | LocalConnectorElementModel
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './dom-renderer';
|
||||
export * from './element-renderer';
|
||||
@@ -6,9 +6,11 @@ import {
|
||||
import { ConnectionOverlay } from './connector-manager';
|
||||
import { ConnectorTool } from './connector-tool';
|
||||
import { effects } from './effects';
|
||||
import { ConnectorElementRendererExtension } from './element-renderer';
|
||||
import { ConnectorDomRendererExtension } from './element-renderer/connector-dom';
|
||||
import { ConnectorFilter } from './element-transform';
|
||||
import {
|
||||
ConnectorDomRendererExtension,
|
||||
ConnectorElementRendererExtension,
|
||||
} from './renderer';
|
||||
import { connectorToolbarExtension } from './toolbar/config';
|
||||
import { connectorQuickTool } from './toolbar/quick-tool';
|
||||
import { ConnectorElementView, ConnectorInteraction } from './view/view';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * from './adapter';
|
||||
export * from './command';
|
||||
export * from './element-renderer';
|
||||
export * from './element-view';
|
||||
export * from './renderer';
|
||||
export * from './text/text';
|
||||
export * from './toolbar/config';
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { DomElementRendererExtension } from '@blocksuite/affine-block-surface';
|
||||
import { FontWeight, type GroupElementModel } from '@blocksuite/affine-model';
|
||||
|
||||
import {
|
||||
GROUP_TITLE_FONT,
|
||||
GROUP_TITLE_FONT_SIZE,
|
||||
GROUP_TITLE_PADDING,
|
||||
} from './consts';
|
||||
import { titleRenderParams } from './utils';
|
||||
|
||||
export const GroupDomRendererExtension = DomElementRendererExtension(
|
||||
'group',
|
||||
(model: GroupElementModel, domElement, renderer) => {
|
||||
const { zoom } = renderer.viewport;
|
||||
const [, , w, h] = model.deserializedXYWH;
|
||||
|
||||
const renderParams = titleRenderParams(model, zoom);
|
||||
model.externalXYWH = renderParams.titleBound.serialize();
|
||||
|
||||
domElement.innerHTML = '';
|
||||
domElement.style.outlineColor = '';
|
||||
domElement.style.outlineWidth = '';
|
||||
domElement.style.outlineStyle = '';
|
||||
|
||||
const elements = renderer.provider.selectedElements?.() || [];
|
||||
|
||||
const renderTitle = () => {
|
||||
const { text } = renderParams;
|
||||
const titleElement = document.createElement('div');
|
||||
titleElement.style.transform = `translate(0, -100%)`;
|
||||
titleElement.style.fontFamily = GROUP_TITLE_FONT;
|
||||
titleElement.style.fontWeight = `${FontWeight.Regular}`;
|
||||
titleElement.style.fontStyle = 'normal';
|
||||
titleElement.style.fontSize = `${GROUP_TITLE_FONT_SIZE}px`;
|
||||
titleElement.style.color = renderer.getPropertyValue('--affine-blue');
|
||||
titleElement.style.textAlign = 'left';
|
||||
titleElement.style.padding = `${GROUP_TITLE_PADDING[0]}px ${GROUP_TITLE_PADDING[1]}px`;
|
||||
titleElement.textContent = text;
|
||||
domElement.replaceChildren(titleElement);
|
||||
};
|
||||
|
||||
if (elements.includes(model.id)) {
|
||||
if (model.showTitle) {
|
||||
renderTitle();
|
||||
} else {
|
||||
domElement.style.outlineColor =
|
||||
renderer.getPropertyValue('--affine-blue');
|
||||
domElement.style.outlineWidth = '2px';
|
||||
domElement.style.outlineStyle = 'solid';
|
||||
}
|
||||
} else if (model.childElements.some(child => elements.includes(child.id))) {
|
||||
domElement.style.outlineColor = '#8FD1FF';
|
||||
domElement.style.outlineWidth = '2px';
|
||||
domElement.style.outlineStyle = 'solid';
|
||||
}
|
||||
|
||||
domElement.style.width = `${w * zoom}px`;
|
||||
domElement.style.height = `${h * zoom}px`;
|
||||
domElement.style.overflow = 'visible';
|
||||
domElement.style.pointerEvents = 'none';
|
||||
}
|
||||
);
|
||||
+1
-1
@@ -6,7 +6,7 @@ import {
|
||||
import type { GroupElementModel } from '@blocksuite/affine-model';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
|
||||
import { titleRenderParams } from './utils.js';
|
||||
import { titleRenderParams } from './utils';
|
||||
|
||||
export const group: ElementRenderer<GroupElementModel> = (
|
||||
model,
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './dom-renderer';
|
||||
export * from './element-renderer';
|
||||
+1
-1
@@ -13,7 +13,7 @@ import {
|
||||
GROUP_TITLE_FONT_SIZE,
|
||||
GROUP_TITLE_OFFSET,
|
||||
GROUP_TITLE_PADDING,
|
||||
} from './consts.js';
|
||||
} from './consts';
|
||||
|
||||
export function titleRenderParams(group: GroupElementModel, zoom: number) {
|
||||
let text = group.title.toString().trim();
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
GROUP_TITLE_FONT_SIZE,
|
||||
GROUP_TITLE_OFFSET,
|
||||
GROUP_TITLE_PADDING,
|
||||
} from '../element-renderer/consts';
|
||||
} from '../renderer/consts';
|
||||
|
||||
export function mountGroupTitleEditor(
|
||||
group: GroupElementModel,
|
||||
|
||||
@@ -4,9 +4,12 @@ import {
|
||||
} from '@blocksuite/affine-ext-loader';
|
||||
|
||||
import { effects } from './effects';
|
||||
import { GroupElementRendererExtension } from './element-renderer';
|
||||
import { GroupElementView, GroupInteraction } from './element-view';
|
||||
import { GroupInteractionExtension } from './interaction-ext';
|
||||
import {
|
||||
GroupDomRendererExtension,
|
||||
GroupElementRendererExtension,
|
||||
} from './renderer';
|
||||
import { groupToolbarExtension } from './toolbar/config';
|
||||
|
||||
export class GroupViewExtension extends ViewExtensionProvider {
|
||||
@@ -20,6 +23,7 @@ export class GroupViewExtension extends ViewExtensionProvider {
|
||||
override setup(context: ViewExtensionContext) {
|
||||
super.setup(context);
|
||||
context.register(GroupElementRendererExtension);
|
||||
context.register(GroupDomRendererExtension);
|
||||
context.register(GroupElementView);
|
||||
if (this.isEdgeless(context.scope)) {
|
||||
context.register(groupToolbarExtension);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * from './adapter';
|
||||
export * from './element-renderer';
|
||||
export * from './indicator-overlay';
|
||||
export * from './interactivity';
|
||||
export * from './renderer';
|
||||
export * from './toolbar/config';
|
||||
export * from './toolbar/senior-tool';
|
||||
export * from './utils';
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { DomElementRendererExtension } from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
connectorBaseDomRenderer,
|
||||
ConnectorPathGenerator,
|
||||
} from '@blocksuite/affine-gfx-connector';
|
||||
import type {
|
||||
MindmapElementModel,
|
||||
MindmapNode,
|
||||
} from '@blocksuite/affine-model';
|
||||
import type { GfxModel } from '@blocksuite/std/gfx';
|
||||
|
||||
export const MindmapDomRendererExtension = DomElementRendererExtension(
|
||||
'mindmap',
|
||||
(model: MindmapElementModel, domElement, renderer) => {
|
||||
const bound = model.elementBound;
|
||||
|
||||
const { zoom } = renderer.viewport;
|
||||
// Set element size and position
|
||||
domElement.style.width = `${bound.w * zoom}px`;
|
||||
domElement.style.height = `${bound.h * zoom}px`;
|
||||
domElement.style.overflow = 'visible';
|
||||
domElement.style.pointerEvents = 'none';
|
||||
|
||||
const newChildren: HTMLDivElement[] = [];
|
||||
|
||||
const traverse = (node: MindmapNode) => {
|
||||
const connectors = model.getConnectors(node);
|
||||
if (!connectors) return;
|
||||
|
||||
connectors.reverse().forEach(result => {
|
||||
const { connector, outdated } = result;
|
||||
const elementGetter = (id: string) =>
|
||||
model.surface.getElementById(id) ??
|
||||
(model.surface.store.getModelById(id) as GfxModel);
|
||||
|
||||
if (outdated) {
|
||||
ConnectorPathGenerator.updatePath(connector, null, elementGetter);
|
||||
}
|
||||
|
||||
const connectorContainer = document.createElement('div');
|
||||
connectorContainer.style.position = 'absolute';
|
||||
connectorContainer.style.transformOrigin = 'top left';
|
||||
const geometricStyles = {
|
||||
left: `${(connector.x - bound.x) * zoom}px`,
|
||||
top: `${(connector.y - bound.y) * zoom}px`,
|
||||
};
|
||||
const opacityStyle = { opacity: node.element.opacity };
|
||||
Object.assign(connectorContainer.style, geometricStyles, opacityStyle);
|
||||
|
||||
connectorBaseDomRenderer(connector, connectorContainer, renderer);
|
||||
newChildren.push(connectorContainer);
|
||||
});
|
||||
|
||||
if (node.detail.collapsed) {
|
||||
return;
|
||||
} else {
|
||||
node.children.forEach(traverse);
|
||||
}
|
||||
};
|
||||
|
||||
model.tree && traverse(model.tree);
|
||||
|
||||
domElement.replaceChildren(...newChildren);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './dom-renderer';
|
||||
export * from './element-renderer';
|
||||
@@ -4,9 +4,12 @@ import {
|
||||
} from '@blocksuite/affine-ext-loader';
|
||||
|
||||
import { effects } from './effects';
|
||||
import { MindmapElementRendererExtension } from './element-renderer';
|
||||
import { MindMapIndicatorOverlay } from './indicator-overlay';
|
||||
import { MindMapDragExtension } from './interactivity';
|
||||
import {
|
||||
MindmapDomRendererExtension,
|
||||
MindmapElementRendererExtension,
|
||||
} from './renderer';
|
||||
import {
|
||||
mindmapToolbarExtension,
|
||||
shapeMindmapToolbarExtension,
|
||||
@@ -25,6 +28,7 @@ export class MindmapViewExtension extends ViewExtensionProvider {
|
||||
override setup(context: ViewExtensionContext) {
|
||||
super.setup(context);
|
||||
context.register(MindmapElementRendererExtension);
|
||||
context.register(MindmapDomRendererExtension);
|
||||
context.register(mindMapSeniorTool);
|
||||
context.register(mindmapToolbarExtension);
|
||||
context.register(shapeMindmapToolbarExtension);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
DefaultTool,
|
||||
EdgelessLegacySlotIdentifier,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import { on } from '@blocksuite/affine-shared/utils';
|
||||
import type { PointerEventState } from '@blocksuite/std';
|
||||
import { BaseTool, MouseButton, type ToolOptions } from '@blocksuite/std/gfx';
|
||||
@@ -64,12 +67,15 @@ export class PanTool extends BaseTool<PanToolOption> {
|
||||
const { toolType, options: originalToolOptions } = currentTool;
|
||||
const selectionToRestore = this.gfx.selection.surfaceSelections;
|
||||
if (!toolType) return;
|
||||
// restore to DefaultTool if previous tool is CopilotTool
|
||||
if (toolType.toolName === 'copilot') {
|
||||
this.controller.setTool(DefaultTool);
|
||||
return;
|
||||
}
|
||||
|
||||
let finalOptions: ToolOptions<BaseTool<any>> | undefined =
|
||||
originalToolOptions;
|
||||
const PRESENT_TOOL_NAME = 'frameNavigator';
|
||||
|
||||
if (toolType.toolName === PRESENT_TOOL_NAME) {
|
||||
if (toolType.toolName === 'frameNavigator') {
|
||||
// When restoring PresentTool (frameNavigator) after a temporary pan (e.g., via middle mouse button),
|
||||
// set 'restoredAfterPan' to true. This allows PresentTool to avoid an unwanted viewport reset
|
||||
// and maintain the panned position.
|
||||
@@ -93,15 +99,17 @@ export class PanTool extends BaseTool<PanToolOption> {
|
||||
});
|
||||
}
|
||||
|
||||
this.controller.setTool(PanTool, {
|
||||
panning: true,
|
||||
requestAnimationFrame(() => {
|
||||
this.controller.setTool(PanTool, {
|
||||
panning: true,
|
||||
});
|
||||
});
|
||||
|
||||
const dispose = on(document, 'pointerup', evt => {
|
||||
if (evt.button === MouseButton.MIDDLE) {
|
||||
restoreToPrevious();
|
||||
dispose();
|
||||
}
|
||||
dispose();
|
||||
});
|
||||
|
||||
return false;
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export * from './highlighter';
|
||||
export * from './shape';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DomRenderer } from '@blocksuite/affine-block-surface';
|
||||
import { isRTL } from '@blocksuite/affine-gfx-text';
|
||||
import type { ShapeElementModel } from '@blocksuite/affine-model';
|
||||
import { DefaultTheme } from '@blocksuite/affine-model';
|
||||
import { SVGShapeBuilder } from '@blocksuite/global/gfx';
|
||||
@@ -99,6 +100,8 @@ export const shapeDomRenderer = (
|
||||
const unscaledWidth = model.w;
|
||||
const unscaledHeight = model.h;
|
||||
|
||||
const newChildren: Element[] = [];
|
||||
|
||||
const fillColor = renderer.getColorValue(
|
||||
model.fillColor,
|
||||
DefaultTheme.shapeFillColor,
|
||||
@@ -170,8 +173,7 @@ export const shapeDomRenderer = (
|
||||
}
|
||||
svg.append(polygon);
|
||||
|
||||
// Replace existing children to avoid memory leaks
|
||||
element.replaceChildren(svg);
|
||||
newChildren.push(svg);
|
||||
} else {
|
||||
// Standard rendering for other shapes (e.g., rect, ellipse)
|
||||
// innerHTML was already cleared by applyShapeSpecificStyles if necessary
|
||||
@@ -179,9 +181,42 @@ export const shapeDomRenderer = (
|
||||
applyBorderStyles(model, element, strokeColor, zoom); // Uses standard CSS border
|
||||
}
|
||||
|
||||
applyTransformStyles(model, element);
|
||||
if (model.textDisplay && model.text) {
|
||||
const str = model.text.toString();
|
||||
const textElement = document.createElement('div');
|
||||
if (isRTL(str)) {
|
||||
textElement.dir = 'rtl';
|
||||
}
|
||||
textElement.style.position = 'absolute';
|
||||
textElement.style.inset = '0';
|
||||
textElement.style.display = 'flex';
|
||||
textElement.style.flexDirection = 'column';
|
||||
textElement.style.justifyContent =
|
||||
model.textVerticalAlign === 'center'
|
||||
? 'center'
|
||||
: model.textVerticalAlign === 'top'
|
||||
? 'flex-start'
|
||||
: 'flex-end';
|
||||
textElement.style.whiteSpace = 'pre-wrap';
|
||||
textElement.style.wordBreak = 'break-word';
|
||||
textElement.style.textAlign = model.textAlign;
|
||||
textElement.style.alignmentBaseline = 'alphabetic';
|
||||
textElement.style.fontFamily = model.fontFamily;
|
||||
textElement.style.fontSize = `${model.fontSize * zoom}px`;
|
||||
textElement.style.fontWeight = model.fontWeight;
|
||||
textElement.style.color = renderer.getColorValue(
|
||||
model.color,
|
||||
DefaultTheme.shapeTextColor,
|
||||
true
|
||||
);
|
||||
textElement.textContent = str;
|
||||
newChildren.push(textElement);
|
||||
}
|
||||
|
||||
element.style.zIndex = renderer.layerManager.getZIndex(model).toString();
|
||||
// Replace existing children to avoid memory leaks
|
||||
element.replaceChildren(...newChildren);
|
||||
|
||||
applyTransformStyles(model, element);
|
||||
|
||||
manageClassNames(model, element);
|
||||
applyShadowStyles(model, element, renderer);
|
||||
|
||||
@@ -4,10 +4,7 @@ import {
|
||||
} from '@blocksuite/affine-ext-loader';
|
||||
|
||||
import { effects } from './effects';
|
||||
import {
|
||||
HighlighterElementRendererExtension,
|
||||
ShapeElementRendererExtension,
|
||||
} from './element-renderer';
|
||||
import { ShapeElementRendererExtension } from './element-renderer';
|
||||
import { ShapeDomRendererExtension } from './element-renderer/shape-dom';
|
||||
import { ShapeElementView, ShapeViewInteraction } from './element-view';
|
||||
import { ShapeTool } from './shape-tool';
|
||||
@@ -24,7 +21,6 @@ export class ShapeViewExtension extends ViewExtensionProvider {
|
||||
override setup(context: ViewExtensionContext) {
|
||||
super.setup(context);
|
||||
if (this.isEdgeless(context.scope)) {
|
||||
context.register(HighlighterElementRendererExtension);
|
||||
context.register(ShapeElementRendererExtension);
|
||||
context.register(ShapeDomRendererExtension);
|
||||
context.register(ShapeElementView);
|
||||
|
||||
@@ -150,6 +150,9 @@ 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,
|
||||
|
||||
@@ -131,7 +131,7 @@ export class HighlighterElementModel extends GfxPrimitiveElementModel<Highlighte
|
||||
instance['_local'].delete('commands');
|
||||
})
|
||||
@derive((lineWidth: number, instance: Instance) => {
|
||||
const oldBound = instance.elementBound;
|
||||
const oldBound = Bound.fromXYWH(instance.deserializedXYWH);
|
||||
|
||||
if (
|
||||
lineWidth === instance.lineWidth ||
|
||||
|
||||
@@ -15,6 +15,7 @@ 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;
|
||||
@@ -41,6 +42,7 @@ 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,
|
||||
|
||||
@@ -114,6 +114,7 @@ export class PreviewHelper {
|
||||
});
|
||||
|
||||
let width: number = 500;
|
||||
// oxlint-disable-next-line no-unassigned-vars
|
||||
let height;
|
||||
|
||||
const noteBlock = this.widget.host.querySelector('affine-note');
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
requiredProperties,
|
||||
ShadowlessElement,
|
||||
} from '@blocksuite/std';
|
||||
import { html, nothing, type PropertyValues } from 'lit';
|
||||
import { html, nothing } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
@@ -80,18 +80,9 @@ export class AffineKeyboardToolPanel extends SignalWatcher(
|
||||
</div>`;
|
||||
}
|
||||
|
||||
protected override willUpdate(changedProperties: PropertyValues<this>) {
|
||||
if (changedProperties.has('height')) {
|
||||
this.style.height = this.height;
|
||||
}
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor config: KeyboardToolPanelConfig | null = null;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor context!: KeyboardToolbarContext;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor height = '';
|
||||
}
|
||||
|
||||
@@ -377,7 +377,10 @@ export class AffineKeyboardToolbar extends SignalWatcher(
|
||||
<affine-keyboard-tool-panel
|
||||
.config=${this._currentPanelConfig}
|
||||
.context=${this._context}
|
||||
.height=${this.panelHeight}
|
||||
style=${styleMap({
|
||||
height: this.panelHeight,
|
||||
paddingBottom: this.keyboard.appTabSafeArea$.value,
|
||||
})}
|
||||
></affine-keyboard-tool-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -65,6 +65,98 @@ export class Unzip {
|
||||
this.unzipped = fflate.unzipSync(new Uint8Array(await blob.arrayBuffer()));
|
||||
}
|
||||
|
||||
private fixFileNameEncoding(fileName: string): string {
|
||||
try {
|
||||
// check if contains non-ASCII characters
|
||||
if (fileName.split('').some(char => char.charCodeAt(0) > 127)) {
|
||||
// try different encodings
|
||||
const fixedName = this.tryDifferentEncodings(fileName);
|
||||
if (fixedName && fixedName !== fileName) {
|
||||
return fixedName;
|
||||
}
|
||||
}
|
||||
return fileName;
|
||||
} catch {
|
||||
return fileName;
|
||||
}
|
||||
}
|
||||
|
||||
// try different encodings
|
||||
private tryDifferentEncodings(fileName: string): string | null {
|
||||
try {
|
||||
// convert string to bytes
|
||||
const bytes = new Uint8Array(fileName.length);
|
||||
for (let i = 0; i < fileName.length; i++) {
|
||||
bytes[i] = fileName.charCodeAt(i);
|
||||
}
|
||||
|
||||
// try different encodings
|
||||
// The macOS system zip tool creates archives with UTF-8 encoded filenames.
|
||||
// However, this implementation doesn't strictly adhere to the ZIP specification.
|
||||
// Simply forcing UTF-8 encoding when unzipping should resolve filename corruption issues.
|
||||
const encodings = ['utf-8'];
|
||||
|
||||
for (const encoding of encodings) {
|
||||
try {
|
||||
const decoder = new TextDecoder(encoding);
|
||||
const result = decoder.decode(bytes);
|
||||
|
||||
// check if decoded result is valid
|
||||
if (result && this.isValidDecodedString(result)) {
|
||||
return result;
|
||||
}
|
||||
} catch {
|
||||
// ignore encoding error, try next encoding
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore conversion error
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// check if decoded string is valid
|
||||
private isValidDecodedString(str: string): boolean {
|
||||
// check if contains control characters
|
||||
const controlCharCodes = new Set([
|
||||
0x00,
|
||||
0x01,
|
||||
0x02,
|
||||
0x03,
|
||||
0x04,
|
||||
0x05,
|
||||
0x06,
|
||||
0x07,
|
||||
0x08, // \x00-\x08
|
||||
0x0b,
|
||||
0x0c, // \x0B, \x0C
|
||||
0x0e,
|
||||
0x0f,
|
||||
0x10,
|
||||
0x11,
|
||||
0x12,
|
||||
0x13,
|
||||
0x14,
|
||||
0x15,
|
||||
0x16,
|
||||
0x17,
|
||||
0x18,
|
||||
0x19,
|
||||
0x1a,
|
||||
0x1b,
|
||||
0x1c,
|
||||
0x1d,
|
||||
0x1e,
|
||||
0x1f, // \x0E-\x1F
|
||||
0x7f, // \x7F
|
||||
]);
|
||||
|
||||
return !str
|
||||
.split('')
|
||||
.some(char => controlCharCodes.has(char.charCodeAt(0)));
|
||||
}
|
||||
|
||||
*[Symbol.iterator]() {
|
||||
const keys = Object.keys(this.unzipped ?? {});
|
||||
let index = 0;
|
||||
@@ -81,7 +173,10 @@ export class Unzip {
|
||||
const content = new File([this.unzipped![path]], fileName, {
|
||||
type: mime ?? '',
|
||||
}) as Blob;
|
||||
yield { path, content, index };
|
||||
|
||||
const fixedPath = this.fixFileNameEncoding(path);
|
||||
|
||||
yield { path: fixedPath, content, index };
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,15 +91,11 @@ export class KeyboardControl {
|
||||
const disposables = new DisposableGroup();
|
||||
if (IS_ANDROID) {
|
||||
disposables.add(
|
||||
this._dispatcher.add(
|
||||
'beforeInput',
|
||||
ctx => {
|
||||
if (this.composition) return false;
|
||||
const binding = androidBindKeymapPatch(keymap);
|
||||
return binding(ctx);
|
||||
},
|
||||
options
|
||||
)
|
||||
this._dispatcher.add('beforeInput', ctx => {
|
||||
if (this.composition) return false;
|
||||
const binding = androidBindKeymapPatch(keymap);
|
||||
return binding(ctx);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -226,6 +226,18 @@ export class UIEventDispatcher extends LifeCycleWatcher {
|
||||
this._setActive(false);
|
||||
}
|
||||
});
|
||||
// When the selection is outside the host, the event dispatcher should be inactive
|
||||
this.disposables.addFromEvent(document, 'selectionchange', () => {
|
||||
const sel = document.getSelection();
|
||||
if (!sel || sel.rangeCount === 0) return;
|
||||
const { anchorNode, focusNode } = sel;
|
||||
if (
|
||||
(anchorNode && !this.host.contains(anchorNode)) ||
|
||||
(focusNode && !this.host.contains(focusNode))
|
||||
) {
|
||||
this._setActive(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _buildEventScopeBySelection(name: EventName) {
|
||||
|
||||
@@ -104,7 +104,7 @@ export function bindKeymap(
|
||||
};
|
||||
}
|
||||
|
||||
// In Android, the keypress event dose not contain
|
||||
// In some IME of Android like, the keypress event dose not contain
|
||||
// the information about what key is pressed. See
|
||||
// https://stackoverflow.com/a/68188679
|
||||
// https://stackoverflow.com/a/66724830
|
||||
|
||||
@@ -57,7 +57,7 @@ export type CanvasLayer = BaseLayer<GfxPrimitiveElementModel> & {
|
||||
type: 'canvas';
|
||||
|
||||
/**
|
||||
* The z-index of canvas layer.
|
||||
* The z-index of the first element in this canvas layer.
|
||||
*
|
||||
* A canvas layer renders all the elements in a single canvas,
|
||||
* this property is used to render the canvas with correct z-index.
|
||||
@@ -165,8 +165,7 @@ export class LayerManager extends GfxExtension {
|
||||
];
|
||||
curLayer.zIndex = currentCSSZindex;
|
||||
layers.push(curLayer as LayerManager['layers'][number]);
|
||||
currentCSSZindex +=
|
||||
curLayer.type === 'block' ? curLayer.elements.length : 1;
|
||||
currentCSSZindex += curLayer.elements.length;
|
||||
}
|
||||
};
|
||||
const addLayer = (type: 'canvas' | 'block') => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { IS_ANDROID } from '@blocksuite/global/env';
|
||||
import type { BaseTextAttributes } from '@blocksuite/store';
|
||||
|
||||
import type { InlineEditor } from '../inline-editor.js';
|
||||
@@ -41,11 +42,10 @@ export class EventService<TextAttributes extends BaseTextAttributes> {
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _onBeforeInput = (event: InputEvent) => {
|
||||
private readonly _onBeforeInput = async (event: InputEvent) => {
|
||||
const range = this.editor.rangeService.getNativeRange();
|
||||
if (
|
||||
this.editor.isReadonly ||
|
||||
this._isComposing ||
|
||||
!range ||
|
||||
!this._isRangeCompletelyInRoot(range)
|
||||
)
|
||||
@@ -54,33 +54,29 @@ export class EventService<TextAttributes extends BaseTextAttributes> {
|
||||
let inlineRange = this.editor.toInlineRange(range);
|
||||
if (!inlineRange) return;
|
||||
|
||||
if (this._isComposing) {
|
||||
if (IS_ANDROID && event.inputType === 'insertCompositionText') {
|
||||
this._compositionInlineRange = inlineRange;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let ifHandleTargetRange = true;
|
||||
|
||||
if (event.inputType.startsWith('delete')) {
|
||||
if (
|
||||
isInEmbedGap(range.commonAncestorContainer) &&
|
||||
inlineRange.length === 0 &&
|
||||
inlineRange.index > 0
|
||||
) {
|
||||
inlineRange = {
|
||||
index: inlineRange.index - 1,
|
||||
length: 1,
|
||||
};
|
||||
ifHandleTargetRange = false;
|
||||
} else if (
|
||||
isInEmptyLine(range.commonAncestorContainer) &&
|
||||
inlineRange.length === 0 &&
|
||||
inlineRange.index > 0
|
||||
// eslint-disable-next-line sonarjs/no-duplicated-branches
|
||||
) {
|
||||
// do not use target range when deleting across lines
|
||||
if (
|
||||
event.inputType.startsWith('delete') &&
|
||||
(isInEmbedGap(range.commonAncestorContainer) ||
|
||||
// https://github.com/toeverything/blocksuite/issues/5381
|
||||
inlineRange = {
|
||||
index: inlineRange.index - 1,
|
||||
length: 1,
|
||||
};
|
||||
ifHandleTargetRange = false;
|
||||
}
|
||||
isInEmptyLine(range.commonAncestorContainer)) &&
|
||||
inlineRange.length === 0 &&
|
||||
inlineRange.index > 0
|
||||
) {
|
||||
// do not use target range when deleting across lines
|
||||
inlineRange = {
|
||||
index: inlineRange.index - 1,
|
||||
length: 1,
|
||||
};
|
||||
ifHandleTargetRange = false;
|
||||
}
|
||||
|
||||
if (ifHandleTargetRange) {
|
||||
@@ -97,11 +93,24 @@ export class EventService<TextAttributes extends BaseTextAttributes> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!inlineRange) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (IS_ANDROID) {
|
||||
this.editor.rerenderWholeEditor();
|
||||
await this.editor.waitForUpdate();
|
||||
if (
|
||||
event.inputType === 'deleteContentBackward' &&
|
||||
!(inlineRange.index === 0 && inlineRange.length === 0)
|
||||
) {
|
||||
// when press backspace at offset 1, double characters will be removed.
|
||||
// because we mock backspace key event `androidBindKeymapPatch` in blocksuite/framework/std/src/event/keymap.ts
|
||||
// so we need to stop the event propagation to prevent the double characters removal.
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
const ctx: BeforeinputHookCtx<TextAttributes> = {
|
||||
inlineEditor: this.editor,
|
||||
raw: event,
|
||||
@@ -346,11 +355,9 @@ export class EventService<TextAttributes extends BaseTextAttributes> {
|
||||
return;
|
||||
}
|
||||
|
||||
this.editor.disposables.addFromEvent(
|
||||
eventSource,
|
||||
'beforeinput',
|
||||
this._onBeforeInput
|
||||
);
|
||||
this.editor.disposables.addFromEvent(eventSource, 'beforeinput', e => {
|
||||
this._onBeforeInput(e).catch(console.error);
|
||||
});
|
||||
this.editor.disposables.addFromEvent(
|
||||
eventSource,
|
||||
'compositionstart',
|
||||
|
||||
@@ -12,11 +12,7 @@ import type { SurfaceBlockModel } from '../gfx/model/surface/surface-model.js';
|
||||
|
||||
export function getLayerEndZIndex(layers: Layer[], layerIndex: number) {
|
||||
const layer = layers[layerIndex];
|
||||
return layer
|
||||
? layer.type === 'block'
|
||||
? layer.zIndex + layer.elements.length - 1
|
||||
: layer.zIndex
|
||||
: 0;
|
||||
return layer ? layer.zIndex + layer.elements.length - 1 : 0;
|
||||
}
|
||||
|
||||
export function updateLayersZIndex(layers: Layer[], startIdx: number) {
|
||||
@@ -27,7 +23,7 @@ export function updateLayersZIndex(layers: Layer[], startIdx: number) {
|
||||
const curLayer = layers[i];
|
||||
|
||||
curLayer.zIndex = curIndex;
|
||||
curIndex += curLayer.type === 'block' ? curLayer.elements.length : 1;
|
||||
curIndex += curLayer.elements.length;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -200,7 +200,7 @@ test('layer zindex should update correctly when elements changed', async () => {
|
||||
expect(service.layer.layers[1].zIndex).toBe(3);
|
||||
|
||||
expect(service.layer.layers[2].type).toBe('block');
|
||||
expect(service.layer.layers[2].zIndex).toBe(4);
|
||||
expect(service.layer.layers[2].zIndex).toBe(5);
|
||||
};
|
||||
assert2StepState();
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"**/node_modules",
|
||||
".yarn",
|
||||
".github/helm",
|
||||
".git",
|
||||
".vscode",
|
||||
".yarnrc.yml",
|
||||
".docker",
|
||||
|
||||
+1
-1
@@ -82,7 +82,7 @@
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.0.0",
|
||||
"msw": "^2.6.8",
|
||||
"oxlint": "^1.1.0",
|
||||
"oxlint": "^1.15.0",
|
||||
"prettier": "^3.4.2",
|
||||
"semver": "^7.6.3",
|
||||
"serve": "^14.2.4",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use tiktoken_rs::{get_bpe_from_tokenizer, tokenizer::Tokenizer as TiktokenTokenizer};
|
||||
|
||||
#[napi]
|
||||
pub struct Tokenizer {
|
||||
inner: tiktoken_rs::CoreBPE,
|
||||
@@ -7,6 +9,10 @@ pub struct Tokenizer {
|
||||
|
||||
#[napi]
|
||||
pub fn from_model_name(model_name: String) -> Option<Tokenizer> {
|
||||
if model_name.starts_with("gpt-5") {
|
||||
let bpe = get_bpe_from_tokenizer(TiktokenTokenizer::O200kBase).ok()?;
|
||||
return Some(Tokenizer { inner: bpe });
|
||||
}
|
||||
let bpe = tiktoken_rs::get_bpe_from_model(&model_name).ok()?;
|
||||
Some(Tokenizer { inner: bpe })
|
||||
}
|
||||
@@ -31,7 +37,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_tokenizer() {
|
||||
let tokenizer = from_model_name("gpt-4.1".to_string()).unwrap();
|
||||
let tokenizer = from_model_name("gpt-5".to_string()).unwrap();
|
||||
let content = "Hello, world!";
|
||||
let count = tokenizer.count(content.to_string(), None);
|
||||
assert!(count > 0);
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
-- 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");
|
||||
@@ -28,12 +28,12 @@
|
||||
"dependencies": {
|
||||
"@affine/reader": "workspace:*",
|
||||
"@affine/server-native": "workspace:*",
|
||||
"@ai-sdk/anthropic": "^1.2.12",
|
||||
"@ai-sdk/google": "^1.2.18",
|
||||
"@ai-sdk/google-vertex": "^2.2.23",
|
||||
"@ai-sdk/openai": "^1.3.22",
|
||||
"@ai-sdk/openai-compatible": "^0.2.14",
|
||||
"@ai-sdk/perplexity": "^1.1.9",
|
||||
"@ai-sdk/anthropic": "^2.0.1",
|
||||
"@ai-sdk/google": "^2.0.4",
|
||||
"@ai-sdk/google-vertex": "^3.0.5",
|
||||
"@ai-sdk/openai": "^2.0.10",
|
||||
"@ai-sdk/openai-compatible": "^1.0.5",
|
||||
"@ai-sdk/perplexity": "^2.0.1",
|
||||
"@apollo/server": "^4.11.3",
|
||||
"@aws-sdk/client-s3": "^3.779.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.779.0",
|
||||
@@ -75,7 +75,7 @@
|
||||
"@prisma/instrumentation": "^6.7.0",
|
||||
"@react-email/components": "0.0.38",
|
||||
"@socket.io/redis-adapter": "^8.3.0",
|
||||
"ai": "^4.3.4",
|
||||
"ai": "^5.0.10",
|
||||
"bullmq": "^5.40.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cross-env": "^7.0.3",
|
||||
|
||||
@@ -111,17 +111,18 @@ 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)
|
||||
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)
|
||||
|
||||
features WorkspaceFeature[]
|
||||
docs WorkspaceDoc[]
|
||||
@@ -133,6 +134,7 @@ model Workspace {
|
||||
comments Comment[]
|
||||
commentAttachments CommentAttachment[]
|
||||
|
||||
@@index([lastCheckEmbeddings])
|
||||
@@map("workspaces")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type { ExecutionContext, TestFn } from 'ava';
|
||||
import ava from 'ava';
|
||||
import { z } from 'zod';
|
||||
@@ -5,6 +7,7 @@ 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 {
|
||||
@@ -30,6 +33,8 @@ import { TestAssets } from './utils/copilot';
|
||||
type Tester = {
|
||||
auth: AuthService;
|
||||
module: TestingModule;
|
||||
models: Models;
|
||||
service: ServerService;
|
||||
prompt: PromptService;
|
||||
factory: CopilotProviderFactory;
|
||||
workflow: CopilotWorkflowService;
|
||||
@@ -66,12 +71,15 @@ 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;
|
||||
@@ -84,7 +92,7 @@ test.serial.before(async t => {
|
||||
});
|
||||
|
||||
test.serial.before(async t => {
|
||||
const { prompt, executors } = t.context;
|
||||
const { prompt, executors, models, service } = t.context;
|
||||
|
||||
executors.image.register();
|
||||
executors.text.register();
|
||||
@@ -98,6 +106,28 @@ 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-5-mini',
|
||||
complex_text_generation: 'gpt-5-mini',
|
||||
coding: 'gpt-5-mini',
|
||||
quick_decision_making: 'gpt-5-mini',
|
||||
quick_text_generation: 'gpt-5-mini',
|
||||
polish_and_summarize: 'gemini-2.5-flash',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test.after(async t => {
|
||||
@@ -532,12 +562,17 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
|
||||
'Make it shorter',
|
||||
'Section Edit',
|
||||
'Chat With AFFiNE AI',
|
||||
'Search With AFFiNE AI',
|
||||
],
|
||||
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(result.includes('AFFiNE'), 'should include original keyword');
|
||||
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,
|
||||
},
|
||||
@@ -595,13 +630,17 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
|
||||
messages: [
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: TestAssets.AFFiNE,
|
||||
content: TestAssets.SSOT,
|
||||
params: { language: 'Simplified Chinese' },
|
||||
},
|
||||
],
|
||||
verifier: (t: ExecutionContext<Tester>, result: string) => {
|
||||
assertNotWrappedInCodeBlock(t, result);
|
||||
t.assert(result.includes('AFFiNE'), 'should include keyword');
|
||||
const cleared = result.toLowerCase();
|
||||
t.assert(
|
||||
cleared.includes('单一') || cleared.includes('SSOT'),
|
||||
'explain code result should include keyword'
|
||||
);
|
||||
},
|
||||
type: 'text' as const,
|
||||
},
|
||||
@@ -623,7 +662,7 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
|
||||
content.includes('classroom') ||
|
||||
content.includes('school') ||
|
||||
content.includes('sky'),
|
||||
'should include keyword'
|
||||
'explain code result should include keyword'
|
||||
);
|
||||
},
|
||||
type: 'text' as const,
|
||||
@@ -645,20 +684,7 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
|
||||
type: 'image' as const,
|
||||
},
|
||||
{
|
||||
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'],
|
||||
promptName: ['Generate image'],
|
||||
messages: [
|
||||
{
|
||||
role: 'user' as const,
|
||||
@@ -706,7 +732,7 @@ for (const {
|
||||
[
|
||||
...prompt.finish(
|
||||
messages.reduce(
|
||||
// @ts-expect-error
|
||||
// @ts-expect-error params not typed
|
||||
(acc, m) => Object.assign(acc, m.params),
|
||||
{}
|
||||
)
|
||||
@@ -776,7 +802,7 @@ for (const {
|
||||
[
|
||||
...prompt.finish(
|
||||
finalMessage.reduce(
|
||||
// @ts-expect-error
|
||||
// @ts-expect-error params not typed
|
||||
(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
|
||||
// @ts-expect-error type not typed
|
||||
override type: CopilotProviderType = CopilotProviderType.Gemini;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ProjectRoot } from '@affine-tools/utils/path';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import type { TestFn } from 'ava';
|
||||
import ava from 'ava';
|
||||
import { nanoid } from 'nanoid';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { EventBus, JobQueue } from '../base';
|
||||
@@ -211,7 +212,9 @@ test('should be able to manage prompt', async t => {
|
||||
'should have two messages'
|
||||
);
|
||||
|
||||
await prompt.update(promptName, [{ role: 'system', content: 'hello' }]);
|
||||
await prompt.update(promptName, {
|
||||
messages: [{ role: 'system', content: 'hello' }],
|
||||
});
|
||||
t.is(
|
||||
(await prompt.get(promptName))!.finish({}).length,
|
||||
1,
|
||||
@@ -370,7 +373,7 @@ test('should be able to update chat session prompt', async t => {
|
||||
// Update the session
|
||||
const updatedSessionId = await session.update({
|
||||
sessionId,
|
||||
promptName: 'Search With AFFiNE AI',
|
||||
promptName: 'Chat With AFFiNE AI',
|
||||
userId,
|
||||
});
|
||||
t.is(updatedSessionId, sessionId, 'should update session with same id');
|
||||
@@ -380,7 +383,7 @@ test('should be able to update chat session prompt', async t => {
|
||||
t.truthy(updatedSession, 'should retrieve updated session');
|
||||
t.is(
|
||||
updatedSession?.config.promptName,
|
||||
'Search With AFFiNE AI',
|
||||
'Chat With AFFiNE AI',
|
||||
'should have updated prompt name'
|
||||
);
|
||||
});
|
||||
@@ -409,7 +412,7 @@ test('should be able to fork chat session', async t => {
|
||||
|
||||
// fork session
|
||||
const s1 = (await session.get(sessionId))!;
|
||||
// @ts-expect-error
|
||||
// @ts-expect-error find maybe return undefined
|
||||
const latestMessageId = s1.finish({}).find(m => m.role === 'assistant')!.id;
|
||||
const forkedSessionId1 = await session.fork({
|
||||
userId,
|
||||
@@ -1338,16 +1341,16 @@ test('TextStreamParser should format different types of chunks correctly', t =>
|
||||
textDelta: {
|
||||
chunk: {
|
||||
type: 'text-delta' as const,
|
||||
textDelta: 'Hello world',
|
||||
} as any,
|
||||
text: 'Hello world',
|
||||
},
|
||||
expected: 'Hello world',
|
||||
description: 'should format text-delta correctly',
|
||||
},
|
||||
reasoning: {
|
||||
chunk: {
|
||||
type: 'reasoning' as const,
|
||||
textDelta: 'I need to think about this',
|
||||
} as any,
|
||||
type: 'reasoning-delta' as const,
|
||||
text: 'I need to think about this',
|
||||
},
|
||||
expected: '\n> [!]\n> I need to think about this',
|
||||
description: 'should format reasoning as callout',
|
||||
},
|
||||
@@ -1356,8 +1359,8 @@ test('TextStreamParser should format different types of chunks correctly', t =>
|
||||
type: 'tool-call' as const,
|
||||
toolName: 'web_search_exa' as const,
|
||||
toolCallId: 'test-id-1',
|
||||
args: { query: 'test query', mode: 'AUTO' as const },
|
||||
} as any,
|
||||
input: { query: 'test query', mode: 'AUTO' as const },
|
||||
},
|
||||
expected: '\n> [!]\n> \n> Searching the web "test query"\n> ',
|
||||
description: 'should format web search tool call correctly',
|
||||
},
|
||||
@@ -1366,8 +1369,8 @@ test('TextStreamParser should format different types of chunks correctly', t =>
|
||||
type: 'tool-call' as const,
|
||||
toolName: 'web_crawl_exa' as const,
|
||||
toolCallId: 'test-id-2',
|
||||
args: { url: 'https://example.com' },
|
||||
} as any,
|
||||
input: { url: 'https://example.com' },
|
||||
},
|
||||
expected: '\n> [!]\n> \n> Crawling the web "https://example.com"\n> ',
|
||||
description: 'should format web crawl tool call correctly',
|
||||
},
|
||||
@@ -1376,8 +1379,8 @@ test('TextStreamParser should format different types of chunks correctly', t =>
|
||||
type: 'tool-result' as const,
|
||||
toolName: 'web_search_exa' as const,
|
||||
toolCallId: 'test-id-1',
|
||||
args: { query: 'test query', mode: 'AUTO' as const },
|
||||
result: [
|
||||
input: { query: 'test query', mode: 'AUTO' as const },
|
||||
output: [
|
||||
{
|
||||
title: 'Test Title',
|
||||
url: 'https://test.com',
|
||||
@@ -1404,7 +1407,7 @@ test('TextStreamParser should format different types of chunks correctly', t =>
|
||||
chunk: {
|
||||
type: 'error' as const,
|
||||
error: { type: 'testError', message: 'Test error message' },
|
||||
} as any,
|
||||
},
|
||||
errorMessage: 'Test error message',
|
||||
description: 'should throw error for error chunks',
|
||||
},
|
||||
@@ -1434,78 +1437,85 @@ test('TextStreamParser should process a sequence of message chunks', t => {
|
||||
chunks: [
|
||||
// Reasoning chunks
|
||||
{
|
||||
type: 'reasoning' as const,
|
||||
textDelta: 'The user is asking about',
|
||||
} as any,
|
||||
id: nanoid(),
|
||||
type: 'reasoning-delta' as const,
|
||||
text: 'The user is asking about',
|
||||
},
|
||||
{
|
||||
type: 'reasoning' as const,
|
||||
textDelta: ' recent advances in quantum computing',
|
||||
} as any,
|
||||
id: nanoid(),
|
||||
type: 'reasoning-delta' as const,
|
||||
text: ' recent advances in quantum computing',
|
||||
},
|
||||
{
|
||||
type: 'reasoning' as const,
|
||||
textDelta: ' and how it might impact',
|
||||
} as any,
|
||||
id: nanoid(),
|
||||
type: 'reasoning-delta' as const,
|
||||
text: ' and how it might impact',
|
||||
},
|
||||
{
|
||||
type: 'reasoning' as const,
|
||||
textDelta: ' cryptography and data security.',
|
||||
} as any,
|
||||
id: nanoid(),
|
||||
type: 'reasoning-delta' as const,
|
||||
text: ' cryptography and data security.',
|
||||
},
|
||||
{
|
||||
type: 'reasoning' as const,
|
||||
textDelta:
|
||||
' I should provide information on quantum supremacy achievements',
|
||||
} as any,
|
||||
id: nanoid(),
|
||||
type: 'reasoning-delta' as const,
|
||||
text: ' I should provide information on quantum supremacy achievements',
|
||||
},
|
||||
|
||||
// Text delta
|
||||
{
|
||||
id: nanoid(),
|
||||
type: 'text-delta' as const,
|
||||
textDelta:
|
||||
'Let me search for the latest breakthroughs in quantum computing and their ',
|
||||
} as any,
|
||||
text: 'Let me search for the latest breakthroughs in quantum computing and their ',
|
||||
},
|
||||
|
||||
// Tool call
|
||||
{
|
||||
type: 'tool-call' as const,
|
||||
toolCallId: 'toolu_01ABCxyz123456789',
|
||||
toolName: 'web_search_exa' as const,
|
||||
args: {
|
||||
input: {
|
||||
query: 'latest quantum computing breakthroughs cryptography impact',
|
||||
},
|
||||
} as any,
|
||||
},
|
||||
|
||||
// Tool result
|
||||
{
|
||||
type: 'tool-result' as const,
|
||||
toolCallId: 'toolu_01ABCxyz123456789',
|
||||
toolName: 'web_search_exa' as const,
|
||||
args: {
|
||||
input: {
|
||||
query: 'latest quantum computing breakthroughs cryptography impact',
|
||||
},
|
||||
result: [
|
||||
output: [
|
||||
{
|
||||
title: 'IBM Unveils 1000-Qubit Quantum Processor',
|
||||
url: 'https://example.com/tech/quantum-computing-milestone',
|
||||
},
|
||||
],
|
||||
} as any,
|
||||
},
|
||||
|
||||
// More text deltas
|
||||
{
|
||||
id: nanoid(),
|
||||
type: 'text-delta' as const,
|
||||
textDelta: 'implications for security.',
|
||||
} as any,
|
||||
text: 'implications for security.',
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
type: 'text-delta' as const,
|
||||
textDelta: '\n\nQuantum computing has made ',
|
||||
} as any,
|
||||
text: '\n\nQuantum computing has made ',
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
type: 'text-delta' as const,
|
||||
textDelta: 'remarkable progress in the past year. ',
|
||||
} as any,
|
||||
text: 'remarkable progress in the past year. ',
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
type: 'text-delta' as const,
|
||||
textDelta:
|
||||
'The development of more stable qubits has accelerated research significantly.',
|
||||
} as any,
|
||||
text: 'The development of more stable qubits has accelerated research significantly.',
|
||||
},
|
||||
],
|
||||
expected:
|
||||
'\n> [!]\n> The user is asking about recent advances in quantum computing and how it might impact cryptography and data security. I should provide information on quantum supremacy achievements\n\nLet me search for the latest breakthroughs in quantum computing and their \n> [!]\n> \n> Searching the web "latest quantum computing breakthroughs cryptography impact"\n> \n> \n> \n> [IBM Unveils 1000-Qubit Quantum Processor](https://example.com/tech/quantum-computing-milestone)\n> \n> \n> \n\nimplications for security.\n\nQuantum computing has made remarkable progress in the past year. The development of more stable qubits has accelerated research significantly.',
|
||||
|
||||
@@ -57,15 +57,6 @@ export class MockCopilotProvider extends OpenAIProvider {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'gpt-4.1',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text, ModelInputType.Image],
|
||||
output: [ModelOutputType.Text, ModelOutputType.Object],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'gpt-4.1-2025-04-14',
|
||||
capabilities: [
|
||||
@@ -76,7 +67,25 @@ export class MockCopilotProvider extends OpenAIProvider {
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'gpt-4.1-mini',
|
||||
id: 'gpt-5',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text, ModelInputType.Image],
|
||||
output: [ModelOutputType.Text, ModelOutputType.Object],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'gpt-5-2025-08-07',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text, ModelInputType.Image],
|
||||
output: [ModelOutputType.Text, ModelOutputType.Object],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'gpt-5-mini',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text, ModelInputType.Image],
|
||||
|
||||
+22
@@ -101,6 +101,28 @@ Generated by [AVA](https://avajs.dev).
|
||||
|
||||
0
|
||||
|
||||
## should check need to be embedded
|
||||
|
||||
> document with no embedding should need embedding
|
||||
|
||||
true
|
||||
|
||||
> document with recent embedding should not need embedding
|
||||
|
||||
false
|
||||
|
||||
> document updated after embedding and older-than-10m should need embedding
|
||||
|
||||
true
|
||||
|
||||
> should not need embedding when only 10-minute window passed without updates
|
||||
|
||||
false
|
||||
|
||||
> should need embedding when doc updated and last embedding older than 10 minutes
|
||||
|
||||
true
|
||||
|
||||
## should filter outdated doc id style in embedding status
|
||||
|
||||
> should include modern doc format
|
||||
|
||||
BIN
Binary file not shown.
@@ -48,7 +48,7 @@ let docId = 'doc1';
|
||||
|
||||
test.beforeEach(async t => {
|
||||
await t.context.module.initTestingDB();
|
||||
await t.context.copilotSession.createPrompt('prompt-name', 'gpt-4.1');
|
||||
await t.context.copilotSession.createPrompt('prompt-name', 'gpt-5-mini');
|
||||
user = await t.context.user.create({
|
||||
email: 'test@affine.pro',
|
||||
});
|
||||
|
||||
@@ -58,9 +58,9 @@ const createTestPrompts = async (
|
||||
copilotSession: CopilotSessionModel,
|
||||
db: PrismaClient
|
||||
) => {
|
||||
await copilotSession.createPrompt(TEST_PROMPTS.NORMAL, 'gpt-4.1');
|
||||
await copilotSession.createPrompt(TEST_PROMPTS.NORMAL, 'gpt-5-mini');
|
||||
await db.aiPrompt.create({
|
||||
data: { name: TEST_PROMPTS.ACTION, model: 'gpt-4.1', action: 'edit' },
|
||||
data: { name: TEST_PROMPTS.ACTION, model: 'gpt-5-mini', action: 'edit' },
|
||||
});
|
||||
};
|
||||
|
||||
@@ -116,7 +116,7 @@ const addMessagesToSession = async (
|
||||
await copilotSession.updateMessages({
|
||||
sessionId,
|
||||
userId: user.id,
|
||||
prompt: { model: 'gpt-4.1' },
|
||||
prompt: { model: 'gpt-5-mini' },
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
@@ -807,7 +807,7 @@ test('should handle fork and session attachment operations', async t => {
|
||||
pinned: forkConfig.pinned,
|
||||
title: null,
|
||||
parentSessionId,
|
||||
prompt: { name: TEST_PROMPTS.NORMAL, action: null, model: 'gpt-4.1' },
|
||||
prompt: { name: TEST_PROMPTS.NORMAL, action: null, model: 'gpt-5-mini' },
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
|
||||
@@ -293,7 +293,10 @@ test('should check need to be embedded', async t => {
|
||||
workspace.id,
|
||||
docId
|
||||
);
|
||||
t.true(needsEmbedding, 'document with no embedding should need embedding');
|
||||
t.snapshot(
|
||||
needsEmbedding,
|
||||
'document with no embedding should need embedding'
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
@@ -313,7 +316,7 @@ test('should check need to be embedded', async t => {
|
||||
workspace.id,
|
||||
docId
|
||||
);
|
||||
t.false(
|
||||
t.snapshot(
|
||||
needsEmbedding,
|
||||
'document with recent embedding should not need embedding'
|
||||
);
|
||||
@@ -328,15 +331,83 @@ test('should check need to be embedded', async t => {
|
||||
editorId: user.id,
|
||||
});
|
||||
|
||||
// simulate an old embedding
|
||||
const oldEmbeddingTime = new Date(Date.now() - 25 * 60 * 1000);
|
||||
await t.context.db.aiWorkspaceEmbedding.updateMany({
|
||||
where: { workspaceId: workspace.id, docId },
|
||||
data: { updatedAt: oldEmbeddingTime },
|
||||
});
|
||||
|
||||
let needsEmbedding = await t.context.copilotWorkspace.checkDocNeedEmbedded(
|
||||
workspace.id,
|
||||
docId
|
||||
);
|
||||
t.true(
|
||||
t.snapshot(
|
||||
needsEmbedding,
|
||||
'document updated after embedding should need embedding'
|
||||
'document updated after embedding and older-than-10m should need embedding'
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
// only time passed (>10m since last embedding) but no doc updates => should NOT re-embed
|
||||
const baseNow = Date.now();
|
||||
const docId2 = randomUUID();
|
||||
const t0 = baseNow - 30 * 60 * 1000; // snapshot updated 30 minutes ago
|
||||
const t1 = baseNow - 25 * 60 * 1000; // embedding updated 25 minutes ago
|
||||
|
||||
await t.context.doc.upsert({
|
||||
spaceId: workspace.id,
|
||||
docId: docId2,
|
||||
blob: Uint8Array.from([1, 2, 3]),
|
||||
timestamp: t0,
|
||||
editorId: user.id,
|
||||
});
|
||||
|
||||
await t.context.copilotContext.insertWorkspaceEmbedding(
|
||||
workspace.id,
|
||||
docId2,
|
||||
[
|
||||
{
|
||||
index: 0,
|
||||
content: 'content2',
|
||||
embedding: Array.from({ length: 1024 }, () => 1),
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
await t.context.db.aiWorkspaceEmbedding.updateMany({
|
||||
where: { workspaceId: workspace.id, docId: docId2 },
|
||||
data: { updatedAt: new Date(t1) },
|
||||
});
|
||||
|
||||
let needsEmbedding = await t.context.copilotWorkspace.checkDocNeedEmbedded(
|
||||
workspace.id,
|
||||
docId2
|
||||
);
|
||||
t.snapshot(
|
||||
needsEmbedding,
|
||||
'should not need embedding when only 10-minute window passed without updates'
|
||||
);
|
||||
|
||||
const t2 = baseNow - 5 * 60 * 1000; // doc updated 5 minutes ago
|
||||
await t.context.doc.upsert({
|
||||
spaceId: workspace.id,
|
||||
docId: docId2,
|
||||
blob: Uint8Array.from([7, 8, 9]),
|
||||
timestamp: t2,
|
||||
editorId: user.id,
|
||||
});
|
||||
|
||||
needsEmbedding = await t.context.copilotWorkspace.checkDocNeedEmbedded(
|
||||
workspace.id,
|
||||
docId2
|
||||
);
|
||||
t.snapshot(
|
||||
needsEmbedding,
|
||||
'should need embedding when doc updated and last embedding older than 10 minutes'
|
||||
);
|
||||
}
|
||||
// --- new cases end ---
|
||||
});
|
||||
|
||||
test('should check embedding table', async t => {
|
||||
|
||||
@@ -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
|
||||
// @ts-expect-error DEPLOYMENT_TYPE is readonly
|
||||
env.DEPLOYMENT_TYPE = 'selfhosted';
|
||||
await using module = await createTestingModule();
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ export async function createTestingModule(
|
||||
// setting up
|
||||
let imports = moduleDef.imports ?? [buildAppModule(globalThis.env)];
|
||||
imports =
|
||||
// @ts-expect-error
|
||||
// @ts-expect-error ignore the type error
|
||||
imports[0].module?.name === 'AppModule'
|
||||
? imports
|
||||
: dedupeModules([
|
||||
|
||||
@@ -99,7 +99,7 @@ export class ServerService implements OnApplicationBootstrap {
|
||||
}
|
||||
});
|
||||
this.configFactory.override(overrides);
|
||||
this.event.emit('config.changed', { updates: overrides });
|
||||
await this.event.emitAsync('config.changed', { updates: overrides });
|
||||
this.event.broadcast('config.changed.broadcast', { updates: overrides });
|
||||
return overrides;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
import { JOB_SIGNAL, JobQueue, metrics, OnJob } from '../../base';
|
||||
import { Models } from '../../models';
|
||||
import { PgWorkspaceDocStorageAdapter } from '../doc';
|
||||
import { DatabaseDocReader, PgWorkspaceDocStorageAdapter } from '../doc';
|
||||
|
||||
declare global {
|
||||
interface Jobs {
|
||||
@@ -13,13 +13,23 @@ declare global {
|
||||
docId: string;
|
||||
};
|
||||
'doc.recordPendingDocUpdatesCount': {};
|
||||
'doc.findEmptySummaryDocs': {
|
||||
lastFixedWorkspaceSid?: number;
|
||||
};
|
||||
'doc.autoFixedDocSummary': {
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DocServiceCronJob {
|
||||
private readonly logger = new Logger(DocServiceCronJob.name);
|
||||
|
||||
constructor(
|
||||
private readonly workspace: PgWorkspaceDocStorageAdapter,
|
||||
private readonly docReader: DatabaseDocReader,
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly job: JobQueue,
|
||||
private readonly models: Models
|
||||
@@ -86,4 +96,74 @@ export class DocServiceCronJob {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_30_SECONDS)
|
||||
async scheduleFindEmptySummaryDocs() {
|
||||
await this.job.add(
|
||||
'doc.findEmptySummaryDocs',
|
||||
{},
|
||||
{
|
||||
// make sure only one job is running at a time
|
||||
delay: 30 * 1000,
|
||||
jobId: 'findEmptySummaryDocs',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@OnJob('doc.findEmptySummaryDocs')
|
||||
async findEmptySummaryDocs(payload: Jobs['doc.findEmptySummaryDocs']) {
|
||||
const startSid = payload.lastFixedWorkspaceSid ?? 0;
|
||||
const workspaces = await this.models.workspace.list(
|
||||
{ sid: { gt: startSid } },
|
||||
{ id: true, sid: true },
|
||||
100
|
||||
);
|
||||
|
||||
if (workspaces.length === 0) {
|
||||
return JOB_SIGNAL.Repeat;
|
||||
}
|
||||
|
||||
let addedCount = 0;
|
||||
for (const workspace of workspaces) {
|
||||
const docIds = await this.models.doc.findEmptySummaryDocIds(workspace.id);
|
||||
for (const docId of docIds) {
|
||||
// ignore root doc
|
||||
if (docId === workspace.id) {
|
||||
continue;
|
||||
}
|
||||
await this.job.add(
|
||||
'doc.autoFixedDocSummary',
|
||||
{ workspaceId: workspace.id, docId },
|
||||
{
|
||||
jobId: `autoFixedDocSummary/${workspace.id}/${docId}`,
|
||||
}
|
||||
);
|
||||
addedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
const nextSid = workspaces[workspaces.length - 1].sid;
|
||||
this.logger.log(
|
||||
`Auto added ${addedCount} docs to queue, lastFixedWorkspaceSid: ${startSid} -> ${nextSid}`
|
||||
);
|
||||
|
||||
// update the lastFixedWorkspaceSid in the payload and repeat the job after 30 seconds
|
||||
payload.lastFixedWorkspaceSid = nextSid;
|
||||
return JOB_SIGNAL.Repeat;
|
||||
}
|
||||
|
||||
@OnJob('doc.autoFixedDocSummary')
|
||||
async autoFixedDocSummary(payload: Jobs['doc.autoFixedDocSummary']) {
|
||||
const { workspaceId, docId } = payload;
|
||||
const content = await this.docReader.getDocContent(workspaceId, docId);
|
||||
if (!content) {
|
||||
this.logger.warn(
|
||||
`Summary for doc ${docId} in workspace ${workspaceId} not found`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.models.doc.upsertMeta(workspaceId, docId, content);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import test from 'ava';
|
||||
|
||||
import { createModule } from '../../__tests__/create-module';
|
||||
import { Mockers } from '../../__tests__/mocks';
|
||||
import { Models } from '..';
|
||||
|
||||
const module = await createModule({});
|
||||
|
||||
const models = module.get(Models);
|
||||
const owner = await module.create(Mockers.User);
|
||||
|
||||
test.after.always(async () => {
|
||||
await module.close();
|
||||
});
|
||||
|
||||
test('should find null summary doc ids', async t => {
|
||||
const workspace = await module.create(Mockers.Workspace, {
|
||||
owner,
|
||||
});
|
||||
|
||||
const docId = randomUUID();
|
||||
await module.create(Mockers.DocMeta, {
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
});
|
||||
|
||||
const docIds = await models.doc.findEmptySummaryDocIds(workspace.id);
|
||||
t.deepEqual(docIds, [docId]);
|
||||
});
|
||||
|
||||
test('should ignore summary is not null', async t => {
|
||||
const workspace = await module.create(Mockers.Workspace, {
|
||||
owner,
|
||||
});
|
||||
|
||||
const docId = randomUUID();
|
||||
await module.create(Mockers.DocMeta, {
|
||||
workspaceId: workspace.id,
|
||||
docId,
|
||||
summary: 'test',
|
||||
});
|
||||
|
||||
const docIds = await models.doc.findEmptySummaryDocIds(workspace.id);
|
||||
t.is(docIds.length, 0);
|
||||
});
|
||||
@@ -67,12 +67,17 @@ export class BlobModel extends BaseModel {
|
||||
});
|
||||
}
|
||||
|
||||
async list(workspaceId: string) {
|
||||
async list(
|
||||
workspaceId: string,
|
||||
options?: { where: Prisma.BlobWhereInput; select?: Prisma.BlobSelect }
|
||||
) {
|
||||
return await this.db.blob.findMany({
|
||||
where: {
|
||||
...options?.where,
|
||||
workspaceId,
|
||||
deletedAt: null,
|
||||
},
|
||||
select: options?.select,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -148,3 +148,36 @@ export type IgnoredDoc = {
|
||||
createdByAvatar: string | undefined;
|
||||
updatedBy: string | undefined;
|
||||
};
|
||||
|
||||
export const EMBEDDING_DIMENSIONS = 1024;
|
||||
|
||||
const FILTER_PREFIX = [
|
||||
'Title: ',
|
||||
'Created at: ',
|
||||
'Updated at: ',
|
||||
'Created by: ',
|
||||
'Updated by: ',
|
||||
];
|
||||
|
||||
export function clearEmbeddingContent(content: string): string {
|
||||
const lines = content.split('\n');
|
||||
let maxLines = 5;
|
||||
while (maxLines > 0 && lines.length > 0) {
|
||||
if (FILTER_PREFIX.some(prefix => lines[0].startsWith(prefix))) {
|
||||
lines.shift();
|
||||
maxLines--;
|
||||
} else {
|
||||
// only process consecutive metadata rows
|
||||
break;
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function clearEmbeddingChunk(chunk: ChunkSimilarity): ChunkSimilarity {
|
||||
if (chunk.content) {
|
||||
const content = clearEmbeddingContent(chunk.content);
|
||||
return { ...chunk, content };
|
||||
}
|
||||
return chunk;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Prisma } from '@prisma/client';
|
||||
import { CopilotSessionNotFound } from '../base';
|
||||
import { BaseModel } from './base';
|
||||
import {
|
||||
clearEmbeddingContent,
|
||||
ContextBlob,
|
||||
ContextConfigSchema,
|
||||
ContextDoc,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
CopilotContext,
|
||||
DocChunkSimilarity,
|
||||
Embedding,
|
||||
EMBEDDING_DIMENSIONS,
|
||||
FileChunkSimilarity,
|
||||
MinimalContextConfigSchema,
|
||||
} from './common/copilot';
|
||||
@@ -203,6 +205,19 @@ export class CopilotContextModel extends BaseModel {
|
||||
return Prisma.join(groups.map(row => Prisma.sql`(${Prisma.join(row)})`));
|
||||
}
|
||||
|
||||
async getFileContent(
|
||||
contextId: string,
|
||||
fileId: string,
|
||||
chunk?: number
|
||||
): Promise<string | undefined> {
|
||||
const file = await this.db.aiContextEmbedding.findMany({
|
||||
where: { contextId, fileId, chunk },
|
||||
select: { content: true },
|
||||
orderBy: { chunk: 'asc' },
|
||||
});
|
||||
return file?.map(f => clearEmbeddingContent(f.content)).join('\n');
|
||||
}
|
||||
|
||||
async insertFileEmbedding(
|
||||
contextId: string,
|
||||
fileId: string,
|
||||
@@ -249,6 +264,19 @@ export class CopilotContextModel extends BaseModel {
|
||||
return similarityChunks.filter(c => Number(c.distance) <= threshold);
|
||||
}
|
||||
|
||||
async getWorkspaceContent(
|
||||
workspaceId: string,
|
||||
docId: string,
|
||||
chunk?: number
|
||||
): Promise<string | undefined> {
|
||||
const file = await this.db.aiWorkspaceEmbedding.findMany({
|
||||
where: { workspaceId, docId, chunk },
|
||||
select: { content: true },
|
||||
orderBy: { chunk: 'asc' },
|
||||
});
|
||||
return file?.map(f => clearEmbeddingContent(f.content)).join('\n');
|
||||
}
|
||||
|
||||
async insertWorkspaceEmbedding(
|
||||
workspaceId: string,
|
||||
docId: string,
|
||||
@@ -273,15 +301,30 @@ export class CopilotContextModel extends BaseModel {
|
||||
VALUES ${values}
|
||||
ON CONFLICT (workspace_id, doc_id, chunk)
|
||||
DO UPDATE SET
|
||||
content = EXCLUDED.content,
|
||||
embedding = EXCLUDED.embedding,
|
||||
updated_at = excluded.updated_at;
|
||||
`;
|
||||
}
|
||||
|
||||
async fulfillEmptyEmbedding(workspaceId: string, docId: string) {
|
||||
const emptyEmbedding = {
|
||||
index: 0,
|
||||
content: '',
|
||||
embedding: Array.from({ length: EMBEDDING_DIMENSIONS }, () => 0),
|
||||
};
|
||||
await this.models.copilotContext.insertWorkspaceEmbedding(
|
||||
workspaceId,
|
||||
docId,
|
||||
[emptyEmbedding]
|
||||
);
|
||||
}
|
||||
|
||||
async deleteWorkspaceEmbedding(workspaceId: string, docId: string) {
|
||||
await this.db.aiWorkspaceEmbedding.deleteMany({
|
||||
where: { workspaceId, docId },
|
||||
});
|
||||
await this.fulfillEmptyEmbedding(workspaceId, docId);
|
||||
}
|
||||
|
||||
async matchWorkspaceEmbedding(
|
||||
|
||||
@@ -6,13 +6,14 @@ import { Prisma, PrismaClient } from '@prisma/client';
|
||||
|
||||
import { PaginationInput } from '../base';
|
||||
import { BaseModel } from './base';
|
||||
import type {
|
||||
BlobChunkSimilarity,
|
||||
CopilotWorkspaceFile,
|
||||
CopilotWorkspaceFileMetadata,
|
||||
Embedding,
|
||||
FileChunkSimilarity,
|
||||
IgnoredDoc,
|
||||
import {
|
||||
type BlobChunkSimilarity,
|
||||
clearEmbeddingContent,
|
||||
type CopilotWorkspaceFile,
|
||||
type CopilotWorkspaceFileMetadata,
|
||||
type Embedding,
|
||||
type FileChunkSimilarity,
|
||||
type IgnoredDoc,
|
||||
} from './common';
|
||||
|
||||
@Injectable()
|
||||
@@ -152,21 +153,57 @@ export class CopilotWorkspaceConfigModel extends BaseModel {
|
||||
return docIds.filter(id => ignored.has(id));
|
||||
}
|
||||
|
||||
// check if a docId has only placeholder embeddings
|
||||
@Transactional()
|
||||
async hasPlaceholder(workspaceId: string, docId: string): Promise<boolean> {
|
||||
const [total, nonPlaceholder] = await Promise.all([
|
||||
this.db.aiWorkspaceEmbedding.count({ where: { workspaceId, docId } }),
|
||||
this.db.aiWorkspaceEmbedding.count({
|
||||
where: {
|
||||
workspaceId,
|
||||
docId,
|
||||
NOT: { AND: [{ chunk: 0 }, { content: '' }] },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return total > 0 && nonPlaceholder === 0;
|
||||
}
|
||||
|
||||
private getEmbeddableCondition(
|
||||
workspaceId: string,
|
||||
ignoredDocIds?: string[]
|
||||
): Prisma.SnapshotWhereInput {
|
||||
const condition: Prisma.SnapshotWhereInput['AND'] = [
|
||||
{ id: { not: workspaceId } },
|
||||
{ id: { not: { contains: '$' } } },
|
||||
{ id: { not: { contains: ':settings:' } } },
|
||||
{ blob: { not: new Uint8Array([0, 0]) } },
|
||||
];
|
||||
if (ignoredDocIds && ignoredDocIds.length > 0) {
|
||||
condition.push({ id: { notIn: ignoredDocIds } });
|
||||
}
|
||||
return { workspaceId, AND: condition };
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async listEmbeddableDocIds(workspaceId: string) {
|
||||
const condition = this.getEmbeddableCondition(workspaceId);
|
||||
const rows = await this.db.snapshot.findMany({
|
||||
where: condition,
|
||||
select: { id: true },
|
||||
});
|
||||
return rows.map(r => r.id);
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async getEmbeddingStatus(workspaceId: string) {
|
||||
const ignoredDocIds = (await this.listIgnoredDocIds(workspaceId)).map(
|
||||
d => d.docId
|
||||
);
|
||||
const snapshotCondition = {
|
||||
const snapshotCondition = this.getEmbeddableCondition(
|
||||
workspaceId,
|
||||
AND: [
|
||||
{ id: { notIn: ignoredDocIds } },
|
||||
{ id: { not: workspaceId } },
|
||||
{ id: { not: { contains: '$' } } },
|
||||
{ id: { not: { contains: ':settings:' } } },
|
||||
{ blob: { not: new Uint8Array([0, 0]) } },
|
||||
],
|
||||
};
|
||||
ignoredDocIds
|
||||
);
|
||||
|
||||
const [docTotal, docEmbedded, fileTotal, fileEmbedded] = await Promise.all([
|
||||
this.db.snapshot.findMany({
|
||||
@@ -206,10 +243,9 @@ export class CopilotWorkspaceConfigModel extends BaseModel {
|
||||
@Transactional()
|
||||
async checkDocNeedEmbedded(workspaceId: string, docId: string) {
|
||||
// NOTE: check if the document needs re-embedding.
|
||||
// 1. check if there have been any recent updates to the document snapshot and update
|
||||
// 2. check if the embedding is older than the snapshot and update
|
||||
// 3. check if the embedding is older than 10 minutes (avoid frequent updates)
|
||||
// if all conditions are met, re-embedding is required.
|
||||
// 1. first-time embedding when no embedding exists
|
||||
// 2. re-embedding only when the doc has updates newer than the last embedding
|
||||
// AND the last embedding is older than 10 minutes (avoid frequent updates)
|
||||
const result = await this.db.$queryRaw<{ needs_embedding: boolean }[]>`
|
||||
SELECT
|
||||
EXISTS (
|
||||
@@ -244,8 +280,7 @@ export class CopilotWorkspaceConfigModel extends BaseModel {
|
||||
AND e.doc_id = docs.doc_id
|
||||
WHERE
|
||||
e.updated_at IS NULL
|
||||
OR docs.updated_at > e.updated_at
|
||||
OR e.updated_at < NOW() - INTERVAL '10 minutes'
|
||||
OR (docs.updated_at > e.updated_at AND e.updated_at < NOW() - INTERVAL '10 minutes')
|
||||
) AS needs_embedding;
|
||||
`;
|
||||
|
||||
@@ -379,6 +414,33 @@ export class CopilotWorkspaceConfigModel extends BaseModel {
|
||||
return similarityChunks.filter(c => Number(c.distance) <= threshold);
|
||||
}
|
||||
|
||||
async getBlobContent(
|
||||
workspaceId: string,
|
||||
blobId: string,
|
||||
chunk?: number
|
||||
): Promise<string | undefined> {
|
||||
const blob = await this.db.aiWorkspaceBlobEmbedding.findMany({
|
||||
where: { workspaceId, blobId, chunk },
|
||||
select: { content: true },
|
||||
orderBy: { chunk: 'asc' },
|
||||
});
|
||||
return blob?.map(f => clearEmbeddingContent(f.content)).join('\n');
|
||||
}
|
||||
|
||||
async getBlobChunkSizes(workspaceId: string, blobIds: string[]) {
|
||||
const sizes = await this.db.aiWorkspaceBlobEmbedding.groupBy({
|
||||
by: ['blobId'],
|
||||
_count: { chunk: true },
|
||||
where: { workspaceId, blobId: { in: blobIds } },
|
||||
});
|
||||
return sizes.reduce((acc, cur) => {
|
||||
if (cur._count.chunk) {
|
||||
acc.set(cur.blobId, cur._count.chunk);
|
||||
}
|
||||
return acc;
|
||||
}, new Map<string, number>());
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async insertBlobEmbeddings(
|
||||
workspaceId: string,
|
||||
|
||||
@@ -696,5 +696,18 @@ export class DocModel extends BaseModel {
|
||||
return [count, rows] as const;
|
||||
}
|
||||
|
||||
async findEmptySummaryDocIds(workspaceId: string) {
|
||||
const rows = await this.db.workspaceDoc.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
summary: null,
|
||||
},
|
||||
select: {
|
||||
docId: true,
|
||||
},
|
||||
});
|
||||
return rows.map(row => row.docId);
|
||||
}
|
||||
|
||||
// #endregion
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export type UpdateWorkspaceInput = Pick<
|
||||
| 'name'
|
||||
| 'avatarKey'
|
||||
| 'indexed'
|
||||
| 'lastCheckEmbeddings'
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
@@ -49,7 +50,11 @@ export class WorkspaceModel extends BaseModel {
|
||||
/**
|
||||
* Update the workspace with the given data.
|
||||
*/
|
||||
async update(workspaceId: string, data: UpdateWorkspaceInput) {
|
||||
async update(
|
||||
workspaceId: string,
|
||||
data: UpdateWorkspaceInput,
|
||||
notifyUpdate = true
|
||||
) {
|
||||
const workspace = await this.db.workspace.update({
|
||||
where: {
|
||||
id: workspaceId,
|
||||
@@ -60,7 +65,9 @@ export class WorkspaceModel extends BaseModel {
|
||||
`Updated workspace ${workspaceId} with data ${JSON.stringify(data)}`
|
||||
);
|
||||
|
||||
this.event.emit('workspace.updated', workspace);
|
||||
if (notifyUpdate) {
|
||||
this.event.emit('workspace.updated', workspace);
|
||||
}
|
||||
|
||||
return workspace;
|
||||
}
|
||||
@@ -81,25 +88,15 @@ export class WorkspaceModel extends BaseModel {
|
||||
});
|
||||
}
|
||||
|
||||
async listAfterSid(sid: number, limit: number) {
|
||||
return await this.db.workspace.findMany({
|
||||
where: {
|
||||
sid: { gt: sid },
|
||||
},
|
||||
take: limit,
|
||||
orderBy: {
|
||||
sid: 'asc',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async list<S extends Prisma.WorkspaceSelect>(
|
||||
where: Prisma.WorkspaceWhereInput = {},
|
||||
select?: S
|
||||
select?: S,
|
||||
limit?: number
|
||||
) {
|
||||
return (await this.db.workspace.findMany({
|
||||
where,
|
||||
select,
|
||||
take: limit,
|
||||
orderBy: {
|
||||
sid: 'asc',
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
StorageJSONSchema,
|
||||
StorageProviderConfig,
|
||||
} from '../../base';
|
||||
import { CopilotPromptScenario } from './prompt/prompts';
|
||||
import {
|
||||
AnthropicOfficialConfig,
|
||||
AnthropicVertexConfig,
|
||||
@@ -24,6 +25,7 @@ declare global {
|
||||
key: string;
|
||||
}>;
|
||||
storage: ConfigItem<StorageProviderConfig>;
|
||||
scenarios: ConfigItem<CopilotPromptScenario>;
|
||||
providers: {
|
||||
openai: ConfigItem<OpenAIConfig>;
|
||||
fal: ConfigItem<FalConfig>;
|
||||
@@ -40,20 +42,32 @@ declare global {
|
||||
|
||||
defineModuleConfig('copilot', {
|
||||
enabled: {
|
||||
desc: 'Whether to enable the copilot plugin.',
|
||||
desc: '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>',
|
||||
default: false,
|
||||
},
|
||||
scenarios: {
|
||||
desc: 'Use custom models in scenarios and override default settings.',
|
||||
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-5-mini',
|
||||
quick_text_generation: 'gemini-2.5-flash',
|
||||
polish_and_summarize: 'gemini-2.5-flash',
|
||||
},
|
||||
},
|
||||
},
|
||||
'providers.openai': {
|
||||
desc: 'The config for the openai provider.',
|
||||
default: {
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
fallback: {
|
||||
text: '',
|
||||
structured: '',
|
||||
image: '',
|
||||
embedding: '',
|
||||
},
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
},
|
||||
link: 'https://github.com/openai/openai-node',
|
||||
},
|
||||
@@ -67,54 +81,30 @@ defineModuleConfig('copilot', {
|
||||
desc: 'The config for the gemini provider.',
|
||||
default: {
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
fallback: {
|
||||
text: '',
|
||||
structured: '',
|
||||
image: '',
|
||||
embedding: '',
|
||||
},
|
||||
baseURL: 'https://generativelanguage.googleapis.com/v1beta',
|
||||
},
|
||||
},
|
||||
'providers.geminiVertex': {
|
||||
desc: 'The config for the gemini provider in Google Vertex AI.',
|
||||
default: {
|
||||
baseURL: '',
|
||||
fallback: {
|
||||
text: '',
|
||||
structured: '',
|
||||
image: '',
|
||||
embedding: '',
|
||||
},
|
||||
},
|
||||
default: {},
|
||||
schema: VertexSchema,
|
||||
},
|
||||
'providers.perplexity': {
|
||||
desc: 'The config for the perplexity provider.',
|
||||
default: {
|
||||
apiKey: '',
|
||||
fallback: {
|
||||
text: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
'providers.anthropic': {
|
||||
desc: 'The config for the anthropic provider.',
|
||||
default: {
|
||||
apiKey: '',
|
||||
fallback: {
|
||||
text: '',
|
||||
},
|
||||
baseURL: 'https://api.anthropic.com/v1',
|
||||
},
|
||||
},
|
||||
'providers.anthropicVertex': {
|
||||
desc: 'The config for the anthropic provider in Google Vertex AI.',
|
||||
default: {
|
||||
baseURL: '',
|
||||
fallback: {
|
||||
text: '',
|
||||
},
|
||||
},
|
||||
default: {},
|
||||
schema: VertexSchema,
|
||||
},
|
||||
'providers.morph': {
|
||||
|
||||
@@ -232,7 +232,7 @@ export class CopilotContextService implements OnApplicationBootstrap {
|
||||
const embedding = await this.embeddingClient.getEmbedding(content, signal);
|
||||
if (!embedding) return [];
|
||||
|
||||
const [fileChunks, workspaceChunks, scopedWorkspaceChunks] =
|
||||
const [fileChunks, blobChunks, workspaceChunks, scopedWorkspaceChunks] =
|
||||
await Promise.all([
|
||||
this.models.copilotWorkspace.matchFileEmbedding(
|
||||
workspaceId,
|
||||
@@ -240,6 +240,12 @@ export class CopilotContextService implements OnApplicationBootstrap {
|
||||
topK * 2,
|
||||
threshold
|
||||
),
|
||||
this.models.copilotWorkspace.matchBlobEmbedding(
|
||||
workspaceId,
|
||||
embedding,
|
||||
topK * 2,
|
||||
threshold
|
||||
),
|
||||
this.models.copilotContext.matchWorkspaceEmbedding(
|
||||
embedding,
|
||||
workspaceId,
|
||||
@@ -259,6 +265,7 @@ export class CopilotContextService implements OnApplicationBootstrap {
|
||||
|
||||
if (
|
||||
!fileChunks.length &&
|
||||
!blobChunks.length &&
|
||||
!workspaceChunks.length &&
|
||||
!scopedWorkspaceChunks?.length
|
||||
) {
|
||||
@@ -267,7 +274,12 @@ export class CopilotContextService implements OnApplicationBootstrap {
|
||||
|
||||
return await this.embeddingClient.reRank(
|
||||
content,
|
||||
[...fileChunks, ...workspaceChunks, ...(scopedWorkspaceChunks || [])],
|
||||
[
|
||||
...fileChunks,
|
||||
...blobChunks,
|
||||
...workspaceChunks,
|
||||
...(scopedWorkspaceChunks || []),
|
||||
],
|
||||
topK,
|
||||
signal
|
||||
);
|
||||
|
||||
@@ -55,7 +55,7 @@ export class ContextSession implements AsyncDisposable {
|
||||
return this.config.docs.map(d => ({ ...d }));
|
||||
}
|
||||
|
||||
get files() {
|
||||
get files(): Required<ContextFile>[] {
|
||||
return this.config.files.map(f => this.fulfillFile(f));
|
||||
}
|
||||
|
||||
@@ -135,6 +135,36 @@ export class ContextSession implements AsyncDisposable {
|
||||
return record;
|
||||
}
|
||||
|
||||
async getBlobMetadata() {
|
||||
const blobIds = this.blobs.map(b => b.id);
|
||||
const blobs = await this.models.blob.list(this.config.workspaceId, {
|
||||
where: { key: { in: blobIds } },
|
||||
select: { key: true, mime: true },
|
||||
});
|
||||
const blobChunkSizes = await this.models.copilotWorkspace.getBlobChunkSizes(
|
||||
this.config.workspaceId,
|
||||
blobIds
|
||||
);
|
||||
return blobs
|
||||
.filter(b => !!blobChunkSizes.get(b.key))
|
||||
.map(b => ({
|
||||
id: b.key,
|
||||
mimeType: b.mime,
|
||||
chunkSize: blobChunkSizes.get(b.key),
|
||||
}));
|
||||
}
|
||||
|
||||
async getBlobContent(
|
||||
blobId: string,
|
||||
chunk?: number
|
||||
): Promise<string | undefined> {
|
||||
return this.models.copilotWorkspace.getBlobContent(
|
||||
this.config.workspaceId,
|
||||
blobId,
|
||||
chunk
|
||||
);
|
||||
}
|
||||
|
||||
async removeBlobRecord(blobId: string): Promise<boolean> {
|
||||
const index = this.config.blobs.findIndex(b => b.id === blobId);
|
||||
if (index >= 0) {
|
||||
@@ -203,6 +233,19 @@ export class ContextSession implements AsyncDisposable {
|
||||
return this.config.files.find(f => f.id === fileId);
|
||||
}
|
||||
|
||||
async getFileContent(
|
||||
fileId: string,
|
||||
chunk?: number
|
||||
): Promise<string | undefined> {
|
||||
const file = this.getFile(fileId);
|
||||
if (!file) return undefined;
|
||||
return this.models.copilotContext.getFileContent(
|
||||
this.contextId,
|
||||
fileId,
|
||||
chunk
|
||||
);
|
||||
}
|
||||
|
||||
async removeFile(fileId: string): Promise<boolean> {
|
||||
await this.models.copilotContext.deleteFileEmbedding(
|
||||
this.contextId,
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
UnsplashIsNotConfigured,
|
||||
} from '../../base';
|
||||
import { CurrentUser, Public } from '../../core/auth';
|
||||
import { CopilotContextService } from './context';
|
||||
import {
|
||||
CopilotProvider,
|
||||
CopilotProviderFactory,
|
||||
@@ -75,6 +76,7 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly chatSession: ChatSessionService,
|
||||
private readonly context: CopilotContextService,
|
||||
private readonly provider: CopilotProviderFactory,
|
||||
private readonly workflow: CopilotWorkflowService,
|
||||
private readonly storage: CopilotStorage
|
||||
@@ -204,14 +206,30 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
retry
|
||||
);
|
||||
|
||||
if (latestMessage) {
|
||||
params = Object.assign({}, params, latestMessage.params, {
|
||||
content: latestMessage.content,
|
||||
attachments: latestMessage.attachments,
|
||||
});
|
||||
}
|
||||
const context = await this.context.getBySessionId(sessionId);
|
||||
const contextParams =
|
||||
(Array.isArray(context?.files) && context.files.length > 0) ||
|
||||
(Array.isArray(context?.blobs) && context.blobs.length > 0)
|
||||
? {
|
||||
contextFiles: [
|
||||
...context.files,
|
||||
...(await context.getBlobMetadata()),
|
||||
],
|
||||
}
|
||||
: {};
|
||||
const lastParams = latestMessage
|
||||
? {
|
||||
...latestMessage.params,
|
||||
content: latestMessage.content,
|
||||
attachments: latestMessage.attachments,
|
||||
}
|
||||
: {};
|
||||
|
||||
const finalMessage = session.finish(params);
|
||||
const finalMessage = session.finish({
|
||||
...params,
|
||||
...lastParams,
|
||||
...contextParams,
|
||||
});
|
||||
|
||||
return {
|
||||
provider,
|
||||
|
||||
@@ -93,8 +93,11 @@ export class CopilotCronJobs {
|
||||
params: Jobs['copilot.workspace.cleanupTrashedDocEmbeddings']
|
||||
) {
|
||||
const nextSid = params.nextSid ?? 0;
|
||||
let workspaces = await this.models.workspace.listAfterSid(
|
||||
nextSid,
|
||||
// only consider workspaces that cleared their embeddings more than 24 hours ago
|
||||
const oneDayAgo = new Date(Date.now() - OneDay);
|
||||
const workspaces = await this.models.workspace.list(
|
||||
{ sid: { gt: nextSid }, lastCheckEmbeddings: { lt: oneDayAgo } },
|
||||
{ id: true, sid: true },
|
||||
CLEANUP_EMBEDDING_JOB_BATCH_SIZE
|
||||
);
|
||||
if (!workspaces.length) {
|
||||
|
||||
@@ -2,11 +2,16 @@ import { Logger } from '@nestjs/common';
|
||||
import type { ModuleRef } from '@nestjs/core';
|
||||
|
||||
import {
|
||||
Config,
|
||||
CopilotPromptNotFound,
|
||||
CopilotProviderNotSupported,
|
||||
} from '../../../base';
|
||||
import { CopilotFailedToGenerateEmbedding } from '../../../base/error/errors.gen';
|
||||
import { ChunkSimilarity, Embedding } from '../../../models';
|
||||
import {
|
||||
ChunkSimilarity,
|
||||
Embedding,
|
||||
EMBEDDING_DIMENSIONS,
|
||||
} from '../../../models';
|
||||
import { PromptService } from '../prompt';
|
||||
import {
|
||||
type CopilotProvider,
|
||||
@@ -15,11 +20,7 @@ import {
|
||||
ModelInputType,
|
||||
ModelOutputType,
|
||||
} from '../providers';
|
||||
import {
|
||||
EMBEDDING_DIMENSIONS,
|
||||
EmbeddingClient,
|
||||
type ReRankResult,
|
||||
} from './types';
|
||||
import { EmbeddingClient, type ReRankResult } from './types';
|
||||
|
||||
const EMBEDDING_MODEL = 'gemini-embedding-001';
|
||||
const RERANK_PROMPT = 'Rerank results';
|
||||
@@ -28,6 +29,7 @@ class ProductionEmbeddingClient extends EmbeddingClient {
|
||||
private readonly logger = new Logger(ProductionEmbeddingClient.name);
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly providerFactory: CopilotProviderFactory,
|
||||
private readonly prompt: PromptService
|
||||
) {
|
||||
@@ -36,7 +38,9 @@ class ProductionEmbeddingClient extends EmbeddingClient {
|
||||
|
||||
override async configured(): Promise<boolean> {
|
||||
const embedding = await this.providerFactory.getProvider({
|
||||
modelId: EMBEDDING_MODEL,
|
||||
modelId: this.config.copilot?.scenarios?.override_enabled
|
||||
? this.config.copilot.scenarios.scenarios?.embedding || EMBEDDING_MODEL
|
||||
: EMBEDDING_MODEL,
|
||||
outputType: ModelOutputType.Embedding,
|
||||
});
|
||||
const result = Boolean(embedding);
|
||||
@@ -209,12 +213,13 @@ export async function getEmbeddingClient(
|
||||
if (EMBEDDING_CLIENT) {
|
||||
return EMBEDDING_CLIENT;
|
||||
}
|
||||
const config = moduleRef.get(Config, { strict: false });
|
||||
const providerFactory = moduleRef.get(CopilotProviderFactory, {
|
||||
strict: false,
|
||||
});
|
||||
const prompt = moduleRef.get(PromptService, { strict: false });
|
||||
|
||||
const client = new ProductionEmbeddingClient(providerFactory, prompt);
|
||||
const client = new ProductionEmbeddingClient(config, providerFactory, prompt);
|
||||
if (await client.configured()) {
|
||||
EMBEDDING_CLIENT = client;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { getEmbeddingClient, MockEmbeddingClient } from './client';
|
||||
export { CopilotEmbeddingJob } from './job';
|
||||
export type { Chunk, DocFragment } from './types';
|
||||
export { EMBEDDING_DIMENSIONS, EmbeddingClient } from './types';
|
||||
export { EmbeddingClient } from './types';
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
EventBus,
|
||||
JobQueue,
|
||||
mapAnyError,
|
||||
OneDay,
|
||||
OnEvent,
|
||||
OnJob,
|
||||
} from '../../../base';
|
||||
@@ -19,7 +20,7 @@ import { CopilotStorage } from '../storage';
|
||||
import { readStream } from '../utils';
|
||||
import { getEmbeddingClient } from './client';
|
||||
import type { Chunk, DocFragment } from './types';
|
||||
import { EMBEDDING_DIMENSIONS, EmbeddingClient } from './types';
|
||||
import { EmbeddingClient } from './types';
|
||||
|
||||
@Injectable()
|
||||
export class CopilotEmbeddingJob {
|
||||
@@ -391,17 +392,8 @@ export class CopilotEmbeddingJob {
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
private async fulfillEmptyEmbedding(workspaceId: string, docId: string) {
|
||||
const emptyEmbedding = {
|
||||
index: 0,
|
||||
content: '',
|
||||
embedding: Array.from({ length: EMBEDDING_DIMENSIONS }, () => 0),
|
||||
};
|
||||
await this.models.copilotContext.insertWorkspaceEmbedding(
|
||||
workspaceId,
|
||||
docId,
|
||||
[emptyEmbedding]
|
||||
);
|
||||
private normalize(s: string) {
|
||||
return s.replaceAll(/[\p{White_Space}]+/gu, '');
|
||||
}
|
||||
|
||||
@OnJob('copilot.embedding.docs')
|
||||
@@ -441,6 +433,21 @@ export class CopilotEmbeddingJob {
|
||||
if (!hasNewDoc && fragment) {
|
||||
// fast fall for empty doc, journal is easily to create a empty doc
|
||||
if (fragment.summary.trim()) {
|
||||
const existsContent =
|
||||
await this.models.copilotContext.getWorkspaceContent(
|
||||
workspaceId,
|
||||
docId
|
||||
);
|
||||
if (
|
||||
existsContent &&
|
||||
this.normalize(existsContent) === this.normalize(fragment.summary)
|
||||
) {
|
||||
this.logger.log(
|
||||
`Doc ${docId} in workspace ${workspaceId} has no content change, skipping embedding.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const embeddings = await this.embeddingClient.getFileEmbeddings(
|
||||
new File(
|
||||
[fragment.summary],
|
||||
@@ -465,13 +472,19 @@ export class CopilotEmbeddingJob {
|
||||
this.logger.warn(
|
||||
`Doc ${docId} in workspace ${workspaceId} has no summary, fulfilling empty embedding.`
|
||||
);
|
||||
await this.fulfillEmptyEmbedding(workspaceId, docId);
|
||||
await this.models.copilotContext.fulfillEmptyEmbedding(
|
||||
workspaceId,
|
||||
docId
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`Doc ${docId} in workspace ${workspaceId} has no fragment, fulfilling empty embedding.`
|
||||
);
|
||||
await this.fulfillEmptyEmbedding(workspaceId, docId);
|
||||
await this.models.copilotContext.fulfillEmptyEmbedding(
|
||||
workspaceId,
|
||||
docId
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
@@ -489,7 +502,10 @@ export class CopilotEmbeddingJob {
|
||||
`Doc ${docId} in workspace ${workspaceId} has no content, fulfilling empty embedding.`
|
||||
);
|
||||
// if the doc is empty, we still need to fulfill the embedding
|
||||
await this.fulfillEmptyEmbedding(workspaceId, docId);
|
||||
await this.models.copilotContext.fulfillEmptyEmbedding(
|
||||
workspaceId,
|
||||
docId
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -511,6 +527,7 @@ export class CopilotEmbeddingJob {
|
||||
return;
|
||||
}
|
||||
|
||||
const oneMonthAgo = new Date(Date.now() - OneDay * 30);
|
||||
const snapshot = await this.models.doc.getSnapshot(
|
||||
workspaceId,
|
||||
workspaceId
|
||||
@@ -518,21 +535,72 @@ export class CopilotEmbeddingJob {
|
||||
if (!snapshot) {
|
||||
this.logger.warn(`workspace snapshot ${workspaceId} not found`);
|
||||
return;
|
||||
} else if (
|
||||
// always check if never cleared
|
||||
workspace.lastCheckEmbeddings > new Date(0) &&
|
||||
snapshot.updatedAt < oneMonthAgo
|
||||
) {
|
||||
this.logger.verbose(
|
||||
`workspace ${workspaceId} is too old, skipping embeddings cleanup`
|
||||
);
|
||||
await this.models.workspace.update(
|
||||
workspaceId,
|
||||
{ lastCheckEmbeddings: new Date() },
|
||||
false
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const [docIdsInEmbedding, docIdsInSnapshots] = await Promise.all([
|
||||
this.models.copilotContext.listWorkspaceDocEmbedding(workspaceId),
|
||||
this.models.copilotWorkspace.listEmbeddableDocIds(workspaceId),
|
||||
]);
|
||||
|
||||
if (!docIdsInEmbedding.length && !docIdsInSnapshots.length) {
|
||||
this.logger.verbose(
|
||||
`No doc embeddings and snapshots found in workspace ${workspaceId}, skipping cleanup`
|
||||
);
|
||||
await this.models.workspace.update(
|
||||
workspaceId,
|
||||
{ lastCheckEmbeddings: new Date() },
|
||||
false
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const docIdsInWorkspace = readAllDocIdsFromWorkspaceSnapshot(snapshot.blob);
|
||||
const docIdsInEmbedding =
|
||||
await this.models.copilotContext.listWorkspaceDocEmbedding(workspaceId);
|
||||
const docIdsInWorkspaceSet = new Set(docIdsInWorkspace);
|
||||
|
||||
const deletedDocIds = docIdsInEmbedding.filter(
|
||||
docId => !docIdsInWorkspaceSet.has(docId)
|
||||
const deletedDocIds = new Set(
|
||||
[...docIdsInEmbedding, ...docIdsInSnapshots].filter(
|
||||
docId => !docIdsInWorkspaceSet.has(docId)
|
||||
)
|
||||
);
|
||||
for (const docId of deletedDocIds) {
|
||||
const isPlaceholder = await this.models.copilotWorkspace.hasPlaceholder(
|
||||
workspaceId,
|
||||
docId
|
||||
);
|
||||
if (isPlaceholder) continue;
|
||||
await this.models.copilotContext.deleteWorkspaceEmbedding(
|
||||
workspaceId,
|
||||
docId
|
||||
);
|
||||
}
|
||||
|
||||
await this.models.workspace.update(
|
||||
workspaceId,
|
||||
{ lastCheckEmbeddings: new Date() },
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
@OnEvent('workspace.updated')
|
||||
async onWorkspaceUpdated({ id }: Events['workspace.updated']) {
|
||||
if (!this.supportEmbedding) return;
|
||||
|
||||
await this.queue.add('copilot.embedding.cleanupTrashedDocEmbeddings', {
|
||||
workspaceId: id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,8 +98,6 @@ export type Chunk = {
|
||||
content: string;
|
||||
};
|
||||
|
||||
export const EMBEDDING_DIMENSIONS = 1024;
|
||||
|
||||
export abstract class EmbeddingClient {
|
||||
async configured() {
|
||||
return true;
|
||||
|
||||
@@ -6,9 +6,9 @@ import z from 'zod';
|
||||
|
||||
import { DocReader } from '../../../core/doc';
|
||||
import { AccessController } from '../../../core/permission';
|
||||
import { clearEmbeddingChunk } from '../../../models';
|
||||
import { IndexerService } from '../../indexer';
|
||||
import { CopilotContextService } from '../context';
|
||||
import { clearEmbeddingChunk } from '../utils';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceMcpProvider {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user