Compare commits

...

23 Commits

Author SHA1 Message Date
yoyoyohamapi 8024172569 feat(core): block diff ui 2025-07-02 10:42:28 +08:00
yoyoyohamapi b434b95548 feat(core): markdown-diff & patch apply 2025-07-02 10:40:23 +08:00
fengmk2 e8bc8f2d63 feat(server): add comment-attachment storage (#12911)
close CLOUD-230












#### PR Dependency Tree


* **PR #12911** 👈
  * **PR #12761**
    * **PR #12924**
      * **PR #12925**

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

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

* **New Features**
* Added support for uploading and retrieving comment attachments in
workspace documents via a new API endpoint.
* Introduced a service for managing comment attachments, including
storage, retrieval, deletion, and URL generation.
* Implemented localized error messages and improved error handling for
missing comment attachments.

* **Bug Fixes**
  * Improved error feedback when comment attachments are not found.

* **Tests**
* Added comprehensive tests for comment attachment storage, retrieval,
deletion, API endpoint behavior, and permission checks.

* **Documentation**
* Updated GraphQL schema and localization files to include new error
types for comment attachments.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-01 07:04:52 +00:00
DarkSky 6e034185cf feat: title of session (#12971)
fix AI-253
2025-07-01 05:24:42 +00:00
Lakr 2be3f84196 fix: 🚑 compiler issue on newer syntax (#12974)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Refactor**
* Updated method and function signatures to accept any type conforming
to the chat cell view model protocol, improving flexibility and
extensibility of chat cell configuration and height estimation.
  * Simplified internal logic for determining text color in chat cells.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-01 13:06:06 +08:00
EYHN f46d288b1b fix(core): fix client crash (#12966)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved reliability when displaying OAuth provider icons by handling
cases where the provider may not be recognized, preventing potential
errors during authentication.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-01 03:38:13 +00:00
Lakr 9529adf33e chore: remove intelligent button from release (#12970)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* The "Intelligents" button is now only shown in debug builds; it will
not appear in production versions.
* **Bug Fixes**
* Removed all references to the "Intelligents" plugin and related UI,
ensuring a cleaner production app experience.
* **Chores**
* Cleaned up project settings and removed redundant or empty
configuration entries.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-30 10:48:58 +00:00
Peng Xiao 03aeb44dc9 fix(editor): peekable conditions for edgeless note block (#12969)
fix AF-2704

#### PR Dependency Tree


* **PR #12969** 👈

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

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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved peek behavior to allow peeking inside note blocks, even when
the hit target differs from the current model, as long as the current
model is contained within the note block. This enhances usability when
interacting with nested note blocks.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-30 07:13:02 +00:00
德布劳外 · 贾贵 c9aad0d55e refactor(core): open embedding settings when click check-status button (#12772)
> CLOSE BS-3582

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

- **New Features**
- Added a "Check status" button in the AI chat interface that opens the
embedding settings panel for improved user access.

- **Refactor**
- Simplified the embedding status tooltip to display static information
and a direct link to settings, removing dynamic progress updates.
- Integrated workspace dialog service across AI chat components for
consistent dialog management.

- **Tests**
- Updated end-to-end tests to verify that clicking the "Check status"
button opens the embedding settings panel.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-30 07:00:17 +00:00
Lakr 29ae6afe71 chore: created first ai stream (#12968)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Introduced a redesigned chat interface with new cell types for user,
assistant, system, loading, and error messages.
  * Added streaming chat responses and improved session management.
* Enhanced input box behavior, allowing sending messages with the return
key and inserting new lines via the edit menu.
* Added new GraphQL queries for fetching recent and latest chat
sessions.

* **Refactor**
* Replaced previous chat message and session management with a new, more
structured view model system.
* Updated chat view to use a custom table view component for better
message rendering and empty state handling.
* Simplified and improved error and loading state handling in the chat
UI.

* **Bug Fixes**
  * Improved error reporting and retry options for failed chat messages.
  * Fixed inconsistent property types for message and error identifiers.

* **Style**
* Updated UI components for chat cells with modern layouts and
consistent styling.

* **Chores**
  * Added a new package dependency for event streaming support.
* Renamed various internal properties and classes for clarity and
consistency.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-30 06:51:00 +00:00
Cats Juice 32787bc88b fix(core): fix ai input style in chat block and simply img rendering (#12943)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Style**
* Improved visual styling and cursor behavior for chat input send, stop,
and preference trigger buttons.
* Enhanced appearance and interactivity cues for the chat input
preference trigger.

* **Refactor**
* Simplified image preview grid by using CSS hover states for close
button visibility and switching to background images for previews.
* Streamlined image deletion process for a more intuitive user
experience.

* **Tests**
* Updated image upload test to wait for image container elements,
improving test reliability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-30 06:22:04 +00:00
EYHN bbafce2c40 fix(ios): fix testflight (#12964)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Chores**
* Updated internal workflow configuration for iOS TestFlight uploads. No
impact on app features or user experience.
* Improved version handling to preserve full version strings for iOS
marketing versions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-30 06:06:21 +00:00
Peng Xiao f7f69c3bc4 chore(core): do remove timeout for audio transcription job (#12965)
#### PR Dependency Tree


* **PR #12965** 👈

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

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

* **Bug Fixes**
* Improved request timeout handling to ensure timeouts are only set when
appropriate and provide clearer error messages.
* Updated audio transcription submission to wait indefinitely for
completion, preventing requests from being aborted due to timeouts.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-30 06:00:53 +00:00
fengmk2 5599c39e97 feat(server): add read doc tool (#12811)
close AI-186















#### PR Dependency Tree


* **PR #12811** 👈

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

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

- **New Features**
- Added a new tool enabling users to read document content and metadata
within a workspace, with enforced access control.
- **Improvements**
- Updated tool interfaces and outputs to support the document reading
functionality seamlessly.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-30 03:37:34 +00:00
EYHN 6b2639cbbb fix(ios): fix xcode marketing version (#12963)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved version handling to ensure only the main version number is
used, ignoring any suffixes after a hyphen when updating the iOS
marketing version.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-30 03:35:45 +00:00
Richard Lora 82b3c0d264 feat(core): add allowGuestDemoWorkspace flag to force login (#12779)
https://github.com/user-attachments/assets/41a659c9-6def-4492-be8e-5910eb148d6f

This PR enforces login‑first access (#8716) by disabling or enabling the
guest demo workspace via Admin Server Client Page and redirecting
unauthenticated users straight to `/sign‑in`.

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

* **New Features**
* Added a configuration option to control whether guest users can create
demo workspaces.
* Updated server and client interfaces, GraphQL schema, and queries to
support the new guest demo workspace flag.

* **Bug Fixes**
* Improved sign-out behavior to redirect users appropriately based on
guest demo workspace permissions.
* Enhanced navigation flow to handle guest demo workspace access and
user authentication state.

* **Tests**
* Added tests to verify sign-out logic when guest demo workspaces are
enabled or disabled.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: liuyi <forehalo@gmail.com>
Co-authored-by: fengmk2 <fengmk2@gmail.com>
2025-06-29 14:17:18 +00:00
Wu Yue a4680d236d fix(core): ai make it real ci timeout (#12954)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Refactor**
* Replaced the boolean flag for selecting AI workflow endpoints with a
clear and flexible enum, enhancing clarity and maintainability for
AI-powered features.

* **Tests**
  * Simplified example text in AI action tests to improve consistency.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com>
2025-06-28 12:56:12 +00:00
github-actions[bot] f88e1dffb6 chore(i18n): sync translations (#12604)
New Crowdin translations by [Crowdin GH
Action](https://github.com/crowdin/github-action)

Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
Co-authored-by: fengmk2 <fengmk2@gmail.com>
2025-06-28 09:00:17 +00:00
fengmk2 e773930256 feat(server): add comment-attachment model (#12909)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Introduced support for comment attachments, allowing users to add,
view, and manage attachments linked to comments within documents.
* **Tests**
* Added comprehensive tests to ensure correct behavior for adding,
updating, deleting, listing, and retrieving comment attachments.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->


#### PR Dependency Tree


* **PR #12909** 👈
  * **PR #12911**
    * **PR #12761**
      * **PR #12924**
        * **PR #12925**

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)
2025-06-28 08:45:24 +00:00
DarkSky 1c1dade2d5 feat(server): add morph doc edit tool (#12789)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Introduced support for the Morph provider in the copilot module,
enabling integration with the Morph LLM API.
- Added a new document editing tool that allows users to propose and
apply edits to existing documents via AI assistance.
- **Configuration**
- Added configuration options for the Morph provider in both backend and
admin interfaces.
- **Enhancements**
- Expanded tool support to include document editing capabilities within
the copilot feature set.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-28 08:27:08 +00:00
fengmk2 e2a799c70a feat(server): comment model (#12760)
close CLOUD-226

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

- **New Features**
- Introduced support for comments and replies within workspaces and
documents, enabling users to create, update, delete, and resolve
comments, as well as manage threaded replies.
- **Bug Fixes**
- Added user-friendly error messages and handling for situations where
comments or replies are not found.
- **Tests**
- Added comprehensive tests to ensure correct behavior of comment and
reply operations.
- **Localization**
  - Added English translations for new comment and reply error messages.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->














#### PR Dependency Tree


* **PR #12760** 👈
  * **PR #12909**
    * **PR #12911**
      * **PR #12761**
        * **PR #12924**
          * **PR #12925**

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)
2025-06-28 08:01:53 +00:00
DarkSky 9b881eb59a feat(server): faster reranking based on confidence (#12957)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Improved document reranking with a more streamlined and accurate
scoring system.
* Enhanced support for binary ("yes"/"no") document relevance judgments.

* **Improvements**
* Simplified user prompts and output formats for reranking tasks, making
results easier to interpret.
  * Increased reliability and consistency in document ranking results.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-06-28 03:41:53 +00:00
DarkSky e6f91cced6 feat(server): remove context prefetch & integrate context search (#12956)
fix AI-173
2025-06-27 23:45:49 +08:00
259 changed files with 7496 additions and 1893 deletions
+15
View File
@@ -565,6 +565,11 @@
"type": "boolean",
"description": "Only allow users with early access features to access the app\n@default false",
"default": false
},
"allowGuestDemoWorkspace": {
"type": "boolean",
"description": "Whether allow guest users to create demo workspaces.\n@default true",
"default": true
}
}
},
@@ -592,6 +597,11 @@
"type": "string",
"description": "Allowed version range of the app that allowed to access the server. Requires 'client/versionControl.enabled' to be true to take effect.\n@default \">=0.20.0\"",
"default": ">=0.20.0"
},
"allowGuestDemoWorkspace": {
"type": "boolean",
"description": "Allow guests to access demo workspace.\n@default true",
"default": true
}
}
},
@@ -732,6 +742,11 @@
},
"default": {}
},
"providers.morph": {
"type": "object",
"description": "The config for the morph provider.\n@default {}",
"default": {}
},
"unsplash": {
"type": "object",
"description": "The config for the unsplash key.\n@default {\"key\":\"\"}",
+1
View File
@@ -132,6 +132,7 @@ jobs:
cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles
fastlane beta
env:
BUILD_TARGET: distribution
BUILD_PROVISION_PROFILE: ${{ secrets.BUILD_PROVISION_PROFILE }}
PP_PATH: ${{ runner.temp }}/build_pp.mobileprovision
APPLE_STORE_CONNECT_API_KEY_ID: ${{ secrets.APPLE_STORE_CONNECT_API_KEY_ID }}
@@ -23,7 +23,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"file-type": "^21.0.0",
"lit": "^3.2.0",
"minimatch": "^10.0.1",
@@ -24,7 +24,7 @@
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"lit": "^3.2.0",
"minimatch": "^10.0.1",
"rxjs": "^7.8.1",
@@ -25,7 +25,7 @@
"@floating-ui/dom": "^1.6.10",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/mdast": "^4.0.4",
"emoji-mart": "^5.6.0",
"lit": "^3.2.0",
+1 -1
View File
@@ -27,7 +27,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/mdast": "^4.0.4",
"lit": "^3.2.0",
"minimatch": "^10.0.1",
@@ -24,7 +24,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/mdast": "^4.0.4",
"lit": "^3.2.0",
"minimatch": "^10.0.1",
@@ -42,6 +42,7 @@ import { computed, signal } from '@preact/signals-core';
import { css, nothing, unsafeCSS } from 'lit';
import { html } from 'lit/static-html.js';
import { repeat } from 'lit/directives/repeat.js';
import { BlockQueryDataSource } from './data-source.js';
import type { DataViewBlockModel } from './data-view-model.js';
@@ -303,9 +304,16 @@ export class DataViewBlockComponent extends CaptionedBlockComponent<DataViewBloc
},
});
override renderBlock() {
const widgets = html`${repeat(
Object.entries(this.widgets),
([id]) => id,
([_, widget]) => widget
)}`;
return html`
<div contenteditable="false" style="position: relative">
${this.dataViewRootLogic.render()}
${widgets}
</div>
`;
}
@@ -28,7 +28,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/mdast": "^4.0.4",
"date-fns": "^4.0.0",
"lit": "^3.2.0",
@@ -45,6 +45,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 { popSideDetail } from './components/layout.js';
import { DatabaseConfigExtension } from './config.js';
import { EditorHostKey } from './context/host-context.js';
@@ -428,9 +429,15 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent<DatabaseBloc
})
);
override renderBlock() {
const widgets = html`${repeat(
Object.entries(this.widgets),
([id]) => id,
([_, widget]) => widget
)}`;
return html`
<div contenteditable="false" class="${databaseContentStyles}">
${this.dataViewRootLogic.value.render()}
${this.dataViewRootLogic.value.render()} ${widgets}
</div>
`;
}
@@ -21,7 +21,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/mdast": "^4.0.4",
"lit": "^3.2.0",
"minimatch": "^10.0.1",
@@ -26,7 +26,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"lit": "^3.2.0",
"minimatch": "^10.0.1",
"rxjs": "^7.8.1",
@@ -26,7 +26,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
+1 -1
View File
@@ -26,7 +26,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
+1 -1
View File
@@ -25,7 +25,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/mdast": "^4.0.4",
"lit": "^3.2.0",
"minimatch": "^10.0.1",
+1 -1
View File
@@ -25,7 +25,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"file-type": "^21.0.0",
"lit": "^3.2.0",
"minimatch": "^10.0.1",
+1 -1
View File
@@ -25,7 +25,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/katex": "^0.16.7",
"@types/mdast": "^4.0.4",
"katex": "^0.16.11",
+1 -1
View File
@@ -24,7 +24,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/mdast": "^4.0.4",
"lit": "^3.2.0",
"minimatch": "^10.0.1",
@@ -23,6 +23,7 @@ import { effect } from '@preact/signals-core';
import { html, nothing, type TemplateResult } from 'lit';
import { query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import { correctNumberedListsOrderToPrev } from './commands/utils.js';
@@ -138,6 +139,11 @@ export class ListBlockComponent extends CaptionedBlockComponent<ListBlockModel>
override renderBlock(): TemplateResult<1> {
const { model, _onClickIcon } = this;
const widgets = html`${repeat(
Object.entries(this.widgets),
([id]) => id,
([_, widget]) => widget
)}`;
const collapsed = this.store.readonly
? this._readonlyCollapsed
: model.props.collapsed;
@@ -199,7 +205,7 @@ export class ListBlockComponent extends CaptionedBlockComponent<ListBlockModel>
></rich-text>
</div>
${children}
${children} ${widgets}
</div>
`;
}
+1 -1
View File
@@ -27,7 +27,7 @@
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"@types/mdast": "^4.0.4",
"@vanilla-extract/css": "^1.17.0",
@@ -23,7 +23,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/mdast": "^4.0.4",
"lit": "^3.2.0",
"minimatch": "^10.0.1",
@@ -26,6 +26,7 @@ import { computed, effect, signal } from '@preact/signals-core';
import { html, nothing, type TemplateResult } from 'lit';
import { query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
@@ -227,6 +228,12 @@ export class ParagraphBlockComponent extends CaptionedBlockComponent<ParagraphBl
}
override renderBlock(): TemplateResult<1> {
const widgets = html`${repeat(
Object.entries(this.widgets),
([id]) => id,
([_, widget]) => widget
)}`;
const { type$ } = this.model.props;
const collapsed = this.store.readonly
? this._readonlyCollapsed
@@ -340,7 +347,7 @@ export class ParagraphBlockComponent extends CaptionedBlockComponent<ParagraphBl
`}
</div>
${children}
${children} ${widgets}
</div>
`;
}
+1 -1
View File
@@ -44,7 +44,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"dompurify": "^3.2.4",
"html2canvas": "^1.4.1",
@@ -25,7 +25,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"fractional-indexing": "^3.2.0",
"lit": "^3.2.0",
@@ -20,7 +20,7 @@
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"fractional-indexing": "^3.2.0",
"html2canvas": "^1.4.1",
+1 -1
View File
@@ -21,7 +21,7 @@
"@lit/context": "^1.1.2",
"@lottiefiles/dotlottie-wc": "^0.5.0",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/hast": "^3.0.4",
"@types/katex": "^0.16.7",
"@types/lodash-es": "^4.17.12",
@@ -1,7 +1,14 @@
import { NoteBlockModel } from '@blocksuite/affine-model';
import { DocModeProvider } from '@blocksuite/affine-shared/services';
import { isInsideEdgelessEditor } from '@blocksuite/affine-shared/utils';
import {
isInsideEdgelessEditor,
matchModels,
} from '@blocksuite/affine-shared/utils';
import type { Constructor } from '@blocksuite/global/utils';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import {
GfxBlockElementModel,
GfxControllerIdentifier,
} from '@blocksuite/std/gfx';
import type { BlockModel } from '@blocksuite/store';
import type { LitElement, TemplateResult } from 'lit';
@@ -72,6 +79,20 @@ export const Peekable =
);
if (hitTarget && hitTarget !== model) {
// Check if hitTarget is a GfxBlockElementModel (which extends BlockModel)
// and if it's a NoteBlockModel, then check if current model is inside it
if (
hitTarget instanceof GfxBlockElementModel &&
matchModels(hitTarget, [NoteBlockModel])
) {
let curModel: BlockModel | null = model;
while (curModel) {
if (curModel === hitTarget) {
return true; // Model is inside the NoteBlockModel, allow peek
}
curModel = curModel.parent;
}
}
return false;
}
+1 -1
View File
@@ -21,7 +21,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"clsx": "^2.1.1",
"date-fns": "^4.0.0",
+1 -1
View File
@@ -22,7 +22,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
@@ -21,7 +21,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"lit": "^3.2.0",
"rxjs": "^7.8.1"
},
@@ -24,7 +24,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"lit": "^3.2.0",
"minimatch": "^10.0.1",
"rxjs": "^7.8.1",
@@ -24,7 +24,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
@@ -24,7 +24,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@vanilla-extract/css": "^1.17.0",
"lit": "^3.2.0",
"minimatch": "^10.0.1",
+1 -1
View File
@@ -23,7 +23,7 @@
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
+1 -1
View File
@@ -24,7 +24,7 @@
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
+1 -1
View File
@@ -24,7 +24,7 @@
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
+1 -1
View File
@@ -26,7 +26,7 @@
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
+1 -1
View File
@@ -30,7 +30,7 @@
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
+1 -1
View File
@@ -26,7 +26,7 @@
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
+1 -1
View File
@@ -23,7 +23,7 @@
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
+1 -1
View File
@@ -24,7 +24,7 @@
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
+1 -1
View File
@@ -25,7 +25,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
+1 -1
View File
@@ -23,7 +23,7 @@
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
@@ -22,7 +22,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"collapse-white-space": "^2.1.0",
"date-fns": "^4.0.0",
+1 -1
View File
@@ -23,7 +23,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/hast": "^3.0.4",
"@types/katex": "^0.16.7",
"@types/lodash-es": "^4.17.12",
+1 -1
View File
@@ -22,7 +22,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"collapse-white-space": "^2.1.0",
"date-fns": "^4.0.0",
@@ -21,7 +21,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"collapse-white-space": "^2.1.0",
"date-fns": "^4.0.0",
@@ -27,7 +27,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/hast": "^3.0.4",
"@types/katex": "^0.16.7",
"@types/lodash-es": "^4.17.12",
@@ -21,7 +21,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"collapse-white-space": "^2.1.0",
"date-fns": "^4.0.0",
+1 -1
View File
@@ -13,7 +13,7 @@
"@blocksuite/global": "workspace:*",
"@blocksuite/std": "workspace:*",
"@blocksuite/store": "workspace:*",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"fractional-indexing": "^3.2.0",
"lodash-es": "^4.17.21",
+1 -1
View File
@@ -20,7 +20,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"collapse-white-space": "^2.1.0",
"date-fns": "^4.0.0",
+3 -2
View File
@@ -18,7 +18,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/bytes": "^3.1.5",
"@types/hast": "^3.0.4",
"@types/lodash-es": "^4.17.12",
@@ -63,7 +63,8 @@
"./theme": "./src/theme/index.ts",
"./styles": "./src/styles/index.ts",
"./services": "./src/services/index.ts",
"./adapters": "./src/adapters/index.ts"
"./adapters": "./src/adapters/index.ts",
"./test-utils": "./src/test-utils/index.ts"
},
"files": [
"src",
@@ -4,7 +4,7 @@
import { describe, expect, it } from 'vitest';
import { getFirstBlockCommand } from '../../../commands/block-crud/get-first-content-block';
import { affine } from '../../helpers/affine-template';
import { affine } from '../../../test-utils';
describe('commands/block-crud', () => {
describe('getFirstBlockCommand', () => {
@@ -4,7 +4,7 @@
import { describe, expect, it } from 'vitest';
import { getLastBlockCommand } from '../../../commands/block-crud/get-last-content-block';
import { affine } from '../../helpers/affine-template';
import { affine } from '../../../test-utils';
describe('commands/block-crud', () => {
describe('getLastBlockCommand', () => {
@@ -1,13 +1,11 @@
/**
* @vitest-environment happy-dom
*/
import '../../helpers/affine-test-utils';
import type { TextSelection } from '@blocksuite/std';
import { describe, expect, it } from 'vitest';
import { replaceSelectedTextWithBlocksCommand } from '../../../commands/model-crud/replace-selected-text-with-blocks';
import { affine, block } from '../../helpers/affine-template';
import { affine, block } from '../../../test-utils';
describe('commands/model-crud', () => {
describe('replaceSelectedTextWithBlocksCommand', () => {
@@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest';
import { isNothingSelectedCommand } from '../../../commands/selection/is-nothing-selected';
import { ImageSelection } from '../../../selection';
import { affine } from '../../helpers/affine-template';
import { affine } from '../../../test-utils';
describe('commands/selection', () => {
describe('isNothingSelectedCommand', () => {
@@ -1,298 +0,0 @@
import {
CodeBlockSchemaExtension,
DatabaseBlockSchemaExtension,
ImageBlockSchemaExtension,
ListBlockSchemaExtension,
NoteBlockSchemaExtension,
ParagraphBlockSchemaExtension,
RootBlockSchemaExtension,
} from '@blocksuite/affine-model';
import { TextSelection } from '@blocksuite/std';
import { type Block, type Store } from '@blocksuite/store';
import { Text } from '@blocksuite/store';
import { TestWorkspace } from '@blocksuite/store/test';
import { createTestHost } from './create-test-host';
// Extensions array
const extensions = [
RootBlockSchemaExtension,
NoteBlockSchemaExtension,
ParagraphBlockSchemaExtension,
ListBlockSchemaExtension,
ImageBlockSchemaExtension,
DatabaseBlockSchemaExtension,
CodeBlockSchemaExtension,
];
// Mapping from tag names to flavours
const tagToFlavour: Record<string, string> = {
'affine-page': 'affine:page',
'affine-note': 'affine:note',
'affine-paragraph': 'affine:paragraph',
'affine-list': 'affine:list',
'affine-image': 'affine:image',
'affine-database': 'affine:database',
'affine-code': 'affine:code',
};
interface SelectionInfo {
anchorBlockId?: string;
anchorOffset?: number;
focusBlockId?: string;
focusOffset?: number;
cursorBlockId?: string;
cursorOffset?: number;
}
/**
* Parse template strings and build BlockSuite document structure,
* then create a host object with the document
*
* Example:
* ```
* const host = affine`
* <affine-page id="page">
* <affine-note id="note">
* <affine-paragraph id="paragraph-1">Hello, world<anchor /></affine-paragraph>
* <affine-paragraph id="paragraph-2">Hello, world<focus /></affine-paragraph>
* </affine-note>
* </affine-page>
* `;
* ```
*/
export function affine(strings: TemplateStringsArray, ...values: any[]) {
// Merge template strings and values
let htmlString = '';
strings.forEach((str, i) => {
htmlString += str;
if (i < values.length) {
htmlString += values[i];
}
});
// Create a new doc
const workspace = new TestWorkspace({});
workspace.meta.initialize();
const doc = workspace.createDoc('test-doc');
const store = doc.getStore({ extensions });
let selectionInfo: SelectionInfo = {};
// Use DOMParser to parse HTML string
doc.load(() => {
const parser = new DOMParser();
const dom = parser.parseFromString(htmlString.trim(), 'text/html');
const root = dom.body.firstElementChild;
if (!root) {
throw new Error('Template must contain a root element');
}
buildDocFromElement(store, root, null, selectionInfo);
});
// Create host object
const host = createTestHost(store);
// Set selection if needed
if (selectionInfo.anchorBlockId && selectionInfo.focusBlockId) {
const anchorBlock = store.getBlock(selectionInfo.anchorBlockId);
const anchorTextLength = anchorBlock?.model?.text?.length ?? 0;
const focusOffset = selectionInfo.focusOffset ?? 0;
const anchorOffset = selectionInfo.anchorOffset ?? 0;
if (selectionInfo.anchorBlockId === selectionInfo.focusBlockId) {
const selection = host.selection.create(TextSelection, {
from: {
blockId: selectionInfo.anchorBlockId,
index: anchorOffset,
length: focusOffset,
},
to: null,
});
host.selection.setGroup('note', [selection]);
} else {
const selection = host.selection.create(TextSelection, {
from: {
blockId: selectionInfo.anchorBlockId,
index: anchorOffset,
length: anchorTextLength - anchorOffset,
},
to: {
blockId: selectionInfo.focusBlockId,
index: 0,
length: focusOffset,
},
});
host.selection.setGroup('note', [selection]);
}
} else if (selectionInfo.cursorBlockId) {
const selection = host.selection.create(TextSelection, {
from: {
blockId: selectionInfo.cursorBlockId,
index: selectionInfo.cursorOffset ?? 0,
length: 0,
},
to: null,
});
host.selection.setGroup('note', [selection]);
}
return host;
}
/**
* Create a single block from template string
*
* Example:
* ```
* const block = block`<affine-note />`
* ```
*/
export function block(
strings: TemplateStringsArray,
...values: any[]
): Block | null {
// Merge template strings and values
let htmlString = '';
strings.forEach((str, i) => {
htmlString += str;
if (i < values.length) {
htmlString += values[i];
}
});
// Create a temporary doc to hold the block
const workspace = new TestWorkspace({});
workspace.meta.initialize();
const doc = workspace.createDoc('temp-doc');
const store = doc.getStore({ extensions });
let blockId: string | null = null;
const selectionInfo: SelectionInfo = {};
// Use DOMParser to parse HTML string
doc.load(() => {
const parser = new DOMParser();
const dom = parser.parseFromString(htmlString.trim(), 'text/html');
const root = dom.body.firstElementChild;
if (!root) {
throw new Error('Template must contain a root element');
}
// Create a root block if needed
const flavour = tagToFlavour[root.tagName.toLowerCase()];
if (
flavour === 'affine:paragraph' ||
flavour === 'affine:list' ||
flavour === 'affine:code'
) {
const pageId = store.addBlock('affine:page', {});
const noteId = store.addBlock('affine:note', {}, pageId);
blockId = buildDocFromElement(store, root, noteId, selectionInfo);
} else {
blockId = buildDocFromElement(store, root, null, selectionInfo);
}
});
// Return the created block
return blockId ? (store.getBlock(blockId) ?? null) : null;
}
/**
* Recursively build document structure
* @param doc
* @param element
* @param parentId
* @param selectionInfo
* @returns
*/
function buildDocFromElement(
doc: Store,
element: Element,
parentId: string | null,
selectionInfo: SelectionInfo
): string {
const tagName = element.tagName.toLowerCase();
// Handle selection tags
if (tagName === 'anchor') {
if (!parentId) return '';
const parentBlock = doc.getBlock(parentId);
if (parentBlock) {
const textBeforeCursor = element.previousSibling?.textContent ?? '';
selectionInfo.anchorBlockId = parentId;
selectionInfo.anchorOffset = textBeforeCursor.length;
}
return parentId;
} else if (tagName === 'focus') {
if (!parentId) return '';
const parentBlock = doc.getBlock(parentId);
if (parentBlock) {
const textBeforeCursor = element.previousSibling?.textContent ?? '';
selectionInfo.focusBlockId = parentId;
selectionInfo.focusOffset = textBeforeCursor.length;
}
return parentId;
} else if (tagName === 'cursor') {
if (!parentId) return '';
const parentBlock = doc.getBlock(parentId);
if (parentBlock) {
const textBeforeCursor = element.previousSibling?.textContent ?? '';
selectionInfo.cursorBlockId = parentId;
selectionInfo.cursorOffset = textBeforeCursor.length;
}
return parentId;
}
const flavour = tagToFlavour[tagName];
if (!flavour) {
throw new Error(`Unknown tag name: ${tagName}`);
}
const props: Record<string, any> = {};
const customId = element.getAttribute('id');
// If ID is specified, add it to props
if (customId) {
props.id = customId;
}
// Process element attributes
Array.from(element.attributes).forEach(attr => {
if (attr.name !== 'id') {
// Skip id attribute, we already handled it
props[attr.name] = attr.value;
}
});
// Special handling for different block types based on their flavours
switch (flavour) {
case 'affine:paragraph':
case 'affine:list':
if (element.textContent) {
props.text = new Text(element.textContent);
}
break;
}
// Create block
const blockId = doc.addBlock(flavour, props, parentId);
// Process all child nodes, including text nodes
Array.from(element.children).forEach(child => {
if (child.nodeType === Node.ELEMENT_NODE) {
// Handle element nodes
buildDocFromElement(doc, child as Element, blockId, selectionInfo);
} else if (child.nodeType === Node.TEXT_NODE) {
// Handle text nodes
console.log('buildDocFromElement text node:', child.textContent);
}
});
return blockId;
}
@@ -1,7 +1,7 @@
import { TextSelection } from '@blocksuite/std';
import { describe, expect, it } from 'vitest';
import { affine } from './affine-template';
import { affine } from '../../test-utils';
describe('helpers/affine-template', () => {
it('should create a basic document structure from template', () => {
@@ -7,7 +7,7 @@
### Basic Usage
```typescript
import { affine } from '../__tests__/utils/affine-template';
import { affine } from '@blocksuite/affine-shared/test-utils';
// Create a simple document
const doc = affine`
@@ -0,0 +1,316 @@
import {
CodeBlockSchemaExtension,
DatabaseBlockSchemaExtension,
ImageBlockSchemaExtension,
ListBlockSchemaExtension,
NoteBlockSchemaExtension,
ParagraphBlockSchemaExtension,
RootBlockSchemaExtension,
} from '@blocksuite/affine-model';
import { Container } from '@blocksuite/global/di';
import { TextSelection } from '@blocksuite/std';
import {
type Block,
type ExtensionType,
type Store,
Text,
} from '@blocksuite/store';
import { TestWorkspace } from '@blocksuite/store/test';
import { createTestHost } from './create-test-host';
const DEFAULT_EXTENSIONS = [
RootBlockSchemaExtension,
NoteBlockSchemaExtension,
ParagraphBlockSchemaExtension,
ListBlockSchemaExtension,
ImageBlockSchemaExtension,
DatabaseBlockSchemaExtension,
CodeBlockSchemaExtension,
];
// Mapping from tag names to flavours
const tagToFlavour: Record<string, string> = {
'affine-page': 'affine:page',
'affine-note': 'affine:note',
'affine-paragraph': 'affine:paragraph',
'affine-list': 'affine:list',
'affine-image': 'affine:image',
'affine-database': 'affine:database',
'affine-code': 'affine:code',
};
interface SelectionInfo {
anchorBlockId?: string;
anchorOffset?: number;
focusBlockId?: string;
focusOffset?: number;
cursorBlockId?: string;
cursorOffset?: number;
}
export function createAffineTemplate(
extensions: ExtensionType[] = DEFAULT_EXTENSIONS
) {
/**
* Parse template strings and build BlockSuite document structure,
* then create a host object with the document
*
* Example:
* ```
* const host = affine`
* <affine-page id="page">
* <affine-note id="note">
* <affine-paragraph id="paragraph-1">Hello, world<anchor /></affine-paragraph>
* <affine-paragraph id="paragraph-2">Hello, world<focus /></affine-paragraph>
* </affine-note>
* </affine-page>
* `;
* ```
*/
function affine(strings: TemplateStringsArray, ...values: any[]) {
// Merge template strings and values
let htmlString = '';
strings.forEach((str, i) => {
htmlString += str;
if (i < values.length) {
htmlString += values[i];
}
});
// Create a new doc
const workspace = new TestWorkspace({});
workspace.meta.initialize();
const doc = workspace.createDoc('test-doc');
const container = new Container();
extensions.forEach(extension => {
extension.setup(container);
});
const store = doc.getStore({ extensions, provider: container.provider() });
let selectionInfo: SelectionInfo = {};
// Use DOMParser to parse HTML string
doc.load(() => {
const parser = new DOMParser();
const dom = parser.parseFromString(htmlString.trim(), 'text/html');
const root = dom.body.firstElementChild;
if (!root) {
throw new Error('Template must contain a root element');
}
buildDocFromElement(store, root, null, selectionInfo);
});
// Create host object
const host = createTestHost(store);
// Set selection if needed
if (selectionInfo.anchorBlockId && selectionInfo.focusBlockId) {
const anchorBlock = store.getBlock(selectionInfo.anchorBlockId);
const anchorTextLength = anchorBlock?.model?.text?.length ?? 0;
const focusOffset = selectionInfo.focusOffset ?? 0;
const anchorOffset = selectionInfo.anchorOffset ?? 0;
if (selectionInfo.anchorBlockId === selectionInfo.focusBlockId) {
const selection = host.selection.create(TextSelection, {
from: {
blockId: selectionInfo.anchorBlockId,
index: anchorOffset,
length: focusOffset,
},
to: null,
});
host.selection.setGroup('note', [selection]);
} else {
const selection = host.selection.create(TextSelection, {
from: {
blockId: selectionInfo.anchorBlockId,
index: anchorOffset,
length: anchorTextLength - anchorOffset,
},
to: {
blockId: selectionInfo.focusBlockId,
index: 0,
length: focusOffset,
},
});
host.selection.setGroup('note', [selection]);
}
} else if (selectionInfo.cursorBlockId) {
const selection = host.selection.create(TextSelection, {
from: {
blockId: selectionInfo.cursorBlockId,
index: selectionInfo.cursorOffset ?? 0,
length: 0,
},
to: null,
});
host.selection.setGroup('note', [selection]);
}
return host;
}
/**
* Create a single block from template string
*
* Example:
* ```
* const block = block`<affine-note />`
* ```
*/
function block(
strings: TemplateStringsArray,
...values: any[]
): Block | null {
// Merge template strings and values
let htmlString = '';
strings.forEach((str, i) => {
htmlString += str;
if (i < values.length) {
htmlString += values[i];
}
});
// Create a temporary doc to hold the block
const workspace = new TestWorkspace({});
workspace.meta.initialize();
const doc = workspace.createDoc('temp-doc');
const store = doc.getStore({ extensions });
let blockId: string | null = null;
const selectionInfo: SelectionInfo = {};
// Use DOMParser to parse HTML string
doc.load(() => {
const parser = new DOMParser();
const dom = parser.parseFromString(htmlString.trim(), 'text/html');
const root = dom.body.firstElementChild;
if (!root) {
throw new Error('Template must contain a root element');
}
// Create a root block if needed
const flavour = tagToFlavour[root.tagName.toLowerCase()];
if (
flavour === 'affine:paragraph' ||
flavour === 'affine:list' ||
flavour === 'affine:code'
) {
const pageId = store.addBlock('affine:page', {});
const noteId = store.addBlock('affine:note', {}, pageId);
blockId = buildDocFromElement(store, root, noteId, selectionInfo);
} else {
blockId = buildDocFromElement(store, root, null, selectionInfo);
}
});
// Return the created block
return blockId ? (store.getBlock(blockId) ?? null) : null;
}
return {
affine,
block,
};
}
export const { affine, block } = createAffineTemplate();
/**
* Recursively build document structure
* @param doc
* @param element
* @param parentId
* @param selectionInfo
* @returns
*/
function buildDocFromElement(
doc: Store,
element: Element,
parentId: string | null,
selectionInfo: SelectionInfo
): string {
const tagName = element.tagName.toLowerCase();
// Handle selection tags
if (tagName === 'anchor') {
if (!parentId) return '';
const parentBlock = doc.getBlock(parentId);
if (parentBlock) {
const textBeforeCursor = element.previousSibling?.textContent ?? '';
selectionInfo.anchorBlockId = parentId;
selectionInfo.anchorOffset = textBeforeCursor.length;
}
return parentId;
} else if (tagName === 'focus') {
if (!parentId) return '';
const parentBlock = doc.getBlock(parentId);
if (parentBlock) {
const textBeforeCursor = element.previousSibling?.textContent ?? '';
selectionInfo.focusBlockId = parentId;
selectionInfo.focusOffset = textBeforeCursor.length;
}
return parentId;
} else if (tagName === 'cursor') {
if (!parentId) return '';
const parentBlock = doc.getBlock(parentId);
if (parentBlock) {
const textBeforeCursor = element.previousSibling?.textContent ?? '';
selectionInfo.cursorBlockId = parentId;
selectionInfo.cursorOffset = textBeforeCursor.length;
}
return parentId;
}
const flavour = tagToFlavour[tagName];
if (!flavour) {
throw new Error(`Unknown tag name: ${tagName}`);
}
const props: Record<string, any> = {};
const customId = element.getAttribute('id');
// If ID is specified, add it to props
if (customId) {
props.id = customId;
}
// Process element attributes
Array.from(element.attributes).forEach(attr => {
if (attr.name !== 'id') {
// Skip id attribute, we already handled it
props[attr.name] = attr.value;
}
});
// Special handling for different block types based on their flavours
switch (flavour) {
case 'affine:paragraph':
case 'affine:list':
if (element.textContent) {
props.text = new Text(element.textContent);
}
break;
}
// Create block
const blockId = doc.addBlock(flavour, props, parentId);
// Process all child nodes, including text nodes
Array.from(element.children).forEach(child => {
if (child.nodeType === Node.ELEMENT_NODE) {
// Handle element nodes
buildDocFromElement(doc, child as Element, blockId, selectionInfo);
} else if (child.nodeType === Node.TEXT_NODE) {
// Handle text nodes
console.log('buildDocFromElement text node:', child.textContent);
}
});
return blockId;
}
@@ -63,10 +63,8 @@ function compareBlocks(
if (JSON.stringify(actualProps) !== JSON.stringify(expectedProps))
return false;
// eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let i = 0; i < actual.children.length; i++) {
if (!compareBlocks(actual.children[i], expected.children[i], compareId))
return false;
for (const [i, child] of actual.children.entries()) {
if (!compareBlocks(child, expected.children[i], compareId)) return false;
}
return true;
@@ -240,7 +240,7 @@ export function createTestHost(doc: Store): EditorHost {
std.selection = new MockSelectionStore();
std.command = new CommandManager(std as any);
// @ts-expect-error
// @ts-expect-error dev-only
host.command = std.command;
host.selection = std.selection;
@@ -0,0 +1,3 @@
export * from './affine-template';
export * from './affine-test-utils';
export * from './create-test-host';
@@ -27,7 +27,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
@@ -20,7 +20,7 @@
"@blocksuite/icons": "^2.2.12",
"@blocksuite/std": "workspace:*",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"lit": "^3.2.0",
"rxjs": "^7.8.1"
},
@@ -21,7 +21,7 @@
"@blocksuite/std": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"lit": "^3.2.0",
"rxjs": "^7.8.1",
"yjs": "^13.6.21"
@@ -25,7 +25,7 @@
"@blocksuite/std": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"lit": "^3.2.0",
"rxjs": "^7.8.1",
"yjs": "^13.6.21"
@@ -22,7 +22,7 @@
"@floating-ui/dom": "^1.6.13",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
@@ -20,7 +20,7 @@
"@blocksuite/std": "workspace:*",
"@floating-ui/dom": "^1.6.13",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
@@ -20,7 +20,7 @@
"@blocksuite/std": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"lit": "^3.2.0",
"rxjs": "^7.8.1"
},
@@ -37,7 +37,7 @@
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"fflate": "^0.8.2",
"lit": "^3.2.0",
@@ -23,7 +23,7 @@
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"fflate": "^0.8.2",
"lit": "^3.2.0",
@@ -22,7 +22,7 @@
"@blocksuite/std": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"lit": "^3.2.0",
"rxjs": "^7.8.1",
"yjs": "^13.6.21"
@@ -20,7 +20,7 @@
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"fflate": "^0.8.2",
"lit": "^3.2.0",
@@ -19,7 +19,7 @@
"@blocksuite/icons": "^2.2.12",
"@blocksuite/std": "workspace:*",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
@@ -16,7 +16,7 @@
"@blocksuite/global": "workspace:*",
"@blocksuite/std": "workspace:*",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"lit": "^3.2.0",
"rxjs": "^7.8.1"
},
@@ -20,7 +20,7 @@
"@blocksuite/store": "workspace:*",
"@floating-ui/dom": "^1.6.13",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
@@ -22,7 +22,7 @@
"@blocksuite/std": "workspace:*",
"@floating-ui/dom": "^1.6.13",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
@@ -19,7 +19,7 @@
"@blocksuite/std": "workspace:*",
"@floating-ui/dom": "^1.6.13",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.21",
@@ -28,6 +28,21 @@ import { ShadowlessElement } from './shadowless-element.js';
export const storeContext = createContext<Store>('store');
export const stdContext = createContext<BlockStdScope>('std');
function isMatchFlavour(widgetFlavour: string, block: BlockModel) {
if (widgetFlavour.endsWith('/*')) {
const path = widgetFlavour.slice(0, -2).split('/');
let current: BlockModel | null = block.parent;
for (let i = path.length - 1; i >= 0; i--) {
if (!current || current.flavour !== path[i]) {
return false;
}
current = current.parent;
}
return true;
}
return block.flavour === widgetFlavour;
}
@requiredProperties({
store: PropTypes.instanceOf(Store),
std: PropTypes.object,
@@ -61,7 +76,7 @@ export class EditorHost extends SignalWatcher(
const widgets = Array.from(widgetViews.entries()).reduce(
(mapping, [key, tag]) => {
const [widgetFlavour, id] = key.split('|');
if (widgetFlavour === flavour) {
if (isMatchFlavour(widgetFlavour, model)) {
const template = html`<${tag} ${unsafeStatic(WIDGET_ID_ATTR)}=${id}></${tag}>`;
mapping[id] = template;
}
+1 -1
View File
@@ -19,7 +19,7 @@
"@lit/context": "^1.1.3",
"@lottiefiles/dotlottie-wc": "^0.5.0",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.15",
"@toeverything/theme": "^1.1.16",
"@vanilla-extract/css": "^1.17.0",
"lit": "^3.2.0",
"rxjs": "^7.8.1",
@@ -0,0 +1,67 @@
-- CreateTable
CREATE TABLE "comments" (
"sid" INT GENERATED BY DEFAULT AS IDENTITY,
"id" VARCHAR NOT NULL,
"workspace_id" VARCHAR NOT NULL,
"doc_id" VARCHAR NOT NULL,
"user_id" VARCHAR NOT NULL,
"content" JSONB NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ(3),
"resolved" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "comments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "replies" (
"sid" INT GENERATED BY DEFAULT AS IDENTITY,
"id" VARCHAR NOT NULL,
"user_id" VARCHAR NOT NULL,
"comment_id" VARCHAR NOT NULL,
"workspace_id" VARCHAR NOT NULL,
"doc_id" VARCHAR NOT NULL,
"content" JSONB NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ(3),
CONSTRAINT "replies_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "comments_sid_key" ON "comments"("sid");
-- CreateIndex
CREATE INDEX "comments_workspace_id_doc_id_sid_idx" ON "comments"("workspace_id", "doc_id", "sid");
-- CreateIndex
CREATE INDEX "comments_workspace_id_doc_id_updated_at_idx" ON "comments"("workspace_id", "doc_id", "updated_at");
-- CreateIndex
CREATE INDEX "comments_user_id_idx" ON "comments"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "replies_sid_key" ON "replies"("sid");
-- CreateIndex
CREATE INDEX "replies_comment_id_sid_idx" ON "replies"("comment_id", "sid");
-- CreateIndex
CREATE INDEX "replies_workspace_id_doc_id_updated_at_idx" ON "replies"("workspace_id", "doc_id", "updated_at");
-- CreateIndex
CREATE INDEX "replies_user_id_idx" ON "replies"("user_id");
-- AddForeignKey
ALTER TABLE "comments" ADD CONSTRAINT "comments_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "comments" ADD CONSTRAINT "comments_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "replies" ADD CONSTRAINT "replies_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "replies" ADD CONSTRAINT "replies_comment_id_fkey" FOREIGN KEY ("comment_id") REFERENCES "comments"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "comment_attachments" (
"sid" INT GENERATED BY DEFAULT AS IDENTITY,
"workspace_id" VARCHAR NOT NULL,
"doc_id" VARCHAR NOT NULL,
"key" VARCHAR NOT NULL,
"size" INTEGER NOT NULL,
"mime" VARCHAR NOT NULL,
"name" VARCHAR NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" VARCHAR,
CONSTRAINT "comment_attachments_pkey" PRIMARY KEY ("workspace_id","doc_id","key")
);
-- CreateIndex
CREATE UNIQUE INDEX "comment_attachments_sid_key" ON "comment_attachments"("sid");
-- AddForeignKey
ALTER TABLE "comment_attachments" ADD CONSTRAINT "comment_attachments_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "comment_attachments" ADD CONSTRAINT "comment_attachments_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ai_sessions_metadata" ADD COLUMN "title" VARCHAR;
+1
View File
@@ -32,6 +32,7 @@
"@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",
"@apollo/server": "^4.11.3",
"@aws-sdk/client-s3": "^3.779.0",
+80 -7
View File
@@ -46,6 +46,9 @@ model User {
// receive notifications
notifications Notification[] @relation("user_notifications")
settings UserSettings?
comments Comment[]
replies Reply[]
commentAttachments CommentAttachment[] @relation("createdCommentAttachments")
@@index([email])
@@map("users")
@@ -119,13 +122,15 @@ model Workspace {
avatarKey String? @map("avatar_key") @db.VarChar
indexed Boolean @default(false)
features WorkspaceFeature[]
docs WorkspaceDoc[]
permissions WorkspaceUserRole[]
docPermissions WorkspaceDocUserRole[]
blobs Blob[]
ignoredDocs AiWorkspaceIgnoredDocs[]
embedFiles AiWorkspaceFiles[]
features WorkspaceFeature[]
docs WorkspaceDoc[]
permissions WorkspaceUserRole[]
docPermissions WorkspaceDocUserRole[]
blobs Blob[]
ignoredDocs AiWorkspaceIgnoredDocs[]
embedFiles AiWorkspaceFiles[]
comments Comment[]
commentAttachments CommentAttachment[]
@@map("workspaces")
}
@@ -438,6 +443,7 @@ model AiSession {
promptName String @map("prompt_name") @db.VarChar(32)
promptAction String? @default("") @map("prompt_action") @db.VarChar(32)
pinned Boolean @default(false)
title String? @db.VarChar
// the session id of the parent session if this session is a forked session
parentSessionId String? @map("parent_session_id") @db.VarChar
messageCost Int @default(0)
@@ -856,3 +862,70 @@ model UserSettings {
@@map("user_settings")
}
model Comment {
// NOTE: manually set this column type to identity in migration file
sid Int @unique @default(autoincrement()) @db.Integer
id String @id @default(uuid()) @db.VarChar
workspaceId String @map("workspace_id") @db.VarChar
docId String @map("doc_id") @db.VarChar
userId String @map("user_id") @db.VarChar
content Json @db.JsonB
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
// whether the comment is resolved
resolved Boolean @default(false) @map("resolved")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
replies Reply[]
@@index([workspaceId, docId, sid])
@@index([workspaceId, docId, updatedAt])
@@index([userId])
@@map("comments")
}
model Reply {
// NOTE: manually set this column type to identity in migration file
sid Int @unique @default(autoincrement()) @db.Integer
id String @id @default(uuid()) @db.VarChar
userId String @map("user_id") @db.VarChar
commentId String @map("comment_id") @db.VarChar
// query new replies by workspaceId and docId
workspaceId String @map("workspace_id") @db.VarChar
docId String @map("doc_id") @db.VarChar
content Json @db.JsonB
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade)
@@index([commentId, sid])
@@index([workspaceId, docId, updatedAt])
@@index([userId])
@@map("replies")
}
model CommentAttachment {
// NOTE: manually set this column type to identity in migration file
sid Int @unique @default(autoincrement())
workspaceId String @map("workspace_id") @db.VarChar
docId String @map("doc_id") @db.VarChar
key String @db.VarChar
size Int @db.Integer
mime String @db.VarChar
name String @db.VarChar
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
createdBy String? @map("created_by") @db.VarChar
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
// will delete creator record if creator's account is deleted
createdByUser User? @relation(name: "createdCommentAttachments", fields: [createdBy], references: [id], onDelete: SetNull)
@@id([workspaceId, docId, key])
@@map("comment_attachments")
}
@@ -330,3 +330,45 @@ Generated by [AVA](https://avajs.dev).
],
{},
]
## should handle generateSessionTitle correctly under various conditions
> should generate title when conditions are met
{
chatWithPromptCalled: undefined,
exists: true,
title: 'What is Machine Learning?',
}
> should not generate title when session already has title
{
chatWithPromptCalled: false,
exists: true,
title: 'Existing Title',
}
> should not generate title when no user messages exist
{
chatWithPromptCalled: false,
exists: true,
title: null,
}
> should not generate title when no assistant messages exist
{
chatWithPromptCalled: false,
exists: true,
title: null,
}
> should use correct prompt for title generation
{
content: `[user]: Explain quantum computing briefly␊
[assistant]: Quantum computing uses quantum mechanics principles.`,
promptName: 'Summary as title',
}
@@ -11,7 +11,11 @@ import { EventBus, JobQueue } from '../base';
import { ConfigModule } from '../base/config';
import { AuthService } from '../core/auth';
import { QuotaModule } from '../core/quota';
import { ContextCategories, WorkspaceModel } from '../models';
import {
ContextCategories,
CopilotSessionModel,
WorkspaceModel,
} from '../models';
import { CopilotModule } from '../plugins/copilot';
import { CopilotContextService } from '../plugins/copilot/context';
import {
@@ -57,12 +61,13 @@ import { MockCopilotProvider } from './mocks';
import { createTestingModule, TestingModule } from './utils';
import { WorkflowTestCases } from './utils/copilot';
const test = ava as TestFn<{
type Context = {
auth: AuthService;
module: TestingModule;
db: PrismaClient;
event: EventBus;
workspace: WorkspaceModel;
copilotSession: CopilotSessionModel;
context: CopilotContextService;
prompt: PromptService;
transcript: CopilotTranscriptionService;
@@ -78,7 +83,8 @@ const test = ava as TestFn<{
html: CopilotCheckHtmlExecutor;
json: CopilotCheckJsonExecutor;
};
}>;
};
const test = ava as TestFn<Context>;
let userId: string;
test.before(async t => {
@@ -119,6 +125,7 @@ test.before(async t => {
const db = module.get(PrismaClient);
const event = module.get(EventBus);
const workspace = module.get(WorkspaceModel);
const copilotSession = module.get(CopilotSessionModel);
const prompt = module.get(PromptService);
const factory = module.get(CopilotProviderFactory);
@@ -136,6 +143,7 @@ test.before(async t => {
t.context.db = db;
t.context.event = event;
t.context.workspace = workspace;
t.context.copilotSession = copilotSession;
t.context.prompt = prompt;
t.context.factory = factory;
t.context.session = session;
@@ -1752,3 +1760,168 @@ test('should be able to manage workspace embedding', async t => {
t.is(ret2.length, 0, 'should not match workspace context');
}
});
test('should handle generateSessionTitle correctly under various conditions', async t => {
const { prompt, session, workspace, copilotSession } = t.context;
await prompt.set('test', 'model', [{ role: 'user', content: '{{content}}' }]);
const createSession = async (
options: {
userMessage?: string;
assistantMessage?: string;
existingTitle?: string;
} = {}
) => {
const ws = await workspace.create(userId);
const sessionId = await session.create({
docId: 'test-doc',
workspaceId: ws.id,
userId,
promptName: 'test',
pinned: false,
});
if (options.existingTitle) {
await copilotSession.update({
userId,
sessionId,
title: options.existingTitle,
});
}
const chatSession = await session.get(sessionId);
if (chatSession) {
if (options.userMessage) {
chatSession.push({
role: 'user',
content: options.userMessage,
createdAt: new Date(),
});
}
if (options.assistantMessage) {
chatSession.push({
role: 'assistant',
content: options.assistantMessage,
createdAt: new Date(),
});
}
await chatSession.save();
}
return sessionId;
};
const testCases = [
{
name: 'should generate title when conditions are met',
setup: () =>
createSession({
userMessage: 'What is machine learning?',
assistantMessage:
'Machine learning is a subset of artificial intelligence.',
}),
mockFn: () => 'What is Machine Learning?',
expectSnapshot: true,
},
{
name: 'should not generate title when session already has title',
setup: () =>
createSession({
userMessage: 'Test message',
assistantMessage: 'Test response',
existingTitle: 'Existing Title',
}),
mockFn: () => 'New Title',
expectSnapshot: true,
expectNotCalled: true,
},
{
name: 'should not generate title when no user messages exist',
setup: () =>
createSession({ assistantMessage: 'Hello! How can I help you?' }),
mockFn: () => 'New Title',
expectSnapshot: true,
expectNotCalled: true,
},
{
name: 'should not generate title when no assistant messages exist',
setup: () => createSession({ userMessage: 'What is AI?' }),
mockFn: () => 'New Title',
expectSnapshot: true,
expectNotCalled: true,
},
{
name: 'should handle errors gracefully',
setup: () =>
createSession({
userMessage: 'Test question',
assistantMessage: 'Test answer',
}),
mockFn: () => {
throw new Error('Mock error for testing');
},
expectError: 'Mock error for testing',
},
];
for (const testCase of testCases) {
const sessionId = await testCase.setup();
let chatWithPromptCalled = false;
const mockStub = Sinon.stub(session, 'chatWithPrompt').callsFake(
async () => {
chatWithPromptCalled = true;
return testCase.mockFn();
}
);
if (testCase.expectError) {
await t.throwsAsync(
() => session.generateSessionTitle({ sessionId }),
{ message: testCase.expectError },
testCase.name
);
} else {
await session.generateSessionTitle({ sessionId });
if (testCase.expectSnapshot) {
const sessionState = await session.getSession(sessionId);
t.snapshot(
{
chatWithPromptCalled: testCase.expectNotCalled
? chatWithPromptCalled
: undefined,
title: sessionState?.title,
exists: !!sessionState,
},
testCase.name
);
}
}
mockStub.restore();
}
{
const sessionId = await createSession({
userMessage: 'Explain quantum computing briefly',
assistantMessage: 'Quantum computing uses quantum mechanics principles.',
});
let capturedArgs: any[] = [];
Sinon.stub(session, 'chatWithPrompt').callsFake(async (...args) => {
capturedArgs = args;
return 'Quantum Computing Explained';
});
await session.generateSessionTitle({ sessionId });
t.snapshot(
{
promptName: capturedArgs[0],
content: capturedArgs[1]?.content,
},
'should use correct prompt for title generation'
);
}
});
@@ -0,0 +1,118 @@
import { randomUUID } from 'node:crypto';
import { mock } from 'node:test';
import { CommentAttachmentStorage } from '../../../core/storage';
import { Mockers } from '../../mocks';
import { app, e2e } from '../test';
async function createWorkspace() {
const owner = await app.create(Mockers.User);
const workspace = await app.create(Mockers.Workspace, {
owner,
});
return {
owner,
workspace,
};
}
e2e.afterEach.always(() => {
mock.reset();
});
// #region comment attachment
e2e(
'should get comment attachment not found when key is not exists',
async t => {
const { owner, workspace } = await createWorkspace();
await app.login(owner);
const docId = randomUUID();
const res = await app.GET(
`/api/workspaces/${workspace.id}/docs/${docId}/comment-attachments/not-exists`
);
t.is(res.status, 404);
t.is(res.body.message, 'Comment attachment not found.');
}
);
e2e(
'should get comment attachment no permission when user is not member',
async t => {
const { workspace } = await createWorkspace();
// signup a new user
await app.signup();
const docId = randomUUID();
const res = await app.GET(
`/api/workspaces/${workspace.id}/docs/${docId}/comment-attachments/some-key`
);
t.is(res.status, 403);
t.regex(
res.body.message,
/You do not have permission to perform Doc.Read action on doc /
);
}
);
e2e('should get comment attachment body', async t => {
const { owner, workspace } = await createWorkspace();
await app.login(owner);
const docId = randomUUID();
const key = randomUUID();
const attachment = app.get(CommentAttachmentStorage);
await attachment.put(
workspace.id,
docId,
key,
'test.txt',
Buffer.from('test')
);
const res = await app.GET(
`/api/workspaces/${workspace.id}/docs/${docId}/comment-attachments/${key}`
);
t.is(res.status, 200);
t.is(res.headers['content-type'], 'text/plain');
t.is(res.headers['content-length'], '4');
t.is(res.headers['cache-control'], 'private, max-age=2592000, immutable');
t.regex(
res.headers['last-modified'],
/^\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/
);
t.is(res.text, 'test');
});
e2e('should get comment attachment redirect url', async t => {
const { owner, workspace } = await createWorkspace();
await app.login(owner);
const docId = randomUUID();
const key = randomUUID();
const attachment = app.get(CommentAttachmentStorage);
mock.method(attachment, 'get', async () => {
return {
body: null,
metadata: null,
redirectUrl: `https://foo.com/${key}`,
};
});
const res = await app.GET(
`/api/workspaces/${workspace.id}/docs/${docId}/comment-attachments/${key}`
);
t.is(res.status, 302);
t.is(res.headers['location'], `https://foo.com/${key}`);
});
// #endregion
@@ -58,6 +58,7 @@ test.beforeEach(async t => {
workspaceId: workspace.id,
docId,
userId: user.id,
title: null,
promptName: 'prompt-name',
promptAction: null,
});
@@ -82,6 +82,7 @@ const createTestSession = async (
workspaceId: workspace.id,
docId: null,
pinned: false,
title: null,
promptName: TEST_PROMPTS.NORMAL,
promptAction: null,
...overrides,
@@ -297,6 +298,7 @@ test('should pin and unpin sessions', async t => {
promptName: 'test-prompt',
promptAction: null,
pinned: true,
title: null,
});
const firstSession = await copilotSession.get(firstSessionId);
@@ -312,6 +314,7 @@ test('should pin and unpin sessions', async t => {
promptName: 'test-prompt',
promptAction: null,
pinned: true,
title: null,
});
const sessionStatesAfterSecondPin = await getSessionStates(db, [
@@ -796,6 +799,7 @@ test('should handle fork and session attachment operations', async t => {
workspaceId: workspace.id,
docId: forkConfig.docId,
pinned: forkConfig.pinned,
title: null,
parentSessionId,
prompt: { name: TEST_PROMPTS.NORMAL, action: null, model: 'gpt-4.1' },
messages: [
@@ -907,4 +907,18 @@ export const USER_FRIENDLY_ERRORS = {
args: { reason: 'string' },
message: ({ reason }) => `Invalid indexer input: ${reason}`,
},
// comment and reply errors
comment_not_found: {
type: 'resource_not_found',
message: 'Comment not found.',
},
reply_not_found: {
type: 'resource_not_found',
message: 'Reply not found.',
},
comment_attachment_not_found: {
type: 'resource_not_found',
message: 'Comment attachment not found.',
},
} satisfies Record<string, UserFriendlyErrorOptions>;
@@ -1067,6 +1067,24 @@ export class InvalidIndexerInput extends UserFriendlyError {
super('invalid_input', 'invalid_indexer_input', message, args);
}
}
export class CommentNotFound extends UserFriendlyError {
constructor(message?: string) {
super('resource_not_found', 'comment_not_found', message);
}
}
export class ReplyNotFound extends UserFriendlyError {
constructor(message?: string) {
super('resource_not_found', 'reply_not_found', message);
}
}
export class CommentAttachmentNotFound extends UserFriendlyError {
constructor(message?: string) {
super('resource_not_found', 'comment_attachment_not_found', message);
}
}
export enum ErrorNames {
INTERNAL_SERVER_ERROR,
NETWORK_ERROR,
@@ -1202,7 +1220,10 @@ export enum ErrorNames {
INVALID_APP_CONFIG_INPUT,
SEARCH_PROVIDER_NOT_FOUND,
INVALID_SEARCH_PROVIDER_REQUEST,
INVALID_INDEXER_INPUT
INVALID_INDEXER_INPUT,
COMMENT_NOT_FOUND,
REPLY_NOT_FOUND,
COMMENT_ATTACHMENT_NOT_FOUND
}
registerEnumType(ErrorNames, {
name: 'ErrorNames'
@@ -4,6 +4,7 @@ import { defineModuleConfig } from '../../base';
export interface ServerFlags {
earlyAccessControl: boolean;
allowGuestDemoWorkspace: boolean;
}
declare global {
@@ -75,4 +76,8 @@ defineModuleConfig('flags', {
desc: 'Only allow users with early access features to access the app',
default: false,
},
allowGuestDemoWorkspace: {
desc: 'Whether allow guest users to create demo workspaces.',
default: true,
},
});
@@ -85,6 +85,7 @@ export class ServerConfigResolver {
baseUrl: this.url.requestBaseUrl,
type: env.DEPLOYMENT_TYPE,
features: this.server.features,
allowGuestDemoWorkspace: this.config.flags.allowGuestDemoWorkspace,
};
}
@@ -38,4 +38,9 @@ export class ServerConfigType {
@Field(() => [ServerFeature], { description: 'enabled server features' })
features!: ServerFeature[];
@Field(() => Boolean, {
description: 'Whether allow guest users to create demo workspaces.',
})
allowGuestDemoWorkspace!: boolean;
}
@@ -0,0 +1,141 @@
import { randomUUID } from 'node:crypto';
import { Readable } from 'node:stream';
import test from 'ava';
import { createModule } from '../../../__tests__/create-module';
import { Mockers } from '../../../__tests__/mocks';
import { Models } from '../../../models';
import { CommentAttachmentStorage, StorageModule } from '..';
const module = await createModule({
imports: [StorageModule],
});
const storage = module.get(CommentAttachmentStorage);
const models = module.get(Models);
test.before(async () => {
await storage.onConfigInit();
});
test.after.always(async () => {
await module.close();
});
test('should put comment attachment', async t => {
const workspace = await module.create(Mockers.Workspace);
const docId = randomUUID();
const key = randomUUID();
const blob = Buffer.from('test');
await storage.put(workspace.id, docId, key, 'test.txt', blob);
const item = await models.commentAttachment.get(workspace.id, docId, key);
t.truthy(item);
t.is(item?.workspaceId, workspace.id);
t.is(item?.docId, docId);
t.is(item?.key, key);
t.is(item?.mime, 'text/plain');
t.is(item?.size, blob.length);
t.is(item?.name, 'test.txt');
});
test('should get comment attachment', async t => {
const workspace = await module.create(Mockers.Workspace);
const docId = randomUUID();
const key = randomUUID();
const blob = Buffer.from('test');
await storage.put(workspace.id, docId, key, 'test.txt', blob);
const item = await storage.get(workspace.id, docId, key);
t.truthy(item);
t.is(item?.metadata?.contentType, 'text/plain');
t.is(item?.metadata?.contentLength, blob.length);
// body is readable stream
t.truthy(item?.body);
const bytes = await readableToBytes(item?.body as Readable);
t.is(bytes.toString(), 'test');
});
test('should get comment attachment with access url', async t => {
const workspace = await module.create(Mockers.Workspace);
const docId = randomUUID();
const key = randomUUID();
const blob = Buffer.from('test');
await storage.put(workspace.id, docId, key, 'test.txt', blob);
const url = storage.getUrl(workspace.id, docId, key);
t.truthy(url);
t.is(
url,
`http://localhost:3010/api/workspaces/${workspace.id}/docs/${docId}/comment-attachments/${key}`
);
});
test('should delete comment attachment', async t => {
const workspace = await module.create(Mockers.Workspace);
const docId = randomUUID();
const key = randomUUID();
const blob = Buffer.from('test');
await storage.put(workspace.id, docId, key, 'test.txt', blob);
await storage.delete(workspace.id, docId, key);
const item = await models.commentAttachment.get(workspace.id, docId, key);
t.is(item, null);
});
test('should handle comment.attachment.delete event', async t => {
const workspace = await module.create(Mockers.Workspace);
const docId = randomUUID();
const key = randomUUID();
const blob = Buffer.from('test');
await storage.put(workspace.id, docId, key, 'test.txt', blob);
await storage.onCommentAttachmentDelete({
workspaceId: workspace.id,
docId,
key,
});
const item = await models.commentAttachment.get(workspace.id, docId, key);
t.is(item, null);
});
test('should handle workspace.deleted event', async t => {
const workspace = await module.create(Mockers.Workspace);
const docId = randomUUID();
const key1 = randomUUID();
const key2 = randomUUID();
const blob1 = Buffer.from('test');
const blob2 = Buffer.from('test2');
await storage.put(workspace.id, docId, key1, 'test.txt', blob1);
await storage.put(workspace.id, docId, key2, 'test.txt', blob2);
const count = module.event.count('comment.attachment.delete');
await storage.onWorkspaceDeleted({
id: workspace.id,
});
t.is(module.event.count('comment.attachment.delete'), count + 2);
});
async function readableToBytes(stream: Readable) {
const chunks: Buffer[] = [];
let chunk: Buffer;
for await (chunk of stream) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
@@ -2,12 +2,16 @@ import './config';
import { Module } from '@nestjs/common';
import { AvatarStorage, WorkspaceBlobStorage } from './wrappers';
import {
AvatarStorage,
CommentAttachmentStorage,
WorkspaceBlobStorage,
} from './wrappers';
@Module({
providers: [WorkspaceBlobStorage, AvatarStorage],
exports: [WorkspaceBlobStorage, AvatarStorage],
providers: [WorkspaceBlobStorage, AvatarStorage, CommentAttachmentStorage],
exports: [WorkspaceBlobStorage, AvatarStorage, CommentAttachmentStorage],
})
export class StorageModule {}
export { AvatarStorage, WorkspaceBlobStorage };
export { AvatarStorage, CommentAttachmentStorage, WorkspaceBlobStorage };
@@ -0,0 +1,128 @@
import { Injectable, Logger } from '@nestjs/common';
import {
autoMetadata,
Config,
EventBus,
OnEvent,
type StorageProvider,
StorageProviderFactory,
URLHelper,
} from '../../../base';
import { Models } from '../../../models';
declare global {
interface Events {
'comment.attachment.delete': {
workspaceId: string;
docId: string;
key: string;
};
}
}
@Injectable()
export class CommentAttachmentStorage {
private readonly logger = new Logger(CommentAttachmentStorage.name);
private provider!: StorageProvider;
get config() {
return this.AFFiNEConfig.storages.blob;
}
constructor(
private readonly AFFiNEConfig: Config,
private readonly event: EventBus,
private readonly storageFactory: StorageProviderFactory,
private readonly models: Models,
private readonly url: URLHelper
) {}
@OnEvent('config.init')
async onConfigInit() {
this.provider = this.storageFactory.create(this.config.storage);
}
@OnEvent('config.changed')
async onConfigChanged(event: Events['config.changed']) {
if (event.updates.storages?.blob?.storage) {
this.provider = this.storageFactory.create(this.config.storage);
}
}
private storageKey(workspaceId: string, docId: string, key: string) {
return `comment-attachments/${workspaceId}/${docId}/${key}`;
}
async put(
workspaceId: string,
docId: string,
key: string,
name: string,
blob: Buffer
) {
const meta = autoMetadata(blob);
await this.provider.put(
this.storageKey(workspaceId, docId, key),
blob,
meta
);
await this.models.commentAttachment.upsert({
workspaceId,
docId,
key,
name,
mime: meta.contentType ?? 'application/octet-stream',
size: blob.length,
});
}
async get(
workspaceId: string,
docId: string,
key: string,
signedUrl?: boolean
) {
return await this.provider.get(
this.storageKey(workspaceId, docId, key),
signedUrl
);
}
async delete(workspaceId: string, docId: string, key: string) {
await this.provider.delete(this.storageKey(workspaceId, docId, key));
await this.models.commentAttachment.delete(workspaceId, docId, key);
this.logger.log(
`deleted comment attachment ${workspaceId}/${docId}/${key}`
);
}
getUrl(workspaceId: string, docId: string, key: string) {
return this.url.link(
`/api/workspaces/${workspaceId}/docs/${docId}/comment-attachments/${key}`
);
}
@OnEvent('workspace.deleted')
async onWorkspaceDeleted({ id }: Events['workspace.deleted']) {
const attachments = await this.models.commentAttachment.list(id);
for (const attachment of attachments) {
this.event.emit('comment.attachment.delete', {
workspaceId: id,
docId: attachment.docId,
key: attachment.key,
});
}
}
@OnEvent('comment.attachment.delete')
async onCommentAttachmentDelete({
workspaceId,
docId,
key,
}: Events['comment.attachment.delete']) {
await this.delete(workspaceId, docId, key);
}
}

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