Compare commits

...

35 Commits

Author SHA1 Message Date
Peng Xiao
ec510bc140 fix(core): some style issues (#13039)
#### PR Dependency Tree


* **PR #13039** 👈

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**
* Streamlined and unified the rendering and update flow for the document
compose tool, improving clarity and responsiveness of the UI.
* Enhanced error handling and updated preview panel interactions for a
smoother user experience.

* **Style**
* Improved sidebar header layout: increased header height, added sticky
positioning, adjusted padding, and updated background color for better
visibility and usability.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-04 12:46:45 +00:00
L-Sun
6f9c1554b7 fix(editor): keyboard can not open after closing input modal (#13041)
#### PR Dependency Tree


* **PR #13041** 👈

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 handling of keyboard provider fallbacks to ensure more
reliable keyboard behavior when certain features are unavailable.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-04 12:22:09 +00:00
L-Sun
eb9652ed4c fix(editor): adjust highlght style of comment and comment editor flickering (#13040)
### Before


https://github.com/user-attachments/assets/6b98946b-d53c-42fb-b341-e09ba5204523

### After


https://github.com/user-attachments/assets/274341de-33c4-4fd3-b01b-a8f7c25bf2fe



#### PR Dependency Tree


* **PR #13040** 👈

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 comment highlighting: Clicking now cycles through and
highlights individual comments one at a time instead of highlighting all
at once.
* Highlighting behavior is now more flexible, allowing highlighting to
be toggled on or off in certain scenarios.

* **Bug Fixes**
* Prevented flickering in the comment editor when focusing on comments.

* **Refactor**
* Enhanced selection and anchoring logic to support the new highlight
flag and updated types for improved clarity and control.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->


#### PR Dependency Tree


* **PR #13040** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)
2025-07-04 11:55:00 +00:00
L-Sun
ee8c7616bc chore(core): remove client comment feature flag (#13034)
#### PR Dependency Tree


* **PR #13034** 👈

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**
  * Removed the comment feature flag from the application.
* The ability to enable comments now depends solely on server
configuration, not on a feature flag.
* **Chores**
* Cleaned up related feature flag definitions and internal logic for
comment enablement.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: fengmk2 <fengmk2@gmail.com>
2025-07-04 11:26:30 +00:00
Peng Xiao
1452f77c85 fix(core): list comment changes usage (#13036)
fix AF-2710

#### PR Dependency Tree


* **PR #13036** 👈

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**
* Limited the maximum width of the comment input container to 800 pixels
for improved layout consistency.

* **New Features**
* Enhanced comment change listings to include pagination information,
allowing users to navigate through comment changes more effectively.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: fengmk2 <fengmk2@gmail.com>
2025-07-04 11:02:09 +00:00
Wu Yue
2f9a96f1c5 feat(core): support open doc in ai session history (#13035)
Close [AI-240
<img width="533" alt="截屏2025-07-04 18 04 39"
src="https://github.com/user-attachments/assets/726a54b6-3bdb-4e70-9cda-4671d83ae5bd"
/>
](https://linear.app/affine-design/issue/AI-240)

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

* **New Features**
* Enhanced chat toolbar and session history with the ability to open
specific documents directly from the chat interface.
* Added tooltips and improved click handling for clearer user
interactions in chat session and document lists.

* **Bug Fixes**
* Prevented redundant actions when attempting to open already active
sessions or documents.

* **Style**
* Improved tooltip formatting and visual styling for error messages and
tooltips.
* Refined hover effects and layout in chat session history for better
clarity.

* **Refactor**
* Updated tooltip configuration for more precise positioning and
behavior.

* **Chores**
* Minor updates to property defaults for tooltips and chat panel
components.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: fengmk2 <fengmk2@gmail.com>
2025-07-04 11:00:52 +00:00
德布劳外 · 贾贵
c882a8c5da feat(core): markdown-diff & patch apply (#12844)
## New Features
- **Markdown diff**: 
- Introduced block-level diffing for markdown content, enabling
detection of insertions, deletions, and replacements between document
versions.
  - Generate patch operations from markdown diff.
- **Patch Renderer**: Transfer patch operations to a render diff which
can be rendered into page body.
- **Patch apply**:Added functionality to apply patch operations to
documents, supporting block insertion, deletion, and content
replacement.

## Refactors
* Move `affine/shared/__tests__/utils` to
`blocksuite/affine-shared/test-utils`


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

* **New Features**
* Introduced utilities for declarative creation and testing of document
structures using template literals.
* Added new functions and types for block-level markdown diffing and
patch application.
* Provided a utility to generate structured render diffs for markdown
blocks.
* Added a unified test-utils entry point for easier access to testing
helpers.

* **Bug Fixes**
* Updated import paths in test files to use the new test-utils location.

* **Documentation**
* Improved example usage in documentation to reflect the new import
paths for test utilities.

* **Tests**
* Added comprehensive test suites for markdown diffing, patch
application, and render diff utilities.

* **Chores**
* Updated package dependencies and export maps to expose new test
utilities.
* Refactored internal test utilities organization for clarity and
maintainability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

> CLOSE AI-271 AI-272 AI-273
2025-07-04 10:48:49 +00:00
fengmk2
5da56b5b04 chore(server): fix unstable test (#13037)
#### PR Dependency Tree


* **PR #13037** 👈

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**
* Improved test reliability by generating unique user accounts and
prompt names for each test run.
  * Updated test setup to streamline database initialization.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-04 18:46:07 +08:00
fengmk2
831da01432 fix(server): only send comment mention notification when comment author is doc owner (#13033)
close AF-2711



#### PR Dependency Tree


* **PR #13033** 👈

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 an end-to-end test to verify that mention notifications are
correctly sent when replying to a comment authored by the document
owner.

* **Refactor**
* Improved the notification logic to streamline how mention and owner
notifications are sent, reducing redundancy and ensuring correct
recipients.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-04 16:54:16 +08:00
L-Sun
eb56adea46 fix(editor): time issues of comment initialization (#13031)
#### PR Dependency Tree


* **PR #13031** 👈

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 the ability to filter comments by their resolution status
(resolved, unresolved, or all) when viewing or managing comments.

* **Refactor**
* Improved the way commented text is identified and retrieved, resulting
in more reliable comment selection and filtering.
* Enhanced comment retrieval to support asynchronous data loading,
ensuring up-to-date comment information.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-04 08:19:00 +00:00
Hwang
a485ad5c45 feat(server): update tool descriptions and AI prompt (#13032)
update tools description & chat prompt

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

## Summary by CodeRabbit

* **New Features**
* Updated the AFFiNE AI copilot system prompt to reflect support for
multiple AI providers and a more concise, structured format with clearer
guidelines and modular tags.

* **Enhancements**
* Improved descriptions for document search and reading tools, providing
clearer guidance on when and how to use keyword search, semantic search,
and document reading features.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-04 08:06:21 +00:00
fengmk2
296089efc9 feat(core): add comment notification settings (#13029)
![image](https://github.com/user-attachments/assets/1b239592-1c0d-4575-ad3b-bfb3d0c873c8)





#### PR Dependency Tree


* **PR #13029** 👈

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 an option in user settings to enable or disable email
notifications for comments on your documents.
* Updated the user interface to include a toggle for comment email
notifications.
* Extended GraphQL queries and schema to support the new comment email
notification setting.

* **Localization**
* Added new English translations for comment email notification
settings.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-04 08:04:18 +00:00
Cats Juice
882d06b359 fix(core): re-layout ai-chat-content to display preview panel (#13030)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Introduced a split-view layout in the AI chat, allowing chat content
and a preview panel to be displayed side-by-side.
* Added responsive padding and layout adjustments for improved chat
panel appearance.

* **Refactor**
* Simplified the chat panel by removing the previous preview panel
feature and related state from the main chat component.
  * Updated internal logic to support the new split-view structure.

* **Style**
* Adjusted chat panel and workspace chat page styles for better layout
consistency and responsiveness.

* **Chores**
* Improved code organization and import statements for maintainability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-04 08:00:24 +00:00
DarkSky
b9c4d7230e feat(server): update session after doc deletion (#13028)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Sessions associated with a deleted document are now automatically
updated to remove the document reference.

* **Improvements**
* Enhanced session management to better handle documents that have been
deleted.

No visible changes to the user interface; these updates improve backend
handling of session and document relationships.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-04 07:54:19 +00:00
Peng Xiao
d0beab9638 refactor(core): call copilot in tools (#13024)
fix AI-298

#### PR Dependency Tree


* **PR #13024** 👈

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**
* Document and code artifact generation tools now use a single prompt
field for user input, enabling more flexible content creation powered by
AI.

* **Bug Fixes**
* Improved error handling for missing prompt templates or providers
during document and code artifact generation.

* **Refactor**
* Simplified input schemas for document and code artifact tools,
consolidating multiple input fields into a single prompt.
* Output schemas updated to remove metadata and other unused fields for
a cleaner result.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-04 07:23:09 +00:00
Wu Yue
24f1181069 feat(core): support ai recent session history (#13025)
Close [AI-239](https://linear.app/affine-design/issue/AI-239)
Close [AI-240](https://linear.app/affine-design/issue/AI-240)
Close [AI-242](https://linear.app/affine-design/issue/AI-242)

<img width="365" alt="截屏2025-07-04 13 49 25"
src="https://github.com/user-attachments/assets/d7c830f0-cc16-4a26-baf1-480c7d42838f"
/>


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

* **New Features**
* Introduced a floating chat history menu, allowing users to view and
switch between recent AI chat sessions grouped by recency.
* Added a new component for displaying recent chat sessions with
document icons and titles.
* Enhanced chat toolbar with asynchronous confirmation dialogs before
switching or creating sessions.
* Added notification support for chat-related actions and history
clearing.
* Added ability to fetch and display recent AI chat sessions per
workspace.

* **Improvements**
  * Streamlined session management and event handling in the chat panel.
* Improved embedding progress update and context change handling across
chat components.
  * Refined UI for chat history, session switching, and notifications.
* Updated chat components to use direct notification service injection
for better user prompts and toasts.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-04 06:51:35 +00:00
Jakob
eb73c90b2e fix(server): allow MS Office365 / Azure compatibility by making OIDC.preferred_username optional (#13027)
> [!NOTE]
> **This is a reopened (already approved) PR**
> Needed to reopen https://github.com/toeverything/AFFiNE/pull/13011
because commit email was wrong and I could not sign the CLA

Make Office365 / Azure login possible by making preferred_username
optional.
This is NOT send in the token of MS.

To make this work you ALSO need to set the oidc.config.args.id to
"email" (there preferred_username is used as default)
Source:
https://github.com/toeverything/AFFiNE/blob/canary/packages/backend/server/src/plugins/oauth/providers/oidc.ts#L152

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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved compatibility with OIDC providers by allowing the preferred
username field to be optional during user info validation.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-04 06:36:28 +00:00
EYHN
f961d9986f fix(core): fix migrate filter list error (#13022)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved error handling for collection filter migrations, reducing the
chance of failures affecting filter lists.
* **New Features**
* Expanded support for filter conditions on the "Tags" field, including
options like "is empty," "is not empty," "contains all," and more.
* **Enhancements**
* Improved handling of "Is Favourited" and "Is Public" filters for more
consistent results.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-04 06:12:21 +00:00
DarkSky
5a49d5cd24 fix(server): abort behavior in sse stream (#12211)
fix AI-121
fix AI-118

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

- **Bug Fixes**
- Improved handling of connection closures and request abortion for
streaming and non-streaming chat endpoints, ensuring session data is
saved appropriately even if the connection is interrupted.
- **Refactor**
- Streamlined internal logic for managing request signals and connection
events, resulting in more robust and explicit session management during
streaming interactions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-04 06:07:45 +00:00
fengmk2
1b9ed2fb6d fix(server): send comment mention to comment author by default (#13018)
close AF-2708



#### PR Dependency Tree


* **PR #13018** 👈

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 notifications for users when their comment receives a reply,
marking them with a mention.
* **Tests**
* Introduced an end-to-end test to verify that replying to a comment
sends a mention notification to the original comment author.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-04 05:38:32 +00:00
Lakr
ed6adcf4d9 feat: basic chat implementation completed (#13023)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Introduced a new chat list view with improved grouping of messages by
date and support for rich markdown rendering, including math
expressions.
* Added support for displaying user message attachments and hints within
the chat interface.

* **Improvements**
* Enhanced chat cell designs for user, assistant, and system messages,
providing clearer layouts and better text rendering.
* Streamlined chat message streaming with incremental markdown updates
and improved scrolling behavior.
* Updated chat view models to include timestamps and refined message
typing.

* **Bug Fixes**
* Improved handling of streaming responses and error reporting with more
accurate timestamps.

* **Refactor**
* Replaced the legacy table-based chat UI with a modern list-based
implementation.
* Simplified and unified chat cell view models and cell rendering logic.

* **Chores**
* Updated and added several third-party dependencies to support new UI
components and markdown features.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-04 05:27:18 +00:00
Cats Juice
2b0b20cdd4 feat(core): add ai-chat-toolbar for independent chat (#13021)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Introduced an AI chat toolbar for improved session management and
interaction.
  * Added the ability to pin chat sessions and reset chat content.
  * Enhanced chat header layout for better usability.

* **Improvements**
* Streamlined session creation and management within the AI chat
interface.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-04 05:16:20 +00:00
Peng Xiao
fe8cb6bb44 fix(core): some artifact styles (#13020)
fix AI-299, AI-296

#### PR Dependency Tree


* **PR #13020** 👈

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 layout alignment in the artifact preview panel for better
visual consistency.
* Enforced a minimum width for linked document banners to ensure
consistent appearance.

* **Bug Fixes**
* Updated artifact and document compose tools so that clicking an
artifact result always opens the preview panel, instead of toggling or
closing it unexpectedly.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-04 04:15:50 +00:00
EYHN
d0d94066f7 feat(ios): hidden version variant (#13019)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* On iOS devices, the app and editor version numbers in the About
section now display only the main version (e.g., "0.23.0"), hiding any
additional suffixes.
* **Other**
* No visible changes for users on non-iOS platforms; full version
strings remain displayed.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-04 04:13:44 +00:00
Cats Juice
64fb3a7243 feat(core): add an independent AI panel (#13004)
close AI-246, AI-285
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Summary by CodeRabbit

* **New Features**
* Introduced an AI chat interface accessible from the sidebar with a
dedicated "/chat" route.
* Added "AFFiNE Intelligent" button with AI icon to the sidebar for
quick chat access.
* Enhanced chat components with an "independent mode" for improved
message display and layout.
* Improved chat input and content styling, including responsive layout
and onboarding offset support.

* **Improvements**
  * Expanded icon support to include an AI icon in the app.
* Updated utility and schema functions for greater flexibility and error
prevention.
* Added a new chat container style for consistent layout and max width.

* **Bug Fixes**
* Prevented potential errors when certain editor hosts are not provided.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-04 02:10:35 +00:00
fengmk2
e6b456330c chore(server): use localhost sub domain (#13012)
reduce dnsmasq service



#### PR Dependency Tree


* **PR #13012** 👈

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

* **Documentation**
* Updated instructions to use the domain `affine.localhost` instead of
`dev.affine.fail`.
* Simplified setup steps by removing references to DNS service
configuration and custom DNS server entries.

* **Chores**
* Removed DNS server configuration and related files from the
development environment setup.
  * Updated example descriptions to reflect the new domain.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-04 00:23:19 +00:00
DarkSky
2b7a8dcd8a feat(server): use new content reader (#13007)
partial fix AI-280
2025-07-04 00:22:44 +00:00
Peng Xiao
8ed7dea823 feat(core): code artifact tool (#13015)
<img width="1272" alt="image"
src="https://github.com/user-attachments/assets/429ec60a-48a9-490b-b45f-3ce7150ef32a"
/>


#### PR Dependency Tree


* **PR #13015** 👈

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 AI tool for generating self-contained HTML artifacts,
including a dedicated interface for previewing, copying, and downloading
generated HTML.
* Added syntax highlighting and preview capabilities for HTML artifacts
in chat and tool panels.
* Integrated the new HTML artifact tool into the AI chat prompt and
Copilot toolset.

* **Enhancements**
* Improved artifact preview panel layout and sizing for a better user
experience.
* Enhanced HTML preview components to support both model-based and raw
HTML rendering.

* **Dependency Updates**
  * Added the "shiki" library for advanced syntax highlighting.

* **Bug Fixes**
  * None.

* **Chores**
* Updated internal imports and code structure to support new features
and maintain consistency.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-03 23:39:51 +00:00
DarkSky
53968f6f8c feat(server): edit tool intent collect (#12998)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Footnotes are now included in streamed AI responses, formatted as
markdown and appended at the end of the output when available.

* **Improvements**
* Enhanced handling of footnotes across multiple AI providers, ensuring
consistent display of additional information after the main response.

* **Refactor**
  * Removed citation parsing from one provider to streamline output.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-03 23:32:30 +00:00
Peng Xiao
cfc108613c feat(core): support compose a doc tool (#13013)
#### PR Dependency Tree


* **PR #13013** 👈

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 document composition tool for AI chat, allowing users to
generate, preview, and save structured markdown documents directly from
chat interactions.
* Added an artifact preview panel for enhanced document previews within
the chat interface.
* Enabled dynamic content rendering in the chat panel's right section
for richer user experiences.

* **Improvements**
  * Sidebar maximum width increased for greater workspace flexibility.
* Enhanced chat message and split view styling for improved layout and
usability.

* **Bug Fixes**
  * None.

* **Other**
* Registered new custom elements for AI tools and artifact preview
functionality.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-03 14:21:49 +00:00
L-Sun
558279da29 feat(editor): resolve unassociated comments on init (#13008)
#### PR Dependency Tree


* **PR #13008** 👈

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 synchronization between comment states in the editor and the
comment provider to ensure consistency of inline and block comments.

* **Refactor**
* Separated and modularized utility functions for finding commented
texts and blocks, enhancing maintainability and reusability.

* **Style**
* Updated styles to remove background color and border from inline
comments within embedded note content.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-03 12:30:07 +00:00
EYHN
c5b442225f chore(ios): allow stable ios release (#13010)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Chores**
* Updated workflow so the "Testflight" step always runs for iOS builds,
regardless of build type.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-03 12:29:44 +00:00
Wu Yue
134e62a0fa feat(core): support ai chat add, pin and unpin (#13002)
Close [AI-241](https://linear.app/affine-design/issue/AI-241)
Close [AI-237](https://linear.app/affine-design/issue/AI-237)
Close [AI-238](https://linear.app/affine-design/issue/AI-238)

<img width="564" alt="截屏2025-07-03 15 54 10"
src="https://github.com/user-attachments/assets/8654db2b-cb71-4906-9e3b-0a723d7459e1"
/>


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

* **New Features**
* Introduced a chat toolbar with options to create new sessions and
pin/unpin chat sessions.
* Enhanced session management, allowing users to pin sessions and
control session reuse.
* Added the ability to update session properties directly from the chat
panel.

* **Improvements**
* Chat panel now prioritizes pinned sessions and provides clearer
session initialization.
* Editor actions in chat messages are shown only when relevant document
information is present.
  * Toolbar and chat content UI improved for clarity and usability.
  * Scroll position is preserved and restored for pinned chat sessions.
* Session API updated to support more structured input types and new
session creation options.

* **Bug Fixes**
* Actions and toolbar buttons are now conditionally displayed based on
session and message state.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-03 11:26:36 +00:00
fengmk2
92cd2a3d0e chore(server): use jemalloc to reduce RSS (#13001)
close CLOUD-236



#### PR Dependency Tree


* **PR #13001** 👈

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

* **Chores**
* Updated system configuration to preload jemalloc for improved memory
management in the Node.js application.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-03 10:39:34 +00:00
DarkSky
41524425bc fix(server): incorrect list condition (#13005)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved session filtering to use the correct criteria when querying
sessions, ensuring more accurate results based on the action parameter.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-03 10:31:11 +00:00
164 changed files with 5523 additions and 1747 deletions

View File

@@ -15,13 +15,7 @@ yarn affine cert --install
```bash
# certificates will be located at `./.docker/dev/certs/${domain}`
yarn affine cert --domain dev.affine.fail
yarn affine cert --domain affine.localhost
```
### 3. Enable dns and nginx service in compose.yml
### 4. Add custom dns server
```bash
echo "nameserver 127.0.0.1" | sudo tee /etc/resolver/dev.affine.fail
```
### 3. Enable nginx service in compose.yml

View File

@@ -73,17 +73,6 @@ services:
# timeout: 10s
# retries: 120
# dns:
# image: strm/dnsmasq
# volumes:
# - ./dnsmasq.conf:/etc/dnsmasq.d/local.conf
# ports:
# - "53:53/udp"
# cap_add:
# - NET_ADMIN
# depends_on:
# - nginx
# nginx:
# image: nginx:alpine
# volumes:

View File

@@ -1,2 +0,0 @@
log-queries
address=/dev.affine.fail/127.0.0.1

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

@@ -124,7 +124,6 @@ jobs:
package: 'affine_mobile_native'
no-build: 'true'
- name: Testflight
if: ${{ env.BUILD_TYPE != 'stable' }}
working-directory: packages/frontend/apps/ios/App
run: |
echo -n "${{ env.BUILD_PROVISION_PROFILE }}" | base64 --decode -o $PP_PATH

View File

@@ -2,7 +2,9 @@ export * from './adapters';
export * from './clipboard';
export * from './code-block';
export * from './code-block-config';
export * from './code-block-service';
export * from './code-preview-extension';
export * from './code-toolbar';
export * from './highlight/const';
export * from './turbo/code-layout-handler';
export * from './turbo/code-painter.worker';

View File

@@ -5,3 +5,4 @@ export * from './edgeless-clipboard-config';
export * from './embed-edgeless-linked-doc-block';
export * from './embed-linked-doc-block';
export * from './embed-linked-doc-spec';
export { getEmbedLinkedDocIcons } from './utils';

View File

@@ -168,6 +168,7 @@ export const styles = css`
.affine-embed-linked-doc-banner {
margin: 12px 12px 0px 0px;
width: 204px;
min-width: 204px;
max-width: 100%;
height: 102px;
pointer-events: none;

View File

@@ -190,7 +190,10 @@ export class Tooltip extends LitElement {
middleware: [
this.autoFlip && flip({ padding: AUTO_FLIP_PADDING }),
this.autoShift && shift({ padding: AUTO_SHIFT_PADDING }),
offset((this.arrow ? TRIANGLE_HEIGHT : 0) + this.offset),
offset({
mainAxis: (this.arrow ? TRIANGLE_HEIGHT : 0) + this.offsetY,
crossAxis: this.offsetX,
}),
arrow({
element: portalRoot.shadowRoot!.querySelector('.arrow')!,
}),
@@ -264,7 +267,7 @@ export class Tooltip extends LitElement {
* Show a triangle arrow pointing to the reference element.
*/
@property({ attribute: false })
accessor arrow = true;
accessor arrow = false;
/**
* changes the placement of the floating element in order to keep it in view,
@@ -303,7 +306,10 @@ export class Tooltip extends LitElement {
* See https://floating-ui.com/docs/offset
*/
@property({ attribute: false })
accessor offset = 4;
accessor offsetY = 6;
@property({ attribute: false })
accessor offsetX = 0;
@property({ attribute: 'tip-position' })
accessor placement: Placement = 'top';

View File

@@ -1,8 +1,10 @@
import { getInlineEditorByModel } from '@blocksuite/affine-rich-text';
import { getSelectedBlocksCommand } from '@blocksuite/affine-shared/commands';
import {
BlockCommentManager,
type CommentId,
CommentProviderIdentifier,
findAllCommentedBlocks,
} from '@blocksuite/affine-shared/services';
import type { AffineInlineEditor } from '@blocksuite/affine-shared/types';
import { DisposableGroup } from '@blocksuite/global/disposable';
@@ -13,8 +15,13 @@ import {
} from '@blocksuite/std';
import type { BaseSelection, BlockModel } from '@blocksuite/store';
import { signal } from '@preact/signals-core';
import difference from 'lodash-es/difference';
import { extractCommentIdFromDelta, findCommentedTexts } from './utils';
import {
extractCommentIdFromDelta,
findAllCommentedTexts,
findCommentedTexts,
} from './utils';
export class InlineCommentManager extends LifeCycleWatcher {
static override key = 'inline-comment-manager';
@@ -31,6 +38,8 @@ export class InlineCommentManager extends LifeCycleWatcher {
const provider = this._provider;
if (!provider) return;
this._init().catch(console.error);
this._disposables.add(provider.onCommentAdded(this._handleAddComment));
this._disposables.add(
provider.onCommentDeleted(this._handleDeleteAndResolve)
@@ -50,6 +59,35 @@ export class InlineCommentManager extends LifeCycleWatcher {
this._disposables.dispose();
}
private async _init() {
const provider = this._provider;
if (!provider) return;
const commentsInProvider = await provider.getComments('unresolved');
const inlineComments = [...findAllCommentedTexts(this.std.store).values()];
const blockComments = findAllCommentedBlocks(this.std.store).flatMap(
block => Object.keys(block.props.comments)
);
const commentsInEditor = [
...new Set([...inlineComments, ...blockComments]),
];
// resolve comments that are in provider but not in editor
// which means the commented content may be deleted
difference(commentsInProvider, commentsInEditor).forEach(comment => {
provider.resolveComment(comment);
});
// remove comments that are in editor but not in provider
// which means the comment may be removed or resolved in provider side
difference(commentsInEditor, commentsInProvider).forEach(comment => {
this._handleDeleteAndResolve(comment);
this.std.get(BlockCommentManager).handleDeleteAndResolve(comment);
});
}
private readonly _handleAddComment = (
id: CommentId,
selections: BaseSelection[]
@@ -119,12 +157,17 @@ export class InlineCommentManager extends LifeCycleWatcher {
};
private readonly _handleDeleteAndResolve = (id: CommentId) => {
const commentedTexts = findCommentedTexts(this.std, id);
const commentedTexts = findCommentedTexts(this.std.store, id);
if (commentedTexts.length === 0) return;
this.std.store.withoutTransact(() => {
commentedTexts.forEach(([selection, inlineEditor]) => {
inlineEditor.formatText(
commentedTexts.forEach(selection => {
const inlineEditor = getInlineEditorByModel(
this.std,
selection.from.blockId
);
inlineEditor?.formatText(
selection.from,
{
[`comment-${id}`]: null,

View File

@@ -41,6 +41,8 @@ export class InlineComment extends WithDisposable(ShadowlessElement) {
})
accessor commentIds!: string[];
private _index: number = 0;
@consume({ context: stdContext })
private accessor _std!: BlockStdScope;
@@ -52,8 +54,8 @@ export class InlineComment extends WithDisposable(ShadowlessElement) {
}
private readonly _handleClick = () => {
const provider = this._provider;
provider && this.commentIds.forEach(id => provider.highlightComment(id));
this._provider?.highlightComment(this.commentIds[this._index]);
this._index = (this._index + 1) % this.commentIds.length;
};
private readonly _handleHighlight = (id: CommentId | null) => {

View File

@@ -1,45 +1,56 @@
import { getInlineEditorByModel } from '@blocksuite/affine-rich-text';
import type { CommentId } from '@blocksuite/affine-shared/services';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { type BlockStdScope, TextSelection } from '@blocksuite/std';
import type { InlineEditor } from '@blocksuite/std/inline';
import type { DeltaInsert } from '@blocksuite/store';
import { TextSelection } from '@blocksuite/std';
import type { DeltaInsert, Store } from '@blocksuite/store';
export function findCommentedTexts(std: BlockStdScope, commentId: CommentId) {
const selections: [TextSelection, InlineEditor][] = [];
std.store.getAllModels().forEach(model => {
const inlineEditor = getInlineEditorByModel(std, model);
if (!inlineEditor) return;
export function findAllCommentedTexts(
store: Store
): Map<TextSelection, CommentId> {
const result = new Map<TextSelection, CommentId>();
inlineEditor.mapDeltasInInlineRange(
{
index: 0,
length: inlineEditor.yTextLength,
},
(delta, rangeIndex) => {
if (
delta.attributes &&
Object.keys(delta.attributes).some(
key => key === `comment-${commentId}`
)
) {
selections.push([
new TextSelection({
from: {
blockId: model.id,
index: rangeIndex,
length: delta.insert.length,
},
to: null,
}),
inlineEditor,
]);
}
store.getAllModels().forEach(model => {
if (!model.text) return;
let index = 0;
model.text.toDelta().forEach(delta => {
if (!delta.insert) return;
const length = delta.insert.length;
if (!delta.attributes) {
index += length;
return;
}
);
Object.keys(delta.attributes)
.filter(key => key.startsWith('comment-'))
.forEach(key => {
const commentId = key.replace('comment-', '');
const selection = new TextSelection({
from: {
blockId: model.id,
index,
length,
},
to: null,
});
result.set(selection, commentId);
});
index += length;
});
});
return selections;
return result;
}
export function findCommentedTexts(
store: Store,
commentId: CommentId
): TextSelection[] {
return [...findAllCommentedTexts(store).entries()]
.filter(([_, id]) => id === commentId)
.map(([selection]) => selection);
}
export function extractCommentIdFromDelta(

View File

@@ -63,7 +63,8 @@
"./theme": "./src/theme/index.ts",
"./styles": "./src/styles/index.ts",
"./services": "./src/services/index.ts",
"./adapters": "./src/adapters/index.ts"
"./adapters": "./src/adapters/index.ts",
"./test-utils": "./src/test-utils/index.ts"
},
"files": [
"src",

View File

@@ -4,7 +4,7 @@
import { describe, expect, it } from 'vitest';
import { getFirstBlockCommand } from '../../../commands/block-crud/get-first-content-block';
import { affine } from '../../helpers/affine-template';
import { affine } from '../../../test-utils';
describe('commands/block-crud', () => {
describe('getFirstBlockCommand', () => {

View File

@@ -4,7 +4,7 @@
import { describe, expect, it } from 'vitest';
import { getLastBlockCommand } from '../../../commands/block-crud/get-last-content-block';
import { affine } from '../../helpers/affine-template';
import { affine } from '../../../test-utils';
describe('commands/block-crud', () => {
describe('getLastBlockCommand', () => {

View File

@@ -1,13 +1,11 @@
/**
* @vitest-environment happy-dom
*/
import '../../helpers/affine-test-utils';
import type { TextSelection } from '@blocksuite/std';
import { describe, expect, it } from 'vitest';
import { replaceSelectedTextWithBlocksCommand } from '../../../commands/model-crud/replace-selected-text-with-blocks';
import { affine, block } from '../../helpers/affine-template';
import { affine, block } from '../../../test-utils';
describe('commands/model-crud', () => {
describe('replaceSelectedTextWithBlocksCommand', () => {

View File

@@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest';
import { isNothingSelectedCommand } from '../../../commands/selection/is-nothing-selected';
import { ImageSelection } from '../../../selection';
import { affine } from '../../helpers/affine-template';
import { affine } from '../../../test-utils';
describe('commands/selection', () => {
describe('isNothingSelectedCommand', () => {

View File

@@ -1,298 +0,0 @@
import {
CodeBlockSchemaExtension,
DatabaseBlockSchemaExtension,
ImageBlockSchemaExtension,
ListBlockSchemaExtension,
NoteBlockSchemaExtension,
ParagraphBlockSchemaExtension,
RootBlockSchemaExtension,
} from '@blocksuite/affine-model';
import { TextSelection } from '@blocksuite/std';
import { type Block, type Store } from '@blocksuite/store';
import { Text } from '@blocksuite/store';
import { TestWorkspace } from '@blocksuite/store/test';
import { createTestHost } from './create-test-host';
// Extensions array
const extensions = [
RootBlockSchemaExtension,
NoteBlockSchemaExtension,
ParagraphBlockSchemaExtension,
ListBlockSchemaExtension,
ImageBlockSchemaExtension,
DatabaseBlockSchemaExtension,
CodeBlockSchemaExtension,
];
// Mapping from tag names to flavours
const tagToFlavour: Record<string, string> = {
'affine-page': 'affine:page',
'affine-note': 'affine:note',
'affine-paragraph': 'affine:paragraph',
'affine-list': 'affine:list',
'affine-image': 'affine:image',
'affine-database': 'affine:database',
'affine-code': 'affine:code',
};
interface SelectionInfo {
anchorBlockId?: string;
anchorOffset?: number;
focusBlockId?: string;
focusOffset?: number;
cursorBlockId?: string;
cursorOffset?: number;
}
/**
* Parse template strings and build BlockSuite document structure,
* then create a host object with the document
*
* Example:
* ```
* const host = affine`
* <affine-page id="page">
* <affine-note id="note">
* <affine-paragraph id="paragraph-1">Hello, world<anchor /></affine-paragraph>
* <affine-paragraph id="paragraph-2">Hello, world<focus /></affine-paragraph>
* </affine-note>
* </affine-page>
* `;
* ```
*/
export function affine(strings: TemplateStringsArray, ...values: any[]) {
// Merge template strings and values
let htmlString = '';
strings.forEach((str, i) => {
htmlString += str;
if (i < values.length) {
htmlString += values[i];
}
});
// Create a new doc
const workspace = new TestWorkspace({});
workspace.meta.initialize();
const doc = workspace.createDoc('test-doc');
const store = doc.getStore({ extensions });
let selectionInfo: SelectionInfo = {};
// Use DOMParser to parse HTML string
doc.load(() => {
const parser = new DOMParser();
const dom = parser.parseFromString(htmlString.trim(), 'text/html');
const root = dom.body.firstElementChild;
if (!root) {
throw new Error('Template must contain a root element');
}
buildDocFromElement(store, root, null, selectionInfo);
});
// Create host object
const host = createTestHost(store);
// Set selection if needed
if (selectionInfo.anchorBlockId && selectionInfo.focusBlockId) {
const anchorBlock = store.getBlock(selectionInfo.anchorBlockId);
const anchorTextLength = anchorBlock?.model?.text?.length ?? 0;
const focusOffset = selectionInfo.focusOffset ?? 0;
const anchorOffset = selectionInfo.anchorOffset ?? 0;
if (selectionInfo.anchorBlockId === selectionInfo.focusBlockId) {
const selection = host.selection.create(TextSelection, {
from: {
blockId: selectionInfo.anchorBlockId,
index: anchorOffset,
length: focusOffset,
},
to: null,
});
host.selection.setGroup('note', [selection]);
} else {
const selection = host.selection.create(TextSelection, {
from: {
blockId: selectionInfo.anchorBlockId,
index: anchorOffset,
length: anchorTextLength - anchorOffset,
},
to: {
blockId: selectionInfo.focusBlockId,
index: 0,
length: focusOffset,
},
});
host.selection.setGroup('note', [selection]);
}
} else if (selectionInfo.cursorBlockId) {
const selection = host.selection.create(TextSelection, {
from: {
blockId: selectionInfo.cursorBlockId,
index: selectionInfo.cursorOffset ?? 0,
length: 0,
},
to: null,
});
host.selection.setGroup('note', [selection]);
}
return host;
}
/**
* Create a single block from template string
*
* Example:
* ```
* const block = block`<affine-note />`
* ```
*/
export function block(
strings: TemplateStringsArray,
...values: any[]
): Block | null {
// Merge template strings and values
let htmlString = '';
strings.forEach((str, i) => {
htmlString += str;
if (i < values.length) {
htmlString += values[i];
}
});
// Create a temporary doc to hold the block
const workspace = new TestWorkspace({});
workspace.meta.initialize();
const doc = workspace.createDoc('temp-doc');
const store = doc.getStore({ extensions });
let blockId: string | null = null;
const selectionInfo: SelectionInfo = {};
// Use DOMParser to parse HTML string
doc.load(() => {
const parser = new DOMParser();
const dom = parser.parseFromString(htmlString.trim(), 'text/html');
const root = dom.body.firstElementChild;
if (!root) {
throw new Error('Template must contain a root element');
}
// Create a root block if needed
const flavour = tagToFlavour[root.tagName.toLowerCase()];
if (
flavour === 'affine:paragraph' ||
flavour === 'affine:list' ||
flavour === 'affine:code'
) {
const pageId = store.addBlock('affine:page', {});
const noteId = store.addBlock('affine:note', {}, pageId);
blockId = buildDocFromElement(store, root, noteId, selectionInfo);
} else {
blockId = buildDocFromElement(store, root, null, selectionInfo);
}
});
// Return the created block
return blockId ? (store.getBlock(blockId) ?? null) : null;
}
/**
* Recursively build document structure
* @param doc
* @param element
* @param parentId
* @param selectionInfo
* @returns
*/
function buildDocFromElement(
doc: Store,
element: Element,
parentId: string | null,
selectionInfo: SelectionInfo
): string {
const tagName = element.tagName.toLowerCase();
// Handle selection tags
if (tagName === 'anchor') {
if (!parentId) return '';
const parentBlock = doc.getBlock(parentId);
if (parentBlock) {
const textBeforeCursor = element.previousSibling?.textContent ?? '';
selectionInfo.anchorBlockId = parentId;
selectionInfo.anchorOffset = textBeforeCursor.length;
}
return parentId;
} else if (tagName === 'focus') {
if (!parentId) return '';
const parentBlock = doc.getBlock(parentId);
if (parentBlock) {
const textBeforeCursor = element.previousSibling?.textContent ?? '';
selectionInfo.focusBlockId = parentId;
selectionInfo.focusOffset = textBeforeCursor.length;
}
return parentId;
} else if (tagName === 'cursor') {
if (!parentId) return '';
const parentBlock = doc.getBlock(parentId);
if (parentBlock) {
const textBeforeCursor = element.previousSibling?.textContent ?? '';
selectionInfo.cursorBlockId = parentId;
selectionInfo.cursorOffset = textBeforeCursor.length;
}
return parentId;
}
const flavour = tagToFlavour[tagName];
if (!flavour) {
throw new Error(`Unknown tag name: ${tagName}`);
}
const props: Record<string, any> = {};
const customId = element.getAttribute('id');
// If ID is specified, add it to props
if (customId) {
props.id = customId;
}
// Process element attributes
Array.from(element.attributes).forEach(attr => {
if (attr.name !== 'id') {
// Skip id attribute, we already handled it
props[attr.name] = attr.value;
}
});
// Special handling for different block types based on their flavours
switch (flavour) {
case 'affine:paragraph':
case 'affine:list':
if (element.textContent) {
props.text = new Text(element.textContent);
}
break;
}
// Create block
const blockId = doc.addBlock(flavour, props, parentId);
// Process all child nodes, including text nodes
Array.from(element.children).forEach(child => {
if (child.nodeType === Node.ELEMENT_NODE) {
// Handle element nodes
buildDocFromElement(doc, child as Element, blockId, selectionInfo);
} else if (child.nodeType === Node.TEXT_NODE) {
// Handle text nodes
console.log('buildDocFromElement text node:', child.textContent);
}
});
return blockId;
}

View File

@@ -1,7 +1,7 @@
import { TextSelection } from '@blocksuite/std';
import { describe, expect, it } from 'vitest';
import { affine } from './affine-template';
import { affine } from '../../test-utils';
describe('helpers/affine-template', () => {
it('should create a basic document structure from template', () => {

View File

@@ -1,8 +1,12 @@
import {
type ReferenceParams,
ReferenceParamsSchema,
} from '@blocksuite/affine-model';
import { ReferenceParamsSchema } from '@blocksuite/affine-model';
import { BaseSelection, SelectionExtension } from '@blocksuite/store';
import z from 'zod';
const HighlightSelectionParamsSchema = ReferenceParamsSchema.extend({
highlight: z.boolean().optional(),
});
type HighlightSelectionParams = z.infer<typeof HighlightSelectionParamsSchema>;
export class HighlightSelection extends BaseSelection {
static override group = 'scene';
@@ -15,16 +19,24 @@ export class HighlightSelection extends BaseSelection {
readonly mode: 'page' | 'edgeless' = 'page';
constructor({ mode, blockIds, elementIds }: ReferenceParams) {
readonly highlight: boolean = true;
constructor({
mode,
blockIds,
elementIds,
highlight = true,
}: HighlightSelectionParams) {
super({ blockId: '[scene-highlight]' });
this.mode = mode ?? 'page';
this.blockIds = blockIds ?? [];
this.elementIds = elementIds ?? [];
this.highlight = highlight;
}
static override fromJSON(json: Record<string, unknown>): HighlightSelection {
const result = ReferenceParamsSchema.parse(json);
const result = HighlightSelectionParamsSchema.parse(json);
return new HighlightSelection(result);
}

View File

@@ -42,10 +42,10 @@ export class BlockCommentManager extends LifeCycleWatcher {
this._disposables.add(provider.onCommentAdded(this._handleAddComment));
this._disposables.add(
provider.onCommentDeleted(this._handleDeleteAndResolve)
provider.onCommentDeleted(this.handleDeleteAndResolve)
);
this._disposables.add(
provider.onCommentResolved(this._handleDeleteAndResolve)
provider.onCommentResolved(this.handleDeleteAndResolve)
);
this._disposables.add(
provider.onCommentHighlighted(this._handleHighlightComment)
@@ -103,7 +103,7 @@ export class BlockCommentManager extends LifeCycleWatcher {
});
};
private readonly _handleDeleteAndResolve = (id: CommentId) => {
readonly handleDeleteAndResolve = (id: CommentId) => {
const commentedBlocks = findCommentedBlocks(this.std.store, id);
this.std.store.withoutTransact(() => {
commentedBlocks.forEach(block => {

View File

@@ -15,7 +15,10 @@ export interface CommentProvider {
addComment: (selections: BaseSelection[]) => void;
resolveComment: (id: CommentId) => void;
highlightComment: (id: CommentId | null) => void;
getComments: () => CommentId[];
getComments: (
type: 'resolved' | 'unresolved' | 'all'
) => Promise<CommentId[]> | CommentId[];
onCommentAdded: (
callback: (id: CommentId, selections: BaseSelection[]) => void

View File

@@ -5,18 +5,23 @@ import type { BlockModel, Store } from '@blocksuite/store';
import type { ToolbarAction } from '../toolbar-service';
import { type CommentId, CommentProviderIdentifier } from './comment-provider';
export function findCommentedBlocks(store: Store, commentId: CommentId) {
export function findAllCommentedBlocks(store: Store) {
type CommentedBlock = BlockModel<{ comments: Record<CommentId, boolean> }>;
return store.getAllModels().filter((block): block is CommentedBlock => {
return (
'comments' in block.props &&
typeof block.props.comments === 'object' &&
block.props.comments !== null &&
commentId in block.props.comments
block.props.comments !== null
);
});
}
export function findCommentedBlocks(store: Store, commentId: CommentId) {
return findAllCommentedBlocks(store).filter(block => {
return block.props.comments[commentId];
});
}
export const blockCommentToolbarButton: Omit<ToolbarAction, 'id'> = {
tooltip: 'Comment',
when: ({ std }) => !!std.getOptional(CommentProviderIdentifier),

View File

@@ -21,7 +21,6 @@ export interface BlockSuiteFlags {
enable_table_virtual_scroll: boolean;
enable_turbo_renderer: boolean;
enable_dom_renderer: boolean;
enable_comment: boolean;
}
export class FeatureFlagService extends StoreExtension {
@@ -47,7 +46,6 @@ export class FeatureFlagService extends StoreExtension {
enable_table_virtual_scroll: false,
enable_turbo_renderer: false,
enable_dom_renderer: false,
enable_comment: false,
});
setFlag(key: keyof BlockSuiteFlags, value: boolean) {

View File

@@ -7,7 +7,7 @@
### Basic Usage
```typescript
import { affine } from '../__tests__/utils/affine-template';
import { affine } from '@blocksuite/affine-shared/test-utils';
// Create a simple document
const doc = affine`

View File

@@ -0,0 +1,316 @@
import {
CodeBlockSchemaExtension,
DatabaseBlockSchemaExtension,
ImageBlockSchemaExtension,
ListBlockSchemaExtension,
NoteBlockSchemaExtension,
ParagraphBlockSchemaExtension,
RootBlockSchemaExtension,
} from '@blocksuite/affine-model';
import { Container } from '@blocksuite/global/di';
import { TextSelection } from '@blocksuite/std';
import {
type Block,
type ExtensionType,
type Store,
Text,
} from '@blocksuite/store';
import { TestWorkspace } from '@blocksuite/store/test';
import { createTestHost } from './create-test-host';
const DEFAULT_EXTENSIONS = [
RootBlockSchemaExtension,
NoteBlockSchemaExtension,
ParagraphBlockSchemaExtension,
ListBlockSchemaExtension,
ImageBlockSchemaExtension,
DatabaseBlockSchemaExtension,
CodeBlockSchemaExtension,
];
// Mapping from tag names to flavours
const tagToFlavour: Record<string, string> = {
'affine-page': 'affine:page',
'affine-note': 'affine:note',
'affine-paragraph': 'affine:paragraph',
'affine-list': 'affine:list',
'affine-image': 'affine:image',
'affine-database': 'affine:database',
'affine-code': 'affine:code',
};
interface SelectionInfo {
anchorBlockId?: string;
anchorOffset?: number;
focusBlockId?: string;
focusOffset?: number;
cursorBlockId?: string;
cursorOffset?: number;
}
export function createAffineTemplate(
extensions: ExtensionType[] = DEFAULT_EXTENSIONS
) {
/**
* Parse template strings and build BlockSuite document structure,
* then create a host object with the document
*
* Example:
* ```
* const host = affine`
* <affine-page id="page">
* <affine-note id="note">
* <affine-paragraph id="paragraph-1">Hello, world<anchor /></affine-paragraph>
* <affine-paragraph id="paragraph-2">Hello, world<focus /></affine-paragraph>
* </affine-note>
* </affine-page>
* `;
* ```
*/
function affine(strings: TemplateStringsArray, ...values: any[]) {
// Merge template strings and values
let htmlString = '';
strings.forEach((str, i) => {
htmlString += str;
if (i < values.length) {
htmlString += values[i];
}
});
// Create a new doc
const workspace = new TestWorkspace({});
workspace.meta.initialize();
const doc = workspace.createDoc('test-doc');
const container = new Container();
extensions.forEach(extension => {
extension.setup(container);
});
const store = doc.getStore({ extensions, provider: container.provider() });
let selectionInfo: SelectionInfo = {};
// Use DOMParser to parse HTML string
doc.load(() => {
const parser = new DOMParser();
const dom = parser.parseFromString(htmlString.trim(), 'text/html');
const root = dom.body.firstElementChild;
if (!root) {
throw new Error('Template must contain a root element');
}
buildDocFromElement(store, root, null, selectionInfo);
});
// Create host object
const host = createTestHost(store);
// Set selection if needed
if (selectionInfo.anchorBlockId && selectionInfo.focusBlockId) {
const anchorBlock = store.getBlock(selectionInfo.anchorBlockId);
const anchorTextLength = anchorBlock?.model?.text?.length ?? 0;
const focusOffset = selectionInfo.focusOffset ?? 0;
const anchorOffset = selectionInfo.anchorOffset ?? 0;
if (selectionInfo.anchorBlockId === selectionInfo.focusBlockId) {
const selection = host.selection.create(TextSelection, {
from: {
blockId: selectionInfo.anchorBlockId,
index: anchorOffset,
length: focusOffset,
},
to: null,
});
host.selection.setGroup('note', [selection]);
} else {
const selection = host.selection.create(TextSelection, {
from: {
blockId: selectionInfo.anchorBlockId,
index: anchorOffset,
length: anchorTextLength - anchorOffset,
},
to: {
blockId: selectionInfo.focusBlockId,
index: 0,
length: focusOffset,
},
});
host.selection.setGroup('note', [selection]);
}
} else if (selectionInfo.cursorBlockId) {
const selection = host.selection.create(TextSelection, {
from: {
blockId: selectionInfo.cursorBlockId,
index: selectionInfo.cursorOffset ?? 0,
length: 0,
},
to: null,
});
host.selection.setGroup('note', [selection]);
}
return host;
}
/**
* Create a single block from template string
*
* Example:
* ```
* const block = block`<affine-note />`
* ```
*/
function block(
strings: TemplateStringsArray,
...values: any[]
): Block | null {
// Merge template strings and values
let htmlString = '';
strings.forEach((str, i) => {
htmlString += str;
if (i < values.length) {
htmlString += values[i];
}
});
// Create a temporary doc to hold the block
const workspace = new TestWorkspace({});
workspace.meta.initialize();
const doc = workspace.createDoc('temp-doc');
const store = doc.getStore({ extensions });
let blockId: string | null = null;
const selectionInfo: SelectionInfo = {};
// Use DOMParser to parse HTML string
doc.load(() => {
const parser = new DOMParser();
const dom = parser.parseFromString(htmlString.trim(), 'text/html');
const root = dom.body.firstElementChild;
if (!root) {
throw new Error('Template must contain a root element');
}
// Create a root block if needed
const flavour = tagToFlavour[root.tagName.toLowerCase()];
if (
flavour === 'affine:paragraph' ||
flavour === 'affine:list' ||
flavour === 'affine:code'
) {
const pageId = store.addBlock('affine:page', {});
const noteId = store.addBlock('affine:note', {}, pageId);
blockId = buildDocFromElement(store, root, noteId, selectionInfo);
} else {
blockId = buildDocFromElement(store, root, null, selectionInfo);
}
});
// Return the created block
return blockId ? (store.getBlock(blockId) ?? null) : null;
}
return {
affine,
block,
};
}
export const { affine, block } = createAffineTemplate();
/**
* Recursively build document structure
* @param doc
* @param element
* @param parentId
* @param selectionInfo
* @returns
*/
function buildDocFromElement(
doc: Store,
element: Element,
parentId: string | null,
selectionInfo: SelectionInfo
): string {
const tagName = element.tagName.toLowerCase();
// Handle selection tags
if (tagName === 'anchor') {
if (!parentId) return '';
const parentBlock = doc.getBlock(parentId);
if (parentBlock) {
const textBeforeCursor = element.previousSibling?.textContent ?? '';
selectionInfo.anchorBlockId = parentId;
selectionInfo.anchorOffset = textBeforeCursor.length;
}
return parentId;
} else if (tagName === 'focus') {
if (!parentId) return '';
const parentBlock = doc.getBlock(parentId);
if (parentBlock) {
const textBeforeCursor = element.previousSibling?.textContent ?? '';
selectionInfo.focusBlockId = parentId;
selectionInfo.focusOffset = textBeforeCursor.length;
}
return parentId;
} else if (tagName === 'cursor') {
if (!parentId) return '';
const parentBlock = doc.getBlock(parentId);
if (parentBlock) {
const textBeforeCursor = element.previousSibling?.textContent ?? '';
selectionInfo.cursorBlockId = parentId;
selectionInfo.cursorOffset = textBeforeCursor.length;
}
return parentId;
}
const flavour = tagToFlavour[tagName];
if (!flavour) {
throw new Error(`Unknown tag name: ${tagName}`);
}
const props: Record<string, any> = {};
const customId = element.getAttribute('id');
// If ID is specified, add it to props
if (customId) {
props.id = customId;
}
// Process element attributes
Array.from(element.attributes).forEach(attr => {
if (attr.name !== 'id') {
// Skip id attribute, we already handled it
props[attr.name] = attr.value;
}
});
// Special handling for different block types based on their flavours
switch (flavour) {
case 'affine:paragraph':
case 'affine:list':
if (element.textContent) {
props.text = new Text(element.textContent);
}
break;
}
// Create block
const blockId = doc.addBlock(flavour, props, parentId);
// Process all child nodes, including text nodes
Array.from(element.children).forEach(child => {
if (child.nodeType === Node.ELEMENT_NODE) {
// Handle element nodes
buildDocFromElement(doc, child as Element, blockId, selectionInfo);
} else if (child.nodeType === Node.TEXT_NODE) {
// Handle text nodes
console.log('buildDocFromElement text node:', child.textContent);
}
});
return blockId;
}

View File

@@ -63,10 +63,8 @@ function compareBlocks(
if (JSON.stringify(actualProps) !== JSON.stringify(expectedProps))
return false;
// eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let i = 0; i < actual.children.length; i++) {
if (!compareBlocks(actual.children[i], expected.children[i], compareId))
return false;
for (const [i, child] of actual.children.entries()) {
if (!compareBlocks(child, expected.children[i], compareId)) return false;
}
return true;

View File

@@ -240,7 +240,7 @@ export function createTestHost(doc: Store): EditorHost {
std.selection = new MockSelectionStore();
std.command = new CommandManager(std as any);
// @ts-expect-error
// @ts-expect-error dev-only
host.command = std.command;
host.selection = std.selection;

View File

@@ -0,0 +1,3 @@
export * from './affine-template';
export * from './affine-test-utils';
export * from './create-test-host';

View File

@@ -1,12 +1,13 @@
import type { EditorHost } from '@blocksuite/std';
export function isInsidePageEditor(host: EditorHost) {
export function isInsidePageEditor(host?: EditorHost) {
if (!host) return false;
return Array.from(host.children).some(
v => v.tagName.toLowerCase() === 'affine-page-root'
);
}
export function isInsideEdgelessEditor(host: EditorHost) {
export function isInsideEdgelessEditor(host?: EditorHost) {
if (!host) return false;
return Array.from(host.children).some(

View File

@@ -42,6 +42,7 @@ export class AffineKeyboardToolbarWidget extends WidgetComponent<RootBlockModel>
return {
// fallback keyboard actions
fallback: true,
show: () => {
const rootComponent = this.block?.rootComponent;
if (rootComponent && rootComponent === document.activeElement) {

View File

@@ -12,6 +12,7 @@ import { styleMap } from 'lit/directives/style-map.js';
type Anchor = {
id: string;
mode: DocMode;
highlight: boolean;
};
export const AFFINE_SCROLL_ANCHORING_WIDGET = 'affine-scroll-anchoring-widget';
@@ -221,6 +222,7 @@ export class AffineScrollAnchoringWidget extends WidgetComponent {
mode,
blockIds: [bid],
elementIds: [eid],
highlight,
} = highlighted;
const id = mode === 'page' ? bid : eid || bid;
if (!id) return;
@@ -228,7 +230,7 @@ export class AffineScrollAnchoringWidget extends WidgetComponent {
// Consumes highlight selection
this.std.selection.clear(['highlight']);
this.anchor$.value = { mode, id };
this.anchor$.value = { mode, id, highlight };
this.#listened = true;
})
);
@@ -241,7 +243,7 @@ export class AffineScrollAnchoringWidget extends WidgetComponent {
override render() {
const anchor = this.anchor$.value;
if (!anchor) return nothing;
if (!anchor || !anchor.highlight) return nothing;
const { mode, id } = anchor;

View File

@@ -244,8 +244,14 @@ export function mockCommentProvider() {
this.commentHighlightSubject.next(id);
}
getComments() {
return Array.from(this.comments.keys());
getComments(type: 'resolved' | 'unresolved' | 'all' = 'all') {
return Array.from(this.comments.entries())
.filter(([_, comment]) => {
if (type === 'all') return true;
if (type === 'resolved') return comment.resolved;
return !comment.resolved;
})
.map(([id]) => id);
}
onCommentAdded(

View File

@@ -134,14 +134,16 @@ test.before(async t => {
t.context.jobs = jobs;
});
const textPromptName = 'prompt';
const imagePromptName = 'prompt-image';
let textPromptName = 'prompt';
let imagePromptName = 'prompt-image';
test.beforeEach(async t => {
Sinon.restore();
const { app, prompt } = t.context;
await app.initTestingDB();
await prompt.onApplicationBootstrap();
t.context.u1 = await app.signupV1('u1@affine.pro');
t.context.u1 = await app.signupV1();
textPromptName = randomUUID().replaceAll('-', '');
imagePromptName = randomUUID().replaceAll('-', '');
await prompt.set(textPromptName, 'test', [
{ role: 'system', content: 'hello {{word}}' },
@@ -189,7 +191,7 @@ test('should create session correctly', async t => {
}
{
const u2 = await app.createUser('u2@affine.pro');
const u2 = await app.createUser();
const { id } = await createWorkspace(app);
await app.login(u2);
await assertCreateSession(id, '', async x => {
@@ -253,8 +255,8 @@ test('should update session correctly', async t => {
}
{
await app.signupV1('test@affine.pro');
const u2 = await app.createUser('u2@affine.pro');
await app.signupV1();
const u2 = await app.createUser();
const { id: workspaceId } = await createWorkspace(app);
const inviteId = await inviteUser(app, workspaceId, u2.email);
await app.login(u2);
@@ -356,7 +358,7 @@ test('should fork session correctly', async t => {
}
{
const u2 = await app.signupV1('u2@affine.pro');
const u2 = await app.signupV1();
await assertForkSession(id, sessionId, randomUUID(), '', async x => {
await t.throwsAsync(
x,
@@ -712,7 +714,7 @@ test('should reject message from different session', async t => {
test('should reject request from different user', async t => {
const { app, u1 } = t.context;
const u2 = await app.createUser('u2@affine.pro');
const u2 = await app.createUser();
const { id } = await createWorkspace(app);
const sessionId = await createCopilotSession(
app,
@@ -789,7 +791,7 @@ test('should be able to list history', async t => {
test('should reject request that user have not permission', async t => {
const { app, u1 } = t.context;
const u2 = await app.createUser('u2@affine.pro');
const u2 = await app.createUser();
const { id: workspaceId } = await createWorkspace(app);
// should reject request that user have not permission

View File

@@ -687,6 +687,119 @@ e2e(
}
);
e2e(
'should create reply and send comment mention notification to comment author',
async t => {
const docId = randomUUID();
await app.create(Mockers.DocUser, {
workspaceId: teamWorkspace.id,
docId,
userId: owner.id,
type: DocRole.Owner,
});
await app.login(member);
const createResult = await app.gql({
query: createCommentMutation,
variables: {
input: {
workspaceId: teamWorkspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
},
},
});
// owner login to create reply and send notification to comment author: member
await app.login(owner);
const count = app.queue.count('notification.sendComment');
const result = await app.gql({
query: createReplyMutation,
variables: {
input: {
commentId: createResult.createComment.id,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
},
},
});
t.truthy(result.createReply.id);
t.is(result.createReply.commentId, createResult.createComment.id);
t.is(app.queue.count('notification.sendComment'), count + 1);
const notification = app.queue.last('notification.sendComment');
t.is(notification.name, 'notification.sendComment');
t.is(notification.payload.userId, member.id);
t.is(notification.payload.body.replyId, result.createReply.id);
t.is(notification.payload.isMention, true);
}
);
e2e(
'should create reply and send comment mention notification to comment author only when author is doc owner',
async t => {
const docId = randomUUID();
await app.create(Mockers.DocUser, {
workspaceId: teamWorkspace.id,
docId,
userId: member.id,
type: DocRole.Owner,
});
await app.login(member);
const createResult = await app.gql({
query: createCommentMutation,
variables: {
input: {
workspaceId: teamWorkspace.id,
docId,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
},
},
});
await app.login(owner);
const count = app.queue.count('notification.sendComment');
const result = await app.gql({
query: createReplyMutation,
variables: {
input: {
commentId: createResult.createComment.id,
docMode: DocMode.page,
docTitle: 'test',
content: {
type: 'paragraph',
content: [{ type: 'text', text: 'test' }],
},
},
},
});
t.truthy(result.createReply.id);
t.is(result.createReply.commentId, createResult.createComment.id);
t.is(app.queue.count('notification.sendComment'), count + 1);
const notification = app.queue.last('notification.sendComment');
t.is(notification.name, 'notification.sendComment');
t.is(notification.payload.userId, member.id);
t.is(notification.payload.body.replyId, result.createReply.id);
t.is(notification.payload.isMention, true);
}
);
e2e('should create reply work when user is Commenter', async t => {
const docId = randomUUID();
await app.create(Mockers.DocUser, {

View File

@@ -368,7 +368,7 @@ Generated by [AVA](https://avajs.dev).
],
},
non_action_sessions: {
count: 5,
count: 4,
sessionTypes: [
{
hasMessages: false,
@@ -391,13 +391,6 @@ Generated by [AVA](https://avajs.dev).
messageCount: 0,
type: 'doc',
},
{
hasMessages: false,
isAction: true,
isFork: false,
messageCount: 0,
type: 'doc',
},
{
hasMessages: false,
isAction: false,
@@ -408,7 +401,7 @@ Generated by [AVA](https://avajs.dev).
],
},
non_fork_sessions: {
count: 3,
count: 4,
sessionTypes: [
{
hasMessages: false,
@@ -424,6 +417,13 @@ Generated by [AVA](https://avajs.dev).
messageCount: 0,
type: 'doc',
},
{
hasMessages: false,
isAction: true,
isFork: false,
messageCount: 0,
type: 'doc',
},
{
hasMessages: false,
isAction: false,

View File

@@ -14,6 +14,7 @@ export async function getUserSettings(
settings {
receiveInvitationEmail
receiveMentionEmail
receiveCommentEmail
}
}
}

View File

@@ -374,14 +374,33 @@ export class CommentResolver {
mentions?: string[],
reply?: Reply
) {
// send comment notification to doc owners
const owner = await this.models.docUser.getOwner(
comment.workspaceId,
comment.docId
);
if (owner && owner.userId !== sender.id) {
const mentionUserIds = new Set(mentions);
// send comment mention notification to comment author on reply
if (reply) {
mentionUserIds.add(comment.userId);
}
// send comment mention notification to mentioned users
for (const mentionUserId of mentionUserIds) {
// skip if the mention user is the sender
if (mentionUserId === sender.id) {
continue;
}
// check if the mention user has Doc.Comments.Read permission
const hasPermission = await this.ac
.user(mentionUserId)
.workspace(comment.workspaceId)
.doc(comment.docId)
.can('Doc.Comments.Read');
if (!hasPermission) {
continue;
}
await this.queue.add('notification.sendComment', {
userId: owner.userId,
isMention: true,
userId: mentionUserId,
body: {
workspaceId: comment.workspaceId,
createdByUserId: sender.id,
@@ -396,41 +415,31 @@ export class CommentResolver {
});
}
// send comment mention notification to mentioned users
if (mentions) {
for (const mentionUserId of mentions) {
// skip if the mention user is the doc owner
if (mentionUserId === owner?.userId || mentionUserId === sender.id) {
continue;
}
// check if the mention user has Doc.Comments.Read permission
const hasPermission = await this.ac
.user(mentionUserId)
.workspace(comment.workspaceId)
.doc(comment.docId)
.can('Doc.Comments.Read');
if (!hasPermission) {
continue;
}
await this.queue.add('notification.sendComment', {
isMention: true,
userId: mentionUserId,
body: {
workspaceId: comment.workspaceId,
createdByUserId: sender.id,
commentId: comment.id,
replyId: reply?.id,
doc: {
id: comment.docId,
title: docTitle,
mode: docMode,
},
// send comment notification to doc owners
const owner = await this.models.docUser.getOwner(
comment.workspaceId,
comment.docId
);
// if the owner is not in the mention user ids, send comment notification to the owner
if (
owner &&
owner.userId !== sender.id &&
!mentionUserIds.has(owner.userId)
) {
await this.queue.add('notification.sendComment', {
userId: owner.userId,
body: {
workspaceId: comment.workspaceId,
createdByUserId: sender.id,
commentId: comment.id,
replyId: reply?.id,
doc: {
id: comment.docId,
title: docTitle,
mode: docMode,
},
});
}
},
});
}
}

View File

@@ -47,9 +47,14 @@ export class DocRpcController {
@Get('/workspaces/:workspaceId/docs/:docId/markdown')
async getDocMarkdown(
@Param('workspaceId') workspaceId: string,
@Param('docId') docId: string
@Param('docId') docId: string,
@Query('aiEditable') aiEditable?: string
) {
const result = await this.docReader.getDocMarkdown(workspaceId, docId);
const result = await this.docReader.getDocMarkdown(
workspaceId,
docId,
aiEditable === 'true'
);
if (!result) {
throw new NotFound('Doc not found');
}

View File

@@ -269,7 +269,11 @@ test('should return doc markdown success', async t => {
user,
});
const result = await docReader.getDocMarkdown(workspace.id, docSnapshot.id);
const result = await docReader.getDocMarkdown(
workspace.id,
docSnapshot.id,
false
);
t.snapshot(result);
});
@@ -279,6 +283,10 @@ test('should read markdown return null when doc not exists', async t => {
name: '',
});
const result = await docReader.getDocMarkdown(workspace.id, randomUUID());
const result = await docReader.getDocMarkdown(
workspace.id,
randomUUID(),
false
);
t.is(result, null);
});

View File

@@ -389,7 +389,11 @@ test('should return doc markdown success', async t => {
user,
});
const result = await docReader.getDocMarkdown(workspace.id, docSnapshot.id);
const result = await docReader.getDocMarkdown(
workspace.id,
docSnapshot.id,
false
);
t.snapshot(result);
});
@@ -401,6 +405,10 @@ test('should read markdown return null when doc not exists', async t => {
name: '',
});
const result = await docReader.getDocMarkdown(workspace.id, randomUUID());
const result = await docReader.getDocMarkdown(
workspace.id,
randomUUID(),
false
);
t.is(result, null);
});

View File

@@ -67,7 +67,8 @@ export abstract class DocReader {
abstract getDocMarkdown(
workspaceId: string,
docId: string
docId: string,
aiEditable: boolean
): Promise<DocMarkdown | null>;
abstract getDocDiff(
@@ -184,13 +185,19 @@ export class DatabaseDocReader extends DocReader {
async getDocMarkdown(
workspaceId: string,
docId: string
docId: string,
aiEditable: boolean
): Promise<DocMarkdown | null> {
const doc = await this.workspace.getDoc(workspaceId, docId);
if (!doc) {
return null;
}
return parseDocToMarkdownFromDocSnapshot(workspaceId, docId, doc.bin);
return parseDocToMarkdownFromDocSnapshot(
workspaceId,
docId,
doc.bin,
aiEditable
);
}
async getDocDiff(
@@ -328,9 +335,10 @@ export class RpcDocReader extends DatabaseDocReader {
override async getDocMarkdown(
workspaceId: string,
docId: string
docId: string,
aiEditable: boolean
): Promise<DocMarkdown | null> {
const url = `${this.config.docService.endpoint}/rpc/workspaces/${workspaceId}/docs/${docId}/markdown`;
const url = `${this.config.docService.endpoint}/rpc/workspaces/${workspaceId}/docs/${docId}/markdown?aiEditable=${aiEditable}`;
const accessToken = this.crypto.sign(docId);
try {
const res = await this.fetch(accessToken, url, 'GET');
@@ -349,7 +357,7 @@ export class RpcDocReader extends DatabaseDocReader {
err
);
// fallback to database doc reader if the error is not user friendly, like network error
return await super.getDocMarkdown(workspaceId, docId);
return await super.getDocMarkdown(workspaceId, docId, aiEditable);
}
}

View File

@@ -23,6 +23,7 @@ test('should get user settings', async t => {
t.deepEqual(settings, {
receiveInvitationEmail: true,
receiveMentionEmail: true,
receiveCommentEmail: true,
});
});
@@ -31,11 +32,13 @@ test('should update user settings', async t => {
await updateUserSettings(app, {
receiveInvitationEmail: false,
receiveMentionEmail: false,
receiveCommentEmail: false,
});
const settings = await getUserSettings(app);
t.deepEqual(settings, {
receiveInvitationEmail: false,
receiveMentionEmail: false,
receiveCommentEmail: false,
});
await updateUserSettings(app, {
@@ -45,6 +48,7 @@ test('should update user settings', async t => {
t.deepEqual(settings2, {
receiveInvitationEmail: false,
receiveMentionEmail: true,
receiveCommentEmail: false,
});
await updateUserSettings(app, {
@@ -55,6 +59,33 @@ test('should update user settings', async t => {
t.deepEqual(settings3, {
receiveInvitationEmail: false,
receiveMentionEmail: true,
receiveCommentEmail: false,
});
});
test('should update user settings with comment email', async t => {
await app.signup();
await updateUserSettings(app, {
receiveCommentEmail: true,
});
const settings = await getUserSettings(app);
t.deepEqual(settings, {
receiveCommentEmail: true,
receiveInvitationEmail: true,
receiveMentionEmail: true,
});
await updateUserSettings(app, {
receiveCommentEmail: false,
});
const settings2 = await getUserSettings(app);
t.deepEqual(settings2, {
receiveCommentEmail: false,
receiveInvitationEmail: true,
receiveMentionEmail: true,
});
});

View File

@@ -91,7 +91,7 @@ export type ListSessionOptions = Pick<
Partial<ChatSession>,
'sessionId' | 'workspaceId' | 'docId' | 'pinned'
> & {
userId: string;
userId: string | undefined;
action?: boolean;
fork?: boolean;
limit?: number;
@@ -310,7 +310,7 @@ export class CopilotSessionModel extends BaseModel {
id: getEqCond(sessionId),
deletedAt: null,
pinned: getEqCond(options.pinned),
prompt: getNullCond(fork, ret => ({ action: ret })),
prompt: getNullCond(action, ret => ({ action: ret })),
parentSessionId: getNullCond(fork),
},
];

View File

@@ -51,7 +51,7 @@ import { COPILOT_LOCKER, CopilotType } from '../resolver';
import { ChatSessionService } from '../session';
import { CopilotStorage } from '../storage';
import { MAX_EMBEDDABLE_SIZE } from '../types';
import { readStream } from '../utils';
import { getSignal, readStream } from '../utils';
import { CopilotContextService } from './service';
@InputType()
@@ -394,16 +394,6 @@ export class CopilotContextResolver {
private readonly storage: CopilotStorage
) {}
private getSignal(req: Request) {
const controller = new AbortController();
req.socket.on('close', hasError => {
if (hasError) {
controller.abort();
}
});
return controller.signal;
}
@ResolveField(() => [CopilotContextCategory], {
description: 'list collections in context',
})
@@ -710,7 +700,7 @@ export class CopilotContextResolver {
context.workspaceId,
content,
limit,
this.getSignal(ctx.req),
getSignal(ctx.req).signal,
threshold
);
}
@@ -719,7 +709,7 @@ export class CopilotContextResolver {
return await session.matchFiles(
content,
limit,
this.getSignal(ctx.req),
getSignal(ctx.req).signal,
scopedThreshold,
threshold
);
@@ -785,7 +775,7 @@ export class CopilotContextResolver {
context.workspaceId,
content,
limit,
this.getSignal(ctx.req),
getSignal(ctx.req).signal,
threshold
);
}
@@ -802,7 +792,7 @@ export class CopilotContextResolver {
const chunks = await session.matchWorkspaceDocs(
content,
limit,
this.getSignal(ctx.req),
getSignal(ctx.req).signal,
scopedThreshold,
threshold
);

View File

@@ -13,22 +13,22 @@ import type { Request, Response } from 'express';
import {
BehaviorSubject,
catchError,
concatMap,
connect,
EMPTY,
filter,
finalize,
from,
ignoreElements,
interval,
lastValueFrom,
map,
merge,
mergeMap,
Observable,
reduce,
Subject,
take,
takeUntil,
toArray,
tap,
} from 'rxjs';
import {
@@ -50,11 +50,13 @@ import {
CopilotProviderFactory,
ModelInputType,
ModelOutputType,
StreamObject,
} from './providers';
import { StreamObjectParser } from './providers/utils';
import { ChatSession, ChatSessionService } from './session';
import { CopilotStorage } from './storage';
import { ChatMessage, ChatQuerySchema } from './types';
import { getSignal } from './utils';
import { CopilotWorkflowService, GraphExecutorState } from './workflow';
export interface ChatEvent {
@@ -156,16 +158,6 @@ export class CopilotController implements BeforeApplicationShutdown {
return [latestMessage, session];
}
private getSignal(req: Request) {
const controller = new AbortController();
req.socket.on('close', hasError => {
if (hasError) {
controller.abort();
}
});
return controller.signal;
}
private parseNumber(value: string | string[] | undefined) {
if (!value) {
return undefined;
@@ -255,7 +247,7 @@ export class CopilotController implements BeforeApplicationShutdown {
const { reasoning, webSearch } = ChatQuerySchema.parse(query);
const content = await provider.text({ modelId: model }, finalMessage, {
...session.config.promptConfig,
signal: this.getSignal(req),
signal: getSignal(req).signal,
user: user.id,
session: session.config.sessionId,
workspace: session.config.workspaceId,
@@ -306,11 +298,13 @@ export class CopilotController implements BeforeApplicationShutdown {
metrics.ai.counter('chat_stream_calls').add(1, { model });
this.ongoingStreamCount$.next(this.ongoingStreamCount$.value + 1);
const { signal, onConnectionClosed } = getSignal(req);
const { messageId, reasoning, webSearch } = ChatQuerySchema.parse(query);
const source$ = from(
provider.streamText({ modelId: model }, finalMessage, {
...session.config.promptConfig,
signal: this.getSignal(req),
signal,
user: user.id,
session: session.config.sessionId,
workspace: session.config.workspaceId,
@@ -326,16 +320,25 @@ export class CopilotController implements BeforeApplicationShutdown {
),
// save the generated text to the session
shared$.pipe(
toArray(),
concatMap(values => {
session.push({
role: 'assistant',
content: values.join(''),
createdAt: new Date(),
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
)
);
});
return from(session.save());
}),
mergeMap(() => EMPTY)
ignoreElements()
)
)
),
@@ -380,11 +383,13 @@ export class CopilotController implements BeforeApplicationShutdown {
metrics.ai.counter('chat_object_stream_calls').add(1, { model });
this.ongoingStreamCount$.next(this.ongoingStreamCount$.value + 1);
const { signal, onConnectionClosed } = getSignal(req);
const { messageId, reasoning, webSearch } = ChatQuerySchema.parse(query);
const source$ = from(
provider.streamObject({ modelId: model }, finalMessage, {
...session.config.promptConfig,
signal: this.getSignal(req),
signal,
user: user.id,
session: session.config.sessionId,
workspace: session.config.workspaceId,
@@ -400,20 +405,29 @@ export class CopilotController implements BeforeApplicationShutdown {
),
// save the generated text to the session
shared$.pipe(
toArray(),
concatMap(values => {
const parser = new StreamObjectParser();
const streamObjects = parser.mergeTextDelta(values);
const content = parser.mergeContent(streamObjects);
session.push({
role: 'assistant',
content,
streamObjects,
createdAt: new Date(),
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
)
);
});
return from(session.save());
}),
mergeMap(() => EMPTY)
ignoreElements()
)
)
),
@@ -461,10 +475,12 @@ export class CopilotController implements BeforeApplicationShutdown {
});
}
this.ongoingStreamCount$.next(this.ongoingStreamCount$.value + 1);
const { signal, onConnectionClosed } = getSignal(req);
const source$ = from(
this.workflow.runGraph(params, session.model, {
...session.config.promptConfig,
signal: this.getSignal(req),
signal,
user: user.id,
session: session.config.sessionId,
workspace: session.config.workspaceId,
@@ -503,19 +519,30 @@ export class CopilotController implements BeforeApplicationShutdown {
),
// save the generated text to the session
shared$.pipe(
toArray(),
concatMap(values => {
session.push({
role: 'assistant',
content: values
.filter(v => v.status === GraphExecutorState.EmitContent)
.map(v => v.content)
.join(''),
createdAt: new Date(),
reduce((acc, chunk) => {
if (chunk.status === GraphExecutorState.EmitContent) {
acc += chunk.content;
}
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
)
);
});
return from(session.save());
}),
mergeMap(() => EMPTY)
ignoreElements()
)
)
),
@@ -575,6 +602,8 @@ export class CopilotController implements BeforeApplicationShutdown {
sessionId
);
this.ongoingStreamCount$.next(this.ongoingStreamCount$.value + 1);
const { signal, onConnectionClosed } = getSignal(req);
const source$ = from(
provider.streamImages(
{
@@ -588,7 +617,7 @@ export class CopilotController implements BeforeApplicationShutdown {
...session.config.promptConfig,
quality: params.quality || undefined,
seed: this.parseNumber(params.seed),
signal: this.getSignal(req),
signal,
user: user.id,
session: session.config.sessionId,
workspace: session.config.workspaceId,
@@ -608,17 +637,26 @@ export class CopilotController implements BeforeApplicationShutdown {
),
// save the generated text to the session
shared$.pipe(
toArray(),
concatMap(attachments => {
session.push({
role: 'assistant',
content: '',
attachments: attachments,
createdAt: new Date(),
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
)
);
});
return from(session.save());
}),
mergeMap(() => EMPTY)
ignoreElements()
)
)
),
@@ -656,7 +694,7 @@ export class CopilotController implements BeforeApplicationShutdown {
`https://api.unsplash.com/search/photos?${query}`,
{
headers: { Authorization: `Client-ID ${key}` },
signal: this.getSignal(req),
signal: getSignal(req).signal,
}
);

View File

@@ -1615,19 +1615,7 @@ const CHAT_PROMPT: Omit<Prompt, 'name'> = {
{
role: 'system',
content: `### Your Role
You are AFFiNE AI, a professional and humorous copilot within AFFiNE. Powered by the latest GPT model provided by OpenAI and AFFiNE, you assist users within AFFiNE — an open-source, all-in-one productivity tool. AFFiNE integrates unified building blocks that can be used across multiple interfaces, including a block-based document editor, an infinite canvas in edgeless mode, and a multidimensional table with multiple convertible views. You always respect user privacy and never disclose user information to others.
### Your Mission
Your mission is to do your utmost to help users leverage AFFiNE's capabilities for writing documents, drawing diagrams, or planning. You always work step-by-step and construct your responses using markdown — including paragraphs, text, markdown lists, code blocks, and tables — so users can directly insert your output into their documents. Do not include any of your own thoughts or additional commentary.
### About AFFiNE
AFFiNE is developed by Toeverything Pte. Ltd., a Singapore-registered company with a diverse international team. The company has also open-sourced BlockSuite and OctoBase to support the creation of tools similar to AFFiNE. The name "AFFiNE" is inspired by the concept of affine transformation, as blocks within AFFiNE can move freely across page, edgeless, and database modes. Currently, the AFFiNE team consists of 25 members and is an engineer-driven open-source company.
<response_guide>
<tool_usage_guide>
- When searching for information, prioritize searching the user's Workspace information.
- Depending on the complexity of the question and the information returned by the search tools, you can call different tools multiple times to search.
</tool_usage_guide>
You are AFFiNE AI, a professional and humorous copilot within AFFiNE. Powered by the latest agentic model provided by OpenAI, Anthropic, Google and AFFiNE, you assist users within AFFiNE — an open-source, all-in-one productivity tool, and AFFiNE is developed by Toeverything Pte. Ltd., a Singapore-registered company with a diverse international team. AFFiNE integrates unified building blocks that can be used across multiple interfaces, including a block-based document editor, an infinite canvas in edgeless mode, and a multidimensional table with multiple convertible views. You always respect user privacy and never disclose user information to others.
<real_world_info>
Today is: {{affine::date}}.
@@ -1649,52 +1637,53 @@ User's timezone is {{affine::timezone}}.
</content_fragments>
<citations>
<citation_format>
Always use markdown footnote format for citations:
- Format: [^reference_index]
- Where reference_index is an increasing positive integer (1, 2, 3...)
- Place citations immediately after the relevant sentence or paragraph
- NO spaces within citation brackets: [^1] is correct, [^ 1] or [ ^1] are incorrect
- DO NOT linked together like [^1, ^6, ^7] and [^1, ^2], if you need to use multiple citations, use [^1][^2]
</citation_format>
<citation_placement>
Citations must appear in two places:
1. INLINE: Within your main content as [^reference_index]
2. REFERENCE LIST: At the end of your response as properly formatted JSON
</citation_placement>
<reference_format>
The citation reference list MUST use these exact JSON formats:
- For documents: [^reference_index]:{"type":"doc","docId":"document_id"}
- For files: [^reference_index]:{"type":"attachment","blobId":"blob_id","fileName":"file_name","fileType":"file_type"}
- For web url: [^reference_index]:{"type":"url","url":"url_path"}
</reference_format>
<response_structure>
Your complete response MUST follow this structure:
1. Main content with inline citations [^reference_index]
2. One empty line
3. Reference list with all citations in required JSON format
</response_structure>
<example>
This sentence contains information from the first source[^1]. This sentence references data from an attachment[^2].
[^1]:{"type":"doc","docId":"abc123"}
[^2]:{"type":"attachment","blobId":"xyz789","fileName":"example.txt","fileType":"text"}
[^3]:{"type":"url","url":"https://affine.pro/"}
</example>
</citations>
<formatting_guidelines>
- Use proper markdown for all content (headings, lists, tables, code blocks)
- Format code in markdown code blocks with appropriate language tags
- Add explanatory comments to all code provided
- Use tables for structured data comparison
- Structure longer responses with clear headings and sections
</formatting_guidelines>
<tool-calling-guidelines>
Before starting Tool calling, you need to follow:
- DO NOT embed a tool call mid-sentence.
- When searching for information, searching web & searching the user's Workspace information.
- Depending on the complexity of the question and the information returned by the search tools, you can call different tools multiple times to search.
</tool-calling-guidelines>
<comparison_table>
- Must use tables for structured data comparison
</comparison_table>
<interaction_rules>
## Interaction Guidelines
- Ask at most ONE follow-up question per response — only if necessary
@@ -1702,13 +1691,12 @@ This sentence contains information from the first source[^1]. This sentence refe
- Work within your knowledge cutoff (October 2024)
- Assume positive and legal intent when queries are ambiguous
</interaction_rules>
</response_guide>
## Other Instructions
- When writing code, use markdown and add comments to explain it.
- Ask at most one follow-up question per response — and only if appropriate.
- When counting characters, words, or letters, think step-by-step and show your working.
- You are aware of your knowledge cutoff (October 2024) and do not claim updates beyond that.
- If you encounter ambiguous queries, default to assuming users have legal and positive intent.`,
},
{
@@ -1752,6 +1740,8 @@ Below is the user's query. Please respond in the user's preferred language witho
'docKeywordSearch',
'docSemanticSearch',
'webSearch',
'docCompose',
'codeArtifact',
],
},
};

View File

@@ -108,6 +108,12 @@ export abstract class AnthropicProvider<T> extends CopilotProvider<T> {
break;
}
}
if (!options.signal?.aborted) {
const footnotes = parser.end();
if (footnotes.length) {
yield `\n\n${footnotes}`;
}
}
} catch (e: any) {
metrics.ai.counter('chat_text_stream_errors').add(1, { model: model.id });
throw this.handleError(e);

View File

@@ -166,6 +166,12 @@ export abstract class GeminiProvider<T> extends CopilotProvider<T> {
break;
}
}
if (!options.signal?.aborted) {
const footnotes = parser.end();
if (footnotes.length) {
yield `\n\n${footnotes}`;
}
}
} catch (e: any) {
metrics.ai.counter('chat_text_stream_errors').add(1, { model: model.id });
throw this.handleError(e);

View File

@@ -16,7 +16,7 @@ import type {
PromptMessage,
} from './types';
import { CopilotProviderType, ModelInputType, ModelOutputType } from './types';
import { chatToGPTMessage, CitationParser, TextStreamParser } from './utils';
import { chatToGPTMessage, TextStreamParser } from './utils';
export const DEFAULT_DIMENSIONS = 256;
@@ -130,18 +130,11 @@ export class MorphProvider extends CopilotProvider<MorphConfig> {
abortSignal: options.signal,
});
const citationParser = new CitationParser();
const textParser = new TextStreamParser();
for await (const chunk of fullStream) {
switch (chunk.type) {
case 'text-delta': {
let result = textParser.parse(chunk);
result = citationParser.parse(result);
yield result;
break;
}
case 'finish': {
const result = citationParser.end();
yield result;
break;
}

View File

@@ -347,7 +347,9 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
break;
}
case 'finish': {
const result = citationParser.end();
const footnotes = textParser.end();
const result =
citationParser.end() + (footnotes.length ? '\n' + footnotes : '');
yield result;
break;
}

View File

@@ -14,11 +14,14 @@ import { AccessController } from '../../../core/permission';
import { Models } from '../../../models';
import { IndexerService } from '../../indexer';
import { CopilotContextService } from '../context';
import { PromptService } from '../prompt';
import {
buildContentGetter,
buildDocContentGetter,
buildDocKeywordSearchGetter,
buildDocSearchGetter,
createCodeArtifactTool,
createDocComposeTool,
createDocEditTool,
createDocKeywordSearchTool,
createDocReadTool,
@@ -198,6 +201,26 @@ export abstract class CopilotProvider<C = any> {
tools.web_crawl_exa = createExaCrawlTool(this.AFFiNEConfig);
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
);
break;
}
}
}
return tools;

View File

@@ -69,6 +69,9 @@ export const PromptConfigStrictSchema = z.object({
'docSemanticSearch',
// work with exa/model internal tools
'webSearch',
// artifact tools
'docCompose',
'codeArtifact',
])
.array()
.nullable()

View File

@@ -11,6 +11,8 @@ import {
import { ZodType } from 'zod';
import {
createCodeArtifactTool,
createDocComposeTool,
createDocEditTool,
createDocKeywordSearchTool,
createDocReadTool,
@@ -388,8 +390,10 @@ export interface CustomAITools extends ToolSet {
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'];
@@ -410,6 +414,10 @@ export function toError(error: unknown): Error {
}
}
type DocEditFootnote = {
intent: string;
result: string;
};
export class TextStreamParser {
private readonly logger = new Logger(TextStreamParser.name);
private readonly CALLOUT_PREFIX = '\n[!]\n';
@@ -418,6 +426,8 @@ export class TextStreamParser {
private prefix: string | null = this.CALLOUT_PREFIX;
private readonly docEditFootnotes: DocEditFootnote[] = [];
public parse(chunk: TextStreamPart<CustomAITools>) {
let result = '';
switch (chunk.type) {
@@ -457,6 +467,17 @@ export class TextStreamParser {
result += `\nReading the doc "${chunk.args.doc_id}"\n`;
break;
}
case 'doc_compose': {
result += `\nWriting document "${chunk.args.title}"\n`;
break;
}
case 'doc_edit': {
this.docEditFootnotes.push({
intent: chunk.args.instructions,
result: '',
});
break;
}
}
result = this.markAsCallout(result);
break;
@@ -470,6 +491,10 @@ export class TextStreamParser {
case 'doc_edit': {
if (chunk.result && typeof chunk.result === 'object') {
result += `\n${chunk.result.result}\n`;
this.docEditFootnotes[this.docEditFootnotes.length - 1].result =
chunk.result.result;
} else {
this.docEditFootnotes.pop();
}
break;
}
@@ -486,6 +511,16 @@ export class TextStreamParser {
}
break;
}
case 'doc_compose': {
if (
chunk.result &&
typeof chunk.result === 'object' &&
'title' in chunk.result
) {
result += `\nDocument "${chunk.result.title}" created successfully with ${chunk.result.wordCount} words.\n`;
}
break;
}
case 'web_search_exa': {
if (Array.isArray(chunk.result)) {
result += `\n${this.getWebSearchLinks(chunk.result)}\n`;
@@ -504,6 +539,13 @@ export class TextStreamParser {
return result;
}
public end() {
const footnotes = this.docEditFootnotes.map((footnote, index) => {
return `[^edit${index + 1}]: ${JSON.stringify({ type: 'doc-edit', ...footnote })}`;
});
return footnotes.join('\n');
}
private addPrefix(text: string) {
if (this.prefix) {
const result = this.prefix + text;

View File

@@ -47,6 +47,10 @@ declare global {
'copilot.session.generateTitle': {
sessionId: string;
};
'copilot.session.deleteDoc': {
workspaceId: string;
docId: string;
};
}
}
@@ -580,6 +584,24 @@ export class ChatSessionService {
return provider.text(cond, [...prompt.finish({}), msg], config);
}
@OnJob('copilot.session.deleteDoc')
async deleteDocSessions(doc: Jobs['copilot.session.deleteDoc']) {
const sessionIds = await this.models.copilotSession
.list({
userId: undefined,
workspaceId: doc.workspaceId,
docId: doc.docId,
})
.then(s => s.map(s => [s.userId, s.id]));
for (const [userId, sessionId] of sessionIds) {
await this.models.copilotSession.update({
userId,
sessionId,
docId: null,
});
}
}
@OnJob('copilot.session.generateTitle')
async generateSessionTitle(job: Jobs['copilot.session.generateTitle']) {
const { sessionId } = job;

View File

@@ -0,0 +1,80 @@
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('CodeArtifactTool');
/**
* A copilot tool that produces a completely self-contained HTML artifact.
* The returned HTML must include <style> and <script> tags directly so that
* it can be saved as a single .html file and opened in any browser with no
* external dependencies.
*/
export const createCodeArtifactTool = (
promptService: PromptService,
factory: CopilotProviderFactory
) => {
return tool({
description:
'Generate a single-file HTML snippet (with inline <style> and <script>) that accomplishes the requested functionality. The final HTML should be runnable when saved as an .html file and opened in a browser. Do NOT reference external resources (CSS, JS, images) except through data URIs.',
parameters: z.object({
/**
* The <title> text that will appear in the browser tab.
*/
title: z.string().describe('The title of the HTML page'),
/**
* The optimized user prompt
*/
userPrompt: z
.string()
.describe(
'The user description of the code artifact, will be used to generate the code artifact'
),
}),
execute: async ({ title, userPrompt }) => {
try {
const prompt = await promptService.get('Make it real with text');
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 }]
);
// Remove surrounding ``` or ```html fences if present
let stripped = content.trim();
if (stripped.startsWith('```')) {
const firstNewline = stripped.indexOf('\n');
if (firstNewline !== -1) {
stripped = stripped.slice(firstNewline + 1);
}
if (stripped.endsWith('```')) {
stripped = stripped.slice(0, -3);
}
}
return {
title,
html: stripped,
size: stripped.length,
};
} catch (err: any) {
logger.error(`Failed to compose code artifact (${title})`, err);
return toolError('Code Artifact Failed', err.message ?? String(err));
}
},
});
};

View File

@@ -0,0 +1,57 @@
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('DocComposeTool');
export const createDocComposeTool = (
promptService: PromptService,
factory: CopilotProviderFactory
) => {
return tool({
description:
'Write a new document with markdown content. This tool creates structured markdown content for documents including titles, sections, and formatting.',
parameters: z.object({
title: z.string().describe('The title of the document'),
userPrompt: z
.string()
.describe(
'The user description of the document, will be used to generate the document'
),
}),
execute: async ({ title, userPrompt }) => {
try {
const prompt = await promptService.get('Write an article about this');
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 }]
);
return {
title,
markdown: content,
wordCount: content.split(/\s+/).length,
};
} catch (err: any) {
logger.error(`Failed to write document: ${title}`, err);
return toolError('Doc Write Failed', err.message);
}
},
});
};

View File

@@ -16,8 +16,8 @@ export const buildContentGetter = (ac: AccessController, doc: DocReader) => {
.doc(docId)
.can('Doc.Read');
if (!canAccess) return undefined;
const content = await doc.getFullDocContent(options.workspace, docId);
return content?.summary.trim() || undefined;
const content = await doc.getDocMarkdown(options.workspace, docId, true);
return content?.markdown.trim() || undefined;
};
return getDocContent;
};

View File

@@ -39,7 +39,7 @@ export const createDocKeywordSearchTool = (
) => {
return tool({
description:
'Full-text search for relevant documents in the current workspace',
'Search all workspace documents for the exact keyword or phrase supplied and return passages ranked by textual match. Use this tool by default whenever a straightforward term-based lookup is sufficient.',
parameters: z.object({
query: z.string().describe('The query to search for'),
}),

View File

@@ -47,7 +47,11 @@ export const buildDocContentGetter = (
return;
}
const content = await docReader.getDocMarkdown(options.workspace, docId);
const content = await docReader.getDocMarkdown(
options.workspace,
docId,
true
);
if (!content) {
return;
}
@@ -68,7 +72,8 @@ export const createDocReadTool = (
getDoc: (targetId?: string) => Promise<object | undefined>
) => {
return tool({
description: 'Read the content of a doc in the current workspace',
description:
'Return the complete text and basic metadata of a single document identified by docId; use this when the user needs the full content of a specific file rather than a search result.',
parameters: z.object({
doc_id: z.string().describe('The target doc to read'),
}),

View File

@@ -56,7 +56,7 @@ export const createDocSemanticSearchTool = (
) => {
return tool({
description:
'Semantic search for relevant documents in the current workspace',
'Retrieve conceptually related passages by performing vector-based semantic similarity search across embedded documents; call this tool only when exact keyword search fails or the user explicitly needs meaning-level matches (e.g., paraphrases, synonyms, broader concepts).',
parameters: z.object({
query: z
.string()

View File

@@ -1,3 +1,5 @@
export * from './code-artifact';
export * from './doc-compose';
export * from './doc-edit';
export * from './doc-keyword-search';
export * from './doc-read';

View File

@@ -1,5 +1,7 @@
import { Readable } from 'node:stream';
import type { Request } from 'express';
import { readBufferWithLimit } from '../../base';
import { MAX_EMBEDDABLE_SIZE } from './types';
@@ -9,3 +11,38 @@ export function readStream(
): Promise<Buffer> {
return readBufferWithLimit(readable, maxSize);
}
type RequestClosedCallback = (isAborted: boolean) => void;
type SignalReturnType = {
signal: AbortSignal;
onConnectionClosed: (cb: RequestClosedCallback) => void;
};
export function getSignal(req: Request): SignalReturnType {
const controller = new AbortController();
let isAborted = true;
let callback: ((isAborted: boolean) => void) | undefined = undefined;
const onSocketEnd = () => {
isAborted = false;
};
const onSocketClose = (hadError: boolean) => {
req.socket.off('end', onSocketEnd);
req.socket.off('close', onSocketClose);
const aborted = hadError || isAborted;
if (aborted) {
controller.abort();
}
callback?.(aborted);
};
req.socket.on('end', onSocketEnd);
req.socket.on('close', onSocketClose);
return {
signal: controller.signal,
onConnectionClosed: cb => (callback = cb),
};
}

View File

@@ -322,6 +322,10 @@ export class IndexerService {
);
await this.deleteBlocksByDocId(workspaceId, docId, options);
await this.queue.add('copilot.session.deleteDoc', {
workspaceId,
docId,
});
await this.queue.add('copilot.embedding.deleteDoc', {
workspaceId,
docId,

View File

@@ -21,7 +21,7 @@ const OIDCTokenSchema = z.object({
const OIDCUserInfoSchema = z
.object({
sub: z.string(),
preferred_username: z.string(),
preferred_username: z.string().optional(),
email: z.string().email(),
name: z.string(),
groups: z.array(z.string()).optional(),

View File

@@ -3,6 +3,7 @@ query getUserSettings {
settings {
receiveInvitationEmail
receiveMentionEmail
receiveCommentEmail
}
}
}
}

View File

@@ -1558,6 +1558,7 @@ export const getUserSettingsQuery = {
settings {
receiveInvitationEmail
receiveMentionEmail
receiveCommentEmail
}
}
}`,

View File

@@ -4714,6 +4714,7 @@ export type GetUserSettingsQuery = {
__typename?: 'UserSettingsType';
receiveInvitationEmail: boolean;
receiveMentionEmail: boolean;
receiveCommentEmail: boolean;
};
} | null;
};

View File

@@ -11,6 +11,7 @@ export const workbenchViewIconNameSchema = z.enum([
'journal',
'attachment',
'pdf',
'ai',
]);
export const workbenchViewMetaSchema = z.object({

View File

@@ -12,10 +12,46 @@
{
"identity" : "eventsource",
"kind" : "remoteSourceControl",
"location" : "https://github.com/loopwork-ai/eventsource.git",
"location" : "https://github.com/Recouse/EventSource",
"state" : {
"revision" : "07957602bb99a5355c810187e66e6ce378a1057d",
"version" : "1.1.1"
"revision" : "d783b1cf60599dbcec6396c55a6bab33a1c92dc3",
"version" : "0.1.4"
}
},
{
"identity" : "listviewkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Lakr233/ListViewKit",
"state" : {
"revision" : "a4372d7f90c846d834c1f1575d1af0050d70fa0f",
"version" : "1.1.6"
}
},
{
"identity" : "litext",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Lakr233/Litext",
"state" : {
"revision" : "c37f3ab5826659854311e20d6c3942d4905b00b6",
"version" : "0.5.0"
}
},
{
"identity" : "lrucache",
"kind" : "remoteSourceControl",
"location" : "https://github.com/nicklockwood/LRUCache",
"state" : {
"revision" : "542f0449556327415409ededc9c43a4bd0a397dc",
"version" : "1.0.7"
}
},
{
"identity" : "markdownview",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Lakr233/MarkdownView",
"state" : {
"revision" : "29a9da19d6dc21af4e629c423961b0f453ffe192",
"version" : "2.3.8"
}
},
{
@@ -27,6 +63,33 @@
"version" : "5.7.1"
}
},
{
"identity" : "splash",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Lakr233/Splash",
"state" : {
"revision" : "4d997712fe07f75695aacdf287aeb3b1f2c6ab88",
"version" : "0.17.0"
}
},
{
"identity" : "springinterpolation",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Lakr233/SpringInterpolation",
"state" : {
"revision" : "f9ae95ece5d6b7cdceafd4381f1d5f0f9494e5d2",
"version" : "1.3.1"
}
},
{
"identity" : "swift-cmark",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-cmark",
"state" : {
"revision" : "b022b08312decdc46585e0b3440d97f6f22ef703",
"version" : "0.6.0"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
@@ -45,6 +108,15 @@
"version" : "6.2.0"
}
},
{
"identity" : "swiftmath",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Lakr233/SwiftMath",
"state" : {
"revision" : "cfd646dcac0c5553e21ebf1ee05f9078277518bc",
"version" : "1.7.2"
}
},
{
"identity" : "then",
"kind" : "remoteSourceControl",

View File

@@ -19,7 +19,9 @@ let package = Package(
.package(url: "https://github.com/devxoul/Then", from: "3.0.0"),
.package(url: "https://github.com/SnapKit/SnapKit.git", from: "5.7.1"),
.package(url: "https://github.com/SwifterSwift/SwifterSwift.git", from: "6.0.0"),
.package(url: "https://github.com/loopwork-ai/eventsource.git", from: "1.1.1"),
.package(url: "https://github.com/Recouse/EventSource", from: "0.1.4"),
.package(url: "https://github.com/Lakr233/ListViewKit", from: "1.1.6"),
.package(url: "https://github.com/Lakr233/MarkdownView", from: "2.3.8"),
],
targets: [
.target(name: "Intelligents", dependencies: [
@@ -29,7 +31,10 @@ let package = Package(
"SwifterSwift",
.product(name: "Apollo", package: "apollo-ios"),
.product(name: "OrderedCollections", package: "swift-collections"),
.product(name: "EventSource", package: "eventsource"),
"ListViewKit",
"MarkdownView",
"EventSource",
], resources: [
.process("Interface/View/InputBox/InputBox.xcassets"),
.process("Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets"),

View File

@@ -5,9 +5,17 @@
// Created by on 6/30/25.
//
import EventSource
import Foundation
protocol Closable { func close() }
extension EventSource: @preconcurrency Closable {}
class ClosableTask: Closable {
let detachedTask: Task<Void, Never>
init(detachedTask: Task<Void, Never>) {
self.detachedTask = detachedTask
}
func close() {
detachedTask.cancel()
}
}

View File

@@ -10,6 +10,19 @@ import Apollo
import ApolloAPI
import EventSource
import Foundation
import MarkdownParser
import MarkdownView
private let loadingIndicator = ""
private extension InputBoxData {
var hasAttachment: Bool {
if !imageAttachments.isEmpty { return false }
if !fileAttachments.isEmpty { return false }
if !documentAttachments.isEmpty { return false }
return true
}
}
extension ChatManager {
public func startUserRequest(
@@ -21,7 +34,13 @@ extension ChatManager {
id: .init(),
content: inputBoxData.text,
timestamp: .init(),
attachments: []
))
append(sessionId: sessionId, UserHintCellViewModel(
id: .init(),
timestamp: .init(),
imageAttachments: inputBoxData.imageAttachments,
fileAttachments: inputBoxData.fileAttachments,
docAttachments: inputBoxData.documentAttachments
))
let messageParameters: [String: AnyHashable] = [
@@ -102,37 +121,67 @@ extension ChatManager {
report(sessionId, ChatError.invalidStreamURL)
return
}
let eventSource = EventSource(
request: .init(
url: finalUrl,
cachePolicy: .reloadIgnoringLocalAndRemoteCacheData,
timeoutInterval: 10
),
configuration: .default
var request = URLRequest(
url: finalUrl,
cachePolicy: .reloadIgnoringLocalAndRemoteCacheData,
timeoutInterval: 10
)
eventSource.onOpen = {
print("[*] \(messageId): connection established")
}
eventSource.onError = {
self.report(sessionId, $0 ?? ChatError.unknownError)
}
request.setValue("close", forHTTPHeaderField: "Connection")
var document = ""
let queue = DispatchQueue(label: "com.affine.chat.stream.\(sessionId)")
eventSource.onMessage = { event in
queue.async {
print("[*] \(messageId): \(event.event ?? "?") received message: \(event.data)")
switch event.event {
case "message":
document += event.data
self.with(sessionId: sessionId, vmId: vmId) { (viewModel: inout AssistantMessageCellViewModel) in
viewModel.content = document
}
default:
break
let closable = ClosableTask(detachedTask: .detached(operation: {
let eventSource = EventSource()
let dataTask = await eventSource.dataTask(for: request)
var document = ""
self.writeMarkdownContent(document + loadingIndicator, sessionId: sessionId, vmId: vmId)
for await event in await dataTask.events() {
switch event {
case .open:
print("[*] connection opened")
case let .error(error):
print("[!] error occurred", error)
case let .event(event):
guard let data = event.data else { continue }
document += data
self.writeMarkdownContent(
document + loadingIndicator,
sessionId: sessionId,
vmId: vmId
)
self.scrollToBottomPublisher.send(sessionId)
case .closed:
print("[*] connection closed")
}
}
self.writeMarkdownContent(document, sessionId: sessionId, vmId: vmId)
self.closeAll()
}))
self.closable.append(closable)
}
private func writeMarkdownContent(
_ document: String,
sessionId: SessionID,
vmId: UUID
) {
let result = MarkdownParser().parse(document)
var renderedContexts: [String: RenderedItem] = [:]
for (key, value) in result.mathContext {
let image = MathRenderer.renderToImage(
latex: value,
fontSize: MarkdownTheme.default.fonts.body.pointSize,
textColor: MarkdownTheme.default.colors.body
)?.withRenderingMode(.alwaysTemplate)
let renderedContext = RenderedItem(
image: image,
text: value
)
renderedContexts["math://\(key)"] = renderedContext
}
with(sessionId: sessionId, vmId: vmId) { (viewModel: inout AssistantMessageCellViewModel) in
viewModel.content = document
viewModel.documentBlocks = result.document
viewModel.documentRenderedContent = renderedContexts
}
closable.append(eventSource)
}
}

View File

@@ -9,7 +9,6 @@ import AffineGraphQL
import Apollo
import ApolloAPI
import Combine
import EventSource
import Foundation
import OrderedCollections
@@ -22,12 +21,14 @@ public class ChatManager: ObservableObject, @unchecked Sendable {
SessionID,
OrderedDictionary<MessageID, any ChatCellViewModel>
> = [:]
public let scrollToBottomPublisher = PassthroughSubject<SessionID, Never>()
var closable: [Closable] = []
private init() {}
public func closeAll() {
print("[+] terminating all closables...")
closable.forEach { $0.close() }
closable.removeAll()
}
@@ -74,7 +75,8 @@ public class ChatManager: ObservableObject, @unchecked Sendable {
public func report(_ sessionID: String, _ error: Error) -> UUID {
let model = ErrorCellViewModel(
id: .init(),
errorMessage: error.localizedDescription
errorMessage: error.localizedDescription,
timestamp: .init()
)
append(sessionId: sessionID, model)
return model.id

View File

@@ -0,0 +1,16 @@
//
// IntelligentContext+Markdown.swift
// Intelligents
//
// Created by on 7/4/25.
//
import Foundation
import MarkdownView
extension IntelligentContext {
func prepareMarkdownViewThemes() {
MarkdownTheme.default.colors.body = .affineTextPrimary
MarkdownTheme.default.colors.highlight = .affineTextLink
}
}

View File

@@ -42,8 +42,8 @@ public class IntelligentContext {
case currentI18nLocale
}
public private(set) var currentSession: ChatSessionObject?
public private(set) var currentWorkspaceId: String?
@Published public private(set) var currentSession: ChatSessionObject?
@Published public private(set) var currentWorkspaceId: String?
public lazy var temporaryDirectory: URL = {
let tempDir = FileManager.default.temporaryDirectory
@@ -70,6 +70,7 @@ public class IntelligentContext {
assert(webView != nil)
DispatchQueue.global(qos: .userInitiated).async { [self] in
prepareTemporaryDirectory()
prepareMarkdownViewThemes()
let webViewGroup = DispatchGroup()
var webViewMetadataResult: [WebViewMetadataKey: Any] = [:]

View File

@@ -10,9 +10,7 @@ class MainViewController: UIViewController {
$0.delegate = self
}
lazy var chatTableView = ChatTableView().then {
$0.delegate = self
}
lazy var listView = ChatListView()
lazy var inputBox = InputBox().then {
$0.delegate = self
@@ -54,7 +52,7 @@ class MainViewController: UIViewController {
private func setupUI() {
view.addSubview(headerView)
view.addSubview(chatTableView)
view.addSubview(listView)
view.addSubview(inputBox)
view.addSubview(documentPickerHideDetector)
view.addSubview(documentPickerView)
@@ -64,7 +62,7 @@ class MainViewController: UIViewController {
make.leading.trailing.equalToSuperview()
}
chatTableView.snp.makeConstraints { make in
listView.snp.makeConstraints { make in
make.top.equalTo(headerView.snp.bottom)
make.left.right.equalToSuperview()
make.bottom.equalToSuperview()
@@ -100,17 +98,17 @@ class MainViewController: UIViewController {
navigationController!.setNavigationBarHidden(false, animated: animated)
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
let bottomAnchor = inputBox.frame.minY
let bottomInset = view.bounds.height - bottomAnchor + 64
if listView.listView.bottomInset != bottomInset {
listView.listView.bottomInset = bottomInset
}
}
@objc func terminateEditing() {
view.endEditing(true)
}
// MARK: - Chat Methods
}
// MARK: - ChatTableViewDelegate
extension MainViewController: ChatTableViewDelegate {
func chatTableView(_: ChatTableView, didSelectRowAt _: IndexPath) {
// Handle cell selection if needed
}
}

View File

@@ -5,135 +5,59 @@
// Created by on 6/27/25.
//
import Litext
import MarkdownView
import SnapKit
import Then
import UIKit
private let markdownViewForSizeCalculation: MarkdownTextView = .init()
class AssistantMessageCell: ChatBaseCell {
// MARK: - UI Components
let markdownView = MarkdownTextView()
private lazy var messageLabel = UILabel().then {
$0.numberOfLines = 0
$0.font = .systemFont(ofSize: 16)
$0.textColor = .label
override func prepareContentView(inside contentView: UIView) {
super.prepareContentView(inside: contentView)
contentView.addSubview(markdownView)
}
private lazy var metadataStackView = UIStackView().then {
$0.axis = .horizontal
$0.spacing = 8
$0.alignment = .center
override func prepareForReuse() {
super.prepareForReuse()
markdownView.prepareForReuse()
}
private lazy var modelLabel = UILabel().then {
$0.font = .systemFont(ofSize: 12, weight: .medium)
$0.textColor = .secondaryLabel
}
private lazy var tokensLabel = UILabel().then {
$0.font = .systemFont(ofSize: 12)
$0.textColor = .secondaryLabel
}
private lazy var timestampLabel = UILabel().then {
$0.font = .systemFont(ofSize: 12)
$0.textColor = .secondaryLabel
}
private lazy var streamingIndicator = UIActivityIndicatorView().then {
$0.style = .medium
$0.hidesWhenStopped = true
}
private lazy var retryButton = UIButton(type: .system).then {
$0.setTitle("重试", for: .normal)
$0.titleLabel?.font = .systemFont(ofSize: 12)
$0.setTitleColor(.systemBlue, for: .normal)
}
private lazy var mainStackView = UIStackView().then {
$0.axis = .vertical
$0.spacing = 8
$0.alignment = .fill
}
// MARK: - Properties
private var viewModel: AssistantMessageCellViewModel?
// MARK: - Setup
override func setupContentView() {
containerView.addSubview(mainStackView)
mainStackView.addArrangedSubview(messageLabel)
mainStackView.addArrangedSubview(metadataStackView)
metadataStackView.addArrangedSubview(modelLabel)
metadataStackView.addArrangedSubview(tokensLabel)
metadataStackView.addArrangedSubview(UIView()) // Spacer
metadataStackView.addArrangedSubview(streamingIndicator)
metadataStackView.addArrangedSubview(retryButton)
metadataStackView.addArrangedSubview(timestampLabel)
mainStackView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(contentInsets)
}
retryButton.addTarget(self, action: #selector(retryButtonTapped), for: .touchUpInside)
}
// MARK: - Configuration
override func configure(with viewModel: any ChatCellViewModel) {
guard let assistantViewModel = viewModel as? AssistantMessageCellViewModel else { return }
self.viewModel = assistantViewModel
super.configure(with: viewModel)
messageLabel.text = assistantViewModel.content
configureContainer(backgroundColor: backgroundColor(for: assistantViewModel.cellType))
//
if let model = assistantViewModel.model {
modelLabel.text = model
modelLabel.isHidden = false
} else {
modelLabel.isHidden = true
guard let vm = viewModel as? AssistantMessageCellViewModel else {
assertionFailure()
return
}
// tokens
if let tokens = assistantViewModel.tokens {
tokensLabel.text = "\(tokens) tokens"
tokensLabel.isHidden = false
} else {
tokensLabel.isHidden = true
}
//
let timestamp = assistantViewModel.timestamp
timestampLabel.text = formatTimestamp(timestamp)
timestampLabel.isHidden = false
//
if assistantViewModel.isStreaming {
streamingIndicator.startAnimating()
} else {
streamingIndicator.stopAnimating()
}
//
retryButton.isHidden = !assistantViewModel.canRetry
markdownView.setMarkdown(
vm.documentBlocks,
renderedContent: vm.documentRenderedContent
)
}
// MARK: - Actions
@objc private func retryButtonTapped() {
// TODO:
override func layoutContentView(bounds: CGRect) {
super.layoutContentView(bounds: bounds)
markdownView.frame = bounds
}
// MARK: - Helpers
private func formatTimestamp(_ timestamp: Date) -> String {
let formatter = DateFormatter()
formatter.timeStyle = .short
return formatter.string(from: timestamp)
override class func heightForContent(
for viewModel: any ChatCellViewModel,
width: CGFloat
) -> CGFloat {
let vm = viewModel as! AssistantMessageCellViewModel
markdownViewForSizeCalculation.theme = .default
markdownViewForSizeCalculation.frame = .init(
x: 0, y: 0, width: width, height: .greatestFiniteMagnitude
)
markdownViewForSizeCalculation.setMarkdown(
vm.documentBlocks,
renderedContent: vm.documentRenderedContent,
)
let boundingSize = markdownViewForSizeCalculation.boundingSize(for: width)
return ceil(boundingSize.height)
}
}

View File

@@ -5,37 +5,24 @@
// Created by on 6/27/25.
//
import ListViewKit
import Litext
import MarkdownView
import SnapKit
import Then
import UIKit
class ChatBaseCell: UITableViewCell {
// MARK: - UI Components
///
lazy var containerView = UIView().then {
$0.layer.cornerRadius = 8
$0.layer.cornerCurve = .continuous
class ChatBaseCell: ListRowView {
static var contentInsets: UIEdgeInsets {
.init(top: 0, left: 16, bottom: 16, right: 16)
}
// MARK: - Properties
private let contentView = UIView()
///
var containerInsets: UIEdgeInsets {
UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16)
}
///
var contentInsets: UIEdgeInsets {
UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)
}
// MARK: - Initialization
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupBaseUI()
setupContentView()
init() {
super.init(frame: .zero)
addSubview(contentView)
prepareContentView(inside: contentView)
}
@available(*, unavailable)
@@ -43,68 +30,49 @@ class ChatBaseCell: UITableViewCell {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Setup
private func setupBaseUI() {
backgroundColor = .clear
selectionStyle = .none
contentView.addSubview(containerView)
containerView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(containerInsets)
}
func prepareContentView(inside contentView: UIView) {
_ = contentView
}
///
func setupContentView() {
//
override func layoutSubviews() {
super.layoutSubviews()
let contentInsets = Self.contentInsets
contentView.frame = .init(
x: contentInsets.left,
y: contentInsets.top,
width: bounds.width - contentInsets.left - contentInsets.right,
height: bounds.height - contentInsets.top - contentInsets.bottom
)
layoutContentView(bounds: contentView.bounds)
}
// MARK: - Configuration
///
func configureContainer(backgroundColor: UIColor?, borderColor: UIColor? = nil, borderWidth: CGFloat = 0) {
containerView.backgroundColor = backgroundColor
if let borderColor {
containerView.layer.borderColor = borderColor.cgColor
containerView.layer.borderWidth = borderWidth
} else {
containerView.layer.borderColor = nil
containerView.layer.borderWidth = 0
}
override func addSubview(_ view: UIView) {
assert(view == contentView)
super.addSubview(view)
}
/// ViewModel
func configure(with _: any ChatCellViewModel) {
//
func layoutContentView(bounds: CGRect) {
_ = bounds // override pass
}
// MARK: - Helpers
///
func textColor(for cellType: CellType) -> UIColor {
switch cellType {
case .userMessage, .assistantMessage, .systemMessage:
.label
case .error:
.systemRed
case .loading:
.secondaryLabel
}
class func heightForContent(
for viewModel: any ChatCellViewModel,
width: CGFloat
) -> CGFloat {
_ = viewModel
_ = width
return 0 // override pass
}
///
func backgroundColor(for cellType: CellType) -> UIColor? {
switch cellType {
case .userMessage, .assistantMessage:
.clear
case .systemMessage:
.systemYellow.withAlphaComponent(0.2)
case .error:
.systemRed.withAlphaComponent(0.1)
case .loading:
.systemGray6
}
static func heightForCell(for viewModel: any ChatCellViewModel, width: CGFloat) -> CGFloat {
let contentWidth = width - contentInsets.left - contentInsets.right
return heightForContent(
for: viewModel,
width: contentWidth
) + contentInsets.top + contentInsets.bottom
}
func configure(with viewModel: any ChatCellViewModel) {
_ = viewModel
}
}

View File

@@ -1,69 +0,0 @@
//
// ChatCellFactory.swift
// Intelligents
//
// Created by on 6/27/25.
//
import UIKit
class ChatCellFactory {
// MARK: - Cell Registration
static func registerCells(for tableView: UITableView) {
tableView.register(UserMessageCell.self, forCellReuseIdentifier: CellType.userMessage.rawValue)
tableView.register(AssistantMessageCell.self, forCellReuseIdentifier: CellType.assistantMessage.rawValue)
tableView.register(SystemMessageCell.self, forCellReuseIdentifier: CellType.systemMessage.rawValue)
tableView.register(LoadingCell.self, forCellReuseIdentifier: CellType.loading.rawValue)
tableView.register(ErrorCell.self, forCellReuseIdentifier: CellType.error.rawValue)
}
// MARK: - Cell Creation
static func dequeueCell(
for tableView: UITableView,
at indexPath: IndexPath,
with viewModel: any ChatCellViewModel
) -> ChatBaseCell {
let identifier = viewModel.cellType.rawValue
guard let cell = tableView.dequeueReusableCell(
withIdentifier: identifier,
for: indexPath
) as? ChatBaseCell else {
// cell使cellfallback
let fallbackCell = tableView.dequeueReusableCell(
withIdentifier: CellType.systemMessage.rawValue,
for: indexPath
) as! SystemMessageCell
// fallbackViewModel
let fallbackViewModel = SystemMessageCellViewModel(
id: viewModel.id,
content: "不支持的消息类型: \\(viewModel.cellType.rawValue)",
timestamp: Date()
)
fallbackCell.configure(with: fallbackViewModel)
return fallbackCell
}
cell.configure(with: viewModel)
return cell
}
// MARK: - Height Estimation
static func estimatedHeight(for viewModel: any ChatCellViewModel) -> CGFloat {
switch viewModel.cellType {
case .userMessage,
.assistantMessage:
80
case .systemMessage:
60
case .loading:
100
case .error:
120
}
}
}

View File

@@ -5,93 +5,30 @@
// Created by on 6/27/25.
//
import Litext
import SnapKit
import Then
import UIKit
class ErrorCell: ChatBaseCell {
// MARK: - UI Components
private lazy var iconView = UIImageView().then {
$0.image = UIImage(systemName: "exclamationmark.triangle.fill")
$0.tintColor = .systemRed
$0.contentMode = .scaleAspectFit
override func prepareContentView(inside contentView: UIView) {
super.prepareContentView(inside: contentView)
}
private lazy var errorLabel = UILabel().then {
$0.numberOfLines = 0
$0.font = .systemFont(ofSize: 14, weight: .medium)
$0.textColor = .systemRed
override func prepareForReuse() {
super.prepareForReuse()
}
private lazy var retryButton = UIButton(type: .system).then {
$0.setTitle("Retry", for: .normal)
$0.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
$0.setTitleColor(.systemBlue, for: .normal)
$0.backgroundColor = .systemBlue.withAlphaComponent(0.1)
$0.layer.cornerRadius = 8
$0.layer.cornerCurve = .continuous
$0.contentEdgeInsets = UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16)
override func layoutContentView(bounds: CGRect) {
super.layoutContentView(bounds: bounds)
}
private lazy var contentStackView = UIStackView().then {
$0.axis = .horizontal
$0.spacing = 12
$0.alignment = .top
}
private lazy var textStackView = UIStackView().then {
$0.axis = .vertical
$0.spacing = 12
$0.alignment = .fill
}
// MARK: - Properties
private var viewModel: ErrorCellViewModel?
// MARK: - Setup
override func setupContentView() {
containerView.addSubview(contentStackView)
contentStackView.addArrangedSubview(iconView)
contentStackView.addArrangedSubview(textStackView)
textStackView.addArrangedSubview(errorLabel)
textStackView.addArrangedSubview(retryButton)
contentStackView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(contentInsets)
}
iconView.snp.makeConstraints { make in
make.width.height.equalTo(24)
}
retryButton.addTarget(self, action: #selector(retryButtonTapped), for: .touchUpInside)
}
// MARK: - Configuration
override func configure(with viewModel: any ChatCellViewModel) {
guard let errorViewModel = viewModel as? ErrorCellViewModel else {
assertionFailure()
return
}
self.viewModel = errorViewModel
errorLabel.text = errorViewModel.errorMessage
configureContainer(
backgroundColor: backgroundColor(for: errorViewModel.cellType),
borderColor: .systemRed.withAlphaComponent(0.3),
borderWidth: 1
)
}
// MARK: - Actions
@objc private func retryButtonTapped() {
// TODO:
override class func heightForContent(
for viewModel: any ChatCellViewModel,
width: CGFloat
) -> CGFloat {
_ = viewModel
_ = width
return 0
}
}

View File

@@ -5,85 +5,30 @@
// Created by on 6/27/25.
//
import Litext
import SnapKit
import Then
import UIKit
class LoadingCell: ChatBaseCell {
// MARK: - UI Components
private lazy var activityIndicator = UIActivityIndicatorView().then {
$0.style = .medium
$0.hidesWhenStopped = false
override func prepareContentView(inside contentView: UIView) {
super.prepareContentView(inside: contentView)
}
private lazy var messageLabel = UILabel().then {
$0.numberOfLines = 0
$0.font = .systemFont(ofSize: 14)
$0.textColor = .secondaryLabel
$0.textAlignment = .center
override func prepareForReuse() {
super.prepareForReuse()
}
private lazy var progressView = UIProgressView().then {
$0.progressViewStyle = .default
$0.trackTintColor = .systemGray5
$0.progressTintColor = .systemBlue
override func layoutContentView(bounds: CGRect) {
super.layoutContentView(bounds: bounds)
}
private lazy var stackView = UIStackView().then {
$0.axis = .vertical
$0.spacing = 12
$0.alignment = .center
}
// MARK: - Properties
private var viewModel: LoadingCellViewModel?
// MARK: - Setup
override func setupContentView() {
containerView.addSubview(stackView)
stackView.addArrangedSubview(activityIndicator)
stackView.addArrangedSubview(messageLabel)
stackView.addArrangedSubview(progressView)
stackView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(contentInsets)
}
progressView.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview()
make.height.equalTo(4)
}
activityIndicator.startAnimating()
}
// MARK: - Configuration
override func configure(with viewModel: any ChatCellViewModel) {
guard let loadingViewModel = viewModel as? LoadingCellViewModel else { return }
self.viewModel = loadingViewModel
configureContainer(backgroundColor: backgroundColor(for: loadingViewModel.cellType))
//
if let message = loadingViewModel.message {
messageLabel.text = message
messageLabel.isHidden = false
} else {
messageLabel.text = "Processing..."
messageLabel.isHidden = false
}
//
if let progress = loadingViewModel.progress {
progressView.progress = Float(progress)
progressView.isHidden = false
} else {
progressView.isHidden = true
}
override class func heightForContent(
for viewModel: any ChatCellViewModel,
width: CGFloat
) -> CGFloat {
_ = viewModel
_ = width
return 0
}
}

View File

@@ -5,90 +5,76 @@
// Created by on 6/27/25.
//
import Litext
import SnapKit
import Then
import UIKit
private let labelForSizeCalculation = LTXLabel()
class SystemMessageCell: ChatBaseCell {
// MARK: - UI Components
private lazy var iconView = UIImageView().then {
$0.image = UIImage(systemName: "info.circle.fill")
$0.tintColor = .systemOrange
$0.contentMode = .scaleAspectFit
let contentLabel = LTXLabel().then {
$0.isSelectable = false
}
private lazy var messageLabel = UILabel().then {
$0.numberOfLines = 0
$0.font = .systemFont(ofSize: 14, weight: .medium)
$0.textColor = .label
override func prepareContentView(inside contentView: UIView) {
super.prepareContentView(inside: contentView)
contentView.addSubview(contentLabel)
}
private lazy var timestampLabel = UILabel().then {
$0.font = .systemFont(ofSize: 12)
$0.textColor = .secondaryLabel
$0.textAlignment = .right
override func prepareForReuse() {
super.prepareForReuse()
contentLabel.attributedText = .init()
}
private lazy var contentStackView = UIStackView().then {
$0.axis = .horizontal
$0.spacing = 12
$0.alignment = .top
}
private lazy var textStackView = UIStackView().then {
$0.axis = .vertical
$0.spacing = 4
$0.alignment = .fill
}
// MARK: - Properties
private var viewModel: SystemMessageCellViewModel?
// MARK: - Setup
override func setupContentView() {
containerView.addSubview(contentStackView)
contentStackView.addArrangedSubview(iconView)
contentStackView.addArrangedSubview(textStackView)
textStackView.addArrangedSubview(messageLabel)
textStackView.addArrangedSubview(timestampLabel)
contentStackView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(contentInsets)
}
iconView.snp.makeConstraints { make in
make.width.height.equalTo(20)
}
}
// MARK: - Configuration
override func configure(with viewModel: any ChatCellViewModel) {
guard let systemViewModel = viewModel as? SystemMessageCellViewModel else { return }
self.viewModel = systemViewModel
messageLabel.text = systemViewModel.content
configureContainer(backgroundColor: backgroundColor(for: systemViewModel.cellType))
//
if let timestamp = systemViewModel.timestamp {
timestampLabel.text = formatTimestamp(timestamp)
timestampLabel.isHidden = false
} else {
timestampLabel.isHidden = true
super.configure(with: viewModel)
guard let vm = viewModel as? SystemMessageCellViewModel else {
assertionFailure("")
return
}
contentLabel.attributedText = Self.prepareAttributeText(vm.content)
}
// MARK: - Helpers
override func layoutContentView(bounds: CGRect) {
super.layoutContentView(bounds: bounds)
let textMaxWidth = bounds.width * 0.8
contentLabel.preferredMaxLayoutWidth = textMaxWidth
let textSize = contentLabel.intrinsicContentSize
let labelWidth = textSize.width
let labelHeight = textSize.height
contentLabel.frame = .init(
x: (bounds.width - labelWidth) / 2,
y: 0,
width: labelWidth,
height: labelHeight
)
}
private func formatTimestamp(_ timestamp: Date) -> String {
let formatter = DateFormatter()
formatter.timeStyle = .short
return formatter.string(from: timestamp)
class func prepareAttributeText(_ text: String) -> NSAttributedString {
.init(string: text, attributes: [
.font: UIFont.preferredFont(forTextStyle: .footnote),
.foregroundColor: UIColor.affineTextSecondary,
.paragraphStyle: NSMutableParagraphStyle().then {
$0.lineBreakMode = .byWordWrapping
$0.alignment = .center
$0.lineSpacing = 2
$0.paragraphSpacing = 4
},
])
}
override class func heightForContent(
for viewModel: any ChatCellViewModel,
width: CGFloat
) -> CGFloat {
guard let vm = viewModel as? SystemMessageCellViewModel else {
assertionFailure()
return 0
}
labelForSizeCalculation.attributedText = prepareAttributeText(vm.content)
labelForSizeCalculation.preferredMaxLayoutWidth = width * 0.8
let textSize = labelForSizeCalculation.intrinsicContentSize
return textSize.height
}
}

View File

@@ -0,0 +1,102 @@
//
// UserHintCell.swift
// Intelligents
//
// Created by on 7/4/25.
//
import Litext
import UIKit
private let labelForSizeCalculation = LTXLabel()
private let formatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .short
formatter.locale = .current
return formatter
}()
class UserHintCell: ChatBaseCell {
let contentLabel = LTXLabel().then {
$0.isSelectable = true
}
override func prepareContentView(inside contentView: UIView) {
super.prepareContentView(inside: contentView)
contentView.addSubview(contentLabel)
}
override func prepareForReuse() {
super.prepareForReuse()
contentLabel.attributedText = .init()
}
override func configure(with viewModel: any ChatCellViewModel) {
super.configure(with: viewModel)
guard let vm = viewModel as? UserHintCellViewModel else {
assertionFailure("")
return
}
contentLabel.attributedText = Self.prepareAttributeText(Self.prepareText(vm))
}
override func layoutContentView(bounds: CGRect) {
super.layoutContentView(bounds: bounds)
contentLabel.preferredMaxLayoutWidth = bounds.width
let textSize = contentLabel.intrinsicContentSize
contentLabel.frame = CGRect(
x: bounds.width - textSize.width,
y: 0,
width: textSize.width,
height: bounds.height
)
}
class func prepareText(_ vm: UserHintCellViewModel) -> String {
let attachmentsCount = [
vm.docAttachments.count,
vm.imageAttachments.count,
vm.fileAttachments.count,
].reduce(0, +)
let text: [String] = [
formatter.string(from: vm.timestamp),
{
if attachmentsCount > 0 {
String(localized: "\(attachmentsCount) attachments")
} else {
""
}
}(),
].filter { !$0.isEmpty }
return text.joined(separator: " ")
}
class func prepareAttributeText(_ text: String) -> NSAttributedString {
.init(string: text, attributes: [
.font: UIFont.preferredFont(forTextStyle: .footnote),
.foregroundColor: UIColor.affineTextSecondary,
.paragraphStyle: NSMutableParagraphStyle().then {
$0.lineBreakMode = .byWordWrapping
$0.alignment = .natural
$0.lineSpacing = 2
$0.paragraphSpacing = 4
},
])
}
override class func heightForContent(
for viewModel: any ChatCellViewModel,
width: CGFloat
) -> CGFloat {
guard let vm = viewModel as? UserHintCellViewModel else {
assertionFailure()
return 0
}
labelForSizeCalculation.attributedText = prepareAttributeText(prepareText(vm))
labelForSizeCalculation.preferredMaxLayoutWidth = width
return labelForSizeCalculation.intrinsicContentSize.height
}
}

View File

@@ -5,90 +5,88 @@
// Created by on 6/27/25.
//
import Litext
import SnapKit
import Then
import UIKit
private let labelForSizeCalculation = LTXLabel()
class UserMessageCell: ChatBaseCell {
// MARK: - UI Components
private lazy var messageLabel = UILabel().then {
$0.numberOfLines = 0
$0.font = .systemFont(ofSize: 16)
$0.textColor = .label
let backgroundView = UIView().then {
$0.backgroundColor = .gray.withAlphaComponent(0.05)
$0.layer.cornerRadius = 8
}
private lazy var timestampLabel = UILabel().then {
$0.font = .systemFont(ofSize: 12)
$0.textColor = .secondaryLabel
$0.textAlignment = .right
let contentLabel = LTXLabel().then {
$0.isSelectable = true
}
private lazy var retryIndicator = UIActivityIndicatorView().then {
$0.style = .medium
$0.hidesWhenStopped = true
override func prepareContentView(inside contentView: UIView) {
super.prepareContentView(inside: contentView)
contentView.addSubview(backgroundView)
backgroundView.addSubview(contentLabel)
}
private lazy var stackView = UIStackView().then {
$0.axis = .vertical
$0.spacing = 8
$0.alignment = .fill
override func prepareForReuse() {
super.prepareForReuse()
contentLabel.attributedText = .init()
}
// MARK: - Properties
private var viewModel: UserMessageCellViewModel?
// MARK: - Setup
override func setupContentView() {
containerView.addSubview(stackView)
stackView.addArrangedSubview(messageLabel)
let bottomContainer = UIView()
stackView.addArrangedSubview(bottomContainer)
bottomContainer.addSubview(retryIndicator)
bottomContainer.addSubview(timestampLabel)
stackView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(contentInsets)
}
retryIndicator.snp.makeConstraints { make in
make.leading.centerY.equalToSuperview()
make.width.height.equalTo(16)
}
timestampLabel.snp.makeConstraints { make in
make.trailing.top.bottom.equalToSuperview()
make.leading.greaterThanOrEqualTo(retryIndicator.snp.trailing).offset(8)
}
bottomContainer.snp.makeConstraints { make in
make.height.equalTo(16)
}
}
// MARK: - Configuration
override func configure(with viewModel: any ChatCellViewModel) {
guard let userViewModel = viewModel as? UserMessageCellViewModel else { return }
self.viewModel = userViewModel
messageLabel.text = userViewModel.content
configureContainer(backgroundColor: backgroundColor(for: userViewModel.cellType))
let timestamp = userViewModel.timestamp
timestampLabel.text = formatTimestamp(timestamp)
timestampLabel.isHidden = false
super.configure(with: viewModel)
guard let vm = viewModel as? UserMessageCellViewModel else {
assertionFailure("")
return
}
contentLabel.attributedText = Self.prepareAttributeText(vm.content)
}
// MARK: - Helpers
override func layoutContentView(bounds: CGRect) {
super.layoutContentView(bounds: bounds)
private func formatTimestamp(_ timestamp: Date) -> String {
let formatter = DateFormatter()
formatter.timeStyle = .short
return formatter.string(from: timestamp)
let inset: CGFloat = 8
let textMaxWidth = bounds.width * 0.8 - inset * 2
contentLabel.preferredMaxLayoutWidth = textMaxWidth
let textSize = contentLabel.intrinsicContentSize
let backgroundWidth = textSize.width + inset * 2
backgroundView.frame = .init(
x: bounds.width - backgroundWidth, // right aligned
y: 0,
width: backgroundWidth,
height: bounds.height
)
contentLabel.frame = backgroundView.bounds.insetBy(dx: inset, dy: inset)
}
class func prepareAttributeText(_ text: String) -> NSAttributedString {
.init(string: text, attributes: [
.font: UIFont.preferredFont(forTextStyle: .body),
.foregroundColor: UIColor.affineTextPrimary,
.paragraphStyle: NSMutableParagraphStyle().then {
$0.lineBreakMode = .byWordWrapping
$0.alignment = .natural
$0.lineSpacing = 2
$0.paragraphSpacing = 4
},
])
}
override class func heightForContent(
for viewModel: any ChatCellViewModel,
width: CGFloat
) -> CGFloat {
guard let vm = viewModel as? UserMessageCellViewModel else {
assertionFailure()
return 0
}
labelForSizeCalculation.attributedText = prepareAttributeText(vm.content)
let inset: CGFloat = 8
labelForSizeCalculation.preferredMaxLayoutWidth = width * 0.8 - inset * 2
let textSize = labelForSizeCalculation.intrinsicContentSize
return textSize.height + inset * 2
}
}

View File

@@ -1,44 +0,0 @@
//
// AssistantMessageCellViewModel.swift
// Intelligents
//
// Created by on 6/27/25.
//
import Foundation
struct AssistantMessageCellViewModel: ChatCellViewModel {
var cellType: CellType = .assistantMessage
var id: UUID
var content: String
var timestamp: Date
var isStreaming: Bool = false
var model: String?
var tokens: Int?
var canRetry: Bool = false
var citations: [CitationViewModel]?
var actions: [MessageActionViewModel]?
}
struct CitationViewModel: Codable, Identifiable, Equatable, Hashable {
var id: String
var title: String
var url: String?
var snippet: String?
}
struct MessageActionViewModel: Codable, Identifiable, Equatable, Hashable {
var id: String
var title: String
var actionType: ActionType
var data: [String: String]?
enum ActionType: String, Codable {
case copy
case regenerate
case like
case dislike
case share
case edit
}
}

View File

@@ -0,0 +1,109 @@
//
// CCVM+Assistant.swift
// Intelligents
//
// Created by on 6/27/25.
//
import Foundation
import MarkdownParser
import MarkdownView
struct AssistantMessageCellViewModel: ChatCellViewModel {
static func == (lhs: AssistantMessageCellViewModel, rhs: AssistantMessageCellViewModel) -> Bool {
lhs.hashValue == rhs.hashValue
}
func hash(into hasher: inout Hasher) {
hasher.combine(cellType)
hasher.combine(id)
hasher.combine(content)
hasher.combine(timestamp)
hasher.combine(isStreaming)
hasher.combine(model)
hasher.combine(tokens)
hasher.combine(canRetry)
hasher.combine(citations)
hasher.combine(actions)
}
var cellType: ChatCellType = .assistantMessage
var id: UUID
var content: String
var timestamp: Date
var isStreaming: Bool = false
var model: String?
var tokens: Int?
var canRetry: Bool = false
var citations: [CitationViewModel]?
var actions: [MessageActionViewModel]?
var documentBlocks: [MarkdownBlockNode]
var documentRenderedContent: RenderContext
init(
id: UUID,
content: String,
timestamp: Date,
isStreaming: Bool = false,
model: String? = nil,
tokens: Int? = nil,
canRetry: Bool = false,
citations: [CitationViewModel]? = nil,
actions: [MessageActionViewModel]? = nil
) {
// time expensive rendering should not happen here
assert(!Thread.isMainThread || content.isEmpty)
self.id = id
self.content = content
self.timestamp = timestamp
self.isStreaming = isStreaming
self.model = model
self.tokens = tokens
self.canRetry = canRetry
self.citations = citations
self.actions = actions
let parser = MarkdownParser()
let parserResult = parser.parse(content)
documentBlocks = parserResult.document
var renderedContexts: [String: RenderedItem] = [:]
for (key, value) in parserResult.mathContext {
let image = MathRenderer.renderToImage(
latex: value,
fontSize: MarkdownTheme.default.fonts.body.pointSize,
textColor: MarkdownTheme.default.colors.body
)?.withRenderingMode(.alwaysTemplate)
let renderedContext = RenderedItem(
image: image,
text: value
)
renderedContexts["math://\(key)"] = renderedContext
}
documentRenderedContent = renderedContexts
}
}
struct CitationViewModel: Codable, Identifiable, Equatable, Hashable {
var id: String
var title: String
var url: String?
var snippet: String?
}
struct MessageActionViewModel: Codable, Identifiable, Equatable, Hashable {
var id: String
var title: String
var actionType: ActionType
var data: [String: String]?
enum ActionType: String, Codable {
case copy
case regenerate
case like
case dislike
case share
case edit
}
}

View File

@@ -1,5 +1,5 @@
//
// ErrorCellViewModel.swift
// CCVM+Error.swift
// Intelligents
//
// Created by on 6/26/25.
@@ -8,7 +8,8 @@
import Foundation
struct ErrorCellViewModel: ChatCellViewModel {
var cellType: CellType = .error
var cellType: ChatCellType = .error
var id: UUID
var errorMessage: String
var timestamp: Date
}

View File

@@ -1,5 +1,5 @@
//
// LoadingCellViewModel.swift
// CCVM+Loading.swift
// Intelligents
//
// Created by on 6/26/25.
@@ -8,8 +8,9 @@
import Foundation
struct LoadingCellViewModel: ChatCellViewModel {
var cellType: CellType = .loading
var cellType: ChatCellType = .loading
var id: UUID
var timestamp: Date
var message: String?
var progress: Double?
}

View File

@@ -1,5 +1,5 @@
//
// SystemMessageCellViewModel.swift
// CCVM+System.swift
// Intelligents
//
// Created by on 6/27/25.
@@ -8,8 +8,8 @@
import Foundation
struct SystemMessageCellViewModel: ChatCellViewModel {
var cellType: CellType = .systemMessage
var cellType: ChatCellType = .systemMessage
var id: UUID
var content: String
var timestamp: Date?
var timestamp: Date
}

View File

@@ -0,0 +1,24 @@
//
// CCVM+User.swift
// Intelligents
//
// Created by on 6/27/25.
//
import Foundation
struct UserMessageCellViewModel: ChatCellViewModel {
var cellType: ChatCellType = .userMessage
var id: UUID
var content: String
var timestamp: Date
}
struct UserHintCellViewModel: ChatCellViewModel {
var cellType: ChatCellType = .userAttachmentsHint
var id: UUID
var timestamp: Date
var imageAttachments: [ImageAttachment]
var fileAttachments: [FileAttachment]
var docAttachments: [DocumentAttachment]
}

View File

@@ -1,5 +1,5 @@
//
// CellType.swift
// ChatCellType.swift
// Intelligents
//
// Created by on 6/26/25.
@@ -7,8 +7,9 @@
import Foundation
public enum CellType: String, Codable, CaseIterable {
public enum ChatCellType: String, CaseIterable {
case userMessage
case userAttachmentsHint
case assistantMessage
case systemMessage
case loading

View File

@@ -7,7 +7,8 @@
import Foundation
public protocol ChatCellViewModel: Codable, Identifiable, Equatable, Hashable {
public protocol ChatCellViewModel: Identifiable, Equatable, Hashable {
var id: UUID { get }
var cellType: CellType { get }
var cellType: ChatCellType { get }
var timestamp: Date { get }
}

View File

@@ -1,16 +0,0 @@
//
// UserMessageCellViewModel.swift
// Intelligents
//
// Created by on 6/27/25.
//
import Foundation
struct UserMessageCellViewModel: ChatCellViewModel {
var cellType: CellType = .userMessage
var id: UUID
var content: String
var timestamp: Date
var attachments: [String] = []
}

View File

@@ -0,0 +1,24 @@
//
// ChatItemEntity.swift
// Intelligents
//
// Created by on 7/2/25.
//
import Foundation
import UIKit
struct ChatItemEntity: Identifiable, Hashable, Equatable {
var id: UUID
var object: any ChatCellViewModel
static func == (lhs: ChatItemEntity, rhs: ChatItemEntity) -> Bool {
lhs.id == rhs.id && lhs.object.cellType == rhs.object.cellType && lhs.object.hashValue == rhs.object.hashValue
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
hasher.combine(object.cellType)
hasher.combine(object.hashValue)
}
}

View File

@@ -0,0 +1,88 @@
//
// ChatListView+Adapter.swift
// Intelligents
//
// Created by on 7/2/25.
//
import ListViewKit
import UIKit
private let dayDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
return formatter
}()
extension ChatListView: ListViewAdapter {
func fill(viewModels: [any ChatCellViewModel]) {
assert(!Thread.isMainThread)
var items = viewModels.map { ChatItemEntity(id: $0.id, object: $0) }
items = preprocessItems(items)
DispatchQueue.main.asyncAndWait { [self] in
dataSource.applySnapshot(using: items, animatingDifferences: true)
}
}
private func preprocessItems(_ items: [ChatItemEntity]) -> [ChatItemEntity] {
var ans = [ChatItemEntity]()
// prepend a date hint for each day
let calendar = Calendar.current
var currentDayAnchor: Date?
for item in items {
defer { ans.append(item) }
guard item.object.cellType == .userMessage,
let userMessage = item.object as? UserMessageCellViewModel
else { continue }
let messageDate = userMessage.timestamp
let dayAnchor = calendar.startOfDay(for: messageDate)
if currentDayAnchor == nil || dayAnchor > currentDayAnchor! {
currentDayAnchor = dayAnchor
let dateHint = SystemMessageCellViewModel(
id: .init(),
content: dayDateFormatter.string(from: dayAnchor),
timestamp: .init()
)
ans.append(ChatItemEntity(id: dateHint.id, object: dateHint))
}
}
return ans
}
func listView(_: ListViewKit.ListView, rowKindFor item: ItemType, at _: Int) -> RowKind {
let item = item as! ChatItemEntity
return item.object.cellType
}
func listViewMakeRow(for kind: RowKind) -> ListViewKit.ListRowView {
switch kind as! ChatCellType {
case .userMessage: UserMessageCell()
case .userAttachmentsHint: UserHintCell()
case .assistantMessage: AssistantMessageCell()
case .systemMessage: SystemMessageCell()
case .loading: LoadingCell()
case .error: ErrorCell()
}
}
func listView(_ list: ListViewKit.ListView, heightFor item: ItemType, at _: Int) -> CGFloat {
let item = item as! ChatItemEntity
return switch item.object.cellType {
case .userMessage: UserMessageCell.heightForCell(for: item.object, width: list.bounds.width)
case .userAttachmentsHint: UserHintCell.heightForCell(for: item.object, width: list.bounds.width)
case .assistantMessage: AssistantMessageCell.heightForCell(for: item.object, width: list.bounds.width)
case .systemMessage: SystemMessageCell.heightForCell(for: item.object, width: list.bounds.width)
case .loading: LoadingCell.heightForCell(for: item.object, width: list.bounds.width)
case .error: ErrorCell.heightForCell(for: item.object, width: list.bounds.width)
}
}
func listView(_: ListViewKit.ListView, configureRowView rowView: ListViewKit.ListRowView, for item: ItemType, at _: Int) {
let base = rowView as! ChatBaseCell
let item = item as! ChatItemEntity
base.configure(with: item.object)
}
}

View File

@@ -0,0 +1,86 @@
//
// ChatListView.swift
// Intelligents
//
// Created by on 7/2/25.
//
import Combine
import ListViewKit
import MarkdownView
import UIKit
class ChatListView: UIView {
private(set) lazy var listView = ListView()
private(set) lazy var dataSource = ListViewDiffableDataSource<ChatItemEntity>(listView: listView)
var cancellables: Set<AnyCancellable> = []
init() {
super.init(frame: .zero)
listView.topInset = 8
listView.bottomInset = 64
listView.adapter = self
addSubview(listView)
listView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
let dataSourceQueue = DispatchQueue(label: "com.affine.intelligents.chat.list.dataSource", qos: .userInteractive)
Publishers.CombineLatest(
IntelligentContext.shared.$currentSession
.map { $0?.id ?? "default_session" }
.removeDuplicates(),
ChatManager.shared.$viewModels
)
.receive(on: dataSourceQueue)
.map { sessionIdentifier, viewModels in
.init(viewModels[sessionIdentifier]?.map(\.value) ?? [])
}
.sink { [weak self] viewModels in
guard let self else { return }
fill(viewModels: viewModels)
}
.store(in: &cancellables)
Publishers.CombineLatest(
IntelligentContext.shared.$currentSession
.map { $0?.id ?? "default_session" }
.removeDuplicates(),
ChatManager.shared.scrollToBottomPublisher
)
.receive(on: dataSourceQueue)
.filter { $0 == $1 }
.map { _ in () }
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard let self else { return }
scrollToBottom()
}
.store(in: &cancellables)
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError()
}
deinit {
cancellables.forEach { $0.cancel() }
cancellables.removeAll()
}
func scrollToBottom() {
if listView.contentSize.height <= listView.bounds.height {
// If the content size is smaller than the bounds, no need to scroll.
return
}
let contentOffset = CGPoint(
x: 0,
y: listView.contentSize.height - listView.bounds.height
)
listView.scroll(to: contentOffset)
}
}

View File

@@ -1,154 +0,0 @@
import Combine
import OrderedCollections
import SnapKit
import Then
import UIKit
protocol ChatTableViewDelegate: AnyObject {
func chatTableView(_ tableView: ChatTableView, didSelectRowAt indexPath: IndexPath)
}
class ChatTableView: UIView {
// MARK: - UI Components
lazy var tableView = UITableView().then {
$0.backgroundColor = .clear
$0.separatorStyle = .none
$0.delegate = self
$0.dataSource = self
$0.keyboardDismissMode = .interactive
$0.contentInsetAdjustmentBehavior = .never
$0.tableFooterView = UIView(frame: .init(x: 0, y: 0, width: 100, height: 500))
}
lazy var emptyStateView = UIView().then {
$0.isHidden = true
}
lazy var emptyStateLabel = UILabel().then {
$0.text = "Start a conversation..."
$0.font = .systemFont(ofSize: 18, weight: .medium)
$0.textColor = .systemGray
$0.textAlignment = .center
}
// MARK: - Properties
weak var delegate: ChatTableViewDelegate?
var sessionId: String? {
didSet {
if let sessionId {
bindToSession(sessionId)
}
}
}
private var cancellables = Set<AnyCancellable>()
var cellViewModels: OrderedDictionary<UUID, any ChatCellViewModel> = [:] {
didSet {
updateEmptyState()
tableView.reloadData()
if !cellViewModels.isEmpty {
let indexPath = IndexPath(row: cellViewModels.count - 1, section: 0)
tableView.scrollToRow(at: indexPath, at: .bottom, animated: true)
}
}
}
// MARK: - Initialization
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupUI()
}
// MARK: - Setup
private func setupUI() {
// cell
ChatCellFactory.registerCells(for: tableView)
addSubview(tableView)
addSubview(emptyStateView)
emptyStateView.addSubview(emptyStateLabel)
tableView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
emptyStateView.snp.makeConstraints { make in
make.center.equalTo(tableView)
make.width.lessThanOrEqualTo(tableView).inset(32)
}
emptyStateLabel.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
// MARK: - Public Methods
func scrollToBottom(animated: Bool = true) {
guard !cellViewModels.isEmpty else { return }
let indexPath = IndexPath(row: cellViewModels.count - 1, section: 0)
tableView.scrollToRow(at: indexPath, at: .bottom, animated: animated)
}
// MARK: - Private Methods
private func bindToSession(_ sessionId: String) {
cancellables.removeAll()
ChatManager.shared.$viewModels
.map { $0[sessionId] ?? [:] }
.receive(on: DispatchQueue.main)
.sink { [weak self] viewModels in
self?.cellViewModels = viewModels
}
.store(in: &cancellables)
}
private func updateEmptyState() {
emptyStateView.isHidden = !cellViewModels.isEmpty
tableView.isHidden = cellViewModels.isEmpty
}
}
// MARK: - UITableViewDataSource
extension ChatTableView: UITableViewDataSource {
func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
cellViewModels.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let viewModel = cellViewModels.elements[indexPath.row].value
return ChatCellFactory.dequeueCell(for: tableView, at: indexPath, with: viewModel)
}
}
// MARK: - UITableViewDelegate
extension ChatTableView: UITableViewDelegate {
func tableView(_: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
let viewModel = cellViewModels.elements[indexPath.row].value
return ChatCellFactory.estimatedHeight(for: viewModel)
}
func tableView(_: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
let viewModel = cellViewModels.elements[indexPath.row].value
return ChatCellFactory.estimatedHeight(for: viewModel)
}
func tableView(_: UITableView, didSelectRowAt indexPath: IndexPath) {
delegate?.chatTableView(self, didSelectRowAt: indexPath)
}
}

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