Compare commits

...

59 Commits

Author SHA1 Message Date
Cats Juice
7b53641a94 fix(core): disable creating linked doc in sidebar when show linked is off (#13191)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* The "add linked page" icon button in the navigation panel is now only
visible if enabled in your app settings.

* **Enhancements**
* The navigation panel dynamically updates the available operations
based on your sidebar settings.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-14 08:02:16 +00:00
Cats Juice
3948b8eada feat(core): display doc title with display-config for semantic result (#13194)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Improved document title display in AI chat semantic search results by
fetching titles from a dedicated service for more accurate and
consistent information.

* **Enhancements**
* Enhanced integration between chat and document display features,
ensuring configuration and services are consistently passed through chat
components for better user experience.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Wu Yue <akumatus@gmail.com>
2025-07-14 07:58:17 +00:00
Cats Juice
d05bb9992c style(core): adjust sidebar new page button background (#13193)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Style**
* Updated the background color of the add-page button in the sidebar for
a refreshed appearance.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-14 07:57:46 +00:00
Wu Yue
b2c09825ac feat(core): do not show AI actions in history (#13198)
Close [AI-351](https://linear.app/affine-design/issue/AI-351)

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

* **Bug Fixes**
* Disabled action updates related to document IDs and sessions in the AI
chat content panel.

* **Tests**
* Skipped all end-to-end tests for the "should show chat history in chat
panel" scenario across various AI action test suites. These tests will
no longer run during automated testing.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-14 07:53:14 +00:00
Wu Yue
65453c31c6 feat(core): ai intelligence track (#13187)
Close [AI-335](https://linear.app/affine-design/issue/AI-335)

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

* **New Features**
* Added support for an "independent mode" and document-specific context
in AI chat components, allowing enhanced context handling and tracking.
* Introduced new tracking options to distinguish between current
document and general document actions in chat interactions.

* **Improvements**
* More flexible property handling for independent mode and document
context across chat-related components for consistent behavior and
tracking.
* Enhanced tracking system to support additional event categories and
methods for more granular analytics.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-14 06:43:06 +00:00
Peng Xiao
d9e8ce802f fix(core): loading spinner color issue (#13192)
#### PR Dependency Tree


* **PR #13192** 👈

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

* **Style**
* Updated the loading icon to ensure consistent appearance by explicitly
setting the fill property to none using an inline style.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-14 04:54:47 +00:00
DarkSky
d5f63b9e43 fix(server): recent session missing params (#13188)
fix AI-349
2025-07-14 04:17:48 +00:00
Cats Juice
ebefbeefc8 fix(core): prevent creating session every time in chat page (#13190)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved session handling in chat to prevent redundant session
creation and ensure consistent assignment of session functions.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-14 03:57:51 +00:00
Peng Xiao
4d7d8f215f fix(core): artifact panel theme (#13186)
fix AI-340, AI-344

#### PR Dependency Tree


* **PR #13186** 👈

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

## Summary by CodeRabbit

* **Style**
* Updated the artifact preview panel layout by removing a fixed height
constraint for improved flexibility.
* Refined visual styling of linked document blocks with updated shadow
effects for better aesthetics.

* **New Features**
* Enhanced document preview rendering to respect the current theme,
providing a more consistent visual experience.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->


#### PR Dependency Tree


* **PR #13186** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)
2025-07-14 02:46:28 +00:00
DarkSky
b6187718ea feat(server): add cron job for session cleanup (#13181)
fix AI-338
2025-07-13 13:53:38 +00:00
德布劳外 · 贾贵
3ee82bd9ce test: skip ai chat with multi tags test (#13170)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Tests**
* Temporarily skipped tests related to chatting with tags and specified
documents due to flakiness.
* Improved chat retry test by streamlining status checks for faster
validation.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-13 13:02:03 +00:00
Cats Juice
3dbdb99435 feat(core): add basic ui for doc search related tool calling (#13176)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Introduced support for document semantic search, keyword search, and
document reading tools in chat AI features.
* Added new interactive cards to display results for document keyword
search, semantic search, and reading operations within chat.
* Automatically restores and displays pinned chat sessions when
revisiting the workspace chat page.

* **Improvements**
* Enhanced the chat interface with new components for richer
document-related AI responses.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-12 02:17:37 +00:00
Cats Juice
0d414d914a fix(core): right sidebar switching not work after switching workspace (#13179)
Due to missing the correct unsubscription, switching workspaces triggers
multiple events. As a result, the sidebar cannot be closed on every
second trigger.
2025-07-11 15:15:16 +00:00
github-actions[bot]
41f338bce0 chore(i18n): sync translations (#13178)
New Crowdin translations by [Crowdin GH
Action](https://github.com/crowdin/github-action)

Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2025-07-11 21:41:57 +08:00
Cats Juice
6f87c1ca50 fix(core): hide footer actions for independent ai chat (#13177)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added support for an "independent mode" in assistant chat messages,
which hides editor actions when enabled.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-11 12:59:02 +00:00
EYHN
33f6496d79 feat(core): show server name when delete account (#13175)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Account deletion prompts and confirmation dialogs now display the
specific server name, providing clearer context when deleting your
account.

* **Localization**
* Updated account deletion messages to explicitly mention the server
name.
* Improved translation keys to support server-specific messaging in all
supported languages.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-11 12:38:05 +00:00
DarkSky
847ef00a75 feat(server): add doc meta for semantic search (#13174)
fix AI-339
2025-07-11 18:36:21 +08:00
Wu Yue
93f13e9e01 feat(core): update ai add context button ui (#13172)
Close [AI-301](https://linear.app/affine-design/issue/AI-301)

<img width="571" height="204" alt="截屏2025-07-11 17 33 01"
src="https://github.com/user-attachments/assets/3b7ed81f-1137-4c01-8fe2-9fe5ebf2adf3"
/>


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

* **New Features**
* Introduced a new component for adding context (images, documents,
tags, collections) to AI chat via a plus button and popover menu.
* Added notification feedback for duplicate chip additions and image
upload limits.
* Chips panel now supports collapsing and expanding for improved UI
control.

* **Improvements**
* Refactored chip management for better error handling, feedback, and
external control.
* Streamlined image and document uploads through a unified menu-driven
interface.
* Enhanced chip management methods with clearer naming and robust
synchronization.
* Updated chat input to delegate image upload and context additions to
the new add-context component.

* **Bug Fixes**
* Improved cancellation and cleanup of ongoing chip addition operations
to prevent conflicts.

* **Tests**
* Updated end-to-end tests to reflect the new menu-driven image upload
workflow and removed legacy checks.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: fengmk2 <fengmk2@gmail.com>
2025-07-11 10:10:41 +00:00
fengmk2
a2b86bc6d2 chore(server): enable schedule module by default (#13173)
#### PR Dependency Tree


* **PR #13173** 👈

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

* **Refactor**
* Simplified internal module management to ensure more consistent
availability of core features. No visible changes to user-facing
functionality.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-11 09:54:38 +00:00
Peng Xiao
aee7a8839e fix(core): update code artifact tool prompt (#13171)
#### PR Dependency Tree


* **PR #13171** 👈

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**
* Introduced a new "Code Artifact" prompt that generates HTML files
styled with Tailwind CSS, following a specific color theme and design
guidelines.

* **Style**
  * Minor formatting improvements for consistency.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-11 09:52:16 +00:00
EYHN
0e8ffce126 fix(core): avoid infinite sign in with selfhost (#13169)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* The 404 page now reflects user session state across multiple servers,
showing the appropriate user context when multiple accounts are logged
in.

* **Improvements**
* Enhanced user experience on the 404 page by accurately displaying
information based on the first active logged-in account across all
servers.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-11 09:46:11 +00:00
Peng Xiao
9cda655c9e fix(core): artifact rendering issue in standalone ai chat panel (#13166)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Improved chat component to support document link navigation directly
from chat messages, allowing users to open documents in the workbench
when links are clicked.

* **Refactor**
* Streamlined notification handling and property access in document
composition tools for a cleaner user experience.
* Updated import statements for improved code clarity and
maintainability.
  * Enhanced code artifact tool rendering to ensure consistent theming.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-11 17:53:04 +08:00
L-Sun
15726bd522 fix(editor): missing viewport selector in editor setting (#13168)
#### PR Dependency Tree


* **PR #13168** 👈

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

* **Style**
* Updated CSS class names for the snapshot container to improve
consistency in styling and targeting.


<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-11 09:06:18 +00:00
Peng Xiao
d65a7494a4 fix(core): some artifact tools styling (#13152)
fix BS-3615, BS-3616, BS-3614

#### PR Dependency Tree


* **PR #13152** 👈

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

* **Style**
* Improved consistency of horizontal padding and spacing in AI chat
components.
* Updated chat message containers to enable vertical scrolling and
adjust height behavior.
* Refined artifact tool card appearance with enhanced hover effects,
cursor placement, and updated card structure for a more polished look.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->


#### PR Dependency Tree


* **PR #13152** 👈

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

Co-authored-by: fengmk2 <fengmk2@gmail.com>
2025-07-11 16:48:56 +08:00
fengmk2
0f74e1fa0f fix(server): ignore 409 status error on es delete query (#13162)
close AF-2736



#### PR Dependency Tree


* **PR #13162** 👈

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 error handling for Elasticsearch operations by allowing
certain conflict errors to be ignored, resulting in more robust and
tolerant behavior during data deletion processes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-11 16:30:58 +08:00
Peng Xiao
fef4a9eeb6 fix(core): artifact rendering issue in standalone ai chat panel (#13164)
#### PR Dependency Tree


* **PR #13164** 👈

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**
* Improved integration of workspace context into AI chat, enabling more
responsive interactions when clicking document links within chat
messages.
* Enhanced document opening experience from chat by reacting to link
clicks and providing direct access to related documents.

* **Refactor**
* Streamlined notification handling and workspace context management
within chat-related components for better maintainability and
performance.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-11 08:09:07 +00:00
德布劳外 · 贾贵
58dc53581f fix: hide embedding status tip if embedding completed (#13156)
> CLOSE AI-334

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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved the responsiveness of embedding status updates in the AI chat
composer, reducing unnecessary refreshes when the status has not
changed.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-11 07:31:12 +00:00
德布劳外 · 贾贵
b23f380539 fix(core): remove scroller visiblility test (#13159)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Tests**
* Removed the test verifying the scroll indicator appears when there are
many chat messages.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-11 07:22:08 +00:00
github-actions[bot]
d29a97f86c chore(i18n): sync translations (#13161)
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: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-11 07:19:10 +00:00
github-actions[bot]
0f287f9661 chore(i18n): sync translations (#13160)
New Crowdin translations by [Crowdin GH
Action](https://github.com/crowdin/github-action)

Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2025-07-11 14:47:04 +08:00
github-actions[bot]
18f13626cc chore(i18n): sync translations (#13158)
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: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-11 14:35:45 +08:00
github-actions[bot]
0eeea5e173 chore(i18n): sync translations (#13157)
New Crowdin translations by [Crowdin GH
Action](https://github.com/crowdin/github-action)

Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2025-07-11 14:14:59 +08:00
DarkSky
2052a34d19 chore(server): add detail for error (#13151)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Error messages for unavailable copilot providers now include specific
model IDs for clearer context.
* Added new detailed error messages for embedding generation failures
specifying provider and error details.
* The API and GraphQL schema have been extended with new error types
reflecting these detailed error cases.

* **Bug Fixes**
* Enhanced error handling to detect and report incomplete or missing
embeddings from providers.
* Added safeguards to skip embedding insertions when no embeddings are
provided, preventing unnecessary processing.

* **Documentation**
* Updated localization and translation keys to support dynamic error
messages with model IDs and provider details.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-11 05:55:10 +00:00
DarkSky
b79439b01d fix(server): sse abort behavior (#13153)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved handling of aborted client connections during streaming,
ensuring that session messages accurately reflect if a request was
aborted.
* Enhanced consistency and reliability across all streaming endpoints
when saving session messages after streaming.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-11 04:46:55 +00:00
Cats Juice
2dacba9011 feat(core): restore pinned chat for independent chat (#13154)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Improved chat session management by automatically restoring a pinned
chat session when opening the workspace chat.

* **Enhancements**
* Added support for cancelling certain requests, improving
responsiveness and user experience.

* **Style**
* Updated the label "AFFiNE Intelligence" to "Intelligence" in relevant
UI components for a more concise display.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-11 04:45:59 +00:00
fengmk2
af9c455ee0 feat(server): add process memory usage metrics (#13148)
close CLOUD-235

<img width="2104" height="1200" alt="image"
src="https://github.com/user-attachments/assets/6ea0fd89-ab32-42e3-a675-f00f9e5856ad"
/>



#### PR Dependency Tree


* **PR #13148** 👈

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**
* Introduced a new monitoring service that automatically collects and
logs process memory usage statistics every minute.
* Enhanced system monitoring capabilities by integrating a global
monitoring module.
  * Added support for a new "process" scope in metric tracking.

* **Chores**
* Improved internal module organization by including the monitoring
module in the core functionality set.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-11 04:15:08 +00:00
Peng Xiao
3d45c7623f test(core): add a simple test for comment (#13150)
#### PR Dependency Tree


* **PR #13150** 👈

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

* **Tests**
* Added a new end-to-end test to verify creating and displaying comments
on selected text within a document.
* Updated test retry logic to limit retries to 1 in local or non-CI
environments, and to 3 in CI environments without COPILOT enabled.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-11 04:13:46 +00:00
Wu Yue
e0f88451e1 feat(core): render session title in ai session history (#13147)
Close [AI-331](https://linear.app/affine-design/issue/AI-331)

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

## Summary by CodeRabbit

* **Improvements**
* Session history now displays the session title (or "New chat" if
unavailable) instead of the session ID for a clearer user experience.

* **Performance**
* Recent copilot chat session lists now load faster by excluding message
details from the initial query.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-11 03:42:52 +00:00
Cats Juice
aba0a3d485 fix(core): load chat history content correctly (#13149)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved session handling in chat to prevent potential errors when
reloading sessions.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-11 03:36:56 +00:00
Cats Juice
8b579e3a92 fix(core): ensure new chat when entering chat page (#13146)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added an option to always start a new AI chat session instead of
reusing the latest one.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-11 02:58:16 +00:00
EYHN
d98b45ca3d feat(core): clear all notifications (#13144)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added a "Delete All" option in the notifications list, allowing users
to mark all notifications as read at once.
* Introduced a header with a menu button in the notifications list for
easier access to actions.

* **Style**
* Updated notification list layout with improved structure, including a
header and a scrollable content area.

* **Localization**
* Added a new English localization string for the "Delete all
notifications" action.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-11 02:50:48 +00:00
fengmk2
fc1104cd68 chore(server): add comment attachment storage metrics (#13143)
close AF-2728



#### PR Dependency Tree


* **PR #13143** 👈

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 metrics tracking for comment attachment uploads, including
recording attachment size and total uploads by MIME type.
* Enhanced logging for attachment uploads with detailed information such
as workspace ID, document ID, file size, and user ID.

* **Chores**
* Expanded internal metric categories to include storage-related
metrics.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-10 15:43:12 +00:00
Peng Xiao
46901c472c fix(core): empty style for comment (#13142)
fix AF-2735

#### PR Dependency Tree


* **PR #13142** 👈

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

* **Style**
* Increased padding for empty state elements in the comment sidebar to
improve visual spacing.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-10 13:50:19 +00:00
Wu Yue
9d5c7dd1e9 fix(core): doc reference error in ai answer (#13141)
Close [AI-303](https://linear.app/affine-design/issue/AI-303)

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

## Summary by CodeRabbit

* **New Features**
* Introduced a new AI configuration hook to streamline AI-related
features and integrations.
* Integrated enhanced AI specifications into chat components for
improved AI chat experiences.

* **Refactor**
* Updated chat panels to use the new AI configuration hook, simplifying
extension management and improving maintainability.

* **Chores**
* Improved options handling in the editor view extension for more
flexible configuration.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-10 11:44:30 +00:00
fengmk2
f655e6e8bf feat(server): export title and summary on doc resolver (#13139)
close AF-2732






#### PR Dependency Tree


* **PR #13139** 👈

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 a document summary field, allowing documents to
include and display an optional summary alongside the title.

* **Bug Fixes**
* Improved access control when retrieving documents, ensuring proper
permission checks are enforced.

* **Tests**
* Expanded test coverage to verify correct handling of document title
and summary fields, including cases where the summary is absent.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-10 11:13:19 +00:00
L-Sun
46a9d0f7fe fix(editor): commented heading style (#13140)
Close BS-3613

#### PR Dependency Tree


* **PR #13140** 👈

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

* **Style**
* Updated default text styling to inherit font weight, style, and
decoration from parent elements when bold, italic, underline, or strike
attributes are not set. This may result in text more closely matching
its surrounding context.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-10 11:12:20 +00:00
fengmk2
340aae6476 refactor(server): updates merge delay reduced to 5 seconds (#13138)
close AF-2733



#### PR Dependency Tree


* **PR #13138** 👈

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

* **Refactor**
* Reduced the delay for merging pending document updates from 30 seconds
to 5 seconds, resulting in faster update processing.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-10 09:41:28 +00:00
德布劳外 · 贾贵
6b7d1e91e0 feat(core): apply model tracking (#13128)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added user interaction tracking for document editing and diff review
actions, including accepting, rejecting, applying, and copying changes.
* Introduced tracking for "Accept all" and "Reject all" actions in block
diff views.

* **Chores**
* Enhanced event tracking system with new event types and payloads to
support detailed analytics for editing and review actions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-10 17:25:05 +08:00
fengmk2
3538c78a8b chore(server): use jemalloc to reduce RSS (#13134)
close CLOUD-237



#### PR Dependency Tree


* **PR #13134** 👈
  * **PR #13079**
    * **PR #13125**

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)
2025-07-10 09:03:04 +00:00
Peng Xiao
7d527c7f3a fix(core): cannot download comment files (#13136)
#### PR Dependency Tree


* **PR #13136** 👈

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 timeout of 3 minutes to comment attachment uploads, improving
reliability for long uploads.

* **Refactor**
* Unified the file download process to always use blob conversion,
ensuring consistent behavior for all URLs.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-10 07:48:17 +00:00
DarkSky
ad5a122391 feat(server): summary tools (#13133)
fix AI-281

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

## Summary by CodeRabbit

* **New Features**
* Added a new AI-powered conversation summary tool that generates
concise summaries of key topics, decisions, and details from
conversations, with options to focus on specific areas and adjust
summary length.
* Introduced a new prompt for conversation summarization, supporting
customizable focus and summary length.

* **Bug Fixes**
* Improved tool handling and error messages for conversation
summarization when required input is missing.

* **Tests**
* Expanded test coverage to include scenarios for the new conversation
summary feature.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-10 06:47:34 +00:00
Cats Juice
0f9b9789da fix(core): add missing tooltip effect for independent chat (#13127)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Introduced a tooltip component, making it available throughout the
application.

* **Refactor**
* Centralized tooltip initialization and registration for improved
consistency.
* Updated imports to use the new tooltip module, streamlining component
usage.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-10 05:09:48 +00:00
L-Sun
5b027f7986 fix(core): disable comment in local workspace (#13124)
Close AF-2731

#### PR Dependency Tree


* **PR #13124** 👈

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**
* Comment functionality is now available only for cloud workspaces and
is disabled for local or shared modes.

* **Bug Fixes**
* Improved accuracy in enabling comments based on workspace type and
configuration.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-10 05:08:53 +00:00
Wu Yue
fe00293e3e feat(core): disable pin chat while generating AI answers (#13131)
Close [AI-316](https://linear.app/affine-design/issue/AI-316)

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

## Summary by CodeRabbit

* **New Features**
* Chat status is now displayed and updated in the chat panel and
toolbar, allowing users to see when the chat is generating a response.
* The pin button in the chat toolbar is disabled while the chat is
generating a response, preventing pin actions during this time and
providing feedback via a notification if attempted.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-10 04:25:12 +00:00
Cats Juice
385226083f chore(core): adjust ai page tab name (#13129)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Refactor**
* Replaced the localized workspace title with a hardcoded "AFFiNE
Intelligence" label in the chat view.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-10 04:19:24 +00:00
EYHN
38d8dde6b8 chore(ios): fix ios version (#13130)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added support for specifying a custom iOS App Store version during
release workflows.

* **Chores**
  * Updated the iOS app's marketing version to 0.23.1.
  * Upgraded the iOS workflow runner to macOS 15 and Xcode 16.4.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-10 04:18:33 +00:00
Peng Xiao
ed6fde550f fix(core): some comment editor ux enhancements (#13126)
fix AF-2726, AF-2729

#### PR Dependency Tree


* **PR #13126** 👈

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 drag-and-drop support for file attachments in the comment
editor.
* Improved user feedback with notifications and toasts when downloading
attachments.

* **Bug Fixes**
  * Enhanced error handling and reporting for attachment downloads.

* **Improvements**
* Optimized file download process for same-origin resources to improve
performance.
* Updated default comment filter to show all comments, not just those
for the current mode.

* **Documentation**
* Updated English localization to provide clearer instructions when no
comments are present.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->


#### PR Dependency Tree


* **PR #13126** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)
2025-07-10 03:58:00 +00:00
Wu Yue
11a9e67bc1 feat(core): remove scrollable-text-renderer's dependency on editor host (#13123)
[AI-260](https://linear.app/affine-design/issue/AI-260)

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

## Summary by CodeRabbit

* **Refactor**
* Improved integration of theme support in AI-generated answer
rendering, allowing the renderer to adapt to theme changes dynamically.
* Simplified component interfaces by removing unnecessary dependencies.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-10 03:31:52 +00:00
Wu Yue
899585ba7f fix(core): old ai messages not cleared before retrying (#13119)
Close [AI-258](https://linear.app/affine-design/issue/AI-258)

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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved the retry behavior in AI chat by ensuring previous streaming
data is properly cleared before retrying, resulting in more accurate and
consistent chat message handling.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-10 03:20:30 +00:00
169 changed files with 3571 additions and 1108 deletions

View File

@@ -4,9 +4,15 @@ inputs:
app-version:
description: 'App Version'
required: true
ios-app-version:
description: 'iOS App Store Version (Optional, use App version if empty)'
required: false
type: string
runs:
using: 'composite'
steps:
- name: 'Write Version'
shell: bash
env:
IOS_APP_VERSION: ${{ inputs.ios-app-version }}
run: ./scripts/set-version.sh ${{ inputs.app-version }}

View File

@@ -7,7 +7,10 @@ COPY ./packages/frontend/apps/mobile/dist /app/static/mobile
WORKDIR /app
RUN apt-get update && \
apt-get install -y --no-install-recommends openssl && \
apt-get install -y --no-install-recommends openssl libjemalloc2 && \
rm -rf /var/lib/apt/lists/*
# Enable jemalloc by preloading the library
ENV LD_PRELOAD=libjemalloc.so.2
CMD ["node", "./dist/main.js"]

View File

@@ -12,6 +12,9 @@ on:
build-type:
type: string
required: true
ios-app-version:
type: string
required: false
env:
BUILD_TYPE: ${{ inputs.build-type }}
@@ -78,7 +81,7 @@ jobs:
path: packages/frontend/apps/android/dist
ios:
runs-on: ${{ github.ref_name == 'canary' && 'macos-latest' || 'blaze/macos-14' }}
runs-on: 'macos-15'
needs:
- build-ios-web
steps:
@@ -87,6 +90,7 @@ jobs:
uses: ./.github/actions/setup-version
with:
app-version: ${{ inputs.app-version }}
ios-app-version: ${{ inputs.ios-app-version }}
- name: 'Update Code Sign Identity'
shell: bash
run: ./packages/frontend/apps/ios/update_code_sign_identity.sh
@@ -106,7 +110,7 @@ jobs:
enableScripts: false
- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: 16.2
xcode-version: 16.4
- name: Install Swiftformat
run: brew install swiftformat
- name: Cap sync

View File

@@ -21,6 +21,10 @@ on:
required: true
type: boolean
default: false
ios-app-version:
description: 'iOS App Store Version (Optional, use tag version if empty)'
required: false
type: string
permissions:
contents: write
@@ -117,3 +121,4 @@ jobs:
build-type: ${{ needs.prepare.outputs.BUILD_TYPE }}
app-version: ${{ needs.prepare.outputs.APP_VERSION }}
git-short-hash: ${{ needs.prepare.outputs.GIT_SHORT_HASH }}
ios-app-version: ${{ inputs.ios-app-version }}

View File

@@ -266,6 +266,7 @@
"./components/toggle-button": "./src/components/toggle-button.ts",
"./components/toggle-switch": "./src/components/toggle-switch.ts",
"./components/toolbar": "./src/components/toolbar.ts",
"./components/tooltip": "./src/components/tooltip.ts",
"./components/view-dropdown-menu": "./src/components/view-dropdown-menu.ts",
"./components/tooltip-content-with-shortcut": "./src/components/tooltip-content-with-shortcut.ts",
"./components/resource": "./src/components/resource.ts",

View File

@@ -0,0 +1 @@
export * from '@blocksuite/affine-components/tooltip';

View File

@@ -73,7 +73,8 @@
"./edgeless-line-styles-panel": "./src/edgeless-line-styles-panel/index.ts",
"./edgeless-shape-color-picker": "./src/edgeless-shape-color-picker/index.ts",
"./open-doc-dropdown-menu": "./src/open-doc-dropdown-menu/index.ts",
"./slider": "./src/slider/index.ts"
"./slider": "./src/slider/index.ts",
"./tooltip": "./src/tooltip/index.ts"
},
"files": [
"src",

View File

@@ -18,6 +18,7 @@ export const LoadingIcon = ({
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
fill="none"
style="fill: none;"
>
<style>
.spinner {

View File

@@ -1,3 +1,4 @@
import { effects as tooltipEffects } from '../tooltip/effect.js';
import { EditorIconButton } from './icon-button.js';
import {
EditorMenuAction,
@@ -6,7 +7,6 @@ import {
} from './menu-button.js';
import { EditorToolbarSeparator } from './separator.js';
import { EditorToolbar } from './toolbar.js';
import { Tooltip } from './tooltip.js';
export { EditorChevronDown } from './chevron-down.js';
export { ToolbarMoreMenuConfigExtension } from './config.js';
@@ -20,7 +20,6 @@ export { MenuContext } from './menu-context.js';
export { EditorToolbarSeparator } from './separator.js';
export { darkToolbarStyles, lightToolbarStyles } from './styles.js';
export { EditorToolbar } from './toolbar.js';
export { Tooltip } from './tooltip.js';
export type {
AdvancedMenuItem,
FatMenuItems,
@@ -38,11 +37,12 @@ export {
} from './utils.js';
export function effects() {
tooltipEffects();
customElements.define('editor-toolbar-separator', EditorToolbarSeparator);
customElements.define('editor-toolbar', EditorToolbar);
customElements.define('editor-icon-button', EditorIconButton);
customElements.define('editor-menu-button', EditorMenuButton);
customElements.define('editor-menu-content', EditorMenuContent);
customElements.define('editor-menu-action', EditorMenuAction);
customElements.define('affine-tooltip', Tooltip);
}

View File

@@ -0,0 +1,7 @@
import { Tooltip } from './tooltip.js';
export function effects() {
if (!customElements.get('affine-tooltip')) {
customElements.define('affine-tooltip', Tooltip);
}
}

View File

@@ -0,0 +1,2 @@
export { effects } from './effect.js';
export { Tooltip } from './tooltip.js';

View File

@@ -30,9 +30,9 @@ function inlineTextStyles(
}
return styleMap({
'font-weight': props.bold ? 'bold' : 'normal',
'font-style': props.italic ? 'italic' : 'normal',
'text-decoration': textDecorations.length > 0 ? textDecorations : 'none',
'font-weight': props.bold ? 'bold' : 'inherit',
'font-style': props.italic ? 'italic' : 'inherit',
'text-decoration': textDecorations.length > 0 ? textDecorations : 'inherit',
...inlineCodeStyle,
});
}

View File

@@ -372,3 +372,68 @@ Generated by [AVA](https://avajs.dev).
[assistant]: Quantum computing uses quantum mechanics principles.`,
promptName: 'Summary as title',
}
## should handle copilot cron jobs correctly
> daily job scheduling calls
[
{
args: [
'copilot.session.cleanupEmptySessions',
{},
{
jobId: 'daily-copilot-cleanup-empty-sessions',
},
],
},
{
args: [
'copilot.session.generateMissingTitles',
{},
{
jobId: 'daily-copilot-generate-missing-titles',
},
],
},
]
> cleanup empty sessions calls
[
{
args: [
'Date',
],
},
]
> title generation calls
{
jobCalls: [
{
args: [
'copilot.session.generateTitle',
{
sessionId: 'session1',
},
],
},
{
args: [
'copilot.session.generateTitle',
{
sessionId: 'session2',
},
],
},
],
modelCalls: [
{
args: [
100,
],
},
],
}

View File

@@ -207,6 +207,7 @@ const retry = async (
try {
await callback(t);
} catch (e) {
console.error(`Error during ${action}:`, e);
t.log(`Error during ${action}:`, e);
throw e;
}
@@ -350,10 +351,10 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
params: {
files: [
{
blobId: 'euclidean_distance',
fileName: 'euclidean_distance.rs',
fileType: 'text/rust',
fileContent: TestAssets.Code,
blobId: 'todo_md',
fileName: 'todo.md',
fileType: 'text/markdown',
fileContent: TestAssets.TODO,
},
],
},
@@ -475,6 +476,7 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
},
},
],
config: { model: 'gemini-2.5-pro' },
verifier: (t: ExecutionContext<Tester>, result: string) => {
t.notThrows(() => {
TranscriptionResponseSchema.parse(JSON.parse(result));
@@ -483,6 +485,34 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
type: 'structured' as const,
prefer: CopilotProviderType.Gemini,
},
{
promptName: ['Conversation Summary'],
messages: [
{
role: 'user' as const,
content: '',
params: {
messages: [
{ role: 'user', content: 'what is single source of truth?' },
{ role: 'assistant', content: TestAssets.SSOT },
],
focus: 'technical decisions',
length: 'comprehensive',
},
},
],
verifier: (t: ExecutionContext<Tester>, result: string) => {
assertNotWrappedInCodeBlock(t, result);
const cleared = result.toLowerCase();
t.assert(
cleared.includes('single source of truth') ||
/single.*source/.test(cleared) ||
cleared.includes('ssot'),
'should include original keyword'
);
},
type: 'text' as const,
},
{
promptName: [
'Summary',
@@ -668,11 +698,12 @@ for (const {
t.truthy(provider, 'should have provider');
await retry(`action: ${promptName}`, t, async t => {
const finalConfig = Object.assign({}, prompt.config, config);
const modelId = finalConfig.model || prompt.model;
switch (type) {
case 'text': {
const result = await provider.text(
{ modelId: prompt.model },
{ modelId },
[
...prompt.finish(
messages.reduce(
@@ -691,7 +722,7 @@ for (const {
}
case 'structured': {
const result = await provider.structure(
{ modelId: prompt.model },
{ modelId },
[
...prompt.finish(
messages.reduce(
@@ -710,7 +741,7 @@ for (const {
case 'object': {
const streamObjects: StreamObject[] = [];
for await (const chunk of provider.streamObject(
{ modelId: prompt.model },
{ modelId },
[
...prompt.finish(
messages.reduce(
@@ -742,7 +773,7 @@ for (const {
});
}
const stream = provider.streamImages(
{ modelId: prompt.model },
{ modelId },
[
...prompt.finish(
finalMessage.reduce(

View File

@@ -18,6 +18,7 @@ import {
} from '../models';
import { CopilotModule } from '../plugins/copilot';
import { CopilotContextService } from '../plugins/copilot/context';
import { CopilotCronJobs } from '../plugins/copilot/cron';
import {
CopilotEmbeddingJob,
MockEmbeddingClient,
@@ -77,6 +78,7 @@ type Context = {
jobs: CopilotEmbeddingJob;
storage: CopilotStorage;
workflow: CopilotWorkflowService;
cronJobs: CopilotCronJobs;
executors: {
image: CopilotChatImageExecutor;
text: CopilotChatTextExecutor;
@@ -137,6 +139,7 @@ test.before(async t => {
const jobs = module.get(CopilotEmbeddingJob);
const transcript = module.get(CopilotTranscriptionService);
const workspaceEmbedding = module.get(CopilotWorkspaceService);
const cronJobs = module.get(CopilotCronJobs);
t.context.module = module;
t.context.auth = auth;
@@ -153,6 +156,7 @@ test.before(async t => {
t.context.jobs = jobs;
t.context.transcript = transcript;
t.context.workspaceEmbedding = workspaceEmbedding;
t.context.cronJobs = cronJobs;
t.context.executors = {
image: module.get(CopilotChatImageExecutor),
@@ -1931,3 +1935,71 @@ test('should handle generateSessionTitle correctly under various conditions', as
);
}
});
test('should handle copilot cron jobs correctly', async t => {
const { cronJobs, copilotSession } = t.context;
// mock calls
const mockCleanupResult = { removed: 2, cleaned: 3 };
const mockSessions = [
{ id: 'session1', _count: { messages: 1 } },
{ id: 'session2', _count: { messages: 2 } },
];
const cleanupStub = Sinon.stub(
copilotSession,
'cleanupEmptySessions'
).resolves(mockCleanupResult);
const toBeGenerateStub = Sinon.stub(
copilotSession,
'toBeGenerateTitle'
).resolves(mockSessions);
const jobAddStub = Sinon.stub(cronJobs['jobs'], 'add').resolves();
// daily cleanup job scheduling
{
await cronJobs.dailyCleanupJob();
t.snapshot(
jobAddStub.getCalls().map(call => ({
args: call.args,
})),
'daily job scheduling calls'
);
jobAddStub.reset();
cleanupStub.reset();
toBeGenerateStub.reset();
}
// cleanup empty sessions
{
// mock
cleanupStub.resolves(mockCleanupResult);
toBeGenerateStub.resolves(mockSessions);
await cronJobs.cleanupEmptySessions();
t.snapshot(
cleanupStub.getCalls().map(call => ({
args: call.args.map(arg => (arg instanceof Date ? 'Date' : arg)), // Replace Date with string for stable snapshot
})),
'cleanup empty sessions calls'
);
}
// generate missing titles
await cronJobs.generateMissingTitles();
t.snapshot(
{
modelCalls: toBeGenerateStub.getCalls().map(call => ({
args: call.args,
})),
jobCalls: jobAddStub.getCalls().map(call => ({
args: call.args,
})),
},
'title generation calls'
);
cleanupStub.restore();
toBeGenerateStub.restore();
jobAddStub.restore();
});

View File

@@ -99,3 +99,56 @@ e2e(
t.is(result2.workspace.doc.public, true);
}
);
e2e('should get doc with title and summary', async t => {
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
const docSnapshot = await app.create(Mockers.DocSnapshot, {
workspaceId: workspace.id,
user: owner,
});
const doc = await app.create(Mockers.DocMeta, {
workspaceId: workspace.id,
docId: docSnapshot.id,
title: 'doc1',
summary: 'summary1',
});
const result = await app.gql({
query: getWorkspacePageByIdQuery,
variables: { workspaceId: workspace.id, pageId: doc.docId },
});
t.is(result.workspace.doc.title, doc.title);
t.is(result.workspace.doc.summary, doc.summary);
});
e2e('should get doc with title and null summary', async t => {
const owner = await app.signup();
const workspace = await app.create(Mockers.Workspace, {
owner: { id: owner.id },
});
const docSnapshot = await app.create(Mockers.DocSnapshot, {
workspaceId: workspace.id,
user: owner,
});
const doc = await app.create(Mockers.DocMeta, {
workspaceId: workspace.id,
docId: docSnapshot.id,
title: 'doc1',
});
const result = await app.gql({
query: getWorkspacePageByIdQuery,
variables: { workspaceId: workspace.id, pageId: doc.docId },
});
t.is(result.workspace.doc.title, doc.title);
t.is(result.workspace.doc.summary, null);
});

View File

@@ -565,3 +565,65 @@ Generated by [AVA](https://avajs.dev).
workspaceSessionExists: true,
},
}
## should cleanup empty sessions correctly
> cleanup empty sessions results
{
cleanupResult: {
cleaned: 0,
removed: 0,
},
remainingSessions: [
{
deleted: false,
pinned: false,
type: 'zeroCost',
},
{
deleted: false,
pinned: false,
type: 'zeroCost',
},
{
deleted: false,
pinned: false,
type: 'noMessages',
},
{
deleted: false,
pinned: false,
type: 'noMessages',
},
{
deleted: false,
pinned: false,
type: 'recent',
},
{
deleted: false,
pinned: false,
type: 'withMessages',
},
],
}
## should get sessions for title generation correctly
> sessions for title generation results
{
onlyValidSessionsReturned: true,
sessions: [
{
assistantMessageCount: 1,
isValid: true,
},
{
assistantMessageCount: 2,
isValid: true,
},
],
total: 2,
}

View File

@@ -917,3 +917,178 @@ test('should handle fork and session attachment operations', async t => {
'attach and detach operation results'
);
});
test('should cleanup empty sessions correctly', async t => {
const { copilotSession, db } = t.context;
await createTestPrompts(copilotSession, db);
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
// should be deleted
const neverUsedSessionIds: string[] = [randomUUID(), randomUUID()];
await Promise.all(
neverUsedSessionIds.map(async id => {
await createTestSession(t, { sessionId: id });
await db.aiSession.update({
where: { id },
data: { messageCost: 0, updatedAt: oneDayAgo },
});
})
);
// should be marked as deleted
const emptySessionIds: string[] = [randomUUID(), randomUUID()];
await Promise.all(
emptySessionIds.map(async id => {
await createTestSession(t, { sessionId: id });
await db.aiSession.update({
where: { id },
data: { messageCost: 100, updatedAt: oneDayAgo },
});
})
);
// should not be affected
const recentSessionId = randomUUID();
await createTestSession(t, { sessionId: recentSessionId });
await db.aiSession.update({
where: { id: recentSessionId },
data: { messageCost: 0, updatedAt: twoHoursAgo },
});
// Create session with messages (should not be affected)
const sessionWithMsgId = randomUUID();
await createSessionWithMessages(
t,
{ sessionId: sessionWithMsgId },
'test message'
);
const result = await copilotSession.cleanupEmptySessions(oneDayAgo);
const remainingSessions = await db.aiSession.findMany({
where: {
id: {
in: [
...neverUsedSessionIds,
...emptySessionIds,
recentSessionId,
sessionWithMsgId,
],
},
},
select: { id: true, deletedAt: true, pinned: true },
});
t.snapshot(
{
cleanupResult: result,
remainingSessions: remainingSessions.map(s => ({
deleted: !!s.deletedAt,
pinned: s.pinned,
type: neverUsedSessionIds.includes(s.id)
? 'zeroCost'
: emptySessionIds.includes(s.id)
? 'noMessages'
: s.id === recentSessionId
? 'recent'
: 'withMessages',
})),
},
'cleanup empty sessions results'
);
});
test('should get sessions for title generation correctly', async t => {
const { copilotSession, db } = t.context;
await createTestPrompts(copilotSession, db);
// create valid sessions with messages
const sessionIds: string[] = [randomUUID(), randomUUID()];
await Promise.all(
sessionIds.map(async (id, index) => {
await createTestSession(t, { sessionId: id });
await db.aiSession.update({
where: { id },
data: {
updatedAt: new Date(Date.now() - index * 1000),
messages: {
create: Array.from({ length: index + 1 }, (_, i) => ({
role: 'assistant',
content: `assistant message ${i}`,
})),
},
},
});
})
);
// create excluded sessions
const excludedSessions = [
{
reason: 'hasTitle',
setupFn: async (id: string) => {
await createTestSession(t, { sessionId: id });
await db.aiSession.update({
where: { id },
data: { title: 'Existing Title' },
});
},
},
{
reason: 'isDeleted',
setupFn: async (id: string) => {
await createTestSession(t, { sessionId: id });
await db.aiSession.update({
where: { id },
data: { deletedAt: new Date() },
});
},
},
{
reason: 'noMessages',
setupFn: async (id: string) => {
await createTestSession(t, { sessionId: id });
},
},
{
reason: 'isAction',
setupFn: async (id: string) => {
await createTestSession(t, {
sessionId: id,
promptName: TEST_PROMPTS.ACTION,
});
},
},
{
reason: 'noAssistantMessages',
setupFn: async (id: string) => {
await createTestSession(t, { sessionId: id });
await db.aiSessionMessage.create({
data: { sessionId: id, role: 'user', content: 'User message only' },
});
},
},
];
await Promise.all(
excludedSessions.map(async session => {
await session.setupFn(randomUUID());
})
);
const result = await copilotSession.toBeGenerateTitle(10);
t.snapshot(
{
total: result.length,
sessions: result.map(s => ({
assistantMessageCount: s._count.messages,
isValid: sessionIds.includes(s.id),
})),
onlyValidSessionsReturned: result.every(s => sessionIds.includes(s.id)),
},
'sessions for title generation results'
);
});

View File

@@ -669,7 +669,10 @@ test('should get doc info', async t => {
};
await t.context.doc.upsert(snapshot);
await t.context.doc.upsertMeta(workspace.id, docId);
await t.context.doc.upsertMeta(workspace.id, docId, {
title: 'test title',
summary: 'test summary',
});
const docInfo = await t.context.doc.getDocInfo(workspace.id, docId);
@@ -679,6 +682,8 @@ test('should get doc info', async t => {
updatedAt: new Date(snapshot.timestamp),
creatorId: user.id,
lastUpdaterId: user.id,
title: 'test title',
summary: 'test summary',
});
});

View File

@@ -36,6 +36,7 @@ import { DocRendererModule } from './core/doc-renderer';
import { DocServiceModule } from './core/doc-service';
import { FeatureModule } from './core/features';
import { MailModule } from './core/mail';
import { MonitorModule } from './core/monitor';
import { NotificationModule } from './core/notification';
import { PermissionModule } from './core/permission';
import { QuotaModule } from './core/quota';
@@ -112,6 +113,8 @@ export const FunctionalityModules = [
WebSocketModule,
JobModule.forRoot(),
ModelsModule,
ScheduleModule.forRoot(),
MonitorModule,
];
export class AppModuleBuilder {
@@ -151,12 +154,8 @@ export function buildAppModule(env: Env) {
// basic
.use(...FunctionalityModules)
// enable schedule module on graphql server and doc service
.useIf(
() => env.flavors.graphql || env.flavors.doc,
ScheduleModule.forRoot(),
IndexerModule
)
// enable indexer module on graphql server and doc service
.useIf(() => env.flavors.graphql || env.flavors.doc, IndexerModule)
// auth
.use(UserModule, AuthModule, PermissionModule)

View File

@@ -653,12 +653,19 @@ export const USER_FRIENDLY_ERRORS = {
},
no_copilot_provider_available: {
type: 'internal_server_error',
message: `No copilot provider available.`,
args: { modelId: 'string' },
message: ({ modelId }) => `No copilot provider available: ${modelId}`,
},
copilot_failed_to_generate_text: {
type: 'internal_server_error',
message: `Failed to generate text.`,
},
copilot_failed_to_generate_embedding: {
type: 'internal_server_error',
args: { provider: 'string', message: 'string' },
message: ({ provider, message }) =>
`Failed to generate embedding with ${provider}: ${message}`,
},
copilot_failed_to_create_message: {
type: 'internal_server_error',
message: `Failed to create chat message.`,

View File

@@ -668,10 +668,14 @@ export class CopilotSessionDeleted extends UserFriendlyError {
super('action_forbidden', 'copilot_session_deleted', message);
}
}
@ObjectType()
class NoCopilotProviderAvailableDataType {
@Field() modelId!: string
}
export class NoCopilotProviderAvailable extends UserFriendlyError {
constructor(message?: string) {
super('internal_server_error', 'no_copilot_provider_available', message);
constructor(args: NoCopilotProviderAvailableDataType, message?: string | ((args: NoCopilotProviderAvailableDataType) => string)) {
super('internal_server_error', 'no_copilot_provider_available', message, args);
}
}
@@ -680,6 +684,17 @@ export class CopilotFailedToGenerateText extends UserFriendlyError {
super('internal_server_error', 'copilot_failed_to_generate_text', message);
}
}
@ObjectType()
class CopilotFailedToGenerateEmbeddingDataType {
@Field() provider!: string
@Field() message!: string
}
export class CopilotFailedToGenerateEmbedding extends UserFriendlyError {
constructor(args: CopilotFailedToGenerateEmbeddingDataType, message?: string | ((args: CopilotFailedToGenerateEmbeddingDataType) => string)) {
super('internal_server_error', 'copilot_failed_to_generate_embedding', message, args);
}
}
export class CopilotFailedToCreateMessage extends UserFriendlyError {
constructor(message?: string) {
@@ -1179,6 +1194,7 @@ export enum ErrorNames {
COPILOT_SESSION_DELETED,
NO_COPILOT_PROVIDER_AVAILABLE,
COPILOT_FAILED_TO_GENERATE_TEXT,
COPILOT_FAILED_TO_GENERATE_EMBEDDING,
COPILOT_FAILED_TO_CREATE_MESSAGE,
UNSPLASH_IS_NOT_CONFIGURED,
COPILOT_ACTION_TAKEN,
@@ -1239,5 +1255,5 @@ registerEnumType(ErrorNames, {
export const ErrorDataUnionType = createUnionType({
name: 'ErrorDataUnion',
types: () =>
[GraphqlBadRequestDataType, HttpRequestErrorDataType, QueryTooLongDataType, ValidationErrorDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, InvalidOauthCallbackCodeDataType, MissingOauthQueryParameterDataType, InvalidOauthResponseDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, DocUpdateBlockedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, NoMoreSeatDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderNotSupportedDataType, CopilotProviderSideErrorDataType, CopilotInvalidContextDataType, CopilotContextFileNotSupportedDataType, CopilotFailedToModifyContextDataType, CopilotFailedToMatchContextDataType, CopilotFailedToMatchGlobalContextDataType, CopilotFailedToAddWorkspaceFileEmbeddingDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseToActivateDataType, InvalidLicenseUpdateParamsDataType, UnsupportedClientVersionDataType, MentionUserDocAccessDeniedDataType, InvalidAppConfigDataType, InvalidAppConfigInputDataType, InvalidSearchProviderRequestDataType, InvalidIndexerInputDataType] as const,
[GraphqlBadRequestDataType, HttpRequestErrorDataType, QueryTooLongDataType, ValidationErrorDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, InvalidOauthCallbackCodeDataType, MissingOauthQueryParameterDataType, InvalidOauthResponseDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, DocUpdateBlockedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, NoMoreSeatDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, NoCopilotProviderAvailableDataType, CopilotFailedToGenerateEmbeddingDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderNotSupportedDataType, CopilotProviderSideErrorDataType, CopilotInvalidContextDataType, CopilotContextFileNotSupportedDataType, CopilotFailedToModifyContextDataType, CopilotFailedToMatchContextDataType, CopilotFailedToMatchGlobalContextDataType, CopilotFailedToAddWorkspaceFileEmbeddingDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseToActivateDataType, InvalidLicenseUpdateParamsDataType, UnsupportedClientVersionDataType, MentionUserDocAccessDeniedDataType, InvalidAppConfigDataType, InvalidAppConfigInputDataType, InvalidSearchProviderRequestDataType, InvalidIndexerInputDataType] as const,
});

View File

@@ -59,7 +59,9 @@ export type KnownMetricScopes =
| 'mail'
| 'ai'
| 'event'
| 'queue';
| 'queue'
| 'storage'
| 'process';
const metricCreators: MetricCreators = {
counter(meter: Meter, name: string, opts?: MetricOptions) {

View File

@@ -100,7 +100,7 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
{
// keep it simple to let all update merged in one job
jobId: `doc:merge-pending-updates:${workspaceId}:${docId}`,
delay: 30 * 1000 /* 30s */,
delay: 5 * 1000 /* 5s */,
priority: 100,
}
);

View File

@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { MonitorService } from './service';
@Global()
@Module({
providers: [MonitorService],
})
export class MonitorModule {}

View File

@@ -0,0 +1,28 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { metrics } from '../../base';
@Injectable()
export class MonitorService {
protected logger = new Logger(MonitorService.name);
@Cron(CronExpression.EVERY_MINUTE)
async monitor() {
const memoryUsage = process.memoryUsage();
this.logger.log(
`memory usage: rss: ${memoryUsage.rss}, heapTotal: ${memoryUsage.heapTotal}, heapUsed: ${memoryUsage.heapUsed}, external: ${memoryUsage.external}, arrayBuffers: ${memoryUsage.arrayBuffers}`
);
metrics.process.gauge('node_process_rss').record(memoryUsage.rss);
metrics.process
.gauge('node_process_heap_total')
.record(memoryUsage.heapTotal);
metrics.process
.gauge('node_process_heap_used')
.record(memoryUsage.heapUsed);
metrics.process.gauge('node_process_external').record(memoryUsage.external);
metrics.process
.gauge('node_process_array_buffers')
.record(memoryUsage.arrayBuffers);
}
}

View File

@@ -4,6 +4,7 @@ import {
autoMetadata,
Config,
EventBus,
metrics,
OnEvent,
type StorageProvider,
StorageProviderFactory,
@@ -69,15 +70,23 @@ export class CommentAttachmentStorage {
blob,
meta
);
const mime = meta.contentType ?? 'application/octet-stream';
const size = blob.length;
await this.models.commentAttachment.upsert({
workspaceId,
docId,
key,
name,
mime: meta.contentType ?? 'application/octet-stream',
size: blob.length,
mime,
size,
createdBy: userId,
});
metrics.storage.histogram('comment_attachment_size').record(size, { mime });
metrics.storage.counter('comment_attachment_total').add(1, { mime });
this.logger.log(
`uploaded comment attachment ${workspaceId}/${docId}/${key} with size ${size}, mime: ${mime}, name: ${name}, user: ${userId}`
);
}
async get(

View File

@@ -79,6 +79,9 @@ class DocType {
@Field(() => String, { nullable: true })
title?: string | null;
@Field(() => String, { nullable: true })
summary?: string | null;
}
@InputType()
@@ -250,10 +253,11 @@ export class WorkspaceDocResolver {
deprecationReason: 'use [WorkspaceType.doc] instead',
})
async publicPage(
@CurrentUser() me: CurrentUser,
@Parent() workspace: WorkspaceType,
@Args('pageId') pageId: string
) {
return this.doc(workspace, pageId);
return this.doc(me, workspace, pageId);
}
@ResolveField(() => PaginatedDocType)
@@ -294,11 +298,14 @@ export class WorkspaceDocResolver {
complexity: 2,
})
async doc(
@CurrentUser() me: CurrentUser,
@Parent() workspace: WorkspaceType,
@Args('docId') docId: string
): Promise<DocType> {
const doc = await this.models.doc.getDocInfo(workspace.id, docId);
if (doc) {
// check if doc is readable
await this.ac.user(me.id).doc(workspace.id, docId).assert('Doc.Read');
return doc;
}

View File

@@ -165,6 +165,13 @@ export class CopilotContextModel extends BaseModel {
fileId: string,
embeddings: Embedding[]
) {
if (embeddings.length === 0) {
this.logger.warn(
`No embeddings provided for contextId: ${contextId}, fileId: ${fileId}. Skipping insertion.`
);
return;
}
const values = this.processEmbeddings(contextId, fileId, embeddings);
await this.db.$executeRaw`
@@ -204,6 +211,13 @@ export class CopilotContextModel extends BaseModel {
docId: string,
embeddings: Embedding[]
) {
if (embeddings.length === 0) {
this.logger.warn(
`No embeddings provided for workspaceId: ${workspaceId}, docId: ${docId}. Skipping insertion.`
);
return;
}
const values = this.processEmbeddings(
workspaceId,
docId,

View File

@@ -582,4 +582,57 @@ export class CopilotSessionModel extends BaseModel {
.map(({ messageCost, prompt: { action } }) => (action ? 1 : messageCost))
.reduce((prev, cost) => prev + cost, 0);
}
@Transactional()
async cleanupEmptySessions(earlyThen: Date) {
// delete never used sessions
const { count: removed } = await this.db.aiSession.deleteMany({
where: {
messageCost: 0,
deletedAt: null,
// filter session updated more than 24 hours ago
updatedAt: { lt: earlyThen },
},
});
// mark empty sessions as deleted
const { count: cleaned } = await this.db.aiSession.updateMany({
where: {
deletedAt: null,
messages: { none: {} },
// filter session updated more than 24 hours ago
updatedAt: { lt: earlyThen },
},
data: {
deletedAt: new Date(),
pinned: false,
},
});
return { removed, cleaned };
}
@Transactional()
async toBeGenerateTitle(take: number) {
const sessions = await this.db.aiSession
.findMany({
where: {
title: null,
deletedAt: null,
messages: { some: {} },
// only generate titles for non-actions sessions
prompt: { action: null },
},
select: {
id: true,
// count assistant messages
_count: { select: { messages: { where: { role: 'assistant' } } } },
},
take,
orderBy: { updatedAt: 'desc' },
})
.then(s => s.filter(s => s._count.messages > 0));
return sessions;
}
}

View File

@@ -283,6 +283,13 @@ export class CopilotWorkspaceConfigModel extends BaseModel {
fileId: string,
embeddings: Embedding[]
) {
if (embeddings.length === 0) {
this.logger.warn(
`No embeddings provided for workspaceId: ${workspaceId}, fileId: ${fileId}. Skipping insertion.`
);
return;
}
const values = this.processEmbeddings(workspaceId, fileId, embeddings);
await this.db.$executeRaw`
INSERT INTO "ai_workspace_file_embeddings"

View File

@@ -558,6 +558,8 @@ export class DocModel extends BaseModel {
mode: PublicDocMode;
public: boolean;
defaultRole: DocRole;
title: string | null;
summary: string | null;
createdAt: Date;
updatedAt: Date;
creatorId?: string;
@@ -570,6 +572,8 @@ export class DocModel extends BaseModel {
"workspace_pages"."mode" as "mode",
"workspace_pages"."public" as "public",
"workspace_pages"."defaultRole" as "defaultRole",
"workspace_pages"."title" as "title",
"workspace_pages"."summary" as "summary",
"snapshots"."created_at" as "createdAt",
"snapshots"."updated_at" as "updatedAt",
"snapshots"."created_by" as "creatorId",

View File

@@ -125,7 +125,10 @@ export class CopilotContextService implements OnApplicationBootstrap {
async get(id: string): Promise<ContextSession> {
if (!this.embeddingClient) {
throw new NoCopilotProviderAvailable('embedding client not configured');
throw new NoCopilotProviderAvailable(
{ modelId: 'embedding' },
'embedding client not configured'
);
}
const context = await this.getCachedSession(id);

View File

@@ -124,7 +124,7 @@ export class CopilotController implements BeforeApplicationShutdown {
modelId: model,
});
if (!provider) {
throw new NoCopilotProviderAvailable();
throw new NoCopilotProviderAvailable({ modelId: model });
}
return { provider, model, hasAttachment };
@@ -299,6 +299,13 @@ export class CopilotController implements BeforeApplicationShutdown {
this.ongoingStreamCount$.next(this.ongoingStreamCount$.value + 1);
const { signal, onConnectionClosed } = getSignal(req);
let endBeforePromiseResolve = false;
onConnectionClosed(isAborted => {
if (isAborted) {
endBeforePromiseResolve = true;
}
});
const { messageId, reasoning, webSearch } = ChatQuerySchema.parse(query);
const source$ = from(
@@ -322,21 +329,21 @@ export class CopilotController implements BeforeApplicationShutdown {
shared$.pipe(
reduce((acc, chunk) => acc + chunk, ''),
tap(buffer => {
onConnectionClosed(isAborted => {
session.push({
role: 'assistant',
content: isAborted ? '> Request aborted' : buffer,
createdAt: new Date(),
});
void session
.save()
.catch(err =>
this.logger.error(
'Failed to save session in sse stream',
err
)
);
session.push({
role: 'assistant',
content: endBeforePromiseResolve
? '> Request aborted'
: buffer,
createdAt: new Date(),
});
void session
.save()
.catch(err =>
this.logger.error(
'Failed to save session in sse stream',
err
)
);
}),
ignoreElements()
)
@@ -384,6 +391,13 @@ export class CopilotController implements BeforeApplicationShutdown {
this.ongoingStreamCount$.next(this.ongoingStreamCount$.value + 1);
const { signal, onConnectionClosed } = getSignal(req);
let endBeforePromiseResolve = false;
onConnectionClosed(isAborted => {
if (isAborted) {
endBeforePromiseResolve = true;
}
});
const { messageId, reasoning, webSearch } = ChatQuerySchema.parse(query);
const source$ = from(
@@ -407,25 +421,25 @@ export class CopilotController implements BeforeApplicationShutdown {
shared$.pipe(
reduce((acc, chunk) => acc.concat([chunk]), [] as StreamObject[]),
tap(result => {
onConnectionClosed(isAborted => {
const parser = new StreamObjectParser();
const streamObjects = parser.mergeTextDelta(result);
const content = parser.mergeContent(streamObjects);
session.push({
role: 'assistant',
content: isAborted ? '> Request aborted' : content,
streamObjects: isAborted ? null : streamObjects,
createdAt: new Date(),
});
void session
.save()
.catch(err =>
this.logger.error(
'Failed to save session in sse stream',
err
)
);
const parser = new StreamObjectParser();
const streamObjects = parser.mergeTextDelta(result);
const content = parser.mergeContent(streamObjects);
session.push({
role: 'assistant',
content: endBeforePromiseResolve
? '> Request aborted'
: content,
streamObjects: endBeforePromiseResolve ? null : streamObjects,
createdAt: new Date(),
});
void session
.save()
.catch(err =>
this.logger.error(
'Failed to save session in sse stream',
err
)
);
}),
ignoreElements()
)
@@ -477,6 +491,13 @@ export class CopilotController implements BeforeApplicationShutdown {
this.ongoingStreamCount$.next(this.ongoingStreamCount$.value + 1);
const { signal, onConnectionClosed } = getSignal(req);
let endBeforePromiseResolve = false;
onConnectionClosed(isAborted => {
if (isAborted) {
endBeforePromiseResolve = true;
}
});
const source$ = from(
this.workflow.runGraph(params, session.model, {
...session.config.promptConfig,
@@ -526,21 +547,21 @@ export class CopilotController implements BeforeApplicationShutdown {
return acc;
}, ''),
tap(content => {
onConnectionClosed(isAborted => {
session.push({
role: 'assistant',
content: isAborted ? '> Request aborted' : content,
createdAt: new Date(),
});
void session
.save()
.catch(err =>
this.logger.error(
'Failed to save session in sse stream',
err
)
);
session.push({
role: 'assistant',
content: endBeforePromiseResolve
? '> Request aborted'
: content,
createdAt: new Date(),
});
void session
.save()
.catch(err =>
this.logger.error(
'Failed to save session in sse stream',
err
)
);
}),
ignoreElements()
)
@@ -604,6 +625,13 @@ export class CopilotController implements BeforeApplicationShutdown {
this.ongoingStreamCount$.next(this.ongoingStreamCount$.value + 1);
const { signal, onConnectionClosed } = getSignal(req);
let endBeforePromiseResolve = false;
onConnectionClosed(isAborted => {
if (isAborted) {
endBeforePromiseResolve = true;
}
});
const source$ = from(
provider.streamImages(
{
@@ -639,22 +667,20 @@ export class CopilotController implements BeforeApplicationShutdown {
shared$.pipe(
reduce((acc, chunk) => acc.concat([chunk]), [] as string[]),
tap(attachments => {
onConnectionClosed(isAborted => {
session.push({
role: 'assistant',
content: isAborted ? '> Request aborted' : '',
attachments: isAborted ? [] : attachments,
createdAt: new Date(),
});
void session
.save()
.catch(err =>
this.logger.error(
'Failed to save session in sse stream',
err
)
);
session.push({
role: 'assistant',
content: endBeforePromiseResolve ? '> Request aborted' : '',
attachments: endBeforePromiseResolve ? [] : attachments,
createdAt: new Date(),
});
void session
.save()
.catch(err =>
this.logger.error(
'Failed to save session in sse stream',
err
)
);
}),
ignoreElements()
)

View File

@@ -0,0 +1,67 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { JobQueue, OneDay, OnJob } from '../../base';
import { Models } from '../../models';
declare global {
interface Jobs {
'copilot.session.cleanupEmptySessions': {};
'copilot.session.generateMissingTitles': {};
}
}
const GENERATE_TITLES_BATCH_SIZE = 100;
@Injectable()
export class CopilotCronJobs {
private readonly logger = new Logger(CopilotCronJobs.name);
constructor(
private readonly models: Models,
private readonly jobs: JobQueue
) {}
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async dailyCleanupJob() {
await this.jobs.add(
'copilot.session.cleanupEmptySessions',
{},
{ jobId: 'daily-copilot-cleanup-empty-sessions' }
);
await this.jobs.add(
'copilot.session.generateMissingTitles',
{},
{ jobId: 'daily-copilot-generate-missing-titles' }
);
}
@OnJob('copilot.session.cleanupEmptySessions')
async cleanupEmptySessions() {
const { removed, cleaned } =
await this.models.copilotSession.cleanupEmptySessions(
new Date(Date.now() - OneDay)
);
this.logger.log(
`Cleanup completed: ${removed} sessions deleted, ${cleaned} sessions marked as deleted`
);
}
@OnJob('copilot.session.generateMissingTitles')
async generateMissingTitles() {
const sessions = await this.models.copilotSession.toBeGenerateTitle(
GENERATE_TITLES_BATCH_SIZE
);
for (const session of sessions) {
await this.jobs.add('copilot.session.generateTitle', {
sessionId: session.id,
});
}
this.logger.log(
`Scheduled title generation for ${sessions.length} sessions`
);
}
}

View File

@@ -5,6 +5,7 @@ import {
CopilotPromptNotFound,
CopilotProviderNotSupported,
} from '../../../base';
import { CopilotFailedToGenerateEmbedding } from '../../../base/error/errors.gen';
import { ChunkSimilarity, Embedding } from '../../../models';
import { PromptService } from '../prompt';
import {
@@ -74,6 +75,12 @@ class ProductionEmbeddingClient extends EmbeddingClient {
input,
{ dimensions: EMBEDDING_DIMENSIONS }
);
if (embeddings.length !== input.length) {
throw new CopilotFailedToGenerateEmbedding({
provider: provider.type,
message: `Expected ${input.length} embeddings, got ${embeddings.length}`,
});
}
return Array.from(embeddings.entries()).map(([index, embedding]) => ({
index,

View File

@@ -15,6 +15,7 @@ import {
CopilotContextService,
} from './context';
import { CopilotController } from './controller';
import { CopilotCronJobs } from './cron';
import { CopilotEmbeddingJob } from './embedding';
import { ChatMessageCache } from './message';
import { PromptService } from './prompt';
@@ -64,6 +65,8 @@ import {
CopilotContextResolver,
CopilotContextService,
CopilotEmbeddingJob,
// cron jobs
CopilotCronJobs,
// transcription
CopilotTranscriptionService,
CopilotTranscriptionResolver,

View File

@@ -304,6 +304,7 @@ const textActions: Prompt[] = [
name: 'Transcript audio',
action: 'Transcript audio',
model: 'gemini-2.5-flash',
optionalModels: ['gemini-2.5-flash', 'gemini-2.5-pro'],
messages: [
{
role: 'system',
@@ -366,6 +367,31 @@ Convert a multi-speaker audio recording into a structured JSON format by transcr
requireAttachment: true,
},
},
{
name: 'Conversation Summary',
action: 'Conversation Summary',
model: 'gpt-4.1-2025-04-14',
messages: [
{
role: 'system',
content: `You are an expert conversation summarizer. Your job is to distill long dialogues into clear, compact summaries that preserve every key decision, fact, and open question. When asked, always:
• Honor any explicit “focus” the user gives you.
• Match the desired length style:
- “brief” → 1-2 sentences
- “detailed” → ≈ 5 sentences or short bullet list
- “comprehensive” → full paragraph(s) covering all salient points.
• Write in neutral, third-person prose and never add new information.
Return only the summary text—no headings, labels, or commentary.`,
},
{
role: 'user',
content: `Summarize the conversation below so it can be carried forward without loss.\n\nFocus: {{focus}}\nDesired length: {{length}}\n\nConversation:\n{{#messages}}\n{{role}}: {{content}}\n{{/messages}}`,
},
],
config: {
requireContent: false,
},
},
{
name: 'Summary',
action: 'Summary',
@@ -1770,11 +1796,74 @@ const chat: Prompt[] = [
},
];
const artifactActions: Prompt[] = [
{
name: 'Code Artifact',
model: 'claude-sonnet-4@20250514',
messages: [
{
role: 'system',
content: `
When sent new notes, respond ONLY with the contents of the html file.
DO NOT INCLUDE ANY OTHER TEXT, EXPLANATIONS, APOLOGIES, OR INTRODUCTORY/CLOSING PHRASES.
IF USER DOES NOT SPECIFY A STYLE, FOLLOW THE DEFAULT STYLE.
<generate_guide>
- The results should be a single HTML file.
- Use tailwindcss to style the website
- Put any additional CSS styles in a style tag and any JavaScript in a script tag.
- Use unpkg or skypack to import any required dependencies.
- Use Google fonts to pull in any open source fonts you require.
- Use lucide icons for any icons.
- If you have any images, load them from Unsplash or use solid colored rectangles.
</generate_guide>
<DO_NOT_USE_COLORS>
- DO NOT USE ANY COLORS
</DO_NOT_USE_COLORS>
<DO_NOT_USE_GRADIENTS>
- DO NOT USE ANY GRADIENTS
</DO_NOT_USE_GRADIENTS>
<COLOR_THEME>
- --affine-blue-300: #93e2fd
- --affine-blue-400: #60cffa
- --affine-blue-500: #3ab5f7
- --affine-blue-600: #1e96eb
- --affine-blue-700: #1e67af
- --affine-text-primary-color: #121212
- --affine-text-secondary-color: #8e8d91
- --affine-text-disable-color: #a9a9ad
- --affine-background-overlay-panel-color: #fbfbfc
- --affine-background-secondary-color: #f4f4f5
- --affine-background-primary-color: #fff
</COLOR_THEME>
<default_style_guide>
- MUST USE White and Blue(#1e96eb) as the primary color
- KEEP THE DEFAULT STYLE SIMPLE AND CLEAN
- DO NOT USE ANY COMPLEX STYLES
- DO NOT USE ANY GRADIENTS
- USE LESS SHADOWS
- USE RADIUS 4px or 8px for rounded corners
- USE 12px or 16px for padding
- Use the tailwind color gray, zinc, slate, neutral much more.
- Use 0.5px border should be better
</default_style_guide>
`,
},
{
role: 'user',
content: '{{content}}',
},
],
},
];
export const prompts: Prompt[] = [
...textActions,
...imageActions,
...chat,
...workflows,
...artifactActions,
];
export async function refreshPrompts(db: PrismaClient) {

View File

@@ -21,6 +21,7 @@ import {
buildDocKeywordSearchGetter,
buildDocSearchGetter,
createCodeArtifactTool,
createConversationSummaryTool,
createDocComposeTool,
createDocEditTool,
createDocKeywordSearchTool,
@@ -139,6 +140,11 @@ export abstract class CopilotProvider<C = any> {
if (options?.tools?.length) {
this.logger.debug(`getTools: ${JSON.stringify(options.tools)}`);
const ac = this.moduleRef.get(AccessController, { strict: false });
const docReader = this.moduleRef.get(DocReader, { strict: false });
const models = this.moduleRef.get(Models, { strict: false });
const prompt = this.moduleRef.get(PromptService, {
strict: false,
});
for (const tool of options.tools) {
const toolDef = this.getProviderSpecificTools(tool, model);
@@ -150,9 +156,20 @@ export abstract class CopilotProvider<C = any> {
continue;
}
switch (tool) {
case 'codeArtifact': {
tools.code_artifact = createCodeArtifactTool(prompt, this.factory);
break;
}
case 'conversationSummary': {
tools.conversation_summary = createConversationSummaryTool(
options.session,
prompt,
this.factory
);
break;
}
case 'docEdit': {
const doc = this.moduleRef.get(DocReader, { strict: false });
const getDocContent = buildContentGetter(ac, doc);
const getDocContent = buildContentGetter(ac, docReader);
tools.doc_edit = createDocEditTool(
this.factory,
getDocContent.bind(null, options)
@@ -163,11 +180,15 @@ export abstract class CopilotProvider<C = any> {
const context = this.moduleRef.get(CopilotContextService, {
strict: false,
});
const docContext = options.session
? await context.getBySessionId(options.session)
: null;
const searchDocs = buildDocSearchGetter(ac, context, docContext);
const searchDocs = buildDocSearchGetter(
ac,
context,
docContext,
models
);
tools.doc_semantic_search = createDocSemanticSearchTool(
searchDocs.bind(null, options)
);
@@ -175,9 +196,6 @@ export abstract class CopilotProvider<C = any> {
}
case 'docKeywordSearch': {
if (this.AFFiNEConfig.indexer.enabled) {
const ac = this.moduleRef.get(AccessController, {
strict: false,
});
const indexerService = this.moduleRef.get(IndexerService, {
strict: false,
});
@@ -192,9 +210,6 @@ export abstract class CopilotProvider<C = any> {
break;
}
case 'docRead': {
const ac = this.moduleRef.get(AccessController, { strict: false });
const models = this.moduleRef.get(Models, { strict: false });
const docReader = this.moduleRef.get(DocReader, { strict: false });
const getDoc = buildDocContentGetter(ac, docReader, models);
tools.doc_read = createDocReadTool(getDoc.bind(null, options));
break;
@@ -205,23 +220,7 @@ export abstract class CopilotProvider<C = any> {
break;
}
case 'docCompose': {
const promptService = this.moduleRef.get(PromptService, {
strict: false,
});
tools.doc_compose = createDocComposeTool(
promptService,
this.factory
);
break;
}
case 'codeArtifact': {
const promptService = this.moduleRef.get(PromptService, {
strict: false,
});
tools.code_artifact = createCodeArtifactTool(
promptService,
this.factory
);
tools.doc_compose = createDocComposeTool(prompt, this.factory);
break;
}
}

View File

@@ -60,6 +60,8 @@ export const VertexSchema: JSONSchema = {
export const PromptConfigStrictSchema = z.object({
tools: z
.enum([
'codeArtifact',
'conversationSummary',
// work with morph
'docEdit',
// work with indexer
@@ -71,7 +73,6 @@ export const PromptConfigStrictSchema = z.object({
'webSearch',
// artifact tools
'docCompose',
'codeArtifact',
])
.array()
.nullable()

View File

@@ -6,20 +6,10 @@ import {
ImagePart,
TextPart,
TextStreamPart,
ToolSet,
} from 'ai';
import { ZodType } from 'zod';
import {
createCodeArtifactTool,
createDocComposeTool,
createDocEditTool,
createDocKeywordSearchTool,
createDocReadTool,
createDocSemanticSearchTool,
createExaCrawlTool,
createExaSearchTool,
} from '../tools';
import { CustomAITools } from '../tools';
import { PromptMessage, StreamObject } from './types';
type ChatMessage = CoreUserMessage | CoreAssistantMessage;
@@ -385,17 +375,6 @@ export class CitationParser {
}
}
export interface CustomAITools extends ToolSet {
doc_edit: ReturnType<typeof createDocEditTool>;
doc_semantic_search: ReturnType<typeof createDocSemanticSearchTool>;
doc_keyword_search: ReturnType<typeof createDocKeywordSearchTool>;
doc_read: ReturnType<typeof createDocReadTool>;
doc_compose: ReturnType<typeof createDocComposeTool>;
web_search_exa: ReturnType<typeof createExaSearchTool>;
web_crawl_exa: ReturnType<typeof createExaCrawlTool>;
code_artifact: ReturnType<typeof createCodeArtifactTool>;
}
type ChunkType = TextStreamPart<CustomAITools>['type'];
export function toError(error: unknown): Error {
@@ -451,6 +430,10 @@ export class TextStreamParser {
);
result = this.addPrefix(result);
switch (chunk.toolName) {
case 'conversation_summary': {
result += `\nSummarizing context\n`;
break;
}
case 'web_search_exa': {
result += `\nSearching the web "${chunk.args.query}"\n`;
break;

View File

@@ -569,7 +569,7 @@ export class ChatSessionService {
});
if (!provider) {
throw new NoCopilotProviderAvailable();
throw new NoCopilotProviderAvailable({ modelId: prompt.model });
}
return provider.text(cond, [...prompt.finish({}), msg], config);

View File

@@ -5,9 +5,7 @@ import { z } from 'zod';
import type { PromptService } from '../prompt';
import type { CopilotProviderFactory } from '../providers';
import { toolError } from './error';
const logger = new Logger('CodeArtifactTool');
/**
* A copilot tool that produces a completely self-contained HTML artifact.
* The returned HTML must include <style> and <script> tags directly so that
@@ -37,23 +35,20 @@ export const createCodeArtifactTool = (
}),
execute: async ({ title, userPrompt }) => {
try {
const prompt = await promptService.get('Make it real with text');
const prompt = await promptService.get('Code Artifact');
if (!prompt) {
throw new Error('Prompt not found');
}
const provider = await factory.getProviderByModel(prompt.model);
if (!provider) {
throw new Error('Provider not found');
}
const content = await provider.text(
{
modelId: prompt.model,
},
[...prompt.finish({}), { role: 'user', content: userPrompt }]
prompt.finish({ content: userPrompt })
);
// Remove surrounding ``` or ```html fences if present
let stripped = content.trim();
if (stripped.startsWith('```')) {
@@ -65,7 +60,6 @@ export const createCodeArtifactTool = (
stripped = stripped.slice(0, -3);
}
}
return {
title,
html: stripped,

View File

@@ -0,0 +1,76 @@
import { Logger } from '@nestjs/common';
import { tool } from 'ai';
import { z } from 'zod';
import type { PromptService } from '../prompt';
import type { CopilotProviderFactory } from '../providers';
import { toolError } from './error';
const logger = new Logger('ConversationSummaryTool');
export const createConversationSummaryTool = (
sessionId: string | undefined,
promptService: PromptService,
factory: CopilotProviderFactory
) => {
return tool({
description:
'Create a concise, AI-generated summary of the conversation so far—capturing key topics, decisions, and critical details. Use this tool whenever the context becomes lengthy to preserve essential information that might otherwise be lost to truncation in future turns.',
parameters: z.object({
focus: z
.string()
.optional()
.describe(
'Optional focus area for the summary (e.g., "technical decisions", "user requirements", "project status")'
),
length: z
.enum(['brief', 'detailed', 'comprehensive'])
.default('detailed')
.describe(
'The desired length of the summary: brief (1-2 sentences), detailed (paragraph), comprehensive (multiple paragraphs)'
),
}),
execute: async ({ focus, length }, { messages }) => {
try {
if (!messages || messages.length === 0) {
return toolError(
'No Conversation Context',
'No messages available to summarize'
);
}
const prompt = await promptService.get('Conversation Summary');
const provider = await factory.getProviderByModel(prompt?.model || '');
if (!prompt || !provider) {
return toolError(
'Prompt Not Found',
'Failed to summarize conversation.'
);
}
const summary = await provider.text(
{ modelId: prompt.model },
prompt.finish({
messages: messages.map(m => ({
...m,
content: m.content.toString(),
})),
focus: focus || 'general',
length,
})
);
return {
focusArea: focus || 'general',
messageCount: messages.length,
summary,
timestamp: new Date().toISOString(),
};
} catch (err: any) {
logger.error(`Failed to summarize conversation (${sessionId})`, err);
return toolError('Conversation Summary Failed', err.message);
}
},
});
};

View File

@@ -2,7 +2,7 @@ import { tool } from 'ai';
import { z } from 'zod';
import type { AccessController } from '../../../core/permission';
import type { ChunkSimilarity } from '../../../models';
import type { ChunkSimilarity, Models } from '../../../models';
import type { CopilotContextService } from '../context';
import type { ContextSession } from '../context/session';
import type { CopilotChatOptions } from '../providers';
@@ -11,7 +11,8 @@ import { toolError } from './error';
export const buildDocSearchGetter = (
ac: AccessController,
context: CopilotContextService,
docContext: ContextSession | null
docContext: ContextSession | null,
models: Models
) => {
const searchDocs = async (
options: CopilotChatOptions,
@@ -45,7 +46,24 @@ export const buildDocSearchGetter = (
}
if (!docChunks.length && !fileChunks.length)
return `No results found for "${query}".`;
return [...fileChunks, ...docChunks];
const docMetas = await models.doc
.findAuthors(
docChunks.map(c => ({
// oxlint-disable-next-line no-non-null-assertion
workspaceId: options.workspace!,
docId: c.docId,
}))
)
.then(docs => new Map(docs.filter(d => !!d).map(doc => [doc.id, doc])));
return [
...fileChunks,
...docChunks.map(c => ({
...c,
...docMetas.get(c.docId),
})),
] as ChunkSimilarity[];
};
return searchDocs;
};

View File

@@ -1,4 +1,29 @@
import { ToolSet } from 'ai';
import { createCodeArtifactTool } from './code-artifact';
import { createConversationSummaryTool } from './conversation-summary';
import { createDocComposeTool } from './doc-compose';
import { createDocEditTool } from './doc-edit';
import { createDocKeywordSearchTool } from './doc-keyword-search';
import { createDocReadTool } from './doc-read';
import { createDocSemanticSearchTool } from './doc-semantic-search';
import { createExaCrawlTool } from './exa-crawl';
import { createExaSearchTool } from './exa-search';
export interface CustomAITools extends ToolSet {
code_artifact: ReturnType<typeof createCodeArtifactTool>;
conversation_summary: ReturnType<typeof createConversationSummaryTool>;
doc_edit: ReturnType<typeof createDocEditTool>;
doc_semantic_search: ReturnType<typeof createDocSemanticSearchTool>;
doc_keyword_search: ReturnType<typeof createDocKeywordSearchTool>;
doc_read: ReturnType<typeof createDocReadTool>;
doc_compose: ReturnType<typeof createDocComposeTool>;
web_search_exa: ReturnType<typeof createExaSearchTool>;
web_crawl_exa: ReturnType<typeof createExaCrawlTool>;
}
export * from './code-artifact';
export * from './conversation-summary';
export * from './doc-compose';
export * from './doc-edit';
export * from './doc-keyword-search';

View File

@@ -171,7 +171,7 @@ export class CopilotTranscriptionService {
);
if (!provider) {
throw new NoCopilotProviderAvailable();
throw new NoCopilotProviderAvailable({ modelId });
}
return provider;

View File

@@ -140,10 +140,13 @@ export class ElasticsearchProvider extends SearchProvider {
const result = await this.request(
'POST',
url.toString(),
JSON.stringify({ query })
JSON.stringify({ query }),
'application/json',
// ignore 409 error: version_conflict_engine_exception, version conflict, required seqNo [255898790], primary term [3]. current document has seqNo [256133002] and primary term [3]
[409]
);
this.logger.debug(
`deleted by query ${table} ${JSON.stringify(query)} in ${Date.now() - start}ms, result: ${JSON.stringify(result)}`
`deleted by query ${table} ${JSON.stringify(query)} in ${Date.now() - start}ms, result: ${JSON.stringify(result).substring(0, 500)}`
);
}
@@ -264,7 +267,8 @@ export class ElasticsearchProvider extends SearchProvider {
method: 'POST' | 'PUT',
url: string,
body: string,
contentType = 'application/json'
contentType = 'application/json',
ignoreErrorStatus?: number[]
) {
const headers = {
'Content-Type': contentType,
@@ -280,6 +284,10 @@ export class ElasticsearchProvider extends SearchProvider {
headers,
});
const data = await response.json();
if (ignoreErrorStatus?.includes(response.status)) {
return data;
}
// handle error, status >= 400
// {
// "error": {

View File

@@ -291,6 +291,11 @@ type CopilotFailedToAddWorkspaceFileEmbeddingDataType {
message: String!
}
type CopilotFailedToGenerateEmbeddingDataType {
message: String!
provider: String!
}
type CopilotFailedToMatchContextDataType {
content: String!
contextId: String!
@@ -595,6 +600,7 @@ type DocType {
mode: PublicDocMode!
permissions: DocPermissions!
public: Boolean!
summary: String
title: String
updatedAt: DateTime
workspaceId: String!
@@ -615,7 +621,7 @@ type EditorType {
name: String!
}
union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotContextFileNotSupportedDataType | CopilotDocNotFoundDataType | CopilotFailedToAddWorkspaceFileEmbeddingDataType | CopilotFailedToMatchContextDataType | CopilotFailedToMatchGlobalContextDataType | CopilotFailedToModifyContextDataType | CopilotInvalidContextDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderNotSupportedDataType | CopilotProviderSideErrorDataType | DocActionDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | DocUpdateBlockedDataType | ExpectToGrantDocUserRolesDataType | ExpectToRevokeDocUserRolesDataType | ExpectToUpdateDocUserRoleDataType | GraphqlBadRequestDataType | HttpRequestErrorDataType | InvalidAppConfigDataType | InvalidAppConfigInputDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidIndexerInputDataType | InvalidLicenseToActivateDataType | InvalidLicenseUpdateParamsDataType | InvalidOauthCallbackCodeDataType | InvalidOauthResponseDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | InvalidSearchProviderRequestDataType | MemberNotFoundInSpaceDataType | MentionUserDocAccessDeniedDataType | MissingOauthQueryParameterDataType | NoMoreSeatDataType | NotInSpaceDataType | QueryTooLongDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SpaceShouldHaveOnlyOneOwnerDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedClientVersionDataType | UnsupportedSubscriptionPlanDataType | ValidationErrorDataType | VersionRejectedDataType | WorkspacePermissionNotFoundDataType | WrongSignInCredentialsDataType
union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotContextFileNotSupportedDataType | CopilotDocNotFoundDataType | CopilotFailedToAddWorkspaceFileEmbeddingDataType | CopilotFailedToGenerateEmbeddingDataType | CopilotFailedToMatchContextDataType | CopilotFailedToMatchGlobalContextDataType | CopilotFailedToModifyContextDataType | CopilotInvalidContextDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderNotSupportedDataType | CopilotProviderSideErrorDataType | DocActionDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | DocUpdateBlockedDataType | ExpectToGrantDocUserRolesDataType | ExpectToRevokeDocUserRolesDataType | ExpectToUpdateDocUserRoleDataType | GraphqlBadRequestDataType | HttpRequestErrorDataType | InvalidAppConfigDataType | InvalidAppConfigInputDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidIndexerInputDataType | InvalidLicenseToActivateDataType | InvalidLicenseUpdateParamsDataType | InvalidOauthCallbackCodeDataType | InvalidOauthResponseDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | InvalidSearchProviderRequestDataType | MemberNotFoundInSpaceDataType | MentionUserDocAccessDeniedDataType | MissingOauthQueryParameterDataType | NoCopilotProviderAvailableDataType | NoMoreSeatDataType | NotInSpaceDataType | QueryTooLongDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SpaceShouldHaveOnlyOneOwnerDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedClientVersionDataType | UnsupportedSubscriptionPlanDataType | ValidationErrorDataType | VersionRejectedDataType | WorkspacePermissionNotFoundDataType | WrongSignInCredentialsDataType
enum ErrorNames {
ACCESS_DENIED
@@ -644,6 +650,7 @@ enum ErrorNames {
COPILOT_EMBEDDING_UNAVAILABLE
COPILOT_FAILED_TO_ADD_WORKSPACE_FILE_EMBEDDING
COPILOT_FAILED_TO_CREATE_MESSAGE
COPILOT_FAILED_TO_GENERATE_EMBEDDING
COPILOT_FAILED_TO_GENERATE_TEXT
COPILOT_FAILED_TO_MATCH_CONTEXT
COPILOT_FAILED_TO_MATCH_GLOBAL_CONTEXT
@@ -1335,6 +1342,10 @@ type Mutation {
verifyEmail(token: String!): Boolean!
}
type NoCopilotProviderAvailableDataType {
modelId: String!
}
type NoMoreSeatDataType {
spaceId: String!
}

View File

@@ -82,6 +82,10 @@ export type RequestOptions<Q extends GraphQLQuery> = QueryVariablesOption<Q> & {
* @default 15000
*/
timeout?: number;
/**
* Abort signal
*/
signal?: AbortSignal;
};
export type QueryOptions<Q extends GraphQLQuery> = RequestOptions<Q> & {
@@ -207,6 +211,7 @@ export const gqlFetcherFactory = (
headers,
body: isFormData ? body : JSON.stringify(body),
timeout: options.timeout,
signal: options.signal,
})
).then(async res => {
if (res.headers.get('content-type')?.startsWith('application/json')) {

View File

@@ -3,15 +3,17 @@
query getCopilotRecentSessions(
$workspaceId: String!
$limit: Int = 10
$offset: Int = 0
) {
currentUser {
copilot(workspaceId: $workspaceId) {
chats(
pagination: { first: $limit }
pagination: { first: $limit, offset: $offset }
options: {
action: false
fork: false
sessionOrder: desc
withMessages: true
withMessages: false
}
) {
...PaginatedCopilotChats

View File

@@ -5,6 +5,8 @@ query getWorkspacePageById($workspaceId: String!, $pageId: String!) {
mode
defaultRole
public
title
summary
}
}
}

View File

@@ -1068,12 +1068,12 @@ ${paginatedCopilotChatsFragment}`,
export const getCopilotRecentSessionsQuery = {
id: 'getCopilotRecentSessionsQuery' as const,
op: 'getCopilotRecentSessions',
query: `query getCopilotRecentSessions($workspaceId: String!, $limit: Int = 10) {
query: `query getCopilotRecentSessions($workspaceId: String!, $limit: Int = 10, $offset: Int = 0) {
currentUser {
copilot(workspaceId: $workspaceId) {
chats(
pagination: {first: $limit}
options: {fork: false, sessionOrder: desc, withMessages: true}
pagination: {first: $limit, offset: $offset}
options: {action: false, fork: false, sessionOrder: desc, withMessages: false}
) {
...PaginatedCopilotChats
}
@@ -1584,6 +1584,8 @@ export const getWorkspacePageByIdQuery = {
mode
defaultRole
public
title
summary
}
}
}`,

View File

@@ -375,6 +375,12 @@ export interface CopilotFailedToAddWorkspaceFileEmbeddingDataType {
message: Scalars['String']['output'];
}
export interface CopilotFailedToGenerateEmbeddingDataType {
__typename?: 'CopilotFailedToGenerateEmbeddingDataType';
message: Scalars['String']['output'];
provider: Scalars['String']['output'];
}
export interface CopilotFailedToMatchContextDataType {
__typename?: 'CopilotFailedToMatchContextDataType';
content: Scalars['String']['output'];
@@ -703,6 +709,7 @@ export interface DocType {
mode: PublicDocMode;
permissions: DocPermissions;
public: Scalars['Boolean']['output'];
summary: Maybe<Scalars['String']['output']>;
title: Maybe<Scalars['String']['output']>;
updatedAt: Maybe<Scalars['DateTime']['output']>;
workspaceId: Scalars['String']['output'];
@@ -736,6 +743,7 @@ export type ErrorDataUnion =
| CopilotContextFileNotSupportedDataType
| CopilotDocNotFoundDataType
| CopilotFailedToAddWorkspaceFileEmbeddingDataType
| CopilotFailedToGenerateEmbeddingDataType
| CopilotFailedToMatchContextDataType
| CopilotFailedToMatchGlobalContextDataType
| CopilotFailedToModifyContextDataType
@@ -768,6 +776,7 @@ export type ErrorDataUnion =
| MemberNotFoundInSpaceDataType
| MentionUserDocAccessDeniedDataType
| MissingOauthQueryParameterDataType
| NoCopilotProviderAvailableDataType
| NoMoreSeatDataType
| NotInSpaceDataType
| QueryTooLongDataType
@@ -815,6 +824,7 @@ export enum ErrorNames {
COPILOT_EMBEDDING_UNAVAILABLE = 'COPILOT_EMBEDDING_UNAVAILABLE',
COPILOT_FAILED_TO_ADD_WORKSPACE_FILE_EMBEDDING = 'COPILOT_FAILED_TO_ADD_WORKSPACE_FILE_EMBEDDING',
COPILOT_FAILED_TO_CREATE_MESSAGE = 'COPILOT_FAILED_TO_CREATE_MESSAGE',
COPILOT_FAILED_TO_GENERATE_EMBEDDING = 'COPILOT_FAILED_TO_GENERATE_EMBEDDING',
COPILOT_FAILED_TO_GENERATE_TEXT = 'COPILOT_FAILED_TO_GENERATE_TEXT',
COPILOT_FAILED_TO_MATCH_CONTEXT = 'COPILOT_FAILED_TO_MATCH_CONTEXT',
COPILOT_FAILED_TO_MATCH_GLOBAL_CONTEXT = 'COPILOT_FAILED_TO_MATCH_GLOBAL_CONTEXT',
@@ -1880,6 +1890,11 @@ export interface MutationVerifyEmailArgs {
token: Scalars['String']['input'];
}
export interface NoCopilotProviderAvailableDataType {
__typename?: 'NoCopilotProviderAvailableDataType';
modelId: Scalars['String']['output'];
}
export interface NoMoreSeatDataType {
__typename?: 'NoMoreSeatDataType';
spaceId: Scalars['String']['output'];
@@ -4350,6 +4365,7 @@ export type GetCopilotSessionQuery = {
export type GetCopilotRecentSessionsQueryVariables = Exact<{
workspaceId: Scalars['String']['input'];
limit?: InputMaybe<Scalars['Int']['input']>;
offset?: InputMaybe<Scalars['Int']['input']>;
}>;
export type GetCopilotRecentSessionsQuery = {
@@ -5147,6 +5163,8 @@ export type GetWorkspacePageByIdQuery = {
mode: PublicDocMode;
defaultRole: DocRole;
public: boolean;
title: string | null;
summary: string | null;
};
};
};

View File

@@ -541,7 +541,7 @@
"$(inherited)",
"$(PROJECT_DIR)",
);
MARKETING_VERSION = 0.22.2;
MARKETING_VERSION = 0.23.1;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = app.affine.pro;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -577,7 +577,7 @@
"$(inherited)",
"$(PROJECT_DIR)",
);
MARKETING_VERSION = 0.22.2;
MARKETING_VERSION = 0.23.1;
ONLY_ACTIVE_ARCH = NO;
PRODUCT_BUNDLE_IDENTIFIER = app.affine.pro;
PRODUCT_NAME = "$(TARGET_NAME)";

View File

@@ -1,5 +1,6 @@
import type { MindmapElementModel } from '@blocksuite/affine/model';
import type { EditorHost } from '@blocksuite/affine/std';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import { createAIScrollableTextRenderer } from '../components/ai-scrollable-text-renderer';
import {
@@ -52,5 +53,11 @@ export function actionToAnswerRenderer<
return createImageRenderer(host, { height: 300 });
}
return createAIScrollableTextRenderer(host, {}, 320, true);
return createAIScrollableTextRenderer(
{
theme: host.std.get(ThemeProvider).app$,
},
320,
true
);
}

View File

@@ -11,6 +11,7 @@ import {
} from '@blocksuite/affine/shared/utils';
import type { EditorHost } from '@blocksuite/affine/std';
import { GfxControllerIdentifier } from '@blocksuite/affine/std/gfx';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import {
ChatWithAiIcon,
DeleteIcon,
@@ -306,7 +307,13 @@ export function buildAIPanelConfig(
const ctx = new AIContext();
const searchService = framework.get(AINetworkSearchService);
return {
answerRenderer: createAIScrollableTextRenderer(panel.host, {}, 320, true),
answerRenderer: createAIScrollableTextRenderer(
{
theme: panel.host.std.get(ThemeProvider).app$,
},
320,
true
),
finishStateConfig: buildFinishConfig(panel, 'chat', ctx),
generatingStateConfig: buildGeneratingConfig(),
errorStateConfig: buildErrorConfig(panel),

View File

@@ -20,16 +20,15 @@ import { property, state } from 'lit/decorators.js';
import { keyed } from 'lit/directives/keyed.js';
import { AffineIcon } from '../_common/icons';
import type {
DocDisplayConfig,
SearchMenuConfig,
} from '../components/ai-chat-chips';
import type { SearchMenuConfig } from '../components/ai-chat-add-context';
import type { DocDisplayConfig } from '../components/ai-chat-chips';
import type { ChatContextValue } from '../components/ai-chat-content';
import type {
AINetworkSearchConfig,
AIPlaygroundConfig,
AIReasoningConfig,
} from '../components/ai-chat-input';
import type { ChatStatus } from '../components/ai-chat-messages';
import { createPlaygroundModal } from '../components/playground/modal';
import { AIProvider } from '../provider';
import type { AppSidebarConfig } from './chat-config';
@@ -138,6 +137,9 @@ export class ChatPanel extends SignalWatcher(
@state()
accessor embeddingProgress: [number, number] = [0, 0];
@state()
accessor status: ChatStatus = 'idle';
private isSidebarOpen: Signal<boolean | undefined> = signal(false);
private sidebarWidth: Signal<number | undefined> = signal(undefined);
@@ -171,6 +173,7 @@ export class ChatPanel extends SignalWatcher(
.session=${this.session}
.workspaceId=${this.doc.workspace.id}
.docId=${this.doc.id}
.status=${this.status}
.onNewSession=${this.newSession}
.onTogglePin=${this.togglePin}
.onOpenSession=${this.openSession}
@@ -359,6 +362,7 @@ export class ChatPanel extends SignalWatcher(
private readonly onContextChange = async (
context: Partial<ChatContextValue>
) => {
this.status = context.status ?? 'idle';
if (context.status === 'success') {
await this.rebindSession();
}
@@ -379,6 +383,7 @@ export class ChatPanel extends SignalWatcher(
.affineFeatureFlagService=${this.affineFeatureFlagService}
.affineThemeService=${this.affineThemeService}
.notificationService=${this.notificationService}
.affineWorkspaceDialogService=${this.affineWorkspaceDialogService}
></playground-content>
`;

View File

@@ -3,8 +3,11 @@ import type { AppThemeService } from '@affine/core/modules/theme';
import type { CopilotChatHistoryFragment } from '@affine/graphql';
import { WithDisposable } from '@blocksuite/affine/global/lit';
import { isInsidePageEditor } from '@blocksuite/affine/shared/utils';
import type { EditorHost } from '@blocksuite/affine/std';
import { ShadowlessElement } from '@blocksuite/affine/std';
import {
type BlockStdScope,
type EditorHost,
ShadowlessElement,
} from '@blocksuite/affine/std';
import type { ExtensionType } from '@blocksuite/affine/store';
import type { NotificationService } from '@blocksuite/affine-shared/services';
import type { Signal } from '@preact/signals-core';
@@ -15,6 +18,7 @@ import {
EdgelessEditorActions,
PageEditorActions,
} from '../../_common/chat-actions-handle';
import type { DocDisplayConfig } from '../../components/ai-chat-chips';
import {
type ChatMessage,
type ChatStatus,
@@ -37,6 +41,9 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor host: EditorHost | null | undefined;
@property({ attribute: false })
accessor std: BlockStdScope | null | undefined;
@property({ attribute: false })
accessor item!: ChatMessage;
@@ -73,6 +80,12 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor notificationService!: NotificationService;
@property({ attribute: false })
accessor independentMode: boolean | undefined;
@property({ attribute: false })
accessor docDisplayService!: DocDisplayConfig;
get state() {
const { isLast, status } = this;
return isLast
@@ -124,6 +137,7 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
private renderStreamObjects(answer: StreamObject[]) {
return html`<chat-content-stream-objects
.host=${this.host}
.std=${this.std}
.answer=${answer}
.state=${this.state}
.width=${this.width}
@@ -131,6 +145,7 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
.affineFeatureFlagService=${this.affineFeatureFlagService}
.notificationService=${this.notificationService}
.theme=${this.affineThemeService.appTheme.themeSignal}
.docDisplayService=${this.docDisplayService}
></chat-content-stream-objects>`;
}
@@ -168,7 +183,7 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
: EdgelessEditorActions
: null;
const showActions = host && !!markdown;
const showActions = host && !!markdown && !this.independentMode;
return html`
<chat-copy-more

View File

@@ -0,0 +1,101 @@
import { createLitPortal } from '@blocksuite/affine/components/portal';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import { ShadowlessElement } from '@blocksuite/affine/std';
import { PlusIcon } from '@blocksuite/icons/lit';
import { flip, offset } from '@floating-ui/dom';
import { css, html } from 'lit';
import { property, query } from 'lit/decorators.js';
import type { ChatChip, DocDisplayConfig } from '../ai-chat-chips';
import type { SearchMenuConfig } from './type';
export class AIChatAddContext extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
.ai-chat-add-context {
display: flex;
flex-shrink: 0;
flex-grow: 0;
align-items: center;
justify-content: center;
cursor: pointer;
}
`;
@property({ attribute: false })
accessor docId: string | undefined;
@property({ attribute: false })
accessor independentMode: boolean | undefined;
@property({ attribute: false })
accessor addChip!: (chip: ChatChip) => Promise<void>;
@property({ attribute: false })
accessor addImages!: (images: File[]) => void;
@property({ attribute: false })
accessor docDisplayConfig!: DocDisplayConfig;
@property({ attribute: false })
accessor searchMenuConfig!: SearchMenuConfig;
@property({ attribute: false })
accessor portalContainer: HTMLElement | null = null;
@query('.ai-chat-add-context')
accessor addButton!: HTMLDivElement;
private abortController: AbortController | null = null;
override render() {
return html`
<div
class="ai-chat-add-context"
data-testid="chat-panel-with-button"
@click=${this.toggleAddDocMenu}
>
${PlusIcon()}
</div>
`;
}
private readonly toggleAddDocMenu = () => {
if (this.abortController) {
this.abortController.abort();
return;
}
this.abortController = new AbortController();
this.abortController.signal.addEventListener('abort', () => {
this.abortController = null;
});
createLitPortal({
template: html`
<chat-panel-add-popover
.docId=${this.docId}
.independentMode=${this.independentMode}
.addChip=${this.addChip}
.addImages=${this.addImages}
.searchMenuConfig=${this.searchMenuConfig}
.docDisplayConfig=${this.docDisplayConfig}
.abortController=${this.abortController}
></chat-panel-add-popover>
`,
portalStyles: {
zIndex: 'var(--affine-z-index-popover)',
},
container: this.portalContainer ?? document.body,
computePosition: {
referenceElement: this.addButton,
placement: 'top-start',
middleware: [offset({ crossAxis: -30, mainAxis: 8 }), flip()],
autoUpdate: { animationFrame: true },
},
abortController: this.abortController,
closeOnClickAway: true,
});
};
}

View File

@@ -0,0 +1,2 @@
export * from './ai-chat-add-context';
export * from './type';

View File

@@ -0,0 +1,24 @@
import type {
SearchCollectionMenuAction,
SearchDocMenuAction,
SearchTagMenuAction,
} from '@affine/core/modules/search-menu/services';
import type { LinkedMenuGroup } from '@blocksuite/affine/widgets/linked-doc';
export interface SearchMenuConfig {
getDocMenuGroup: (
query: string,
action: SearchDocMenuAction,
abortSignal: AbortSignal
) => LinkedMenuGroup;
getTagMenuGroup: (
query: string,
action: SearchTagMenuAction,
abortSignal: AbortSignal
) => LinkedMenuGroup;
getCollectionMenuGroup: (
query: string,
action: SearchCollectionMenuAction,
abortSignal: AbortSignal
) => LinkedMenuGroup;
}

View File

@@ -1,7 +1,7 @@
import { toast } from '@affine/component';
import type { TagMeta } from '@affine/core/components/page-list';
import type { CollectionMeta } from '@affine/core/modules/collection';
import track from '@affine/track';
import track, { type EventArgs } from '@affine/track';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import { scrollbarStyle } from '@blocksuite/affine/shared/styles';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
@@ -21,8 +21,8 @@ import { css, html, type TemplateResult } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { MAX_IMAGE_COUNT } from '../ai-chat-input';
import type { ChatChip, DocDisplayConfig, SearchMenuConfig } from './type';
import type { SearchMenuConfig } from '../ai-chat-add-context';
import type { ChatChip, DocDisplayConfig } from './type';
enum AddPopoverMode {
Default = 'default',
@@ -120,6 +120,12 @@ export class ChatPanelAddPopover extends SignalWatcher(
private accessor _query = '';
@property({ attribute: false })
accessor independentMode: boolean | undefined;
@property({ attribute: false })
accessor docId: string | undefined;
@state()
private accessor _searchGroups: MenuGroup[] = [];
@@ -165,35 +171,31 @@ export class ChatPanelAddPopover extends SignalWatcher(
const files = await openFilesWith();
if (!files || files.length === 0) return;
this.abortController.abort();
const images = files.filter(file => file.type.startsWith('image/'));
if (images.length > 0) {
this.addImages(images);
}
const others = files.filter(file => !file.type.startsWith('image/'));
for (const file of others) {
const addChipPromises = others.map(async file => {
if (file.size > 50 * 1024 * 1024) {
toast(`${file.name} is too large, please upload a file less than 50MB`);
} else {
await this.addChip({
file,
state: 'processing',
});
return;
}
}
await this.addChip({
file,
state: 'processing',
});
});
await Promise.all(addChipPromises);
this._track('file');
this.abortController.abort();
};
private readonly _addImageChip = async () => {
if (this.isImageUploadDisabled) return;
const images = await openFilesWith('Images');
if (!images) return;
if (this.uploadImageCount + images.length > MAX_IMAGE_COUNT) {
toast(`You can only upload up to ${MAX_IMAGE_COUNT} images`);
return;
}
this.abortController.abort();
this.addImages(images);
};
@@ -289,9 +291,6 @@ export class ChatPanelAddPopover extends SignalWatcher(
@property({ attribute: 'data-testid', reflect: true })
accessor testId: string = 'ai-search-input';
@property({ attribute: false })
accessor isImageUploadDisabled!: boolean;
@property({ attribute: false })
accessor uploadImageCount!: number;
@@ -498,31 +497,32 @@ export class ChatPanelAddPopover extends SignalWatcher(
}
private readonly _addDocChip = async (meta: DocMeta) => {
this.abortController.abort();
await this.addChip({
docId: meta.id,
state: 'processing',
});
const mode = this.docDisplayConfig.getDocPrimaryMode(meta.id);
this._track('doc', mode);
this.abortController.abort();
const method = meta.id === this.docId ? 'cur-doc' : 'doc';
this._track(method, mode);
};
private readonly _addTagChip = async (tag: TagMeta) => {
this.abortController.abort();
await this.addChip({
tagId: tag.id,
state: 'processing',
});
this._track('tags');
this.abortController.abort();
};
private readonly _addCollectionChip = async (collection: CollectionMeta) => {
this.abortController.abort();
await this.addChip({
collectionId: collection.id,
state: 'processing',
});
this._track('collections');
this.abortController.abort();
};
private readonly _handleKeyDown = (event: KeyboardEvent) => {
@@ -568,10 +568,13 @@ export class ChatPanelAddPopover extends SignalWatcher(
}
private _track(
method: 'doc' | 'file' | 'tags' | 'collections',
method: EventArgs['addEmbeddingDoc']['method'],
type?: 'page' | 'edgeless'
) {
track.$.chatPanel.chatPanelInput.addEmbeddingDoc({
const page = this.independentMode
? track.$.intelligence
: track.$.chatPanel;
page.chatPanelInput.addEmbeddingDoc({
control: 'addButton',
method,
type,

View File

@@ -3,7 +3,7 @@ import { createLitPortal } from '@blocksuite/affine/components/portal';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import { ShadowlessElement } from '@blocksuite/affine/std';
import { MoreVerticalIcon, PlusIcon } from '@blocksuite/icons/lit';
import { MoreVerticalIcon } from '@blocksuite/icons/lit';
import { flip, offset } from '@floating-ui/dom';
import { computed, type Signal, signal } from '@preact/signals-core';
import { css, html, nothing, type PropertyValues } from 'lit';
@@ -11,16 +11,7 @@ import { property, query, state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { isEqual } from 'lodash-es';
import { AIProvider } from '../../provider';
import type {
ChatChip,
CollectionChip,
DocChip,
DocDisplayConfig,
FileChip,
SearchMenuConfig,
TagChip,
} from './type';
import type { ChatChip, DocChip, DocDisplayConfig, FileChip } from './type';
import {
estimateTokenCount,
getChipKey,
@@ -39,44 +30,46 @@ export class ChatPanelChips extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
.chips-wrapper {
.ai-chat-panel-chips {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
padding: 4px 12px;
}
.add-button,
.collapse-button,
.more-candidate-button {
display: flex;
flex-shrink: 0;
flex-grow: 0;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
border-radius: 4px;
box-sizing: border-box;
cursor: pointer;
font-size: 12px;
color: ${unsafeCSSVarV2('icon/primary')};
}
.add-button:hover,
.collapse-button:hover,
.more-candidate-button:hover {
background-color: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
}
.more-candidate-button {
border-width: 1px;
border-style: dashed;
border-color: ${unsafeCSSVarV2('icon/tertiary')};
background: ${unsafeCSSVarV2('layer/background/secondary')};
color: ${unsafeCSSVarV2('icon/secondary')};
}
.more-candidate-button svg {
color: ${unsafeCSSVarV2('icon/secondary')};
.collapse-button,
.more-candidate-button {
display: flex;
flex-shrink: 0;
flex-grow: 0;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
border-radius: 4px;
box-sizing: border-box;
cursor: pointer;
font-size: 12px;
color: ${unsafeCSSVarV2('icon/primary')};
}
.collapse-button:hover,
.more-candidate-button:hover {
background-color: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
}
.more-candidate-button {
border-width: 1px;
border-style: dashed;
border-color: ${unsafeCSSVarV2('icon/tertiary')};
background: ${unsafeCSSVarV2('layer/background/secondary')};
color: ${unsafeCSSVarV2('icon/secondary')};
}
.more-candidate-button svg {
color: ${unsafeCSSVarV2('icon/secondary')};
}
}
`;
@@ -86,38 +79,38 @@ export class ChatPanelChips extends SignalWatcher(
accessor chips!: ChatChip[];
@property({ attribute: false })
accessor createContextId!: () => Promise<string | undefined>;
accessor isCollapsed!: boolean;
@property({ attribute: false })
accessor updateChips!: (chips: ChatChip[]) => void;
accessor independentMode: boolean | undefined;
@property({ attribute: false })
accessor addImages!: (images: File[]) => void;
accessor addChip!: (chip: ChatChip) => Promise<void>;
@property({ attribute: false })
accessor pollContextDocsAndFiles!: () => void;
accessor updateChip!: (
chip: ChatChip,
options: Partial<DocChip | FileChip>
) => void;
@property({ attribute: false })
accessor removeChip!: (chip: ChatChip) => Promise<void>;
@property({ attribute: false })
accessor toggleCollapse!: () => void;
@property({ attribute: false })
accessor docDisplayConfig!: DocDisplayConfig;
@property({ attribute: false })
accessor searchMenuConfig!: SearchMenuConfig;
@property({ attribute: false })
accessor portalContainer: HTMLElement | null = null;
@property({ attribute: 'data-testid', reflect: true })
accessor testId = 'chat-panel-chips';
@query('.add-button')
accessor addButton!: HTMLDivElement;
@query('.more-candidate-button')
accessor moreCandidateButton!: HTMLDivElement;
@state()
accessor isCollapsed = false;
@state()
accessor referenceDocs: Signal<
Array<{
@@ -144,14 +137,7 @@ export class ChatPanelChips extends SignalWatcher(
const isCollapsed = this.isCollapsed && allChips.length > 1;
const chips = isCollapsed ? allChips.slice(0, 1) : allChips;
return html`<div class="chips-wrapper">
<div
class="add-button"
data-testid="chat-panel-with-button"
@click=${this._toggleAddDocMenu}
>
${PlusIcon()}
</div>
return html`<div class="ai-chat-panel-chips">
${repeat(
chips,
chip => getChipKey(chip),
@@ -159,9 +145,10 @@ export class ChatPanelChips extends SignalWatcher(
if (isDocChip(chip)) {
return html`<chat-panel-doc-chip
.chip=${chip}
.addChip=${this._addChip}
.updateChip=${this._updateChip}
.removeChip=${this._removeChip}
.independentMode=${this.independentMode}
.addChip=${this.addChip}
.updateChip=${this.updateChip}
.removeChip=${this.removeChip}
.checkTokenLimit=${this._checkTokenLimit}
.docDisplayConfig=${this.docDisplayConfig}
></chat-panel-doc-chip>`;
@@ -169,7 +156,7 @@ export class ChatPanelChips extends SignalWatcher(
if (isFileChip(chip)) {
return html`<chat-panel-file-chip
.chip=${chip}
.removeChip=${this._removeChip}
.removeChip=${this.removeChip}
></chat-panel-file-chip>`;
}
if (isTagChip(chip)) {
@@ -180,7 +167,7 @@ export class ChatPanelChips extends SignalWatcher(
return html`<chat-panel-tag-chip
.chip=${chip}
.tag=${tag}
.removeChip=${this._removeChip}
.removeChip=${this.removeChip}
></chat-panel-tag-chip>`;
}
if (isCollectionChip(chip)) {
@@ -193,7 +180,7 @@ export class ChatPanelChips extends SignalWatcher(
return html`<chat-panel-collection-chip
.chip=${chip}
.collection=${collection}
.removeChip=${this._removeChip}
.removeChip=${this.removeChip}
></chat-panel-collection-chip>`;
}
return null;
@@ -208,7 +195,7 @@ export class ChatPanelChips extends SignalWatcher(
</div>`
: nothing}
${isCollapsed
? html`<div class="collapse-button" @click=${this._toggleCollapse}>
? html`<div class="collapse-button" @click=${this.toggleCollapse}>
+${allChips.length - 1}
</div>`
: nothing}
@@ -227,14 +214,6 @@ export class ChatPanelChips extends SignalWatcher(
}
protected override updated(_changedProperties: PropertyValues): void {
if (
_changedProperties.has('chatContextValue') &&
_changedProperties.get('chatContextValue')?.status === 'loading' &&
this.isCollapsed === false
) {
this.isCollapsed = true;
}
if (_changedProperties.has('chips')) {
this._updateReferenceDocs();
}
@@ -245,46 +224,6 @@ export class ChatPanelChips extends SignalWatcher(
this._cleanup?.();
}
private readonly _toggleCollapse = () => {
this.isCollapsed = !this.isCollapsed;
};
private readonly _toggleAddDocMenu = () => {
if (this._abortController) {
this._abortController.abort();
return;
}
this._abortController = new AbortController();
this._abortController.signal.addEventListener('abort', () => {
this._abortController = null;
});
createLitPortal({
template: html`
<chat-panel-add-popover
.addChip=${this._addChip}
.addImages=${this.addImages}
.searchMenuConfig=${this.searchMenuConfig}
.docDisplayConfig=${this.docDisplayConfig}
.abortController=${this._abortController}
></chat-panel-add-popover>
`,
portalStyles: {
zIndex: 'var(--affine-z-index-popover)',
},
container: this.portalContainer ?? document.body,
computePosition: {
referenceElement: this.addButton,
placement: 'top-start',
middleware: [offset({ crossAxis: -30, mainAxis: 8 }), flip()],
autoUpdate: { animationFrame: true },
},
abortController: this._abortController,
closeOnClickAway: true,
});
};
private readonly _toggleMoreCandidatesMenu = () => {
if (this._abortController) {
this._abortController.abort();
@@ -303,7 +242,7 @@ export class ChatPanelChips extends SignalWatcher(
createLitPortal({
template: html`
<chat-panel-candidates-popover
.addChip=${this._addChip}
.addChip=${this.addChip}
.referenceDocs=${referenceDocs}
.docDisplayConfig=${this.docDisplayConfig}
.abortController=${this._abortController}
@@ -324,190 +263,6 @@ export class ChatPanelChips extends SignalWatcher(
});
};
private readonly _addChip = async (chip: ChatChip) => {
this.isCollapsed = false;
// remove the chip if it already exists
const chips = this._omitChip(this.chips, chip);
this.updateChips([...chips, chip]);
if (chips.length < this.chips.length) {
await this._removeFromContext(chip);
}
await this._addToContext(chip);
this.pollContextDocsAndFiles();
};
private readonly _updateChip = (
chip: ChatChip,
options: Partial<DocChip | FileChip>
) => {
const index = this._findChipIndex(this.chips, chip);
if (index === -1) {
return;
}
const nextChip: ChatChip = {
...chip,
...options,
};
this.updateChips([
...this.chips.slice(0, index),
nextChip,
...this.chips.slice(index + 1),
]);
};
private readonly _removeChip = async (chip: ChatChip) => {
const chips = this._omitChip(this.chips, chip);
this.updateChips(chips);
if (chips.length < this.chips.length) {
await this._removeFromContext(chip);
}
};
private readonly _addToContext = async (chip: ChatChip) => {
if (isDocChip(chip)) {
return await this._addDocToContext(chip);
}
if (isFileChip(chip)) {
return await this._addFileToContext(chip);
}
if (isTagChip(chip)) {
return await this._addTagToContext(chip);
}
if (isCollectionChip(chip)) {
return await this._addCollectionToContext(chip);
}
return null;
};
private readonly _addDocToContext = async (chip: DocChip) => {
try {
const contextId = await this.createContextId();
if (!contextId || !AIProvider.context) {
throw new Error('Context not found');
}
await AIProvider.context.addContextDoc({
contextId,
docId: chip.docId,
});
} catch (e) {
this._updateChip(chip, {
state: 'failed',
tooltip: e instanceof Error ? e.message : 'Add context doc error',
});
}
};
private readonly _addFileToContext = async (chip: FileChip) => {
try {
const contextId = await this.createContextId();
if (!contextId || !AIProvider.context) {
throw new Error('Context not found');
}
const contextFile = await AIProvider.context.addContextFile(chip.file, {
contextId,
});
this._updateChip(chip, {
state: contextFile.status,
blobId: contextFile.blobId,
fileId: contextFile.id,
});
} catch (e) {
this._updateChip(chip, {
state: 'failed',
tooltip: e instanceof Error ? e.message : 'Add context file error',
});
}
};
private readonly _addTagToContext = async (chip: TagChip) => {
try {
const contextId = await this.createContextId();
if (!contextId || !AIProvider.context) {
throw new Error('Context not found');
}
// TODO: server side docIds calculation
const docIds = this.docDisplayConfig.getTagPageIds(chip.tagId);
await AIProvider.context.addContextTag({
contextId,
tagId: chip.tagId,
docIds,
});
this._updateChip(chip, {
state: 'finished',
});
} catch (e) {
this._updateChip(chip, {
state: 'failed',
tooltip: e instanceof Error ? e.message : 'Add context tag error',
});
}
};
private readonly _addCollectionToContext = async (chip: CollectionChip) => {
try {
const contextId = await this.createContextId();
if (!contextId || !AIProvider.context) {
throw new Error('Context not found');
}
// TODO: server side docIds calculation
const docIds = this.docDisplayConfig.getCollectionPageIds(
chip.collectionId
);
await AIProvider.context.addContextCollection({
contextId,
collectionId: chip.collectionId,
docIds,
});
this._updateChip(chip, {
state: 'finished',
});
} catch (e) {
this._updateChip(chip, {
state: 'failed',
tooltip:
e instanceof Error ? e.message : 'Add context collection error',
});
}
};
private readonly _removeFromContext = async (
chip: ChatChip
): Promise<boolean> => {
try {
const contextId = await this.createContextId();
if (!contextId || !AIProvider.context) {
return true;
}
if (isDocChip(chip)) {
return await AIProvider.context.removeContextDoc({
contextId,
docId: chip.docId,
});
}
if (isFileChip(chip) && chip.fileId) {
return await AIProvider.context.removeContextFile({
contextId,
fileId: chip.fileId,
});
}
if (isTagChip(chip)) {
return await AIProvider.context.removeContextTag({
contextId,
tagId: chip.tagId,
});
}
if (isCollectionChip(chip)) {
return await AIProvider.context.removeContextCollection({
contextId,
collectionId: chip.collectionId,
});
}
return true;
} catch {
return true;
}
};
private readonly _checkTokenLimit = (
newChip: DocChip,
newTokenCount: number
@@ -544,44 +299,4 @@ export class ChatPanelChips extends SignalWatcher(
this.referenceDocs = signal;
this._cleanup = cleanup;
};
private readonly _omitChip = (chips: ChatChip[], chip: ChatChip) => {
return chips.filter(item => {
if (isDocChip(chip)) {
return !isDocChip(item) || item.docId !== chip.docId;
}
if (isFileChip(chip)) {
return !isFileChip(item) || item.file !== chip.file;
}
if (isTagChip(chip)) {
return !isTagChip(item) || item.tagId !== chip.tagId;
}
if (isCollectionChip(chip)) {
return (
!isCollectionChip(item) || item.collectionId !== chip.collectionId
);
}
return true;
});
};
private readonly _findChipIndex = (chips: ChatChip[], chip: ChatChip) => {
return chips.findIndex(item => {
if (isDocChip(chip)) {
return isDocChip(item) && item.docId === chip.docId;
}
if (isFileChip(chip)) {
return isFileChip(item) && item.file === chip.file;
}
if (isTagChip(chip)) {
return isTagChip(item) && item.tagId === chip.tagId;
}
if (isCollectionChip(chip)) {
return (
isCollectionChip(item) && item.collectionId === chip.collectionId
);
}
return -1;
});
};
}

View File

@@ -18,6 +18,9 @@ export class ChatPanelDocChip extends SignalWatcher(
@property({ attribute: false })
accessor chip!: DocChip;
@property({ attribute: false })
accessor independentMode: boolean | undefined;
@property({ attribute: false })
accessor addChip!: (chip: DocChip) => void;
@@ -81,7 +84,10 @@ export class ChatPanelDocChip extends SignalWatcher(
state: 'processing',
});
const mode = this.docDisplayConfig.getDocPrimaryMode(this.chip.docId);
track.$.chatPanel.chatPanelInput.addEmbeddingDoc({
const page = this.independentMode
? track.$.intelligence
: track.$.chatPanel;
page.chatPanelInput.addEmbeddingDoc({
control: 'addButton',
method: 'suggestion',
type: mode,

View File

@@ -1,11 +1,5 @@
import type { TagMeta } from '@affine/core/components/page-list';
import type {
SearchCollectionMenuAction,
SearchDocMenuAction,
SearchTagMenuAction,
} from '@affine/core/modules/search-menu/services';
import type { DocMeta, Store } from '@blocksuite/affine/store';
import type { LinkedMenuGroup } from '@blocksuite/affine/widgets/linked-doc';
import type { Signal } from '@preact/signals-core';
export type ChipState = 'candidate' | 'processing' | 'finished' | 'failed';
@@ -75,21 +69,3 @@ export interface DocDisplayConfig {
};
getCollectionPageIds: (collectionId: string) => string[];
}
export interface SearchMenuConfig {
getDocMenuGroup: (
query: string,
action: SearchDocMenuAction,
abortSignal: AbortSignal
) => LinkedMenuGroup;
getTagMenuGroup: (
query: string,
action: SearchTagMenuAction,
abortSignal: AbortSignal
) => LinkedMenuGroup;
getCollectionMenuGroup: (
query: string,
action: SearchCollectionMenuAction,
abortSignal: AbortSignal
) => LinkedMenuGroup;
}

View File

@@ -78,6 +78,42 @@ export function getChipKey(chip: ChatChip) {
return null;
}
export function omitChip(chips: ChatChip[], chip: ChatChip) {
return chips.filter(item => {
if (isDocChip(chip)) {
return !isDocChip(item) || item.docId !== chip.docId;
}
if (isFileChip(chip)) {
return !isFileChip(item) || item.file !== chip.file;
}
if (isTagChip(chip)) {
return !isTagChip(item) || item.tagId !== chip.tagId;
}
if (isCollectionChip(chip)) {
return !isCollectionChip(item) || item.collectionId !== chip.collectionId;
}
return true;
});
}
export function findChipIndex(chips: ChatChip[], chip: ChatChip) {
return chips.findIndex(item => {
if (isDocChip(chip)) {
return isDocChip(item) && item.docId === chip.docId;
}
if (isFileChip(chip)) {
return isFileChip(item) && item.file === chip.file;
}
if (isTagChip(chip)) {
return isTagChip(item) && item.tagId === chip.tagId;
}
if (isCollectionChip(chip)) {
return isCollectionChip(item) && item.collectionId === chip.collectionId;
}
return -1;
});
}
export function estimateTokenCount(text: string): number {
const chinese = text.match(/[\u4e00-\u9fa5]/g)?.length || 0;
const english = text.replace(/[\u4e00-\u9fa5]/g, '');

View File

@@ -12,20 +12,28 @@ import type {
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import type { EditorHost } from '@blocksuite/affine/std';
import { ShadowlessElement } from '@blocksuite/affine/std';
import { css, html } from 'lit';
import type { NotificationService } from '@blocksuite/affine-shared/services';
import { css, html, type PropertyValues } from 'lit';
import { property, state } from 'lit/decorators.js';
import { AIProvider } from '../../provider';
import type { SearchMenuConfig } from '../ai-chat-add-context';
import type {
ChatChip,
CollectionChip,
DocChip,
DocDisplayConfig,
FileChip,
SearchMenuConfig,
TagChip,
} from '../ai-chat-chips';
import { isCollectionChip, isDocChip, isTagChip } from '../ai-chat-chips';
import {
findChipIndex,
isCollectionChip,
isDocChip,
isFileChip,
isTagChip,
omitChip,
} from '../ai-chat-chips';
import type {
AIChatInputContext,
AINetworkSearchConfig,
@@ -51,7 +59,7 @@ export class AIChatComposer extends SignalWatcher(
`;
@property({ attribute: false })
accessor independentMode!: boolean;
accessor independentMode: boolean | undefined;
@property({ attribute: false })
accessor host: EditorHost | null | undefined;
@@ -77,9 +85,9 @@ export class AIChatComposer extends SignalWatcher(
accessor updateContext!: (context: Partial<AIChatInputContext>) => void;
@property({ attribute: false })
accessor onEmbeddingProgressChange!: (
count: Record<ContextEmbedStatus, number>
) => void;
accessor onEmbeddingProgressChange:
| ((count: Record<ContextEmbedStatus, number>) => void)
| undefined;
@property({ attribute: false })
accessor docDisplayConfig!: DocDisplayConfig;
@@ -105,9 +113,15 @@ export class AIChatComposer extends SignalWatcher(
@property({ attribute: false })
accessor affineWorkspaceDialogService!: WorkspaceDialogService;
@property({ attribute: false })
accessor notificationService!: NotificationService;
@state()
accessor chips: ChatChip[] = [];
@state()
accessor isChipsCollapsed = false;
@state()
accessor embeddingCompleted = false;
@@ -121,11 +135,13 @@ export class AIChatComposer extends SignalWatcher(
return html`
<chat-panel-chips
.chips=${this.chips}
.createContextId=${this._createContextId}
.updateChips=${this.updateChips}
.pollContextDocsAndFiles=${this._pollContextDocsAndFiles}
.isCollapsed=${this.isChipsCollapsed}
.independentMode=${this.independentMode}
.addChip=${this.addChip}
.updateChip=${this.updateChip}
.removeChip=${this.removeChip}
.toggleCollapse=${this.toggleChipsCollapse}
.docDisplayConfig=${this.docDisplayConfig}
.searchMenuConfig=${this.searchMenuConfig}
.portalContainer=${this.portalContainer}
.addImages=${this.addImages}
></chat-panel-chips>
@@ -136,15 +152,18 @@ export class AIChatComposer extends SignalWatcher(
.docId=${this.docId}
.session=${this.session}
.chips=${this.chips}
.addChip=${this.addChip}
.addImages=${this.addImages}
.createSession=${this.createSession}
.chatContextValue=${this.chatContextValue}
.updateContext=${this.updateContext}
.networkSearchConfig=${this.networkSearchConfig}
.reasoningConfig=${this.reasoningConfig}
.docDisplayConfig=${this.docDisplayConfig}
.searchMenuConfig=${this.searchMenuConfig}
.portalContainer=${this.portalContainer}
.onChatSuccess=${this.onChatSuccess}
.trackOptions=${this.trackOptions}
.addImages=${this.addImages}
></ai-chat-input>
<div class="chat-panel-footer">
<ai-chat-composer-tip
@@ -165,7 +184,7 @@ export class AIChatComposer extends SignalWatcher(
override connectedCallback() {
super.connectedCallback();
this._initComposer().catch(console.error);
this.initComposer().catch(console.error);
}
override disconnectedCallback() {
@@ -174,6 +193,17 @@ export class AIChatComposer extends SignalWatcher(
this._abortPollEmbeddingStatus();
}
protected override willUpdate(changedProperties: PropertyValues): void {
if (
changedProperties.has('chatContextValue') &&
changedProperties.get('chatContextValue')?.status !== 'loading' &&
this.chatContextValue.status === 'loading' &&
this.isChipsCollapsed === false
) {
this.isChipsCollapsed = true;
}
}
private readonly _getContextId = async () => {
if (this._contextId) {
return this._contextId;
@@ -190,7 +220,7 @@ export class AIChatComposer extends SignalWatcher(
return this._contextId;
};
private readonly _createContextId = async () => {
private readonly createContextId = async () => {
if (this._contextId) {
return this._contextId;
}
@@ -205,7 +235,7 @@ export class AIChatComposer extends SignalWatcher(
return this._contextId;
};
private readonly _initChips = async () => {
private readonly initChips = async () => {
// context not initialized
const sessionId = this.session?.sessionId;
const contextId = await this._getContextId();
@@ -275,14 +305,206 @@ export class AIChatComposer extends SignalWatcher(
this.chips = chips;
};
private readonly updateChip = (
chip: ChatChip,
options: Partial<DocChip | FileChip>
) => {
const index = findChipIndex(this.chips, chip);
if (index === -1) {
return;
}
const nextChip: ChatChip = {
...chip,
...options,
};
this.updateChips([
...this.chips.slice(0, index),
nextChip,
...this.chips.slice(index + 1),
]);
};
private readonly addChip = async (chip: ChatChip) => {
this.isChipsCollapsed = false;
// if already exists
const index = findChipIndex(this.chips, chip);
if (index !== -1) {
this.notificationService.toast('chip already exists');
return;
}
this.updateChips([...this.chips, chip]);
await this.addToContext(chip);
await this.pollContextDocsAndFiles();
};
private readonly removeChip = async (chip: ChatChip) => {
const chips = omitChip(this.chips, chip);
this.updateChips(chips);
await this.removeFromContext(chip);
};
private readonly addToContext = async (chip: ChatChip) => {
if (isDocChip(chip)) {
return await this.addDocToContext(chip);
}
if (isFileChip(chip)) {
return await this.addFileToContext(chip);
}
if (isTagChip(chip)) {
return await this.addTagToContext(chip);
}
if (isCollectionChip(chip)) {
return await this.addCollectionToContext(chip);
}
return null;
};
private readonly addDocToContext = async (chip: DocChip) => {
try {
const contextId = await this.createContextId();
if (!contextId || !AIProvider.context) {
throw new Error('Context not found');
}
await AIProvider.context.addContextDoc({
contextId,
docId: chip.docId,
});
} catch (e) {
this.updateChip(chip, {
state: 'failed',
tooltip: e instanceof Error ? e.message : 'Add context doc error',
});
}
};
private readonly addFileToContext = async (chip: FileChip) => {
try {
const contextId = await this.createContextId();
if (!contextId || !AIProvider.context) {
throw new Error('Context not found');
}
const contextFile = await AIProvider.context.addContextFile(chip.file, {
contextId,
});
this.updateChip(chip, {
state: contextFile.status,
blobId: contextFile.blobId,
fileId: contextFile.id,
});
} catch (e) {
this.updateChip(chip, {
state: 'failed',
tooltip: e instanceof Error ? e.message : 'Add context file error',
});
}
};
private readonly addTagToContext = async (chip: TagChip) => {
try {
const contextId = await this.createContextId();
if (!contextId || !AIProvider.context) {
throw new Error('Context not found');
}
// TODO: server side docIds calculation
const docIds = this.docDisplayConfig.getTagPageIds(chip.tagId);
await AIProvider.context.addContextTag({
contextId,
tagId: chip.tagId,
docIds,
});
this.updateChip(chip, {
state: 'finished',
});
} catch (e) {
this.updateChip(chip, {
state: 'failed',
tooltip: e instanceof Error ? e.message : 'Add context tag error',
});
}
};
private readonly addCollectionToContext = async (chip: CollectionChip) => {
try {
const contextId = await this.createContextId();
if (!contextId || !AIProvider.context) {
throw new Error('Context not found');
}
// TODO: server side docIds calculation
const docIds = this.docDisplayConfig.getCollectionPageIds(
chip.collectionId
);
await AIProvider.context.addContextCollection({
contextId,
collectionId: chip.collectionId,
docIds,
});
this.updateChip(chip, {
state: 'finished',
});
} catch (e) {
this.updateChip(chip, {
state: 'failed',
tooltip:
e instanceof Error ? e.message : 'Add context collection error',
});
}
};
private readonly removeFromContext = async (
chip: ChatChip
): Promise<boolean> => {
try {
const contextId = await this.createContextId();
if (!contextId || !AIProvider.context) {
return true;
}
if (isDocChip(chip)) {
return await AIProvider.context.removeContextDoc({
contextId,
docId: chip.docId,
});
}
if (isFileChip(chip) && chip.fileId) {
return await AIProvider.context.removeContextFile({
contextId,
fileId: chip.fileId,
});
}
if (isTagChip(chip)) {
return await AIProvider.context.removeContextTag({
contextId,
tagId: chip.tagId,
});
}
if (isCollectionChip(chip)) {
return await AIProvider.context.removeContextCollection({
contextId,
collectionId: chip.collectionId,
});
}
return true;
} catch {
return true;
}
};
private readonly toggleChipsCollapse = () => {
this.isChipsCollapsed = !this.isChipsCollapsed;
};
private readonly addImages = (images: File[]) => {
const oldImages = this.chatContextValue.images;
if (oldImages.length + images.length > MAX_IMAGE_COUNT) {
this.notificationService.toast(
`You can only upload up to ${MAX_IMAGE_COUNT} images`
);
}
this.updateContext({
images: [...oldImages, ...images].slice(0, MAX_IMAGE_COUNT),
});
};
private readonly _pollContextDocsAndFiles = async () => {
private readonly pollContextDocsAndFiles = async () => {
const sessionId = this.session?.sessionId;
const contextId = await this._getContextId();
if (!sessionId || !contextId || !AIProvider.context) {
@@ -302,7 +524,7 @@ export class AIChatComposer extends SignalWatcher(
);
};
private readonly _pollEmbeddingStatus = async () => {
private readonly pollEmbeddingStatus = async () => {
if (this._pollEmbeddingStatusAbortController) {
this._pollEmbeddingStatusAbortController.abort();
}
@@ -317,12 +539,11 @@ export class AIChatComposer extends SignalWatcher(
this.embeddingCompleted = false;
return;
}
const prevCompleted = this.embeddingCompleted;
const completed = status.embedded === status.total;
this.embeddingCompleted = completed;
if (completed) {
this.embeddingCompleted = true;
} else {
this.embeddingCompleted = false;
if (prevCompleted !== completed) {
this.requestUpdate();
}
},
signal
@@ -383,7 +604,7 @@ export class AIChatComposer extends SignalWatcher(
return chip;
});
this.updateChips(nextChips);
this.onEmbeddingProgressChange(count);
this.onEmbeddingProgressChange?.(count);
if (count.processing === 0) {
this._abortPoll();
}
@@ -399,18 +620,18 @@ export class AIChatComposer extends SignalWatcher(
this._pollEmbeddingStatusAbortController = null;
};
private readonly _initComposer = async () => {
private readonly initComposer = async () => {
const userId = (await AIProvider.userInfo)?.id;
if (!userId || !this.session) return;
await this._initChips();
await this.initChips();
const needPoll = this.chips.some(
chip =>
chip.state === 'processing' || isTagChip(chip) || isCollectionChip(chip)
);
if (needPoll) {
await this._pollContextDocsAndFiles();
await this.pollContextDocsAndFiles();
}
await this._pollEmbeddingStatus();
await this.pollEmbeddingStatus();
};
}

View File

@@ -6,8 +6,7 @@ import type {
CopilotChatHistoryFragment,
} from '@affine/graphql';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import type { EditorHost } from '@blocksuite/affine/std';
import { ShadowlessElement } from '@blocksuite/affine/std';
import { type EditorHost, ShadowlessElement } from '@blocksuite/affine/std';
import type { ExtensionType } from '@blocksuite/affine/store';
import type { NotificationService } from '@blocksuite/affine-shared/services';
import { type Signal } from '@preact/signals-core';
@@ -26,7 +25,8 @@ import { styleMap } from 'lit/directives/style-map.js';
import { HISTORY_IMAGE_ACTIONS } from '../../chat-panel/const';
import { type AIChatParams, AIProvider } from '../../provider/ai-provider';
import { extractSelectedContent } from '../../utils/extract';
import type { DocDisplayConfig, SearchMenuConfig } from '../ai-chat-chips';
import type { SearchMenuConfig } from '../ai-chat-add-context';
import type { DocDisplayConfig } from '../ai-chat-chips';
import type {
AINetworkSearchConfig,
AIReasoningConfig,
@@ -63,7 +63,7 @@ export class AIChatContent extends SignalWatcher(
.ai-chat-title {
background: var(--affine-background-primary-color);
position: relative;
padding: 8px 0px;
padding: 8px var(--h-padding);
width: 100%;
height: 36px;
display: flex;
@@ -80,7 +80,8 @@ export class AIChatContent extends SignalWatcher(
ai-chat-messages {
flex: 1;
overflow-y: hidden;
overflow-y: auto;
padding: 0 var(--h-padding);
transition:
flex-grow 0.32s cubic-bezier(0.07, 0.83, 0.46, 1),
padding-top 0.32s ease,
@@ -99,24 +100,31 @@ export class AIChatContent extends SignalWatcher(
container-name: chat-panel-split-view;
}
.chat-panel-main {
--h-padding: 8px;
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
width: 100%;
padding: 8px 24px 0 24px;
padding: 8px calc(24px - var(--h-padding)) 0 calc(24px - var(--h-padding));
max-width: 800px;
margin: 0 auto;
}
ai-chat-composer {
padding: 0 var(--h-padding);
}
@container chat-panel-split-view (width < 540px) {
.chat-panel-main {
padding: 8px 12px 0 12px;
padding: 8px calc(12px - var(--h-padding)) 0
calc(12px - var(--h-padding));
}
}
`;
@property({ attribute: false })
accessor independentMode!: boolean;
accessor independentMode: boolean | undefined;
@property({ attribute: false })
accessor onboardingOffsetY!: number;
@@ -169,9 +177,9 @@ export class AIChatContent extends SignalWatcher(
accessor notificationService!: NotificationService;
@property({ attribute: false })
accessor onEmbeddingProgressChange!: (
count: Record<ContextEmbedStatus, number>
) => void;
accessor onEmbeddingProgressChange:
| ((count: Record<ContextEmbedStatus, number>) => void)
| undefined;
@property({ attribute: false })
accessor onContextChange!: (context: Partial<ChatContextValue>) => void;
@@ -211,14 +219,7 @@ export class AIChatContent extends SignalWatcher(
}
get showActions() {
if (this.docId) {
if (!this.session) {
return true;
}
return this.session.docId === this.docId;
} else {
return false;
}
return false;
}
private readonly updateHistory = async () => {
@@ -261,7 +262,7 @@ export class AIChatContent extends SignalWatcher(
};
private readonly updateActions = async () => {
if (!this.docId || !AIProvider.histories) {
if (!this.docId || !AIProvider.histories || !this.showActions) {
return;
}
const actions = await AIProvider.histories.actions(
@@ -395,7 +396,7 @@ export class AIChatContent extends SignalWatcher(
<ai-chat-messages
class=${classMap({
'ai-chat-messages': true,
'independent-mode': this.independentMode,
'independent-mode': !!this.independentMode,
'no-message': this.messages.length === 0,
})}
${ref(this.chatMessagesRef)}
@@ -416,6 +417,7 @@ export class AIChatContent extends SignalWatcher(
.width=${this.width}
.independentMode=${this.independentMode}
.messages=${this.messages}
.docDisplayService=${this.docDisplayConfig}
></ai-chat-messages>
<ai-chat-composer
style=${styleMap({
@@ -436,6 +438,7 @@ export class AIChatContent extends SignalWatcher(
.docDisplayConfig=${this.docDisplayConfig}
.searchMenuConfig=${this.searchMenuConfig}
.affineWorkspaceDialogService=${this.affineWorkspaceDialogService}
.notificationService=${this.notificationService}
.trackOptions=${{
where: 'chat-panel',
control: 'chat-send',

View File

@@ -1,11 +1,9 @@
import { toast } from '@affine/component';
import type { CopilotChatHistoryFragment } from '@affine/graphql';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import { openFilesWith } from '@blocksuite/affine/shared/utils';
import type { EditorHost } from '@blocksuite/affine/std';
import { ShadowlessElement } from '@blocksuite/affine/std';
import { ArrowUpBigIcon, CloseIcon, ImageIcon } from '@blocksuite/icons/lit';
import { ArrowUpBigIcon, CloseIcon } from '@blocksuite/icons/lit';
import { css, html, nothing } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
@@ -16,6 +14,7 @@ import { type AIError, AIProvider, type AISendParams } from '../../provider';
import { reportResponse } from '../../utils/action-reporter';
import { readBlobAsURL } from '../../utils/image';
import { mergeStreamObjects } from '../../utils/stream-objects';
import type { SearchMenuConfig } from '../ai-chat-add-context';
import type { ChatChip, DocDisplayConfig } from '../ai-chat-chips/type';
import { isDocChip } from '../ai-chat-chips/utils';
import {
@@ -23,7 +22,6 @@ import {
isChatMessage,
StreamObjectSchema,
} from '../ai-chat-messages';
import { MAX_IMAGE_COUNT } from './const';
import type {
AIChatInputContext,
AINetworkSearchConfig,
@@ -292,7 +290,7 @@ export class AIChatInput extends SignalWatcher(
`;
@property({ attribute: false })
accessor independentMode!: boolean;
accessor independentMode: boolean | undefined;
@property({ attribute: false })
accessor host: EditorHost | null | undefined;
@@ -335,6 +333,12 @@ export class AIChatInput extends SignalWatcher(
@property({ attribute: false })
accessor updateContext!: (context: Partial<AIChatInputContext>) => void;
@property({ attribute: false })
accessor addImages!: (images: File[]) => void;
@property({ attribute: false })
accessor addChip!: (chip: ChatChip) => Promise<void>;
@property({ attribute: false })
accessor networkSearchConfig!: AINetworkSearchConfig;
@@ -344,6 +348,9 @@ export class AIChatInput extends SignalWatcher(
@property({ attribute: false })
accessor docDisplayConfig!: DocDisplayConfig;
@property({ attribute: false })
accessor searchMenuConfig!: SearchMenuConfig;
@property({ attribute: false })
accessor isRootSession: boolean = true;
@@ -357,7 +364,7 @@ export class AIChatInput extends SignalWatcher(
accessor testId = 'chat-panel-input-container';
@property({ attribute: false })
accessor addImages!: (images: File[]) => void;
accessor portalContainer: HTMLElement | null = null;
private get _isNetworkActive() {
return (
@@ -370,10 +377,6 @@ export class AIChatInput extends SignalWatcher(
return !!this.reasoningConfig.enabled.value;
}
private get _isImageUploadDisabled() {
return this.chatContextValue.images.length >= MAX_IMAGE_COUNT;
}
override connectedCallback() {
super.connectedCallback();
this._disposables.add(
@@ -453,14 +456,16 @@ export class AIChatInput extends SignalWatcher(
data-testid="chat-panel-input"
></textarea>
<div class="chat-panel-input-actions">
<div
class="chat-input-icon"
data-testid="chat-panel-input-image-upload"
aria-disabled=${this._isImageUploadDisabled}
@click=${this._uploadImageFiles}
>
${ImageIcon()}
<affine-tooltip>Upload</affine-tooltip>
<div class="chat-input-icon">
<ai-chat-add-context
.docId=${this.docId}
.independentMode=${this.independentMode}
.addChip=${this.addChip}
.addImages=${this.addImages}
.docDisplayConfig=${this.docDisplayConfig}
.searchMenuConfig=${this.searchMenuConfig}
.portalContainer=${this.portalContainer}
></ai-chat-add-context>
</div>
<div class="chat-input-footer-spacer"></div>
<chat-input-preference
@@ -555,18 +560,6 @@ export class AIChatInput extends SignalWatcher(
this.updateContext({ images: newImages });
};
private readonly _uploadImageFiles = async (_e: MouseEvent) => {
if (this._isImageUploadDisabled) return;
const images = await openFilesWith('Images');
if (!images) return;
if (this.chatContextValue.images.length + images.length > MAX_IMAGE_COUNT) {
toast(`You can only upload up to ${MAX_IMAGE_COUNT} images`);
return;
}
this.addImages(images);
};
private readonly _onTextareaSend = async (e: MouseEvent | KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();

View File

@@ -6,8 +6,7 @@ import {
type FeatureFlagService,
type NotificationService,
} from '@blocksuite/affine/shared/services';
import type { EditorHost } from '@blocksuite/affine/std';
import { ShadowlessElement } from '@blocksuite/affine/std';
import { type EditorHost, ShadowlessElement } from '@blocksuite/affine/std';
import type { BaseSelection, ExtensionType } from '@blocksuite/affine/store';
import { ArrowDownBigIcon as ArrowDownIcon } from '@blocksuite/icons/lit';
import type { Signal } from '@preact/signals-core';
@@ -21,6 +20,7 @@ import { AffineIcon } from '../../_common/icons';
import { AIPreloadConfig } from '../../chat-panel/preload-config';
import { type AIError, AIProvider, UnauthorizedError } from '../../provider';
import { mergeStreamObjects } from '../../utils/stream-objects';
import type { DocDisplayConfig } from '../ai-chat-chips';
import { type ChatContextValue } from '../ai-chat-content/type';
import type {
AINetworkSearchConfig,
@@ -43,9 +43,8 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) {
display: flex;
flex-direction: column;
gap: 24px;
height: 100%;
min-height: 100%;
position: relative;
overflow-y: auto;
}
chat-panel-assistant-message,
@@ -152,7 +151,7 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) {
accessor avatarUrl = '';
@property({ attribute: false })
accessor independentMode!: boolean;
accessor independentMode: boolean | undefined;
@property({ attribute: false })
accessor messages!: HistoryMessage[];
@@ -204,6 +203,9 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor width: Signal<number | undefined> | undefined;
@property({ attribute: false })
accessor docDisplayService!: DocDisplayConfig;
@query('.chat-panel-messages-container')
accessor messagesContainer: HTMLDivElement | null = null;
@@ -277,7 +279,7 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) {
<div
class=${classMap({
'chat-panel-messages-container': true,
'independent-mode': this.independentMode,
'independent-mode': !!this.independentMode,
})}
data-testid="chat-panel-messages-container"
@scroll=${() => this._debouncedOnScroll()}
@@ -329,6 +331,8 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) {
.notificationService=${this.notificationService}
.retry=${() => this.retry()}
.width=${this.width}
.independentMode=${this.independentMode}
.docDisplayService=${this.docDisplayService}
></chat-message-assistant>`;
} else if (isChatAction(item) && this.host) {
return html`<chat-message-action
@@ -437,6 +441,7 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) {
const last = messages[messages.length - 1];
if ('content' in last) {
last.content = '';
last.streamObjects = [];
last.createdAt = new Date().toISOString();
}
this.updateContext({

View File

@@ -15,6 +15,7 @@ import { css, html } from 'lit';
import { property, query } from 'lit/decorators.js';
import type { DocDisplayConfig } from '../ai-chat-chips';
import type { ChatStatus } from '../ai-chat-messages';
export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
@@ -26,6 +27,9 @@ export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor docId: string | undefined;
@property({ attribute: false })
accessor status!: ChatStatus;
@property({ attribute: false })
accessor onNewSession!: () => void;
@@ -49,6 +53,10 @@ export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
private abortController: AbortController | null = null;
get isGenerating() {
return this.status === 'transmitting' || this.status === 'loading';
}
static override styles = css`
.ai-chat-toolbar {
display: flex;
@@ -72,6 +80,10 @@ export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
height: 16px;
color: ${unsafeCSSVarV2('icon/primary')};
}
&[data-disabled='true'] {
cursor: not-allowed;
}
}
}
`;
@@ -84,7 +96,11 @@ export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
${PlusIcon()}
<affine-tooltip>New Chat</affine-tooltip>
</div>
<div class="chat-toolbar-icon" @click=${this.onTogglePin}>
<div
class="chat-toolbar-icon"
@click=${this.onPinClick}
data-disabled=${this.isGenerating}
>
${pinned ? PinedIcon() : PinIcon()}
<affine-tooltip>
${pinned ? 'Unpin this Chat' : 'Pin this Chat'}
@@ -101,6 +117,16 @@ export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
`;
}
private readonly onPinClick = async () => {
if (this.isGenerating) {
this.notificationService.toast(
'Cannot pin a chat while generating an answer'
);
return;
}
await this.onTogglePin();
};
private readonly unpinConfirm = async () => {
if (this.session && this.session.pinned) {
try {

View File

@@ -225,7 +225,7 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
}}
>
<div class="ai-session-title">
${session.sessionId}
${session.title || 'New chat'}
<affine-tooltip .offsetX=${60}>
Click to open this chat
</affine-tooltip>

View File

@@ -1,7 +1,11 @@
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { WithDisposable } from '@blocksuite/affine/global/lit';
import type { ColorScheme } from '@blocksuite/affine/model';
import { type EditorHost, ShadowlessElement } from '@blocksuite/affine/std';
import {
type BlockStdScope,
type EditorHost,
ShadowlessElement,
} from '@blocksuite/affine/std';
import type { ExtensionType } from '@blocksuite/affine/store';
import type { NotificationService } from '@blocksuite/affine-shared/services';
import type { Signal } from '@preact/signals-core';
@@ -9,6 +13,7 @@ import { css, html, nothing } from 'lit';
import { property } from 'lit/decorators.js';
import type { AffineAIPanelState } from '../../widgets/ai-panel/type';
import type { DocDisplayConfig } from '../ai-chat-chips';
import type { StreamObject } from '../ai-chat-messages';
export class ChatContentStreamObjects extends WithDisposable(
@@ -29,6 +34,9 @@ export class ChatContentStreamObjects extends WithDisposable(
@property({ attribute: false })
accessor host: EditorHost | null | undefined;
@property({ attribute: false })
accessor std: BlockStdScope | null | undefined;
@property({ attribute: false })
accessor state: AffineAIPanelState = 'finished';
@@ -47,6 +55,9 @@ export class ChatContentStreamObjects extends WithDisposable(
@property({ attribute: false })
accessor notificationService!: NotificationService;
@property({ attribute: false })
accessor docDisplayService!: DocDisplayConfig;
private renderToolCall(streamObject: StreamObject) {
if (streamObject.type !== 'tool-call') {
return nothing;
@@ -70,7 +81,7 @@ export class ChatContentStreamObjects extends WithDisposable(
case 'doc_compose':
return html`
<doc-compose-tool
.std=${this.host?.std}
.std=${this.std || this.host?.std}
.data=${streamObject}
.width=${this.width}
.theme=${this.theme}
@@ -80,9 +91,10 @@ export class ChatContentStreamObjects extends WithDisposable(
case 'code_artifact':
return html`
<code-artifact-tool
.std=${this.host?.std}
.std=${this.std || this.host?.std}
.data=${streamObject}
.width=${this.width}
.theme=${this.theme}
></code-artifact-tool>
`;
case 'doc_edit':
@@ -93,6 +105,21 @@ export class ChatContentStreamObjects extends WithDisposable(
.notificationService=${this.notificationService}
></doc-edit-tool>
`;
case 'doc_semantic_search':
return html`<doc-semantic-search-result
.data=${streamObject}
.width=${this.width}
></doc-semantic-search-result>`;
case 'doc_keyword_search':
return html`<doc-keyword-search-result
.data=${streamObject}
.width=${this.width}
></doc-keyword-search-result>`;
case 'doc_read':
return html`<doc-read-result
.data=${streamObject}
.width=${this.width}
></doc-read-result>`;
default: {
const name = streamObject.toolName + ' tool calling';
return html`
@@ -125,7 +152,7 @@ export class ChatContentStreamObjects extends WithDisposable(
case 'doc_compose':
return html`
<doc-compose-tool
.std=${this.host?.std}
.std=${this.std || this.host?.std}
.data=${streamObject}
.width=${this.width}
.theme=${this.theme}
@@ -135,7 +162,7 @@ export class ChatContentStreamObjects extends WithDisposable(
case 'code_artifact':
return html`
<code-artifact-tool
.std=${this.host?.std}
.std=${this.std || this.host?.std}
.data=${streamObject}
.width=${this.width}
.theme=${this.theme}
@@ -151,6 +178,22 @@ export class ChatContentStreamObjects extends WithDisposable(
.notificationService=${this.notificationService}
></doc-edit-tool>
`;
case 'doc_semantic_search':
return html`<doc-semantic-search-result
.data=${streamObject}
.width=${this.width}
.docDisplayService=${this.docDisplayService}
></doc-semantic-search-result>`;
case 'doc_keyword_search':
return html`<doc-keyword-search-result
.data=${streamObject}
.width=${this.width}
></doc-keyword-search-result>`;
case 'doc_read':
return html`<doc-read-result
.data=${streamObject}
.width=${this.width}
></doc-read-result>`;
default: {
const name = streamObject.toolName + ' tool result';
return html`

View File

@@ -1,6 +1,6 @@
import { WithDisposable } from '@blocksuite/affine/global/lit';
import { scrollbarStyle } from '@blocksuite/affine/shared/styles';
import { type EditorHost, ShadowlessElement } from '@blocksuite/affine/std';
import { ShadowlessElement } from '@blocksuite/affine/std';
import type { PropertyValues } from 'lit';
import { css, html } from 'lit';
import { property, query } from 'lit/decorators.js';
@@ -81,9 +81,6 @@ export class AIScrollableTextRenderer extends WithDisposable(
@property({ attribute: false })
accessor answer!: string;
@property({ attribute: false })
accessor host!: EditorHost;
@property({ attribute: false })
accessor state: AffineAIPanelState | undefined;
@@ -101,19 +98,16 @@ export class AIScrollableTextRenderer extends WithDisposable(
}
export const createAIScrollableTextRenderer: (
host: EditorHost,
textRendererOptions: TextRendererOptions,
maxHeight: number,
autoScroll: boolean
) => AffineAIPanelWidgetConfig['answerRenderer'] = (
host,
textRendererOptions,
maxHeight,
autoScroll
) => {
return (answer: string, state: AffineAIPanelState | undefined) => {
return html`<ai-scrollable-text-renderer
.host=${host}
.answer=${answer}
.state=${state}
.textRendererOptions=${textRendererOptions}

View File

@@ -3,6 +3,7 @@ import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import type { ColorScheme } from '@blocksuite/affine/model';
import { ShadowlessElement } from '@blocksuite/affine/std';
import { type NotificationService } from '@blocksuite/affine-shared/services';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import type { Signal } from '@preact/signals-core';
import {
css,
@@ -29,12 +30,17 @@ export abstract class ArtifactTool<
> extends SignalWatcher(WithDisposable(ShadowlessElement)) {
static override styles = css`
.artifact-tool-card {
cursor: pointer;
margin: 8px 0;
}
padding: 10px 0;
.artifact-tool-card:hover {
opacity: 0.8;
.affine-embed-linked-doc-block {
box-shadow: ${unsafeCSSVar('buttonShadow')};
cursor: pointer;
}
.affine-embed-linked-doc-block:hover {
background-color: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
}
}
`;
@@ -119,23 +125,24 @@ export abstract class ArtifactTool<
return html`
<div
class="affine-embed-linked-doc-block artifact-tool-card ${className ??
''} horizontal"
class="artifact-tool-card ${className ?? ''}"
@click=${this.onCardClick}
>
<div class="affine-embed-linked-doc-content">
<div class="affine-embed-linked-doc-content-title">
<div class="affine-embed-linked-doc-content-title-icon">
${resolvedIcon}
</div>
<div class="affine-embed-linked-doc-content-title-text">
${title}
<div class="affine-embed-linked-doc-block horizontal">
<div class="affine-embed-linked-doc-content">
<div class="affine-embed-linked-doc-content-title">
<div class="affine-embed-linked-doc-content-title-icon">
${resolvedIcon}
</div>
<div class="affine-embed-linked-doc-content-title-text">
${title}
</div>
</div>
</div>
${banner
? html`<div class="affine-embed-linked-doc-banner">${banner}</div>`
: nothing}
</div>
${banner
? html`<div class="affine-embed-linked-doc-banner">${banner}</div>`
: nothing}
</div>
`;
}

View File

@@ -104,10 +104,6 @@ export class ArtifactPreviewPanel extends WithDisposable(ShadowlessElement) {
color: ${unsafeCSSVarV2('icon/secondary')};
}
.artifact-panel-content {
height: calc(100% - 52px);
}
.artifact-panel-close:hover {
background-color: ${unsafeCSSVarV2('layer/background/tertiary')};
}

View File

@@ -4,7 +4,6 @@ import { getEmbedLinkedDocIcons } from '@blocksuite/affine/blocks/embed-doc';
import { LoadingIcon } from '@blocksuite/affine/components/icons';
import { RefNodeSlotsProvider } from '@blocksuite/affine/inlines/reference';
import type { ColorScheme } from '@blocksuite/affine/model';
import { NotificationProvider } from '@blocksuite/affine/shared/services';
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import { MarkdownTransformer } from '@blocksuite/affine/widgets/linked-doc';
import { CopyIcon, PageIcon, ToolIcon } from '@blocksuite/icons/lit';
@@ -111,7 +110,6 @@ export class DocComposeTool extends ArtifactTool<
protected override getPreviewContent() {
if (!this.std) return html``;
const std = this.std;
const resultData = this.data;
const title = this.data.args.title;
const result = resultData.type === 'tool-result' ? resultData.result : null;
@@ -122,11 +120,11 @@ export class DocComposeTool extends ArtifactTool<
${successResult
? html`<text-renderer
.answer=${successResult.markdown}
.host=${std.host}
.schema=${std.store.schema}
.schema=${this.std?.store.schema}
.options=${{
customHeading: true,
extensions: getCustomPageEditorBlockSpecs(),
theme: this.theme,
}}
></text-renderer>`
: html`<div class="doc-compose-result-preview-loading">
@@ -161,7 +159,6 @@ export class DocComposeTool extends ArtifactTool<
return;
}
const workspace = std.store.workspace;
const notificationService = std.get(NotificationProvider);
const refNodeSlots = std.getOptional(RefNodeSlotsProvider);
const docId = await MarkdownTransformer.importMarkdownToDoc({
collection: workspace,
@@ -171,7 +168,7 @@ export class DocComposeTool extends ArtifactTool<
extensions: getStoreManager().config.init().value.get('store'),
});
if (docId) {
const open = await notificationService.confirm({
const open = await this.notificationService.confirm({
title: 'Open the doc you just created',
message: 'Doc saved successfully! Would you like to open it now?',
cancelText: 'Cancel',

View File

@@ -1,3 +1,4 @@
import track from '@affine/track';
import { WithDisposable } from '@blocksuite/affine/global/lit';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import { type EditorHost, ShadowlessElement } from '@blocksuite/affine/std';
@@ -203,24 +204,33 @@ export class DocEditTool extends WithDisposable(ShadowlessElement) {
}
private async _handleApply(markdown: string) {
if (!this.host) {
if (!this.host || this.data.type !== 'tool-result') {
return;
}
track.applyModel.chat.$.apply({
instruction: this.data.args.instructions,
});
await this.blockDiffService?.apply(this.host.store, markdown);
}
private async _handleReject(changedMarkdown: string) {
if (!this.host) {
if (!this.host || this.data.type !== 'tool-result') {
return;
}
track.applyModel.chat.$.reject({
instruction: this.data.args.instructions,
});
this.blockDiffService?.setChangedMarkdown(changedMarkdown);
this.blockDiffService?.rejectAll();
}
private async _handleAccept(changedMarkdown: string) {
if (!this.host) {
if (!this.host || this.data.type !== 'tool-result') {
return;
}
track.applyModel.chat.$.accept({
instruction: this.data.args.instructions,
});
await this.blockDiffService?.apply(this.host.store, changedMarkdown);
await this.blockDiffService?.acceptAll(this.host.store);
}
@@ -233,6 +243,7 @@ export class DocEditTool extends WithDisposable(ShadowlessElement) {
if (!this.host) {
return;
}
track.applyModel.chat.$.copy();
const success = await copyText(removeMarkdownComments(changedMarkdown));
if (success) {
this.notificationService.notify({

View File

@@ -0,0 +1,70 @@
import { WithDisposable } from '@blocksuite/global/lit';
import { PageIcon, SearchIcon } from '@blocksuite/icons/lit';
import { ShadowlessElement } from '@blocksuite/std';
import type { Signal } from '@preact/signals-core';
import { html, nothing } from 'lit';
import { property } from 'lit/decorators.js';
import type { ToolResult } from './tool-result-card';
interface DocKeywordSearchToolCall {
type: 'tool-call';
toolCallId: string;
toolName: string;
args: { query: string };
}
interface DocKeywordSearchToolResult {
type: 'tool-result';
toolCallId: string;
toolName: string;
args: { query: string };
result: Array<{
title: string;
docId: string;
}>;
}
export class DocKeywordSearchResult extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor data!: DocKeywordSearchToolCall | DocKeywordSearchToolResult;
@property({ attribute: false })
accessor width: Signal<number | undefined> | undefined;
renderToolCall() {
return html`<tool-call-card
.name=${`Searching workspace documents for "${this.data.args.query}"`}
.icon=${SearchIcon()}
.width=${this.width}
></tool-call-card>`;
}
renderToolResult() {
if (this.data.type !== 'tool-result') {
return nothing;
}
let results: ToolResult[] = [];
try {
results = this.data.result.map(item => ({
title: item.title,
icon: PageIcon(),
}));
} catch (err) {
console.error('Failed to parse result', err);
}
return html`<tool-result-card
.name=${`Found ${this.data.result.length} pages for "${this.data.args.query}"`}
.icon=${SearchIcon()}
.width=${this.width}
.results=${results}
></tool-result-card>`;
}
protected override render() {
if (this.data.type === 'tool-call') {
return this.renderToolCall();
}
return this.renderToolResult();
}
}

View File

@@ -0,0 +1,70 @@
import { WithDisposable } from '@blocksuite/global/lit';
import { PageIcon, ViewIcon } from '@blocksuite/icons/lit';
import { ShadowlessElement } from '@blocksuite/std';
import type { Signal } from '@preact/signals-core';
import { html, nothing } from 'lit';
import { property } from 'lit/decorators.js';
interface DocReadToolCall {
type: 'tool-call';
toolCallId: string;
toolName: string;
args: { doc_id: string };
}
interface DocReadToolResult {
type: 'tool-result';
toolCallId: string;
toolName: string;
args: { doc_id: string };
result: {
title: string;
markdown: string;
};
}
export class DocReadResult extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor data!: DocReadToolCall | DocReadToolResult;
@property({ attribute: false })
accessor width: Signal<number | undefined> | undefined;
renderToolCall() {
// TODO: get document name by doc_id
return html`<tool-call-card
.name=${`Reading document`}
.icon=${ViewIcon()}
.width=${this.width}
></tool-call-card>`;
}
renderToolResult() {
if (this.data.type !== 'tool-result') {
return nothing;
}
// TODO: better markdown rendering
return html`<tool-result-card
.name=${`Read "${this.data.result.title}"`}
.icon=${ViewIcon()}
.width=${this.width}
.results=${[
{
title: this.data.result.title,
icon: PageIcon(),
content: this.data.result.markdown,
},
]}
></tool-result-card>`;
}
protected override render() {
if (this.data.type === 'tool-call') {
return this.renderToolCall();
}
if (this.data.type === 'tool-result') {
return this.renderToolResult();
}
return nothing;
}
}

View File

@@ -0,0 +1,108 @@
import { WithDisposable } from '@blocksuite/global/lit';
import { AiEmbeddingIcon, PageIcon } from '@blocksuite/icons/lit';
import { ShadowlessElement } from '@blocksuite/std';
import type { Signal } from '@preact/signals-core';
import { html, nothing } from 'lit';
import { property } from 'lit/decorators.js';
import type { DocDisplayConfig } from '../ai-chat-chips';
interface DocSemanticSearchToolCall {
type: 'tool-call';
toolCallId: string;
toolName: string;
args: { query: string };
}
interface DocSemanticSearchToolResult {
type: 'tool-result';
toolCallId: string;
toolName: string;
args: { query: string };
result: Array<{
content: string;
docId: string;
}>;
}
function parseResultContent(content: string) {
const properties = [
'Title',
'Created at',
'Updated at',
'Created by',
'Updated by',
];
try {
// A row starts with "Title: ${title}\n"
const title = content.match(/^Title:\s+(.*)\n/)?.[1];
// from first row that not starts with "${propertyName}:" to end of the content
const rows = content.split('\n');
const startIndex = rows.findIndex(
line => !properties.some(property => line.startsWith(`${property}:`))
);
const text = rows.slice(startIndex).join('\n');
return {
title,
content: text,
icon: PageIcon(),
};
} catch (error) {
console.error('Failed to parse result content', error);
return null;
}
}
export class DocSemanticSearchResult extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor data!: DocSemanticSearchToolCall | DocSemanticSearchToolResult;
@property({ attribute: false })
accessor width: Signal<number | undefined> | undefined;
@property({ attribute: false })
accessor docDisplayService!: DocDisplayConfig;
renderToolCall() {
return html`<tool-call-card
.name=${`Finding semantically related pages for "${this.data.args.query}"`}
.icon=${AiEmbeddingIcon()}
.width=${this.width}
></tool-call-card>`;
}
renderToolResult() {
if (this.data.type !== 'tool-result') {
return nothing;
}
return html`<tool-result-card
.name=${`Found semantically related pages for "${this.data.args.query}"`}
.icon=${AiEmbeddingIcon()}
.width=${this.width}
.results=${this.data.result
.map(result => ({
...parseResultContent(result.content),
title: this.docDisplayService.getTitle(result.docId),
}))
.filter(Boolean)}
></tool-result-card>`;
}
protected override render() {
const { data } = this;
if (data.type === 'tool-call') {
return this.renderToolCall();
}
if (data.type === 'tool-result') {
return this.renderToolResult();
}
return nothing;
}
}
declare global {
interface HTMLElementTagNameMap {
'doc-semantic-search-result': DocSemanticSearchResult;
}
}

View File

@@ -7,7 +7,7 @@ import { type Signal } from '@preact/signals-core';
import { css, html, nothing, type TemplateResult } from 'lit';
import { property, state } from 'lit/decorators.js';
interface ToolResult {
export interface ToolResult {
title: string;
icon?: string | TemplateResult<1>;
content?: string;

View File

@@ -1,5 +1,5 @@
import type { CopilotChatHistoryFragment } from '@affine/graphql';
import { Tooltip } from '@blocksuite/affine/components/toolbar';
import { Tooltip } from '@blocksuite/affine/components/tooltip';
import { WithDisposable } from '@blocksuite/affine/global/lit';
import { noop } from '@blocksuite/affine/global/utils';
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';

View File

@@ -1,3 +1,4 @@
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import type { AppThemeService } from '@affine/core/modules/theme';
import type {
@@ -19,7 +20,8 @@ import { throttle } from 'lodash-es';
import type { AppSidebarConfig } from '../../chat-panel/chat-config';
import { HISTORY_IMAGE_ACTIONS } from '../../chat-panel/const';
import { AIProvider } from '../../provider';
import type { DocDisplayConfig, SearchMenuConfig } from '../ai-chat-chips';
import type { SearchMenuConfig } from '../ai-chat-add-context';
import type { DocDisplayConfig } from '../ai-chat-chips';
import type { ChatContextValue } from '../ai-chat-content';
import type {
AINetworkSearchConfig,
@@ -165,6 +167,9 @@ export class PlaygroundChat extends SignalWatcher(
@property({ attribute: false })
accessor affineThemeService!: AppThemeService;
@property({ attribute: false })
accessor affineWorkspaceDialogService!: WorkspaceDialogService;
@property({ attribute: false })
accessor notificationService!: NotificationService;
@@ -351,6 +356,8 @@ export class PlaygroundChat extends SignalWatcher(
.playgroundConfig=${this.playgroundConfig}
.docDisplayConfig=${this.docDisplayConfig}
.searchMenuConfig=${this.searchMenuConfig}
.notificationService=${this.notificationService}
.affineWorkspaceDialogService=${this.affineWorkspaceDialogService}
></ai-chat-composer>
</div>`;
}

View File

@@ -12,7 +12,8 @@ import { repeat } from 'lit/directives/repeat.js';
import type { AppSidebarConfig } from '../../chat-panel/chat-config';
import { AIProvider } from '../../provider';
import type { DocDisplayConfig, SearchMenuConfig } from '../ai-chat-chips';
import type { SearchMenuConfig } from '../ai-chat-add-context';
import type { DocDisplayConfig } from '../ai-chat-chips';
import type {
AINetworkSearchConfig,
AIPlaygroundConfig,

View File

@@ -1,3 +1,5 @@
import { effects as tooltipEffects } from '@blocksuite/affine-components/tooltip';
import { AIChatBlockComponent } from './blocks/ai-chat-block/ai-chat-block';
import { EdgelessAIChatBlockComponent } from './blocks/ai-chat-block/ai-chat-edgeless-block';
import { LitTranscriptionBlock } from './blocks/ai-chat-block/ai-transcription-block';
@@ -24,6 +26,7 @@ import { ChatMessageAction } from './chat-panel/message/action';
import { ChatMessageAssistant } from './chat-panel/message/assistant';
import { ChatMessageUser } from './chat-panel/message/user';
import { ChatPanelSplitView } from './chat-panel/split-view';
import { AIChatAddContext } from './components/ai-chat-add-context';
import { ChatPanelAddPopover } from './components/ai-chat-chips/add-popover';
import { ChatPanelCandidatesPopover } from './components/ai-chat-chips/candidates-popover';
import { ChatPanelChips } from './components/ai-chat-chips/chat-panel-chips';
@@ -54,6 +57,9 @@ import {
} from './components/ai-tools/code-artifact';
import { DocComposeTool } from './components/ai-tools/doc-compose';
import { DocEditTool } from './components/ai-tools/doc-edit';
import { DocKeywordSearchResult } from './components/ai-tools/doc-keyword-search-result';
import { DocReadResult } from './components/ai-tools/doc-read-result';
import { DocSemanticSearchResult } from './components/ai-tools/doc-semantic-search-result';
import { ToolCallCard } from './components/ai-tools/tool-call-card';
import { ToolFailedCard } from './components/ai-tools/tool-failed-card';
import { ToolResultCard } from './components/ai-tools/tool-result-card';
@@ -113,6 +119,7 @@ export function registerAIEffects() {
registerMiniMindmapBlocks();
componentAiItemEffects();
componentPlaygroundEffects();
tooltipEffects();
customElements.define('ask-ai-icon', AskAIIcon);
customElements.define('ask-ai-button', AskAIButton);
@@ -135,6 +142,7 @@ export function registerAIEffects() {
customElements.define('ai-chat-messages', AIChatMessages);
customElements.define('chat-panel', ChatPanel);
customElements.define('ai-chat-input', AIChatInput);
customElements.define('ai-chat-add-context', AIChatAddContext);
customElements.define(
'ai-chat-embedding-status-tooltip',
AIChatEmbeddingStatusTooltip
@@ -203,6 +211,9 @@ export function registerAIEffects() {
customElements.define('tool-call-card', ToolCallCard);
customElements.define('tool-result-card', ToolResultCard);
customElements.define('tool-call-failed', ToolFailedCard);
customElements.define('doc-semantic-search-result', DocSemanticSearchResult);
customElements.define('doc-keyword-search-result', DocKeywordSearchResult);
customElements.define('doc-read-result', DocReadResult);
customElements.define('web-crawl-tool', WebCrawlTool);
customElements.define('web-search-tool', WebSearchTool);
customElements.define('doc-compose-tool', DocComposeTool);

View File

@@ -29,10 +29,8 @@ import {
queryHistoryMessages,
} from '../_common/chat-actions-handle';
import { type AIChatBlockModel } from '../blocks';
import type {
DocDisplayConfig,
SearchMenuConfig,
} from '../components/ai-chat-chips';
import type { SearchMenuConfig } from '../components/ai-chat-add-context';
import type { DocDisplayConfig } from '../components/ai-chat-chips';
import type {
AINetworkSearchConfig,
AIReasoningConfig,
@@ -372,7 +370,7 @@ export class AIChatBlockPeekView extends LitElement {
const last = messages[messages.length - 1];
if ('content' in last) {
last.content = '';
last.id = '';
last.streamObjects = [];
last.createdAt = new Date().toISOString();
}
this.updateContext({
@@ -609,6 +607,7 @@ export class AIChatBlockPeekView extends LitElement {
.docDisplayConfig=${this.docDisplayConfig}
.searchMenuConfig=${this.searchMenuConfig}
.affineWorkspaceDialogService=${this.affineWorkspaceDialogService}
.notificationService=${notificationService}
.onChatSuccess=${this._onChatSuccess}
.trackOptions=${{
where: 'ai-chat-block',

View File

@@ -165,7 +165,8 @@ export class CopilotClient {
docId?: string,
options?: RequestOptions<
typeof getCopilotSessionsQuery
>['variables']['options']
>['variables']['options'],
signal?: AbortSignal
) {
try {
const res = await this.gql({
@@ -176,6 +177,7 @@ export class CopilotClient {
docId,
options,
},
signal,
});
return res.currentUser?.copilot?.chats.edges.map(e => e.node);
} catch (err) {

View File

@@ -1,3 +1,4 @@
import track from '@affine/track';
import { WithDisposable } from '@blocksuite/affine/global/lit';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import { CloseIcon, DoneIcon } from '@blocksuite/icons/lit';
@@ -50,12 +51,12 @@ export class BlockDiffOptions extends WithDisposable(LitElement) {
accessor onReject!: (op: PatchOp) => void;
private readonly _handleAcceptClick = () => {
console.log('accept', this.op);
track.applyModel.widget.block.accept();
this.onAccept(this.op);
};
private readonly _handleRejectClick = () => {
console.log('reject', this.op);
track.applyModel.widget.block.reject();
this.onReject(this.op);
};

View File

@@ -1,3 +1,4 @@
import { track } from '@affine/track';
import { WidgetComponent, WidgetViewExtension } from '@blocksuite/affine/std';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import {
@@ -82,6 +83,16 @@ export class AffineBlockDiffWidgetForPage extends WidgetComponent {
diffs[this.currentIndex].scrollIntoView({ behavior: 'smooth' });
}
async _handleAcceptAll() {
track.applyModel.widget.page.acceptAll();
await this.diffService.acceptAll(this.std.store);
}
_handleRejectAll() {
track.applyModel.widget.page.rejectAll();
this.diffService.rejectAll();
}
get diffService() {
return this.std.get(BlockDiffProvider);
}
@@ -112,7 +123,7 @@ export class AffineBlockDiffWidgetForPage extends WidgetComponent {
</div>
<div
class="ai-block-diff-all-option"
@click=${() => this.diffService.rejectAll()}
@click=${() => this._handleRejectAll()}
>
${CloseIcon({
style: `color: ${unsafeCSSVarV2('icon/secondary')}`,
@@ -121,7 +132,7 @@ export class AffineBlockDiffWidgetForPage extends WidgetComponent {
</div>
<div
class="ai-block-diff-all-option"
@click=${() => this.diffService.acceptAll(this.std.store)}
@click=${() => this._handleAcceptAll()}
>
${DoneIcon({
style: `color: ${unsafeCSSVarV2('icon/activated')}`,

View File

@@ -87,7 +87,7 @@ const usePatchSpecs = (mode: DocMode, shared?: boolean) => {
// comment may not be supported by the server
const enableComment =
serverConfig.features.includes(ServerFeature.Comment) && !shared;
isCloud && serverConfig.features.includes(ServerFeature.Comment) && !shared;
const patchedSpecs = useMemo(() => {
const manager = getViewManager()

View File

@@ -44,6 +44,8 @@ const optionsSchema = z.object({
.args(z.custom<ConfirmModalProps>().optional(), z.any().optional()),
closeConfirmModal: z.function(),
}),
scope: z.enum(['doc', 'workspace']).optional(),
});
export type AffineEditorViewOptions = z.infer<typeof optionsSchema>;
@@ -93,16 +95,7 @@ export class AffineEditorViewExtension extends ViewExtensionProvider<AffineEdito
if (!options) {
return;
}
const {
framework,
reactToLit,
confirmModal,
} = options;
const docService = framework.get(DocService);
const docsService = framework.get(DocsService);
const editorService = framework.get(EditorService);
const { framework, reactToLit, confirmModal, scope = 'doc' } = options;
const referenceRenderer = this._getCustomReferenceRenderer(framework);
@@ -112,12 +105,20 @@ export class AffineEditorViewExtension extends ViewExtensionProvider<AffineEdito
patchNotificationService(confirmModal),
patchOpenDocExtension(),
patchSideBarService(framework),
patchDocModeService(docService, docsService, editorService),
patchFileSizeLimitExtension(framework),
buildDocDisplayMetaExtension(framework),
patchForAudioEmbedView(reactToLit),
])
.register(patchDocUrlExtensions(framework))
.register(patchQuickSearchService(framework));
if (scope === 'doc') {
const docService = framework.get(DocService);
const docsService = framework.get(DocsService);
const editorService = framework.get(EditorService);
context.register([
patchDocModeService(docService, docsService, editorService),
]);
}
}
}

View File

@@ -1,8 +1,9 @@
import { IconButton, notify } from '@affine/component';
import { IconButton, notify, toast } from '@affine/component';
import { LitDocEditor, type PageEditor } from '@affine/core/blocksuite/editors';
import { SnapshotHelper } from '@affine/core/modules/comment/services/snapshot-helper';
import type { CommentAttachment } from '@affine/core/modules/comment/types';
import { PeekViewService } from '@affine/core/modules/peek-view';
import { downloadResourceWithUrl } from '@affine/core/utils/resource';
import { DebugLogger } from '@affine/debug';
import { getAttachmentFileIconRC } from '@blocksuite/affine/components/icons';
import { type RichText, selectTextModel } from '@blocksuite/affine/rich-text';
@@ -80,16 +81,6 @@ export interface CommentEditorRef {
focus: () => void;
}
const download = (url: string, name: string) => {
const element = document.createElement('a');
element.setAttribute('download', name);
element.setAttribute('href', url);
element.style.display = 'none';
document.body.append(element);
element.click();
element.remove();
};
// todo: get rid of circular data changes
const useSnapshotDoc = (
defaultSnapshotOrDoc: DocSnapshot | Store,
@@ -313,6 +304,28 @@ export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
[addAttachments]
);
const handleDragOver = useCallback(
(e: React.DragEvent) => {
if (readonly) return;
// Prevent default to allow drop
e.preventDefault();
},
[readonly]
);
const handleDrop = useCallback(
(e: React.DragEvent) => {
if (readonly) return;
e.preventDefault();
e.stopPropagation();
const files = Array.from(e.dataTransfer?.files ?? []);
if (files.length) {
addAttachments(files);
}
},
[addAttachments, readonly]
);
const openFilePicker = useAsyncCallback(async () => {
if (isUploadDisabled) return;
const files = await openFilesWith('Any');
@@ -382,8 +395,6 @@ export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
if (!attachments) return;
const att = attachments[index];
if (!att) return;
const url = att.url || att.localUrl;
if (!url) return;
if (isImageAttachment(att)) {
// translate attachment index to image index
const imageAttachments = attachments.filter(isImageAttachment);
@@ -391,13 +402,19 @@ export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
if (imageIndex >= 0) {
handleImagePreview(imageIndex);
}
} else if (att.url || att.localUrl) {
} else if (att.url) {
// todo: open attachment preview. for now, just download it
download(url, att.filename ?? att.file?.name ?? 'attachment');
notify({
title: 'Downloading attachment',
message: 'The attachment is being downloaded to your computer.',
downloadResourceWithUrl(
att.url,
att.filename ?? att.file?.name ?? 'attachment'
).catch(e => {
console.error('Failed to download attachment', e);
notify.error({
title: 'Failed to download attachment',
message: e.message,
});
});
toast('The attachment is being downloaded to your computer.');
}
},
[attachments, handleImagePreview]
@@ -538,6 +555,8 @@ export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
onClick={readonly ? undefined : handleClickEditor}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onDragOver={handleDragOver}
onDrop={handleDrop}
data-readonly={!!readonly}
className={clsx(styles.container, 'comment-editor-viewport')}
>

View File

@@ -596,7 +596,7 @@ const CommentList = ({ entity }: { entity: DocCommentEntity }) => {
const [filterState, setFilterState] = useState<CommentFilterState>({
showResolvedComments: false,
onlyMyReplies: false,
onlyCurrentMode: true,
onlyCurrentMode: false,
});
const onFilterChange = useCallback(

View File

@@ -39,6 +39,7 @@ export const commentList = style({
export const empty = style({
height: '100%',
flex: 1,
padding: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',

View File

@@ -0,0 +1,60 @@
import { useConfirmModal, useLitPortalFactory } from '@affine/component';
import { getViewManager } from '@affine/core/blocksuite/manager/view';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { useFramework, useLiveData, useServices } from '@toeverything/infra';
import { useMemo } from 'react';
import { useEnableAI } from './use-enable-ai';
export const useAISpecs = () => {
const framework = useFramework();
const enableAI = useEnableAI();
const confirmModal = useConfirmModal();
const [reactToLit, _portals] = useLitPortalFactory();
const { workspaceService, featureFlagService } = useServices({
WorkspaceService,
FeatureFlagService,
});
const enablePDFEmbedPreview = useLiveData(
featureFlagService.flags.enable_pdf_embed_preview.$
);
const isCloud = workspaceService.workspace.flavour !== 'local';
const specs = useMemo(() => {
const manager = getViewManager()
.config.init()
.foundation(framework)
.ai(enableAI, framework)
.editorConfig(framework)
.editorView({
framework,
reactToLit,
confirmModal,
scope: 'workspace',
})
.cloud(framework, isCloud)
.pdf(enablePDFEmbedPreview, reactToLit)
.database(framework)
.linkedDoc(framework)
.paragraph(enableAI)
.mobile(framework)
.electron(framework)
.linkPreview(framework)
.codeBlockHtmlPreview(framework).value;
return manager.get('page');
}, [
framework,
reactToLit,
enableAI,
enablePDFEmbedPreview,
isCloud,
confirmModal,
]);
return specs;
};

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