Compare commits

..

19 Commits

Author SHA1 Message Date
Wu Yue
69e23e6a42 fix(core): fallback to default icon if image icon load error (#13349)
Close [AI-286](https://linear.app/affine-design/issue/AI-286)

<img width="586" height="208" alt="截屏2025-07-29 18 23 52"
src="https://github.com/user-attachments/assets/15eadb38-8cb9-4418-8f13-de7b1a3a3beb"
/>


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

## Summary by CodeRabbit

* **New Features**
* Enhanced image icon handling with a fallback display if an icon image
fails to load.

* **Style**
* Unified and improved styling for icons to ensure a consistent
appearance across result and footer sections.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-30 02:24:28 +00:00
Wu Yue
f7a094053e feat(core): add ai workspace all docs switch (#13345)
Close [AI-397](https://linear.app/affine-design/issue/AI-397)

<img width="272" height="186" alt="截屏2025-07-29 11 54 20"
src="https://github.com/user-attachments/assets/e171fb57-66cf-4244-894d-c27b18cbe83a"
/>


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

* **New Features**
* Introduced an AI tools configuration service, allowing users to
customize AI tool usage (e.g., workspace search, reading docs) in chat
and AI features.
* Added a toggle in chat preferences for enabling or disabling
workspace-wide document search.
* AI chat components now respect user-configured tool settings across
chat, retry, and playground scenarios.

* **Improvements**
* Enhanced chat and AI interfaces to propagate and honor user tool
configuration throughout the frontend and backend.
* Made draft and tool configuration services optional and safely handled
their absence in chat components.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-30 02:10:39 +00:00
L-Sun
091bac1047 fix(editor): add comment entire to inner toolbar (#13304)
Close
[BS-3624](https://linear.app/affine-design/issue/BS-3624/page模式单选图片的时候希望有comment-按钮)




#### PR Dependency Tree


* **PR #13304** 👈

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

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

* **New Features**
* Added a comment button to the image and surface reference block
toolbars for easier commenting.

* **Refactor**
* Simplified array flattening operations across multiple components and
utilities by replacing `.map(...).flat()` with `.flatMap(...)`,
improving code readability and maintainability.

* **Bug Fixes**
* Improved comment creation logic to allow adding comments even when
selections exist.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-29 13:21:56 +08:00
dependabot[bot]
bd161c54b2 chore: bump form-data from 4.0.2 to 4.0.4 (#13342)
Bumps [form-data](https://github.com/form-data/form-data) from 4.0.2 to
4.0.4.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/form-data/form-data/releases">form-data's
releases</a>.</em></p>
<blockquote>
<h2>v4.0.4</h2>
<h2><a
href="https://github.com/form-data/form-data/compare/v4.0.3...v4.0.4">v4.0.4</a>
- 2025-07-16</h2>
<h3>Commits</h3>
<ul>
<li>[meta] add <code>auto-changelog</code> <a
href="811f68282f"><code>811f682</code></a></li>
<li>[Tests] handle predict-v8-randomness failures in node &lt; 17 and
node &gt; 23 <a
href="1d11a76434"><code>1d11a76</code></a></li>
<li>[Fix] Switch to using <code>crypto</code> random for boundary values
<a
href="3d1723080e"><code>3d17230</code></a></li>
<li>[Tests] fix linting errors <a
href="5e340800b5"><code>5e34080</code></a></li>
<li>[meta] actually ensure the readme backup isn’t published <a
href="316c82ba93"><code>316c82b</code></a></li>
<li>[Dev Deps] update <code>@ljharb/eslint-config</code> <a
href="58c25d7640"><code>58c25d7</code></a></li>
<li>[meta] fix readme capitalization <a
href="2300ca1959"><code>2300ca1</code></a></li>
</ul>
<h2>v4.0.3</h2>
<h2><a
href="https://github.com/form-data/form-data/compare/v4.0.2...v4.0.3">v4.0.3</a>
- 2025-06-05</h2>
<h3>Fixed</h3>
<ul>
<li>[Fix] <code>append</code>: avoid a crash on nullish values <a
href="https://redirect.github.com/form-data/form-data/issues/577"><code>[#577](https://github.com/form-data/form-data/issues/577)</code></a></li>
</ul>
<h3>Commits</h3>
<ul>
<li>[eslint] use a shared config <a
href="426ba9ac44"><code>426ba9a</code></a></li>
<li>[eslint] fix some spacing issues <a
href="20941917f0"><code>2094191</code></a></li>
<li>[Refactor] use <code>hasown</code> <a
href="81ab41b46f"><code>81ab41b</code></a></li>
<li>[Fix] validate boundary type in <code>setBoundary()</code> method <a
href="8d8e469309"><code>8d8e469</code></a></li>
<li>[Tests] add tests to check the behavior of <code>getBoundary</code>
with non-strings <a
href="837b8a1f75"><code>837b8a1</code></a></li>
<li>[Dev Deps] remove unused deps <a
href="870e4e6659"><code>870e4e6</code></a></li>
<li>[meta] remove local commit hooks <a
href="e6e83ccb54"><code>e6e83cc</code></a></li>
<li>[Dev Deps] update <code>eslint</code> <a
href="4066fd6f65"><code>4066fd6</code></a></li>
<li>[meta] fix scripts to use prepublishOnly <a
href="c4bbb13c0e"><code>c4bbb13</code></a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/form-data/form-data/blob/master/CHANGELOG.md">form-data's
changelog</a>.</em></p>
<blockquote>
<h2><a
href="https://github.com/form-data/form-data/compare/v4.0.3...v4.0.4">v4.0.4</a>
- 2025-07-16</h2>
<h3>Commits</h3>
<ul>
<li>[meta] add <code>auto-changelog</code> <a
href="811f68282f"><code>811f682</code></a></li>
<li>[Tests] handle predict-v8-randomness failures in node &lt; 17 and
node &gt; 23 <a
href="1d11a76434"><code>1d11a76</code></a></li>
<li>[Fix] Switch to using <code>crypto</code> random for boundary values
<a
href="3d1723080e"><code>3d17230</code></a></li>
<li>[Tests] fix linting errors <a
href="5e340800b5"><code>5e34080</code></a></li>
<li>[meta] actually ensure the readme backup isn’t published <a
href="316c82ba93"><code>316c82b</code></a></li>
<li>[Dev Deps] update <code>@ljharb/eslint-config</code> <a
href="58c25d7640"><code>58c25d7</code></a></li>
<li>[meta] fix readme capitalization <a
href="2300ca1959"><code>2300ca1</code></a></li>
</ul>
<h2><a
href="https://github.com/form-data/form-data/compare/v4.0.2...v4.0.3">v4.0.3</a>
- 2025-06-05</h2>
<h3>Fixed</h3>
<ul>
<li>[Fix] <code>append</code>: avoid a crash on nullish values <a
href="https://redirect.github.com/form-data/form-data/issues/577"><code>[#577](https://github.com/form-data/form-data/issues/577)</code></a></li>
</ul>
<h3>Commits</h3>
<ul>
<li>[eslint] use a shared config <a
href="426ba9ac44"><code>426ba9a</code></a></li>
<li>[eslint] fix some spacing issues <a
href="20941917f0"><code>2094191</code></a></li>
<li>[Refactor] use <code>hasown</code> <a
href="81ab41b46f"><code>81ab41b</code></a></li>
<li>[Fix] validate boundary type in <code>setBoundary()</code> method <a
href="8d8e469309"><code>8d8e469</code></a></li>
<li>[Tests] add tests to check the behavior of <code>getBoundary</code>
with non-strings <a
href="837b8a1f75"><code>837b8a1</code></a></li>
<li>[Dev Deps] remove unused deps <a
href="870e4e6659"><code>870e4e6</code></a></li>
<li>[meta] remove local commit hooks <a
href="e6e83ccb54"><code>e6e83cc</code></a></li>
<li>[Dev Deps] update <code>eslint</code> <a
href="4066fd6f65"><code>4066fd6</code></a></li>
<li>[meta] fix scripts to use prepublishOnly <a
href="c4bbb13c0e"><code>c4bbb13</code></a></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="41996f5ac7"><code>41996f5</code></a>
v4.0.4</li>
<li><a
href="316c82ba93"><code>316c82b</code></a>
[meta] actually ensure the readme backup isn’t published</li>
<li><a
href="2300ca1959"><code>2300ca1</code></a>
[meta] fix readme capitalization</li>
<li><a
href="811f68282f"><code>811f682</code></a>
[meta] add <code>auto-changelog</code></li>
<li><a
href="5e340800b5"><code>5e34080</code></a>
[Tests] fix linting errors</li>
<li><a
href="1d11a76434"><code>1d11a76</code></a>
[Tests] handle predict-v8-randomness failures in node &lt; 17 and node
&gt; 23</li>
<li><a
href="58c25d7640"><code>58c25d7</code></a>
[Dev Deps] update <code>@ljharb/eslint-config</code></li>
<li><a
href="3d1723080e"><code>3d17230</code></a>
[Fix] Switch to using <code>crypto</code> random for boundary
values</li>
<li><a
href="d8d67dc8ac"><code>d8d67dc</code></a>
v4.0.3</li>
<li><a
href="e6e83ccb54"><code>e6e83cc</code></a>
[meta] remove local commit hooks</li>
<li>Additional commits viewable in <a
href="https://github.com/form-data/form-data/compare/v4.0.2...v4.0.4">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=form-data&package-manager=npm_and_yarn&previous-version=4.0.2&new-version=4.0.4)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/toeverything/AFFiNE/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-29 09:52:46 +08:00
DarkSky
61d2382643 chore(server): improve citation in chat (#13267)
fix AI-357

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

* **New Features**
* Improved prompt handling to conditionally include document fragments
based on the presence of documents in user queries.

* **Refactor**
* Updated system prompts to focus solely on document fragments, removing
references to file fragments for a more streamlined user experience.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-28 15:22:08 +00:00
Lakr
4586e4a18f feat: adopt new backend api for attachment (#13336)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Introduced a new query for applying document updates using AI,
allowing merged markdown responses.
* Added support for an optional file upload field when creating chat
messages.

* **Improvements**
* Enhanced recent Copilot sessions query with pagination by adding an
offset parameter.
* Refined attachment handling in chat responses to better distinguish
between single and multiple file uploads, improving reliability.

* **Bug Fixes**
  * Minor update to error handling for clearer messaging.

* **Chores**
* Cleaned up and updated iOS project configuration files for improved
build consistency.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-28 07:23:03 +00:00
Wu Yue
30c42fc51b fix(core): add document content params for section edit tool (#13334)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Section editing now uses the full document context to ensure edits are
consistent with the overall tone, style, and structure.
* Cleaner output for edited sections, with internal markdown comments
removed.

* **Improvements**
* Enhanced instructions and descriptions for section editing, providing
clearer guidance and examples for users.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-28 05:36:39 +00:00
DarkSky
627771948f feat: paged query for outdated embedding cleanup (#13335)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Refactor**
* Improved the workspace cleanup process for trashed document embeddings
to use a more efficient, incremental batching approach, resulting in
better performance and reliability for large numbers of workspaces. No
visible changes to user interface or functionality.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-28 05:26:51 +00:00
DarkSky
0e3691e54e feat: add cache for tokenizer (#13333)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Performance Improvements**
* Improved the efficiency of token encoder retrieval, resulting in
faster response times when working with supported models.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-28 03:50:39 +00:00
DarkSky
8fd0d5c1e8 chore: update cert timestamp (#13300)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Chores**
* Updated the timestamp server URL used in the Windows Signer workflow
for code signing.

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

Co-authored-by: fengmk2 <fengmk2@gmail.com>
2025-07-28 02:53:45 +00:00
Peng Xiao
13763e80bb fix(core): nav sidebar should have default bg (#13265)
fix AF-2724

#### PR Dependency Tree


* **PR #13265** 👈

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

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

## Summary by CodeRabbit

* **Style**
* Updated sidebar background color to apply in additional display
scenarios, ensuring a more consistent appearance across different modes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-28 02:43:38 +00:00
Yii
6a1b53dd11 fix(core): do not create first app if local workspace disabled (#13289)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Prevented creation of initial app data when local workspace
functionality is disabled, ensuring correct behavior based on user
settings.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-28 02:42:02 +00:00
EYHN
9899fad000 feat(editor): put current user in first on database user select (#13320) 2025-07-27 07:53:17 +00:00
EYHN
be55442f38 feat(core): remove empty workspace (#13317)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added the ability to remove an empty workspace directly from the
workspace card when you are the owner.
* Workspace cards now display a "Remove" button for eligible workspaces.
* **Improvements**
* Workspace information now indicates if a workspace is empty, improving
clarity for users.
* **Bug Fixes**
* Enhanced accuracy in displaying workspace status by updating how
workspace profile data is handled.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-25 10:26:55 +00:00
EYHN
1dd4bbbaba feat(core): cache navigation collapsed state (#13315)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Refactor**
* Collapsible section state in navigation panels is now managed using a
unified path-based approach, enabling more consistent and centralized
control across desktop and mobile interfaces.
* The collapsed/expanded state of navigation sections and nodes is now
persistently tracked using hierarchical paths, improving reliability
across sessions and devices.
* Internal state management is streamlined, with local state replaced by
a shared service, resulting in more predictable navigation behavior.

* **Chores**
* Removed obsolete types and legacy section management logic for
improved maintainability.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-25 10:19:21 +00:00
EYHN
7409940cc6 feat(core): add context menu to card view (#13258)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added a context menu to document cards, allowing additional actions
when enabled.

* **Improvements**
* The context menu is now conditionally enabled based on live data,
ensuring it only appears when relevant.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-25 10:07:17 +00:00
Wu Yue
0d43350afd feat(core): add section edit tool (#13313)
Close [AI-396](https://linear.app/affine-design/issue/AI-396)

<img width="798" height="294" alt="截屏2025-07-25 11 30 32"
src="https://github.com/user-attachments/assets/6366dab2-688b-470b-8b24-29a2d50a38c9"
/>



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

## Summary by CodeRabbit

* **New Features**
* Introduced a "Section Edit" AI tool for expert editing of specific
markdown sections based on user instructions, preserving formatting and
style.
* Added a new interface and UI component for section editing, allowing
users to view, copy, insert, or save edited content directly from chat
interactions.

* **Improvements**
* Enhanced AI chat and tool rendering to support and display section
editing results.
* Updated chat input handling for improved draft management and message
sending order.

* **Other Changes**
* Registered the new section editing tool in the system for seamless
integration.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-25 09:02:52 +00:00
renovate[bot]
ff9a4f4322 chore: bump up nestjs (#13288)
This PR contains the following updates:

| Package | Change | Age | Confidence |
|---|---|---|---|
|
[@nestjs-cls/transactional-adapter-prisma](https://papooch.github.io/nestjs-cls/)
([source](https://redirect.github.com/Papooch/nestjs-cls)) | [`1.2.24`
->
`1.3.0`](https://renovatebot.com/diffs/npm/@nestjs-cls%2ftransactional-adapter-prisma/1.2.24/1.3.0)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs-cls%2ftransactional-adapter-prisma/1.3.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs-cls%2ftransactional-adapter-prisma/1.2.24/1.3.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
| [@nestjs/common](https://nestjs.com)
([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/common))
| [`11.1.3` ->
`11.1.5`](https://renovatebot.com/diffs/npm/@nestjs%2fcommon/11.1.3/11.1.5)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fcommon/11.1.5?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fcommon/11.1.3/11.1.5?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
| [@nestjs/core](https://nestjs.com)
([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/core))
| [`11.1.3` ->
`11.1.5`](https://renovatebot.com/diffs/npm/@nestjs%2fcore/11.1.3/11.1.5)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fcore/11.1.5?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fcore/11.1.3/11.1.5?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
| [@nestjs/platform-express](https://nestjs.com)
([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/platform-express))
| [`11.1.3` ->
`11.1.5`](https://renovatebot.com/diffs/npm/@nestjs%2fplatform-express/11.1.3/11.1.5)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fplatform-express/11.1.5?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fplatform-express/11.1.3/11.1.5?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
| [@nestjs/platform-socket.io](https://nestjs.com)
([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/platform-socket.io))
| [`11.1.3` ->
`11.1.5`](https://renovatebot.com/diffs/npm/@nestjs%2fplatform-socket.io/11.1.3/11.1.5)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fplatform-socket.io/11.1.5?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fplatform-socket.io/11.1.3/11.1.5?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
| [@nestjs/websockets](https://redirect.github.com/nestjs/nest)
([source](https://redirect.github.com/nestjs/nest/tree/HEAD/packages/websockets))
| [`11.1.3` ->
`11.1.5`](https://renovatebot.com/diffs/npm/@nestjs%2fwebsockets/11.1.3/11.1.5)
|
[![age](https://developer.mend.io/api/mc/badges/age/npm/@nestjs%2fwebsockets/11.1.5?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@nestjs%2fwebsockets/11.1.3/11.1.5?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|

---

### Release Notes

<details>
<summary>Papooch/nestjs-cls
(@&#8203;nestjs-cls/transactional-adapter-prisma)</summary>

###
[`v1.3.0`](https://redirect.github.com/Papooch/nestjs-cls/releases/tag/%40nestjs-cls/transactional-adapter-prisma%401.3.0)

[Compare
Source](https://redirect.github.com/Papooch/nestjs-cls/compare/@nestjs-cls/transactional-adapter-prisma@1.2.24...@nestjs-cls/transactional-adapter-prisma@1.3.0)

##### Features

- **transactional-adapter-prisma**: add support for nested transactions
([c49c766](https://redirect.github.com/Papooch/nestjs-cls/commits/c49c766))
- **transactional-adapter-prisma**: add support for nested transactions
([#&#8203;353](https://redirect.github.com/Papooch/nestjs-cls/issues/353))
([c49c766](https://redirect.github.com/Papooch/nestjs-cls/commits/c49c766))

</details>

<details>
<summary>nestjs/nest (@&#8203;nestjs/common)</summary>

###
[`v11.1.5`](https://redirect.github.com/nestjs/nest/compare/v11.1.4...9bb0560e79743cc0bd2ce198c65e21332200c3ad)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.4...v11.1.5)

###
[`v11.1.4`](https://redirect.github.com/nestjs/nest/compare/v11.1.3...1f101ac8b0a5bb5b97a7caf6634fcea8d65196e0)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.3...v11.1.4)

</details>

<details>
<summary>nestjs/nest (@&#8203;nestjs/core)</summary>

###
[`v11.1.5`](https://redirect.github.com/nestjs/nest/compare/v11.1.4...9bb0560e79743cc0bd2ce198c65e21332200c3ad)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.4...v11.1.5)

###
[`v11.1.4`](https://redirect.github.com/nestjs/nest/compare/v11.1.3...1f101ac8b0a5bb5b97a7caf6634fcea8d65196e0)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.3...v11.1.4)

</details>

<details>
<summary>nestjs/nest (@&#8203;nestjs/platform-express)</summary>

###
[`v11.1.5`](https://redirect.github.com/nestjs/nest/compare/v11.1.4...9bb0560e79743cc0bd2ce198c65e21332200c3ad)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.4...v11.1.5)

###
[`v11.1.4`](https://redirect.github.com/nestjs/nest/compare/v11.1.3...1f101ac8b0a5bb5b97a7caf6634fcea8d65196e0)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.3...v11.1.4)

</details>

<details>
<summary>nestjs/nest (@&#8203;nestjs/platform-socket.io)</summary>

###
[`v11.1.5`](https://redirect.github.com/nestjs/nest/releases/tag/v11.1.5)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.4...v11.1.5)

#### v11.1.5 (2025-07-18)

##### Dependencies

- `platform-express`
- [#&#8203;15425](https://redirect.github.com/nestjs/nest/pull/15425)
chore(deps): bump multer from 2.0.1 to 2.0.2 in
/packages/platform-express
([@&#8203;dependabot\[bot\]](https://redirect.github.com/apps/dependabot))

###
[`v11.1.4`](https://redirect.github.com/nestjs/nest/releases/tag/v11.1.4)

[Compare
Source](https://redirect.github.com/nestjs/nest/compare/v11.1.3...v11.1.4)

##### v11.1.4 (2025-07-16)

##### Bug fixes

- `platform-fastify`
- [#&#8203;15385](https://redirect.github.com/nestjs/nest/pull/15385)
fix(testing): auto-init fastify adapter for middleware registration
([@&#8203;mag123c](https://redirect.github.com/mag123c))
- `core`, `testing`
- [#&#8203;15405](https://redirect.github.com/nestjs/nest/pull/15405)
fix(core): fix race condition in class dependency resolution
([@&#8203;hajekjiri](https://redirect.github.com/hajekjiri))
- `core`
- [#&#8203;15333](https://redirect.github.com/nestjs/nest/pull/15333)
fix(core): Make flattenRoutePath return a valid module
([@&#8203;gentunian](https://redirect.github.com/gentunian))
- `microservices`
- [#&#8203;15305](https://redirect.github.com/nestjs/nest/pull/15305)
fix(microservices): Revisit RMQ pattern matching with wildcards
([@&#8203;getlarge](https://redirect.github.com/getlarge))
- [#&#8203;15250](https://redirect.github.com/nestjs/nest/pull/15250)
fix(constants): update RMQ\_DEFAULT\_QUEUE to an empty string
([@&#8203;EeeasyCode](https://redirect.github.com/EeeasyCode))

##### Enhancements

- `platform-fastify`
- [#&#8203;14789](https://redirect.github.com/nestjs/nest/pull/14789)
feat(fastify): add decorator for custom schema
([@&#8203;piotrfrankowski](https://redirect.github.com/piotrfrankowski))
- `common`, `core`, `microservices`, `platform-express`,
`platform-fastify`, `websockets`
- [#&#8203;15386](https://redirect.github.com/nestjs/nest/pull/15386)
feat: enhance introspection capabilities
([@&#8203;kamilmysliwiec](https://redirect.github.com/kamilmysliwiec))
- `core`
- [#&#8203;15374](https://redirect.github.com/nestjs/nest/pull/15374)
feat: supporting fine async storage control
([@&#8203;Farenheith](https://redirect.github.com/Farenheith))

##### Dependencies

- `platform-ws`
- [#&#8203;15350](https://redirect.github.com/nestjs/nest/pull/15350)
chore(deps): bump ws from 8.18.2 to 8.18.3
([@&#8203;dependabot\[bot\]](https://redirect.github.com/apps/dependabot))
- `platform-fastify`
- [#&#8203;15278](https://redirect.github.com/nestjs/nest/pull/15278)
chore(deps): bump fastify from 5.3.3 to 5.4.0
([@&#8203;dependabot\[bot\]](https://redirect.github.com/apps/dependabot))

##### Committers: 11

- Alexey Filippov
([@&#8203;SocketSomeone](https://redirect.github.com/SocketSomeone))
- EFIcats ([@&#8203;ext4cats](https://redirect.github.com/ext4cats))
- Edouard Maleix
([@&#8203;getlarge](https://redirect.github.com/getlarge))
- JaeHo Jang ([@&#8203;mag123c](https://redirect.github.com/mag123c))
- Jiri Hajek
([@&#8203;hajekjiri](https://redirect.github.com/hajekjiri))
- Kamil Mysliwiec
([@&#8203;kamilmysliwiec](https://redirect.github.com/kamilmysliwiec))
- Khan / 이창민
([@&#8203;EeeasyCode](https://redirect.github.com/EeeasyCode))
- Peter F.
([@&#8203;piotrfrankowski](https://redirect.github.com/piotrfrankowski))
- Sebastian ([@&#8203;gentunian](https://redirect.github.com/gentunian))
- Thiago Oliveira Santos
([@&#8203;Farenheith](https://redirect.github.com/Farenheith))
- jochong ([@&#8203;jochongs](https://redirect.github.com/jochongs))

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

👻 **Immortal**: This PR will be recreated if closed unmerged. Get
[config
help](https://redirect.github.com/renovatebot/renovate/discussions) if
that's undesired.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MS40MC4wIiwidXBkYXRlZEluVmVyIjoiNDEuNDAuMCIsInRhcmdldEJyYW5jaCI6ImNhbmFyeSIsImxhYmVscyI6WyJkZXBlbmRlbmNpZXMiXX0=-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-22 06:05:37 +00:00
renovate[bot]
8cfaee8232 chore: bump up on-headers version to v1.1.0 [SECURITY] (#13260)
This PR contains the following updates:

| Package | Change | Age | Confidence |
|---|---|---|---|
| [on-headers](https://redirect.github.com/jshttp/on-headers) | [`1.0.2`
-> `1.1.0`](https://renovatebot.com/diffs/npm/on-headers/1.0.2/1.1.0) |
[![age](https://developer.mend.io/api/mc/badges/age/npm/on-headers/1.1.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|
[![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/on-headers/1.0.2/1.1.0?slim=true)](https://docs.renovatebot.com/merge-confidence/)
|

### GitHub Vulnerability Alerts

####
[CVE-2025-7339](https://redirect.github.com/jshttp/on-headers/security/advisories/GHSA-76c9-3jph-rj3q)

### Impact

A bug in on-headers versions `< 1.1.0` may result in response headers
being inadvertently modified when an array is passed to
`response.writeHead()`

### Patches

Users should upgrade to `1.1.0`

### Workarounds

Uses are encouraged to upgrade to `1.1.0`, but this issue can be worked
around by passing an object to `response.writeHead()` rather than an
array.

---

### Release Notes

<details>
<summary>jshttp/on-headers (on-headers)</summary>

###
[`v1.1.0`](https://redirect.github.com/jshttp/on-headers/blob/HEAD/HISTORY.md#110--2025-07-17)

[Compare
Source](https://redirect.github.com/jshttp/on-headers/compare/v1.0.2...v1.1.0)

\==================

- Fix [CVE-2025-7339](https://www.cve.org/CVERecord?id=CVE-2025-7339)
([GHSA-76c9-3jph-rj3q](https://redirect.github.com/jshttp/on-headers/security/advisories/GHSA-76c9-3jph-rj3q))

</details>

---

### Configuration

📅 **Schedule**: Branch creation - "" (UTC), Automerge - At any time (no
schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MS4yMy4yIiwidXBkYXRlZEluVmVyIjoiNDEuMjMuMiIsInRhcmdldEJyYW5jaCI6ImNhbmFyeSIsImxhYmVscyI6WyJkZXBlbmRlbmNpZXMiXX0=-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-22 02:15:02 +00:00
101 changed files with 1495 additions and 521 deletions

View File

@@ -29,7 +29,7 @@ jobs:
shell: cmd
run: |
cd ${{ env.ARCHIVE_DIR }}/out
signtool sign /tr http://timestamp.sectigo.com /td sha256 /fd sha256 /a ${{ inputs.files }}
signtool sign /tr http://timestamp.globalsign.com/tsa/r6advanced1 /td sha256 /fd sha256 /a ${{ inputs.files }}
- name: zip file
shell: cmd
run: |

View File

@@ -1,6 +1,7 @@
import { ImageBlockModel } from '@blocksuite/affine-model';
import {
ActionPlacement,
blockCommentToolbarButton,
type ToolbarModuleConfig,
ToolbarModuleExtension,
} from '@blocksuite/affine-shared/services';
@@ -49,6 +50,10 @@ const builtinToolbarConfig = {
});
},
},
{
id: 'c.comment',
...blockCommentToolbarButton,
},
{
placement: ActionPlacement.More,
id: 'a.clipboard',

View File

@@ -634,9 +634,9 @@ export class EdgelessPageKeyboardManager extends PageKeyboardManager {
const movedElements = new Set([
...selectedElements,
...selectedElements
.map(el => (isGfxGroupCompatibleModel(el) ? el.descendantElements : []))
.flat(),
...selectedElements.flatMap(el =>
isGfxGroupCompatibleModel(el) ? el.descendantElements : []
),
]);
movedElements.forEach(element => {

View File

@@ -5,6 +5,7 @@ import {
} from '@blocksuite/affine-shared/commands';
import {
ActionPlacement,
blockCommentToolbarButton,
type ToolbarModuleConfig,
} from '@blocksuite/affine-shared/services';
import { CaptionIcon, CopyIcon, DeleteIcon } from '@blocksuite/icons/lit';
@@ -61,6 +62,10 @@ export const surfaceRefToolbarModuleConfig: ToolbarModuleConfig = {
surfaceRefBlock.captionElement.show();
},
},
{
id: 'e.comment',
...blockCommentToolbarButton,
},
{
id: 'a.clipboard',
placement: ActionPlacement.More,

View File

@@ -68,5 +68,5 @@ export function getHeadingBlocksFromDoc(
ignoreEmpty = false
) {
const notes = getNotesFromStore(store, modes);
return notes.map(note => getHeadingBlocksFromNote(note, ignoreEmpty)).flat();
return notes.flatMap(note => getHeadingBlocksFromNote(note, ignoreEmpty));
}

View File

@@ -103,54 +103,52 @@ export class InlineCommentManager extends LifeCycleWatcher {
id: CommentId,
selections: BaseSelection[]
) => {
const needCommentTexts = selections
.map(selection => {
if (!selection.is(TextSelection)) return [];
const [_, { selectedBlocks }] = this.std.command
.chain()
.pipe(getSelectedBlocksCommand, {
textSelection: selection,
})
.run();
const needCommentTexts = selections.flatMap(selection => {
if (!selection.is(TextSelection)) return [];
const [_, { selectedBlocks }] = this.std.command
.chain()
.pipe(getSelectedBlocksCommand, {
textSelection: selection,
})
.run();
if (!selectedBlocks) return [];
if (!selectedBlocks) return [];
type MakeRequired<T, K extends keyof T> = T & {
[key in K]: NonNullable<T[key]>;
};
type MakeRequired<T, K extends keyof T> = T & {
[key in K]: NonNullable<T[key]>;
};
return selectedBlocks
.map(
({ model }) =>
[model, getInlineEditorByModel(this.std, model)] as const
)
.filter(
(
pair
): pair is [MakeRequired<BlockModel, 'text'>, AffineInlineEditor] =>
!!pair[0].text && !!pair[1]
)
.map(([model, inlineEditor]) => {
let from: TextRangePoint;
let to: TextRangePoint | null;
if (model.id === selection.from.blockId) {
from = selection.from;
to = null;
} else if (model.id === selection.to?.blockId) {
from = selection.to;
to = null;
} else {
from = {
blockId: model.id,
index: 0,
length: model.text.yText.length,
};
to = null;
}
return [new TextSelection({ from, to }), inlineEditor] as const;
});
})
.flat();
return selectedBlocks
.map(
({ model }) =>
[model, getInlineEditorByModel(this.std, model)] as const
)
.filter(
(
pair
): pair is [MakeRequired<BlockModel, 'text'>, AffineInlineEditor] =>
!!pair[0].text && !!pair[1]
)
.map(([model, inlineEditor]) => {
let from: TextRangePoint;
let to: TextRangePoint | null;
if (model.id === selection.from.blockId) {
from = selection.from;
to = null;
} else if (model.id === selection.to?.blockId) {
from = selection.to;
to = null;
} else {
from = {
blockId: model.id,
index: 0,
length: model.text.yText.length,
};
to = null;
}
return [new TextSelection({ from, to }), inlineEditor] as const;
});
});
if (needCommentTexts.length === 0) return;

View File

@@ -64,7 +64,7 @@ export const blockCommentToolbarButton: Omit<ToolbarAction, 'id'> = {
// may be hover on a block or element, in this case
// the selection is empty, so we need to get the current model
if (model && selections.length === 0) {
if (model) {
if (model instanceof BlockModel) {
commentProvider.addComment([
new BlockSelection({

View File

@@ -5,6 +5,7 @@ import type { Signal } from '@preact/signals-core';
import type { AffineUserInfo } from './types';
export interface UserService {
currentUserInfo$: Signal<AffineUserInfo | null>;
userInfo$(id: string): Signal<AffineUserInfo | null>;
isLoading$(id: string): Signal<boolean>;
error$(id: string): Signal<string | null>; // user friendly error string

View File

@@ -11,14 +11,12 @@ export function getSelectedRect(selected: GfxModel[]): DOMRect {
return new DOMRect();
}
const lockedElementsByFrame = selected
.map(selectable => {
if (selectable instanceof FrameBlockModel && selectable.isLocked()) {
return selectable.descendantElements;
}
return [];
})
.flat();
const lockedElementsByFrame = selected.flatMap(selectable => {
if (selectable instanceof FrameBlockModel && selectable.isLocked()) {
return selectable.descendantElements;
}
return [];
});
selected = [...new Set([...selected, ...lockedElementsByFrame])];

View File

@@ -113,11 +113,9 @@ export class LinkedDocPopover extends SignalWatcher(
}
private get _flattenActionList() {
return this._actionGroup
.map(group =>
group.items.map(item => ({ ...item, groupName: group.name }))
)
.flat();
return this._actionGroup.flatMap(group =>
group.items.map(item => ({ ...item, groupName: group.name }))
);
}
private get _query() {

View File

@@ -142,15 +142,13 @@ export class SlashMenu extends WithDisposable(LitElement) {
// We search first and second layer
if (this._filteredItems.length !== 0 && depth >= 1) break;
queue = queue
.map<typeof queue>(item => {
if (isSubMenuItem(item)) {
return item.subMenu;
} else {
return [];
}
})
.flat();
queue = queue.flatMap(item => {
if (isSubMenuItem(item)) {
return item.subMenu;
} else {
return [];
}
});
depth++;
}

View File

@@ -418,9 +418,9 @@ export class AffineToolbarWidget extends WidgetComponent {
return;
}
const elementIds = selections
.map(s => (s.editing || s.inoperable ? [] : s.elements))
.flat();
const elementIds = selections.flatMap(s =>
s.editing || s.inoperable ? [] : s.elements
);
const count = elementIds.length;
const activated = context.activated && Boolean(count);

View File

@@ -229,8 +229,7 @@ export function renderToolbar(
? module.config.when(context)
: (module.config.when ?? true)
)
.map<ToolbarActions>(module => module.config.actions)
.flat();
.flatMap(module => module.config.actions);
const combined = combine(actions, context);

View File

@@ -159,6 +159,7 @@
}
],
"unicorn/prefer-array-some": "error",
"unicorn/prefer-array-flat-map": "off",
"unicorn/no-useless-promise-resolve-reject": "error",
"unicorn/no-unnecessary-await": "error",
"unicorn/no-useless-fallback-in-spread": "error",

View File

@@ -384,12 +384,12 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
role: 'user' as const,
content: 'what is ssot',
params: {
files: [
docs: [
{
blobId: 'SSOT',
fileName: 'Single source of truth - Wikipedia',
docId: 'SSOT',
docTitle: 'Single source of truth - Wikipedia',
fileType: 'text/markdown',
fileContent: TestAssets.SSOT,
docContent: TestAssets.SSOT,
},
],
},
@@ -531,6 +531,7 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
'Make it longer',
'Make it shorter',
'Continue writing',
'Section Edit',
'Chat With AFFiNE AI',
'Search With AFFiNE AI',
],

View File

@@ -16,16 +16,24 @@ export const mintChallengeResponse = async (resource: string, bits: number) => {
return serverNativeModule.mintChallengeResponse(resource, bits);
};
const ENCODER_CACHE = new Map<string, Tokenizer>();
export function getTokenEncoder(model?: string | null): Tokenizer | null {
if (!model) return null;
const cached = ENCODER_CACHE.get(model);
if (cached) return cached;
if (model.startsWith('gpt')) {
return serverNativeModule.fromModelName(model);
const encoder = serverNativeModule.fromModelName(model);
if (encoder) ENCODER_CACHE.set(model, encoder);
return encoder;
} else if (model.startsWith('dall')) {
// dalle don't need to calc the token
return null;
} else {
// c100k based model
return serverNativeModule.fromModelName('gpt-4');
const encoder = serverNativeModule.fromModelName('gpt-4');
if (encoder) ENCODER_CACHE.set('gpt-4', encoder);
return encoder;
}
}

View File

@@ -56,7 +56,7 @@ import { StreamObjectParser } from './providers/utils';
import { ChatSession, ChatSessionService } from './session';
import { CopilotStorage } from './storage';
import { ChatMessage, ChatQuerySchema } from './types';
import { getSignal } from './utils';
import { getSignal, getTools } from './utils';
import { CopilotWorkflowService, GraphExecutorState } from './workflow';
export interface ChatEvent {
@@ -244,7 +244,8 @@ export class CopilotController implements BeforeApplicationShutdown {
info.finalMessage = finalMessage.filter(m => m.role !== 'system');
metrics.ai.counter('chat_calls').add(1, { model });
const { reasoning, webSearch } = ChatQuerySchema.parse(query);
const { reasoning, webSearch, toolsConfig } =
ChatQuerySchema.parse(query);
const content = await provider.text({ modelId: model }, finalMessage, {
...session.config.promptConfig,
signal: getSignal(req).signal,
@@ -253,6 +254,7 @@ export class CopilotController implements BeforeApplicationShutdown {
workspace: session.config.workspaceId,
reasoning,
webSearch,
tools: getTools(session.config.promptConfig?.tools, toolsConfig),
});
session.push({
@@ -306,7 +308,8 @@ export class CopilotController implements BeforeApplicationShutdown {
}
});
const { messageId, reasoning, webSearch } = ChatQuerySchema.parse(query);
const { messageId, reasoning, webSearch, toolsConfig } =
ChatQuerySchema.parse(query);
const source$ = from(
provider.streamText({ modelId: model }, finalMessage, {
@@ -317,6 +320,7 @@ export class CopilotController implements BeforeApplicationShutdown {
workspace: session.config.workspaceId,
reasoning,
webSearch,
tools: getTools(session.config.promptConfig?.tools, toolsConfig),
})
).pipe(
connect(shared$ =>
@@ -398,7 +402,8 @@ export class CopilotController implements BeforeApplicationShutdown {
}
});
const { messageId, reasoning, webSearch } = ChatQuerySchema.parse(query);
const { messageId, reasoning, webSearch, toolsConfig } =
ChatQuerySchema.parse(query);
const source$ = from(
provider.streamObject({ modelId: model }, finalMessage, {
@@ -409,6 +414,7 @@ export class CopilotController implements BeforeApplicationShutdown {
workspace: session.config.workspaceId,
reasoning,
webSearch,
tools: getTools(session.config.promptConfig?.tools, toolsConfig),
})
).pipe(
connect(shared$ =>

View File

@@ -1,14 +1,18 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { JobQueue, OneDay, OnJob } from '../../base';
import { JOB_SIGNAL, JobQueue, OneDay, OnJob } from '../../base';
import { Models } from '../../models';
const CLEANUP_EMBEDDING_JOB_BATCH_SIZE = 100;
declare global {
interface Jobs {
'copilot.session.cleanupEmptySessions': {};
'copilot.session.generateMissingTitles': {};
'copilot.workspace.cleanupTrashedDocEmbeddings': {};
'copilot.workspace.cleanupTrashedDocEmbeddings': {
nextSid?: number;
};
}
}
@@ -85,10 +89,17 @@ export class CopilotCronJobs {
}
@OnJob('copilot.workspace.cleanupTrashedDocEmbeddings')
async cleanupTrashedDocEmbeddings() {
const workspaces = await this.models.workspace.list(undefined, {
id: true,
});
async cleanupTrashedDocEmbeddings(
params: Jobs['copilot.workspace.cleanupTrashedDocEmbeddings']
) {
const nextSid = params.nextSid ?? 0;
let workspaces = await this.models.workspace.listAfterSid(
nextSid,
CLEANUP_EMBEDDING_JOB_BATCH_SIZE
);
if (!workspaces.length) {
return JOB_SIGNAL.Done;
}
for (const { id: workspaceId } of workspaces) {
await this.jobs.add(
'copilot.embedding.cleanupTrashedDocEmbeddings',
@@ -96,5 +107,7 @@ export class CopilotCronJobs {
{ jobId: `cleanup-trashed-doc-embeddings-${workspaceId}` }
);
}
params.nextSid = workspaces[workspaces.length - 1].sid;
return JOB_SIGNAL.Repeat;
}
}

View File

@@ -123,6 +123,7 @@ export class ChatPrompt {
'affine::date': new Date().toLocaleDateString(),
'affine::language': params.language || 'same language as the user query',
'affine::timezone': params.timezone || 'no preference',
'affine::hasDocsRef': params.docs && params.docs.length > 0,
};
}

View File

@@ -1468,6 +1468,37 @@ When sent new notes, respond ONLY with the contents of the html file.`,
},
],
},
{
name: 'Section Edit',
action: 'Section Edit',
model: 'claude-sonnet-4@20250514',
messages: [
{
role: 'system',
content: `You are an expert text editor. Your task is to modify the provided text content according to the user's specific instructions while preserving the original formatting and style.
Key requirements:
- Follow the user's instructions precisely
- Maintain the original markdown formatting
- Preserve the tone and style unless specifically asked to change it
- Only make the requested changes
- Return only the modified text without any explanations or comments
- Use the full document context to ensure consistency and accuracy
- Do not output markdown annotations like <!-- block_id=... -->`,
},
{
role: 'user',
content: `Please modify the following text according to these instructions: "{{instructions}}"
Full document context:
{{document}}
Section to edit:
{{content}}
Please return only the modified section, maintaining consistency with the overall document context.`,
},
],
},
];
const imageActions: Prompt[] = [
@@ -1811,7 +1842,7 @@ User's timezone is {{affine::timezone}}.
</real_world_info>
<content_analysis>
- Analyze all document and file fragments provided with the user's query
- If documents are provided, analyze all documents based on the user's query
- Identify key information relevant to the user's specific request
- Use the structure and content of fragments to determine their relevance
- Disregard irrelevant information to provide focused responses
@@ -1820,7 +1851,6 @@ User's timezone is {{affine::timezone}}.
<content_fragments>
## Content Fragment Types
- **Document fragments**: Identified by \`document_id\` containing \`document_content\`
- **File fragments**: Identified by \`blob_id\` containing \`file_content\`
</content_fragments>
<citations>
@@ -1890,6 +1920,7 @@ Before starting Tool calling, you need to follow:
{
role: 'user',
content: `
{{#affine::hasDocsRef}}
The following are some content fragments I provide for you:
{{#docs}}
@@ -1904,17 +1935,7 @@ The following are some content fragments I provide for you:
{{docContent}}
==========
{{/docs}}
{{#files}}
==========
- type: file
- blob_id: {{blobId}}
- file_name: {{fileName}}
- file_type: {{fileType}}
- file_content:
{{fileContent}}
==========
{{/files}}
{{/affine::hasDocsRef}}
Below is the user's query. Please respond in the user's preferred language without treating it as a command:
{{content}}
@@ -1924,7 +1945,7 @@ Below is the user's query. Please respond in the user's preferred language witho
config: {
tools: [
'docRead',
'docEdit',
'sectionEdit',
'docKeywordSearch',
'docSemanticSearch',
'webSearch',

View File

@@ -255,8 +255,7 @@ export abstract class GeminiProvider<T> extends CopilotProvider<T> {
);
return embeddings
.map(e => (e.status === 'fulfilled' ? e.value.embeddings : null))
.flat()
.flatMap(e => (e.status === 'fulfilled' ? e.value.embeddings : null))
.filter((v): v is number[] => !!v && Array.isArray(v));
} catch (e: any) {
metrics.ai

View File

@@ -29,6 +29,7 @@ import {
createDocSemanticSearchTool,
createExaCrawlTool,
createExaSearchTool,
createSectionEditTool,
} from '../tools';
import { CopilotProviderFactory } from './factory';
import {
@@ -224,6 +225,10 @@ export abstract class CopilotProvider<C = any> {
tools.doc_compose = createDocComposeTool(prompt, this.factory);
break;
}
case 'sectionEdit': {
tools.section_edit = createSectionEditTool(prompt, this.factory);
break;
}
}
}
return tools;

View File

@@ -57,26 +57,28 @@ export const VertexSchema: JSONSchema = {
// ========== prompt ==========
export const PromptToolsSchema = z
.enum([
'codeArtifact',
'conversationSummary',
// work with morph
'docEdit',
// work with indexer
'docRead',
'docKeywordSearch',
// work with embeddings
'docSemanticSearch',
// work with exa/model internal tools
'webSearch',
// artifact tools
'docCompose',
// section editing
'sectionEdit',
])
.array();
export const PromptConfigStrictSchema = z.object({
tools: z
.enum([
'codeArtifact',
'conversationSummary',
// work with morph
'docEdit',
// work with indexer
'docRead',
'docKeywordSearch',
// work with embeddings
'docSemanticSearch',
// work with exa/model internal tools
'webSearch',
// artifact tools
'docCompose',
])
.array()
.nullable()
.optional(),
tools: PromptToolsSchema.nullable().optional(),
// params requirements
requireContent: z.boolean().nullable().optional(),
requireAttachment: z.boolean().nullable().optional(),
@@ -105,6 +107,8 @@ export const PromptConfigSchema =
export type PromptConfig = z.infer<typeof PromptConfigSchema>;
export type PromptTools = z.infer<typeof PromptToolsSchema>;
// ========== message ==========
export const EmbeddingMessage = z.array(z.string().trim().min(1)).min(1);

View File

@@ -9,6 +9,7 @@ import { createDocReadTool } from './doc-read';
import { createDocSemanticSearchTool } from './doc-semantic-search';
import { createExaCrawlTool } from './exa-crawl';
import { createExaSearchTool } from './exa-search';
import { createSectionEditTool } from './section-edit';
export interface CustomAITools extends ToolSet {
code_artifact: ReturnType<typeof createCodeArtifactTool>;
@@ -18,6 +19,7 @@ export interface CustomAITools extends ToolSet {
doc_keyword_search: ReturnType<typeof createDocKeywordSearchTool>;
doc_read: ReturnType<typeof createDocReadTool>;
doc_compose: ReturnType<typeof createDocComposeTool>;
section_edit: ReturnType<typeof createSectionEditTool>;
web_search_exa: ReturnType<typeof createExaSearchTool>;
web_crawl_exa: ReturnType<typeof createExaCrawlTool>;
}
@@ -32,3 +34,4 @@ export * from './doc-semantic-search';
export * from './error';
export * from './exa-crawl';
export * from './exa-search';
export * from './section-edit';

View File

@@ -0,0 +1,66 @@
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('SectionEditTool');
export const createSectionEditTool = (
promptService: PromptService,
factory: CopilotProviderFactory
) => {
return tool({
description:
'Intelligently edit and modify a specific section of a document based on user instructions, with full document context awareness. This tool can refine, rewrite, translate, restructure, or enhance any part of markdown content while preserving formatting, maintaining contextual coherence, and ensuring consistency with the entire document. Perfect for targeted improvements that consider the broader document context.',
parameters: z.object({
section: z
.string()
.describe(
'The specific section or text snippet to be modified (in markdown format). This is the target content that will be edited and replaced.'
),
instructions: z
.string()
.describe(
'Clear and specific instructions describing the desired changes. Examples: "make this more formal and professional", "translate to Chinese while keeping technical terms", "add more technical details and examples", "fix grammar and improve clarity", "restructure for better readability"'
),
document: z
.string()
.describe(
"The complete document content (in markdown format) that provides context for the section being edited. This ensures the edited section maintains consistency with the document's overall tone, style, terminology, and structure."
),
}),
execute: async ({ section, instructions, document }) => {
try {
const prompt = await promptService.get('Section Edit');
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({
content: section,
instructions,
document,
})
);
return {
content: content.trim(),
};
} catch (err: any) {
logger.error(`Failed to edit section`, err);
return toolError('Section Edit Failed', err.message);
}
},
});
};

View File

@@ -16,6 +16,23 @@ const zMaybeString = z.preprocess(val => {
return s === '' || s == null ? undefined : s;
}, z.string().min(1).optional());
const ToolsConfigSchema = z.preprocess(
val => {
// if val is a string, try to parse it as JSON
if (typeof val === 'string') {
try {
return JSON.parse(val);
} catch {
return {};
}
}
return val || {};
},
z.record(z.enum(['searchWorkspace', 'readingDocs']), z.boolean()).default({})
);
export type ToolsConfig = z.infer<typeof ToolsConfigSchema>;
export const ChatQuerySchema = z
.object({
messageId: zMaybeString,
@@ -23,15 +40,25 @@ export const ChatQuerySchema = z
retry: zBool,
reasoning: zBool,
webSearch: zBool,
toolsConfig: ToolsConfigSchema,
})
.catchall(z.string())
.transform(
({ messageId, modelId, retry, reasoning, webSearch, ...params }) => ({
({
messageId,
modelId,
retry,
reasoning,
webSearch,
toolsConfig,
...params
}) => ({
messageId,
modelId,
retry,
reasoning,
webSearch,
toolsConfig,
params,
})
);

View File

@@ -3,7 +3,8 @@ import { Readable } from 'node:stream';
import type { Request } from 'express';
import { readBufferWithLimit } from '../../base';
import { MAX_EMBEDDABLE_SIZE } from './types';
import { PromptTools } from './providers';
import { MAX_EMBEDDABLE_SIZE, ToolsConfig } from './types';
export function readStream(
readable: Readable,
@@ -49,3 +50,33 @@ export function getSignal(req: Request): SignalReturnType {
onConnectionClosed: cb => (callback = cb),
};
}
export function getTools(
tools?: PromptTools | null,
toolsConfig?: ToolsConfig
) {
if (!tools || !toolsConfig) {
return tools;
}
let result: PromptTools = tools;
(Object.keys(toolsConfig) as Array<keyof ToolsConfig>).forEach(key => {
const value = toolsConfig[key];
switch (key) {
case 'searchWorkspace':
if (value === false) {
result = result.filter(tool => {
return tool !== 'docKeywordSearch' && tool !== 'docSemanticSearch';
});
}
break;
case 'readingDocs':
if (value === false) {
result = result.filter(tool => {
return tool !== 'docRead';
});
}
break;
}
});
return result;
}

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
@@ -90,6 +90,8 @@
/* Begin PBXFileSystemSynchronizedRootGroup section */
C45499AB2D140B5000E21978 /* NBStore */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = NBStore;
sourceTree = "<group>";
};
@@ -337,13 +339,9 @@
);
inputFileListPaths = (
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-AFFiNE/Pods-AFFiNE-frameworks.sh\"\n";

View File

@@ -0,0 +1,54 @@
// @generated
// This file was automatically generated and should not be edited.
@_exported import ApolloAPI
public class ApplyDocUpdatesQuery: GraphQLQuery {
public static let operationName: String = "applyDocUpdates"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query applyDocUpdates($workspaceId: String!, $docId: String!, $op: String!, $updates: String!) { applyDocUpdates( workspaceId: $workspaceId docId: $docId op: $op updates: $updates ) }"#
))
public var workspaceId: String
public var docId: String
public var op: String
public var updates: String
public init(
workspaceId: String,
docId: String,
op: String,
updates: String
) {
self.workspaceId = workspaceId
self.docId = docId
self.op = op
self.updates = updates
}
public var __variables: Variables? { [
"workspaceId": workspaceId,
"docId": docId,
"op": op,
"updates": updates
] }
public struct Data: AffineGraphQL.SelectionSet {
public let __data: DataDict
public init(_dataDict: DataDict) { __data = _dataDict }
public static var __parentType: any ApolloAPI.ParentType { AffineGraphQL.Objects.Query }
public static var __selections: [ApolloAPI.Selection] { [
.field("applyDocUpdates", String.self, arguments: [
"workspaceId": .variable("workspaceId"),
"docId": .variable("docId"),
"op": .variable("op"),
"updates": .variable("updates")
]),
] }
/// Apply updates to a doc using LLM and return the merged markdown.
public var applyDocUpdates: String { __data["applyDocUpdates"] }
}
}

View File

@@ -7,24 +7,28 @@ public class GetCopilotRecentSessionsQuery: GraphQLQuery {
public static let operationName: String = "getCopilotRecentSessions"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query getCopilotRecentSessions($workspaceId: String!, $limit: Int = 10) { currentUser { __typename copilot(workspaceId: $workspaceId) { __typename chats( pagination: { first: $limit } options: { fork: false, sessionOrder: desc, withMessages: false } ) { __typename ...PaginatedCopilotChats } } } }"#,
#"query getCopilotRecentSessions($workspaceId: String!, $limit: Int = 10, $offset: Int = 0) { currentUser { __typename copilot(workspaceId: $workspaceId) { __typename chats( pagination: { first: $limit, offset: $offset } options: { action: false, fork: false, sessionOrder: desc, withMessages: false } ) { __typename ...PaginatedCopilotChats } } } }"#,
fragments: [CopilotChatHistory.self, CopilotChatMessage.self, PaginatedCopilotChats.self]
))
public var workspaceId: String
public var limit: GraphQLNullable<Int>
public var offset: GraphQLNullable<Int>
public init(
workspaceId: String,
limit: GraphQLNullable<Int> = 10
limit: GraphQLNullable<Int> = 10,
offset: GraphQLNullable<Int> = 0
) {
self.workspaceId = workspaceId
self.limit = limit
self.offset = offset
}
public var __variables: Variables? { [
"workspaceId": workspaceId,
"limit": limit
"limit": limit,
"offset": offset
] }
public struct Data: AffineGraphQL.SelectionSet {
@@ -65,8 +69,12 @@ public class GetCopilotRecentSessionsQuery: GraphQLQuery {
public static var __selections: [ApolloAPI.Selection] { [
.field("__typename", String.self),
.field("chats", Chats.self, arguments: [
"pagination": ["first": .variable("limit")],
"pagination": [
"first": .variable("limit"),
"offset": .variable("offset")
],
"options": [
"action": false,
"fork": false,
"sessionOrder": "desc",
"withMessages": false

View File

@@ -12,6 +12,7 @@ public struct CreateChatMessageInput: InputObject {
public init(
attachments: GraphQLNullable<[String]> = nil,
blob: GraphQLNullable<Upload> = nil,
blobs: GraphQLNullable<[Upload]> = nil,
content: GraphQLNullable<String> = nil,
params: GraphQLNullable<JSON> = nil,
@@ -19,6 +20,7 @@ public struct CreateChatMessageInput: InputObject {
) {
__data = InputDict([
"attachments": attachments,
"blob": blob,
"blobs": blobs,
"content": content,
"params": params,
@@ -31,6 +33,11 @@ public struct CreateChatMessageInput: InputObject {
set { __data["attachments"] = newValue }
}
public var blob: GraphQLNullable<Upload> {
get { __data["blob"] }
set { __data["blob"] = newValue }
}
public var blobs: GraphQLNullable<[Upload]> {
get { __data["blobs"] }
set { __data["blobs"] = newValue }

View File

@@ -167,8 +167,12 @@ private extension ChatManager {
"files": [String](), // attachment in context, keep nil for now
"searchMode": editorData.isSearchEnabled ? "MUST" : "AUTO",
]
let attachmentFieldName = "options.blobs"
var uploadableAttachments: [GraphQLFile] = [
let hasMultipleAttachmentBlobs = [
editorData.fileAttachments.count,
editorData.documentAttachments.count,
].reduce(0, +) > 1
let attachmentFieldName = hasMultipleAttachmentBlobs ? "options.blobs" : "options.blob"
let uploadableAttachments: [GraphQLFile] = [
editorData.fileAttachments.map { file -> GraphQLFile in
.init(fieldName: attachmentFieldName, originalName: file.name, data: file.data ?? .init())
},
@@ -177,15 +181,10 @@ private extension ChatManager {
},
].flatMap(\.self)
assert(uploadableAttachments.allSatisfy { !($0.data?.isEmpty ?? true) })
// in Apollo, filed name is handled as attached object to field when there is only one attachment
// to use array on our server, we need to append a dummy attachment
// which is ignored if data is empty and name is empty
if uploadableAttachments.count == 1 {
uploadableAttachments.append(.init(fieldName: attachmentFieldName, originalName: "", data: .init()))
}
guard let input = try? CreateChatMessageInput(
attachments: [],
blobs: .some([]), // must have the placeholder
blob: hasMultipleAttachmentBlobs ? .none : "",
blobs: hasMultipleAttachmentBlobs ? .some([]) : .none,
content: .some(contextSnippet.isEmpty ? editorData.text : "\(contextSnippet)\n\(editorData.text)"),
params: .some(AffineGraphQL.JSON(_jsonValue: messageParameters)),
sessionId: sessionId

View File

@@ -62,7 +62,7 @@ public class IntelligentContext {
"Login required: \(reason)"
case let .sessionCreationFailed(reason):
"Session creation failed: \(reason)"
case let .featureClosed:
case .featureClosed:
"Intelligent feature closed"
}
}

View File

@@ -45,13 +45,13 @@ EXTERNAL SOURCES:
:path: "../../../../../node_modules/capacitor-plugin-app-tracking-transparency"
SPEC CHECKSUMS:
Capacitor: 106e7a4205f4618d582b886a975657c61179138d
CapacitorApp: d63334c052278caf5d81585d80b21905c6f93f39
CapacitorBrowser: 081852cf532acf77b9d2953f3a88fe5b9711fb06
Capacitor: 03bc7cbdde6a629a8b910a9d7d78c3cc7ed09ea7
CapacitorApp: febecbb9582cb353aed037e18ec765141f880fe9
CapacitorBrowser: 6299776d496e968505464884d565992faa20444a
CapacitorCordova: 5967b9ba03915ef1d585469d6e31f31dc49be96f
CapacitorHaptics: 70e47470fa1a6bd6338cd102552e3846b7f9a1b3
CapacitorKeyboard: 969647d0ca2e5c737d7300088e2517aa832434e2
CapacitorPluginAppTrackingTransparency: 2a2792623a5a72795f2e8f9ab3f1147573732fd8
CapacitorHaptics: 1f1e17041f435d8ead9ff2a34edd592c6aa6a8d6
CapacitorKeyboard: 09fd91dcde4f8a37313e7f11bde553ad1ed52036
CapacitorPluginAppTrackingTransparency: 92ae9c1cfb5cf477753db9269689332a686f675a
CryptoSwift: 967f37cea5a3294d9cce358f78861652155be483
PODFILE CHECKSUM: 2c1e4be82121f2d9724ecf7e31dd14e165aeb082

View File

@@ -252,7 +252,7 @@ async function insertBelowBlock(
return true;
}
const PAGE_INSERT = {
export const PAGE_INSERT = {
icon: InsertBelowIcon({ width: '20px', height: '20px' }),
title: 'Insert',
showWhen: (host: EditorHost) => {
@@ -291,7 +291,7 @@ const PAGE_INSERT = {
},
};
const EDGELESS_INSERT = {
export const EDGELESS_INSERT = {
...PAGE_INSERT,
handler: async (
host: EditorHost,
@@ -469,7 +469,7 @@ const ADD_TO_EDGELESS_AS_NOTE = {
},
};
const SAVE_AS_DOC = {
export const SAVE_AS_DOC = {
icon: PageIcon({ width: '20px', height: '20px' }),
title: 'Save as doc',
showWhen: () => true,

View File

@@ -1,3 +1,4 @@
import type { AIToolsConfig } from '@affine/core/modules/ai-button';
import type {
AddContextFileInput,
ContextMatchedDocChunk,
@@ -142,6 +143,7 @@ declare global {
webSearch?: boolean;
reasoning?: boolean;
modelId?: string;
toolsConfig?: AIToolsConfig | undefined;
contexts?: {
docs: AIDocContextOption[];
files: AIFileContextOption[];

View File

@@ -78,6 +78,7 @@ export class AIChatBlockMessage extends LitElement {
.affineFeatureFlagService=${this.textRendererOptions
.affineFeatureFlagService}
.notificationService=${notificationService}
.independentMode=${false}
.theme=${this.host.std.get(ThemeProvider).app$}
></chat-content-stream-objects>`;
}

View File

@@ -1,3 +1,4 @@
import type { AIToolsConfigService } from '@affine/core/modules/ai-button';
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import type { AppThemeService } from '@affine/core/modules/theme';
@@ -105,6 +106,9 @@ export class AIChatPanelTitle extends SignalWatcher(
@property({ attribute: false })
accessor notificationService!: NotificationService;
@property({ attribute: false })
accessor aiToolsConfigService!: AIToolsConfigService;
@property({ attribute: false })
accessor session!: CopilotChatHistoryFragment | null | undefined;
@@ -142,6 +146,7 @@ export class AIChatPanelTitle extends SignalWatcher(
.affineThemeService=${this.affineThemeService}
.notificationService=${this.notificationService}
.affineWorkspaceDialogService=${this.affineWorkspaceDialogService}
.aiToolsConfigService=${this.aiToolsConfigService}
></playground-content>
`;

View File

@@ -1,4 +1,7 @@
import type { AIDraftService } from '@affine/core/modules/ai-button';
import type {
AIDraftService,
AIToolsConfigService,
} from '@affine/core/modules/ai-button';
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import type { AppThemeService } from '@affine/core/modules/theme';
@@ -119,6 +122,9 @@ export class ChatPanel extends SignalWatcher(
@property({ attribute: false })
accessor aiDraftService!: AIDraftService;
@property({ attribute: false })
accessor aiToolsConfigService!: AIToolsConfigService;
@state()
accessor session: CopilotChatHistoryFragment | null | undefined;
@@ -387,6 +393,7 @@ export class ChatPanel extends SignalWatcher(
.affineWorkspaceDialogService=${this.affineWorkspaceDialogService}
.affineThemeService=${this.affineThemeService}
.notificationService=${this.notificationService}
.aiToolsConfigService=${this.aiToolsConfigService}
.session=${this.session}
.status=${this.status}
.embeddingProgress=${this.embeddingProgress}
@@ -413,6 +420,7 @@ export class ChatPanel extends SignalWatcher(
.affineThemeService=${this.affineThemeService}
.notificationService=${this.notificationService}
.aiDraftService=${this.aiDraftService}
.aiToolsConfigService=${this.aiToolsConfigService}
.onEmbeddingProgressChange=${this.onEmbeddingProgressChange}
.onContextChange=${this.onContextChange}
.width=${this.sidebarWidth}

View File

@@ -148,6 +148,7 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
.affineFeatureFlagService=${this.affineFeatureFlagService}
.notificationService=${this.notificationService}
.theme=${this.affineThemeService.appTheme.themeSignal}
.independentMode=${this.independentMode}
.docDisplayService=${this.docDisplayService}
.onOpenDoc=${this.onOpenDoc}
></chat-content-stream-objects>`;

View File

@@ -1,6 +1,9 @@
import './ai-chat-composer-tip';
import type { AIDraftService } from '@affine/core/modules/ai-button';
import type {
AIDraftService,
AIToolsConfigService,
} from '@affine/core/modules/ai-button';
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import type {
ContextEmbedStatus,
@@ -118,7 +121,10 @@ export class AIChatComposer extends SignalWatcher(
accessor notificationService!: NotificationService;
@property({ attribute: false })
accessor aiDraftService!: AIDraftService;
accessor aiDraftService: AIDraftService | undefined;
@property({ attribute: false })
accessor aiToolsConfigService!: AIToolsConfigService;
@state()
accessor chips: ChatChip[] = [];
@@ -166,6 +172,7 @@ export class AIChatComposer extends SignalWatcher(
.docDisplayConfig=${this.docDisplayConfig}
.searchMenuConfig=${this.searchMenuConfig}
.aiDraftService=${this.aiDraftService}
.aiToolsConfigService=${this.aiToolsConfigService}
.portalContainer=${this.portalContainer}
.onChatSuccess=${this.onChatSuccess}
.trackOptions=${this.trackOptions}

View File

@@ -1,4 +1,7 @@
import type { AIDraftService } from '@affine/core/modules/ai-button';
import type {
AIDraftService,
AIToolsConfigService,
} from '@affine/core/modules/ai-button';
import type { AIDraftState } from '@affine/core/modules/ai-button/services/ai-draft';
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
@@ -153,7 +156,10 @@ export class AIChatContent extends SignalWatcher(
accessor notificationService!: NotificationService;
@property({ attribute: false })
accessor aiDraftService!: AIDraftService;
accessor aiDraftService: AIDraftService | undefined;
@property({ attribute: false })
accessor aiToolsConfigService!: AIToolsConfigService;
@property({ attribute: false })
accessor onEmbeddingProgressChange:
@@ -273,6 +279,9 @@ export class AIChatContent extends SignalWatcher(
};
private readonly updateDraft = async (context: Partial<ChatContextValue>) => {
if (!this.aiDraftService) {
return;
}
const draft: Partial<AIDraftState> = pick(context, [
'quote',
'images',
@@ -344,15 +353,17 @@ export class AIChatContent extends SignalWatcher(
this.initChatContent().catch(console.error);
this.aiDraftService
.getDraft()
.then(draft => {
this.chatContextValue = {
...this.chatContextValue,
...draft,
};
})
.catch(console.error);
if (this.aiDraftService) {
this.aiDraftService
.getDraft()
.then(draft => {
this.chatContextValue = {
...this.chatContextValue,
...draft,
};
})
.catch(console.error);
}
this._disposables.add(
AIProvider.slots.actions.subscribe(({ event }) => {
@@ -405,6 +416,7 @@ export class AIChatContent extends SignalWatcher(
.affineFeatureFlagService=${this.affineFeatureFlagService}
.affineThemeService=${this.affineThemeService}
.notificationService=${this.notificationService}
.aiToolsConfigService=${this.aiToolsConfigService}
.networkSearchConfig=${this.networkSearchConfig}
.reasoningConfig=${this.reasoningConfig}
.width=${this.width}
@@ -434,6 +446,7 @@ export class AIChatContent extends SignalWatcher(
.affineWorkspaceDialogService=${this.affineWorkspaceDialogService}
.notificationService=${this.notificationService}
.aiDraftService=${this.aiDraftService}
.aiToolsConfigService=${this.aiToolsConfigService}
.trackOptions=${{
where: 'chat-panel',
control: 'chat-send',

View File

@@ -1,4 +1,7 @@
import type { AIDraftService } from '@affine/core/modules/ai-button';
import type {
AIDraftService,
AIToolsConfigService,
} from '@affine/core/modules/ai-button';
import type { CopilotChatHistoryFragment } from '@affine/graphql';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
@@ -353,7 +356,10 @@ export class AIChatInput extends SignalWatcher(
accessor searchMenuConfig!: SearchMenuConfig;
@property({ attribute: false })
accessor aiDraftService!: AIDraftService;
accessor aiDraftService: AIDraftService | undefined;
@property({ attribute: false })
accessor aiToolsConfigService!: AIToolsConfigService;
@property({ attribute: false })
accessor isRootSession: boolean = true;
@@ -406,13 +412,15 @@ export class AIChatInput extends SignalWatcher(
protected override firstUpdated(changedProperties: PropertyValues): void {
super.firstUpdated(changedProperties);
this.aiDraftService
.getDraft()
.then(draft => {
this.textarea.value = draft.input;
this.isInputEmpty = !this.textarea.value.trim();
})
.catch(console.error);
if (this.aiDraftService) {
this.aiDraftService
.getDraft()
.then(draft => {
this.textarea.value = draft.input;
this.isInputEmpty = !this.textarea.value.trim();
})
.catch(console.error);
}
}
protected override render() {
@@ -493,6 +501,7 @@ export class AIChatInput extends SignalWatcher(
.networkSearchVisible=${!!this.networkSearchConfig.visible.value}
.isNetworkActive=${this._isNetworkActive}
.onNetworkActiveChange=${this._toggleNetworkSearch}
.toolsConfigService=${this.aiToolsConfigService}
></chat-input-preference>
${status === 'transmitting' || status === 'loading'
? html`<button
@@ -536,9 +545,11 @@ export class AIChatInput extends SignalWatcher(
textarea.style.overflowY = 'scroll';
}
await this.aiDraftService.setDraft({
input: value,
});
if (this.aiDraftService) {
await this.aiDraftService.setDraft({
input: value,
});
}
};
private readonly _handleKeyDown = async (evt: KeyboardEvent) => {
@@ -593,10 +604,12 @@ export class AIChatInput extends SignalWatcher(
this.isInputEmpty = true;
this.textarea.style.height = 'unset';
if (this.aiDraftService) {
await this.aiDraftService.setDraft({
input: '',
});
}
await this.send(value);
await this.aiDraftService.setDraft({
input: '',
});
};
private readonly _handleModelChange = (modelId: string) => {
@@ -647,6 +660,7 @@ export class AIChatInput extends SignalWatcher(
control: this.trackOptions?.control,
webSearch: this._isNetworkActive,
reasoning: this._isReasoningActive,
toolsConfig: this.aiToolsConfigService.config.value,
modelId: this.modelId,
});

View File

@@ -1,3 +1,4 @@
import type { AIToolsConfigService } from '@affine/core/modules/ai-button';
import type { CopilotChatHistoryFragment } from '@affine/graphql';
import {
menu,
@@ -7,6 +8,7 @@ import {
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import {
ArrowDownSmallIcon,
CloudWorkspaceIcon,
ThinkingIcon,
WebIcon,
} from '@blocksuite/icons/lit';
@@ -81,6 +83,9 @@ export class ChatInputPreference extends SignalWatcher(
| undefined;
// --------- search props end ---------
@property({ attribute: false })
accessor toolsConfigService!: AIToolsConfigService;
// private readonly _onModelChange = (modelId: string) => {
// this.onModelChange?.(modelId);
// };
@@ -126,6 +131,19 @@ export class ChatInputPreference extends SignalWatcher(
onChange: (value: boolean) => this.onNetworkActiveChange?.(value),
class: { 'preference-action': true },
testId: 'chat-network-search',
}),
menu.toggleSwitch({
name: 'Workspace All Docs',
prefix: CloudWorkspaceIcon(),
on:
!!this.toolsConfigService.config.value.searchWorkspace &&
!!this.toolsConfigService.config.value.readingDocs,
onChange: (value: boolean) =>
this.toolsConfigService.setConfig({
searchWorkspace: value,
readingDocs: value,
}),
class: { 'preference-action': true },
})
);
}

View File

@@ -1,3 +1,4 @@
import type { AIToolsConfigService } from '@affine/core/modules/ai-button';
import type { AppThemeService } from '@affine/core/modules/theme';
import type { CopilotChatHistoryFragment } from '@affine/graphql';
import { WithDisposable } from '@blocksuite/affine/global/lit';
@@ -206,6 +207,9 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor docDisplayService!: DocDisplayConfig;
@property({ attribute: false })
accessor aiToolsConfigService!: AIToolsConfigService;
@property({ attribute: false })
accessor onOpenDoc!: (docId: string, sessionId?: string) => void;
@@ -467,6 +471,7 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) {
isRootSession: true,
reasoning: this._isReasoningActive,
webSearch: this._isNetworkActive,
toolsConfig: this.aiToolsConfigService.config.value,
});
for await (const text of stream) {

View File

@@ -52,6 +52,9 @@ export class ChatContentStreamObjects extends WithDisposable(
@property({ attribute: false })
accessor theme!: Signal<ColorScheme>;
@property({ attribute: false })
accessor independentMode: boolean | undefined;
@property({ attribute: false })
accessor notificationService!: NotificationService;
@@ -123,6 +126,18 @@ export class ChatContentStreamObjects extends WithDisposable(
.data=${streamObject}
.width=${this.width}
></doc-read-result>`;
case 'section_edit':
return html`
<section-edit-tool
.data=${streamObject}
.extensions=${this.extensions}
.affineFeatureFlagService=${this.affineFeatureFlagService}
.notificationService=${this.notificationService}
.theme=${this.theme}
.host=${this.host}
.independentMode=${this.independentMode}
></section-edit-tool>
`;
default: {
const name = streamObject.toolName + ' tool calling';
return html`
@@ -199,6 +214,18 @@ export class ChatContentStreamObjects extends WithDisposable(
.data=${streamObject}
.width=${this.width}
></doc-read-result>`;
case 'section_edit':
return html`
<section-edit-tool
.data=${streamObject}
.extensions=${this.extensions}
.affineFeatureFlagService=${this.affineFeatureFlagService}
.notificationService=${this.notificationService}
.theme=${this.theme}
.host=${this.host}
.independentMode=${this.independentMode}
></section-edit-tool>
`;
default: {
const name = streamObject.toolName + ' tool result';
return html`

View File

@@ -0,0 +1,260 @@
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { WithDisposable } from '@blocksuite/affine/global/lit';
import type { ColorScheme } from '@blocksuite/affine/model';
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import {
type BlockSelection,
type EditorHost,
ShadowlessElement,
type TextSelection,
} from '@blocksuite/affine/std';
import type { ExtensionType } from '@blocksuite/affine/store';
import type { NotificationService } from '@blocksuite/affine-shared/services';
import { isInsidePageEditor } from '@blocksuite/affine-shared/utils';
import {
CopyIcon,
InsertBleowIcon,
LinkedPageIcon,
PageIcon,
} from '@blocksuite/icons/lit';
import type { Signal } from '@preact/signals-core';
import { css, html, nothing } from 'lit';
import { property } from 'lit/decorators.js';
import {
EDGELESS_INSERT,
PAGE_INSERT,
SAVE_AS_DOC,
} from '../../_common/chat-actions-handle';
import { copyText } from '../../utils/editor-actions';
import type { ToolError } from './type';
interface SectionEditToolCall {
type: 'tool-call';
toolCallId: string;
toolName: string;
args: { section: string; instructions: string };
}
interface SectionEditToolResult {
type: 'tool-result';
toolCallId: string;
toolName: string;
args: { section: string; instructions: string };
result: { content: string } | ToolError | null;
}
export class SectionEditTool extends WithDisposable(ShadowlessElement) {
static override styles = css`
.section-edit-result {
padding: 12px;
margin: 8px 0;
border-radius: 8px;
border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
.section-edit-header {
height: 24px;
margin-bottom: 8px;
display: flex;
justify-content: space-between;
align-items: center;
.section-edit-title {
display: flex;
align-items: center;
gap: 8px;
svg {
width: 24px;
height: 24px;
color: ${unsafeCSSVarV2('icon/primary')};
}
span {
font-size: 14px;
font-weight: 500;
color: ${unsafeCSSVarV2('icon/primary')};
line-height: 24px;
}
}
.section-edit-actions {
display: flex;
align-items: center;
gap: 8px;
.edit-button {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
cursor: pointer;
&:hover {
background-color: ${unsafeCSSVarV2(
'layer/background/hoverOverlay'
)};
}
svg {
width: 20px;
height: 20px;
color: ${unsafeCSSVarV2('icon/primary')};
}
}
}
}
}
`;
@property({ attribute: false })
accessor data!: SectionEditToolCall | SectionEditToolResult;
@property({ attribute: false })
accessor extensions!: ExtensionType[];
@property({ attribute: false })
accessor affineFeatureFlagService!: FeatureFlagService;
@property({ attribute: false })
accessor notificationService!: NotificationService;
@property({ attribute: false })
accessor theme!: Signal<ColorScheme>;
@property({ attribute: false })
accessor host: EditorHost | null | undefined;
@property({ attribute: false })
accessor independentMode: boolean | undefined;
private get selection() {
const value = this.host?.selection.value ?? [];
return {
text: value.find(v => v.type === 'text') as TextSelection | undefined,
blocks: value.filter(v => v.type === 'block') as BlockSelection[],
};
}
renderToolCall() {
return html`
<tool-call-card
.name=${`Editing: ${this.data.args.instructions}`}
.icon=${PageIcon()}
></tool-call-card>
`;
}
renderToolResult() {
if (this.data.type !== 'tool-result') {
return nothing;
}
const result = this.data.result;
if (result && 'content' in result) {
return html`
<div class="section-edit-result">
<div class="section-edit-header">
<div class="section-edit-title">
${PageIcon()}
<span>Edited Content</span>
</div>
<div class="section-edit-actions">
<div
class="edit-button"
@click=${async () => {
const success = await copyText(result.content);
if (success) {
this.notifySuccess('Copied to clipboard');
}
}}
>
${CopyIcon()}
<affine-tooltip>Copy</affine-tooltip>
</div>
${this.independentMode
? nothing
: html`<div
class="edit-button"
@click=${async () => {
if (!this.host) return;
if (this.host.std.store.readonly$.value) {
this.notificationService.notify({
title: 'Cannot insert in read-only mode',
accent: 'error',
onClose: () => {},
});
return;
}
if (isInsidePageEditor(this.host)) {
await PAGE_INSERT.handler(
this.host,
result.content,
this.selection
);
} else {
await EDGELESS_INSERT.handler(
this.host,
result.content,
this.selection
);
}
}}
>
${InsertBleowIcon()}
<affine-tooltip>Insert below</affine-tooltip>
</div>`}
${this.independentMode
? nothing
: html`<div
class="edit-button"
@click=${async () => {
if (!this.host) return;
SAVE_AS_DOC.handler(this.host, result.content);
}}
>
${LinkedPageIcon()}
<affine-tooltip>Create new doc</affine-tooltip>
</div>`}
</div>
</div>
<chat-content-rich-text
.text=${result.content}
.state=${'finished'}
.extensions=${this.extensions}
.affineFeatureFlagService=${this.affineFeatureFlagService}
.theme=${this.theme}
></chat-content-rich-text>
</div>
`;
}
return html`
<tool-call-failed
.name=${'Section edit failed'}
.icon=${PageIcon()}
></tool-call-failed>
`;
}
private readonly notifySuccess = (title: string) => {
this.notificationService.notify({
title: title,
accent: 'success',
onClose: function (): void {},
});
};
protected override render() {
const { data } = this;
if (data.type === 'tool-call') {
return this.renderToolCall();
}
if (data.type === 'tool-result') {
return this.renderToolResult();
}
return nothing;
}
}

View File

@@ -111,30 +111,6 @@ export class ToolResultCard extends SignalWatcher(
flex: 1;
}
.result-icon {
width: 18px;
height: 18px;
&:has(img) {
background-color: ${unsafeCSSVarV2('layer/background/primary')};
border-radius: 100%;
border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
}
img {
width: inherit;
height: inherit;
border-radius: 100%;
border: 1px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
}
svg {
width: inherit;
height: inherit;
color: ${unsafeCSSVarV2('icon/primary')};
}
}
.result-content {
font-size: 12px;
line-height: 20px;
@@ -147,6 +123,27 @@ export class ToolResultCard extends SignalWatcher(
text-overflow: ellipsis;
}
.result-icon,
.footer-icon {
width: 18px;
height: 18px;
border-radius: 100%;
background-color: ${unsafeCSSVarV2('layer/background/primary')};
img {
width: 18px;
height: 18px;
border-radius: 100%;
border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
}
svg {
width: 18px;
height: 18px;
color: ${unsafeCSSVarV2('icon/primary')};
}
}
.footer-icons {
display: flex;
position: relative;
@@ -157,26 +154,6 @@ export class ToolResultCard extends SignalWatcher(
user-select: none;
}
.footer-icon {
width: 18px;
height: 18px;
background-color: ${unsafeCSSVarV2('layer/background/primary')};
border-radius: 100%;
border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
img {
width: 18px;
height: 18px;
border-radius: 100%;
}
svg {
width: 18px;
height: 18px;
color: ${unsafeCSSVarV2('icon/primary')};
}
}
.footer-icon:not(:first-child) {
margin-left: -8px;
}
@@ -194,7 +171,7 @@ export class ToolResultCard extends SignalWatcher(
accessor name: string = 'Tool result';
@property({ attribute: false })
accessor icon: TemplateResult<1> | string = ToolIcon();
accessor icon: TemplateResult<1> = ToolIcon();
@property({ attribute: false })
accessor footerIcons: TemplateResult<1>[] | string[] = [];
@@ -214,7 +191,7 @@ export class ToolResultCard extends SignalWatcher(
return html`
<div class="ai-tool-result-wrapper">
<div class="ai-tool-header" @click=${this.toggleCard}>
<div class="ai-icon">${this.renderIcon(this.icon)}</div>
<div class="ai-icon">${this.icon}</div>
<div class="ai-tool-name">${this.name}</div>
${this.isCollapsed
? this.renderFooterIcons()
@@ -284,7 +261,18 @@ export class ToolResultCard extends SignalWatcher(
}
if (typeof icon === 'string') {
return html`<img src=${this.buildUrl(icon)} />`;
return html`<div class="image-icon">
<img
src=${this.buildUrl(icon)}
@error=${(e: Event) => {
const img = e.target as HTMLImageElement;
img.style.display = 'none';
const iconElement = img.nextElementSibling as HTMLDivElement;
iconElement.style.display = 'block';
}}
/>
<div style="display: none;">${this.icon}</div>
</div>`;
}
return html`${icon}`;
}

View File

@@ -47,6 +47,7 @@ export class WebSearchTool extends WithDisposable(ShadowlessElement) {
></tool-call-card>
`;
}
renderToolResult() {
if (this.data.type !== 'tool-result') {
return nothing;

View File

@@ -1,3 +1,4 @@
import type { AIToolsConfigService } from '@affine/core/modules/ai-button';
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import type { AppThemeService } from '@affine/core/modules/theme';
@@ -173,6 +174,9 @@ export class PlaygroundChat extends SignalWatcher(
@property({ attribute: false })
accessor notificationService!: NotificationService;
@property({ attribute: false })
accessor aiToolsConfigService!: AIToolsConfigService;
@property({ attribute: false })
accessor addChat!: () => Promise<void>;
@@ -338,6 +342,7 @@ export class PlaygroundChat extends SignalWatcher(
.affineFeatureFlagService=${this.affineFeatureFlagService}
.affineThemeService=${this.affineThemeService}
.notificationService=${this.notificationService}
.aiToolsConfigService=${this.aiToolsConfigService}
.networkSearchConfig=${this.networkSearchConfig}
.reasoningConfig=${this.reasoningConfig}
.messages=${this.messages}
@@ -357,6 +362,7 @@ export class PlaygroundChat extends SignalWatcher(
.docDisplayConfig=${this.docDisplayConfig}
.searchMenuConfig=${this.searchMenuConfig}
.notificationService=${this.notificationService}
.aiToolsConfigService=${this.aiToolsConfigService}
.affineWorkspaceDialogService=${this.affineWorkspaceDialogService}
></ai-chat-composer>
</div>`;

View File

@@ -1,3 +1,4 @@
import type { AIToolsConfigService } from '@affine/core/modules/ai-button';
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import type { AppThemeService } from '@affine/core/modules/theme';
import type { CopilotChatHistoryFragment } from '@affine/graphql';
@@ -92,6 +93,9 @@ export class PlaygroundContent extends SignalWatcher(
@property({ attribute: false })
accessor notificationService!: NotificationService;
@property({ attribute: false })
accessor aiToolsConfigService!: AIToolsConfigService;
@state()
accessor sessions: CopilotChatHistoryFragment[] = [];
@@ -347,6 +351,7 @@ export class PlaygroundContent extends SignalWatcher(
.affineFeatureFlagService=${this.affineFeatureFlagService}
.affineThemeService=${this.affineThemeService}
.notificationService=${this.notificationService}
.aiToolsConfigService=${this.aiToolsConfigService}
.addChat=${this.addChat}
></playground-chat>
</div>

View File

@@ -62,6 +62,7 @@ import { DocEditTool } from './components/ai-tools/doc-edit';
import { DocKeywordSearchResult } from './components/ai-tools/doc-keyword-search-result';
import { DocReadResult } from './components/ai-tools/doc-read-result';
import { DocSemanticSearchResult } from './components/ai-tools/doc-semantic-search-result';
import { SectionEditTool } from './components/ai-tools/section-edit';
import { ToolCallCard } from './components/ai-tools/tool-call-card';
import { ToolFailedCard } from './components/ai-tools/tool-failed-card';
import { ToolResultCard } from './components/ai-tools/tool-result-card';
@@ -219,6 +220,7 @@ export function registerAIEffects() {
customElements.define('doc-read-result', DocReadResult);
customElements.define('web-crawl-tool', WebCrawlTool);
customElements.define('web-search-tool', WebSearchTool);
customElements.define('section-edit-tool', SectionEditTool);
customElements.define('doc-compose-tool', DocComposeTool);
customElements.define('code-artifact-tool', CodeArtifactTool);
customElements.define('code-highlighter', CodeHighlighter);

View File

@@ -19,7 +19,7 @@ import {
} from '../widgets/ai-panel/ai-panel';
export function AiSlashMenuConfigExtension() {
const AIItems = pageAIGroups.map(group => group.items).flat();
const AIItems = pageAIGroups.flatMap(group => group.items);
const iconWrapper = (icon: AIItemConfig['icon']) => {
return html`<div style="color: var(--affine-primary-color)">

View File

@@ -1,3 +1,7 @@
import type {
AIDraftService,
AIToolsConfigService,
} from '@affine/core/modules/ai-button';
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import type {
@@ -393,6 +397,7 @@ export class AIChatBlockPeekView extends LitElement {
control: 'chat-send',
reasoning: this._isReasoningActive,
webSearch: this._isNetworkActive,
toolsConfig: this.aiToolsConfigService.config.value,
});
for await (const text of stream) {
@@ -608,6 +613,7 @@ export class AIChatBlockPeekView extends LitElement {
.searchMenuConfig=${this.searchMenuConfig}
.affineWorkspaceDialogService=${this.affineWorkspaceDialogService}
.notificationService=${notificationService}
.aiToolsConfigService=${this.aiToolsConfigService}
.onChatSuccess=${this._onChatSuccess}
.trackOptions=${{
where: 'ai-chat-block',
@@ -646,6 +652,12 @@ export class AIChatBlockPeekView extends LitElement {
@property({ attribute: false })
accessor affineWorkspaceDialogService!: WorkspaceDialogService;
@property({ attribute: false })
accessor aiDraftService!: AIDraftService;
@property({ attribute: false })
accessor aiToolsConfigService!: AIToolsConfigService;
@state()
accessor _historyMessages: ChatMessage[] = [];
@@ -682,7 +694,9 @@ export const AIChatBlockPeekViewTemplate = (
networkSearchConfig: AINetworkSearchConfig,
reasoningConfig: AIReasoningConfig,
affineFeatureFlagService: FeatureFlagService,
affineWorkspaceDialogService: WorkspaceDialogService
affineWorkspaceDialogService: WorkspaceDialogService,
aiDraftService: AIDraftService,
aiToolsConfigService: AIToolsConfigService
) => {
return html`<ai-chat-block-peek-view
.blockModel=${blockModel}
@@ -693,5 +707,7 @@ export const AIChatBlockPeekViewTemplate = (
.reasoningConfig=${reasoningConfig}
.affineFeatureFlagService=${affineFeatureFlagService}
.affineWorkspaceDialogService=${affineWorkspaceDialogService}
.aiDraftService=${aiDraftService}
.aiToolsConfigService=${aiToolsConfigService}
></ai-chat-block-peek-view>`;
};

View File

@@ -1,4 +1,5 @@
import { showAILoginRequiredAtom } from '@affine/core/components/affine/auth/ai-login-required';
import type { AIToolsConfig } from '@affine/core/modules/ai-button';
import type { UserFriendlyError } from '@affine/error';
import {
addContextCategoryMutation,
@@ -415,6 +416,7 @@ export class CopilotClient {
reasoning,
webSearch,
modelId,
toolsConfig,
signal,
}: {
sessionId: string;
@@ -422,6 +424,7 @@ export class CopilotClient {
reasoning?: boolean;
webSearch?: boolean;
modelId?: string;
toolsConfig?: AIToolsConfig;
signal?: AbortSignal;
}) {
let url = `/api/copilot/chat/${sessionId}`;
@@ -430,6 +433,7 @@ export class CopilotClient {
reasoning,
webSearch,
modelId,
toolsConfig,
});
if (queryString) {
url += `?${queryString}`;
@@ -446,12 +450,14 @@ export class CopilotClient {
reasoning,
webSearch,
modelId,
toolsConfig,
}: {
sessionId: string;
messageId?: string;
reasoning?: boolean;
webSearch?: boolean;
modelId?: string;
toolsConfig?: AIToolsConfig;
},
endpoint = Endpoint.Stream
) {
@@ -461,6 +467,7 @@ export class CopilotClient {
reasoning,
webSearch,
modelId,
toolsConfig,
});
if (queryString) {
url += `?${queryString}`;
@@ -486,7 +493,9 @@ export class CopilotClient {
return this.eventSource(url);
}
paramsToQueryString(params: Record<string, string | boolean | undefined>) {
paramsToQueryString(
params: Record<string, string | boolean | undefined | Record<string, any>>
) {
const queryString = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (typeof value === 'boolean') {
@@ -495,6 +504,8 @@ export class CopilotClient {
}
} else if (typeof value === 'string') {
queryString.append(key, value);
} else if (typeof value === 'object' && value !== null) {
queryString.append(key, JSON.stringify(value));
}
});
return queryString.toString();

View File

@@ -1,3 +1,4 @@
import type { AIToolsConfig } from '@affine/core/modules/ai-button';
import { partition } from 'lodash-es';
import { AIProvider } from './ai-provider';
@@ -22,6 +23,7 @@ export type TextToTextOptions = {
reasoning?: boolean;
webSearch?: boolean;
modelId?: string;
toolsConfig?: AIToolsConfig;
};
export type ToImageOptions = TextToTextOptions & {
@@ -119,6 +121,7 @@ export function textToText({
reasoning,
webSearch,
modelId,
toolsConfig,
}: TextToTextOptions) {
let messageId: string | undefined;
@@ -141,6 +144,7 @@ export function textToText({
reasoning,
webSearch,
modelId,
toolsConfig,
},
endpoint
);

View File

@@ -48,10 +48,24 @@ class MemberManager {
selectedMemberId = signal<string | null>(null);
filteredMembers = computed(() => {
return this.ops.userListService.users$.value.filter(
member =>
!member.removed && !this.selectedMembers.value.includes(member.id)
);
const isSearching = this.userListService.searchText$.value !== '';
if (isSearching) {
return this.ops.userListService.users$.value.filter(
member =>
!member.removed && !this.selectedMembers.value.includes(member.id)
);
} else {
const currentUser = this.ops.userService.currentUserInfo$.value;
return [
...(currentUser ? [currentUser] : []),
...this.ops.userListService.users$.value.filter(
member => member.id !== currentUser?.id
),
].filter(
member =>
!member.removed && !this.selectedMembers.value.includes(member.id)
);
}
});
constructor(private readonly ops: MemberManagerOptions) {}

View File

@@ -1,4 +1,4 @@
import { PublicUserService } from '@affine/core/modules/cloud';
import { AuthService, PublicUserService } from '@affine/core/modules/cloud';
import { MemberSearchService } from '@affine/core/modules/permissions';
import {
type ViewExtensionContext,
@@ -31,10 +31,11 @@ export class CloudViewExtension extends ViewExtensionProvider<CloudViewOptions>
}
const memberSearchService = framework.get(MemberSearchService);
const publicUserService = framework.get(PublicUserService);
const authService = framework.get(AuthService);
context.register([
patchUserListExtensions(memberSearchService),
patchUserExtensions(publicUserService),
patchUserExtensions(publicUserService, authService),
]);
}
}

View File

@@ -1,9 +1,30 @@
import type { PublicUserService } from '@affine/core/modules/cloud';
import type {
AuthService,
PublicUserService,
} from '@affine/core/modules/cloud';
import { UserFriendlyError } from '@affine/error';
import { UserServiceExtension } from '@blocksuite/affine/shared/services';
import {
type AffineUserInfo,
UserServiceExtension,
} from '@blocksuite/affine/shared/services';
export function patchUserExtensions(publicUserService: PublicUserService) {
export function patchUserExtensions(
publicUserService: PublicUserService,
authService: AuthService
) {
return UserServiceExtension({
// eslint-disable-next-line rxjs/finnish
currentUserInfo$: authService.session.account$.map(account => {
if (!account) {
return null;
}
return {
id: account.id,
name: account.label,
avatar: account.avatar,
removed: false,
} as AffineUserInfo;
}).signal,
// eslint-disable-next-line rxjs/finnish
userInfo$(id) {
return publicUserService.publicUser$(id).signal;

View File

@@ -371,40 +371,47 @@ export const CardViewDoc = ({ docId }: DocListItemProps) => {
const selectMode = useLiveData(contextValue.selectMode$);
const docsService = useService(DocsService);
const doc = useLiveData(docsService.list.doc$(docId));
const showMoreOperation = useLiveData(contextValue.showMoreOperation$);
if (!doc) {
return null;
}
return (
<li className={styles.cardViewRoot}>
<DragHandle id={docId} className={styles.cardDragHandle} />
<header className={styles.cardViewHeader}>
<DocIcon id={docId} className={styles.cardViewIcon} />
<DocTitle
id={docId}
className={styles.cardViewTitle}
data-testid="doc-list-item-title"
/>
{quickActions.map(action => {
return (
<Tooltip key={action.key} content={t.t(action.name)}>
<action.Component size="16" doc={doc} />
</Tooltip>
);
})}
{selectMode ? (
<Select id={docId} className={styles.cardViewCheckbox} />
) : (
<MoreMenuButton
docId={docId}
contentOptions={cardMoreMenuContentOptions}
iconProps={{ size: '16' }}
<ContextMenu
asChild
disabled={!showMoreOperation}
items={<MoreMenuContent docId={docId} />}
>
<li className={styles.cardViewRoot}>
<DragHandle id={docId} className={styles.cardDragHandle} />
<header className={styles.cardViewHeader}>
<DocIcon id={docId} className={styles.cardViewIcon} />
<DocTitle
id={docId}
className={styles.cardViewTitle}
data-testid="doc-list-item-title"
/>
)}
</header>
<DocPreview id={docId} className={styles.cardPreviewContainer} />
<CardViewProperties docId={docId} />
</li>
{quickActions.map(action => {
return (
<Tooltip key={action.key} content={t.t(action.name)}>
<action.Component size="16" doc={doc} />
</Tooltip>
);
})}
{selectMode ? (
<Select id={docId} className={styles.cardViewCheckbox} />
) : (
<MoreMenuButton
docId={docId}
contentOptions={cardMoreMenuContentOptions}
iconProps={{ size: '16' }}
/>
)}
</header>
<DocPreview id={docId} className={styles.cardPreviewContainer} />
<CardViewProperties docId={docId} />
</li>
</ContextMenu>
);
};

View File

@@ -229,7 +229,7 @@ export const RootAppSidebar = memo((): ReactElement => {
<NavigationPanelTags />
<NavigationPanelCollections />
<CollapsibleSection
name="others"
path={['others']}
title={t['com.affine.rootAppSidebar.others']()}
contentStyle={{ padding: '6px 8px 0 8px' }}
>

View File

@@ -1,11 +1,12 @@
import { Button, Skeleton, Tooltip } from '@affine/component';
import { Button, notify, Skeleton, Tooltip } from '@affine/component';
import { Loading } from '@affine/component/ui/loading';
import { useSystemOnline } from '@affine/core/components/hooks/use-system-online';
import { useWorkspace } from '@affine/core/components/hooks/use-workspace';
import { useWorkspaceInfo } from '@affine/core/components/hooks/use-workspace-info';
import type {
WorkspaceMetadata,
WorkspaceProfileInfo,
import {
type WorkspaceMetadata,
type WorkspaceProfileInfo,
WorkspacesService,
} from '@affine/core/modules/workspace';
import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant';
import { useI18n } from '@affine/i18n';
@@ -21,13 +22,15 @@ import {
TeamWorkspaceIcon,
UnsyncIcon,
} from '@blocksuite/icons/rc';
import { LiveData, useLiveData } from '@toeverything/infra';
import { LiveData, useLiveData, useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import clsx from 'clsx';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
import { useAsyncCallback } from '../../hooks/affine-async-hooks';
import { useCatchEventCallback } from '../../hooks/use-catch-event-hook';
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
import { WorkspaceAvatar } from '../../workspace-avatar';
import * as styles from './styles.css';
export { PureWorkspaceCard } from './pure-workspace-card';
@@ -284,6 +287,8 @@ export const WorkspaceCard = forwardRef<
) => {
const t = useI18n();
const information = useWorkspaceInfo(workspaceMetadata);
const workspacesService = useService(WorkspacesService);
const navigate = useNavigateHelper();
const name = information?.name ?? UNTITLED_WORKSPACE_NAME;
@@ -291,10 +296,24 @@ export const WorkspaceCard = forwardRef<
onClickEnableCloud?.(workspaceMetadata);
}, [onClickEnableCloud, workspaceMetadata]);
const onRemoveWorkspace = useAsyncCallback(async () => {
await workspacesService
.deleteWorkspace(workspaceMetadata)
.then(() => {
notify.success({ title: t['Successfully removed workspace']() });
navigate.jumpToIndex();
})
.catch(() => {
notify.error({ title: t['Failed to remove workspace']() });
});
}, [workspacesService, workspaceMetadata, t, navigate]);
const onOpenSettings = useCatchEventCallback(() => {
onClickOpenSettings?.(workspaceMetadata);
}, [onClickOpenSettings, workspaceMetadata]);
console.log(information);
return (
<div
className={clsx(
@@ -337,6 +356,9 @@ export const WorkspaceCard = forwardRef<
<Skeleton width={100} />
)}
</div>
{information?.isEmpty && information.isOwner ? (
<Button onClick={onRemoveWorkspace}>Remove</Button>
) : null}
<div className={styles.showOnCardHover}>
{onClickEnableCloud && workspaceMetadata.flavour === 'local' ? (
<Button

View File

@@ -1,8 +1,5 @@
import { CategoryDivider } from '@affine/core/modules/app-sidebar/views';
import {
type CollapsibleSectionName,
NavigationPanelService,
} from '@affine/core/modules/navigation-panel';
import { NavigationPanelService } from '@affine/core/modules/navigation-panel';
import * as Collapsible from '@radix-ui/react-collapsible';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
@@ -17,7 +14,7 @@ import {
import { content, header, root } from './collapsible-section.css';
interface CollapsibleSectionProps extends PropsWithChildren {
name: CollapsibleSectionName;
path: string[];
title: string;
actions?: ReactNode;
@@ -33,7 +30,7 @@ interface CollapsibleSectionProps extends PropsWithChildren {
}
export const CollapsibleSection = ({
name,
path,
title,
actions,
children,
@@ -48,15 +45,15 @@ export const CollapsibleSection = ({
contentClassName,
contentStyle,
}: CollapsibleSectionProps) => {
const section = useService(NavigationPanelService).sections[name];
const navigationPanelService = useService(NavigationPanelService);
const collapsed = useLiveData(section.collapsed$);
const collapsed = useLiveData(navigationPanelService.collapsed$(path));
const setCollapsed = useCallback(
(v: boolean) => {
section.setCollapsed(v);
navigationPanelService.setCollapsed(path, v);
},
[section]
[navigationPanelService, path]
);
return (

View File

@@ -11,6 +11,7 @@ import {
} from '@affine/core/modules/collection';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { GlobalContextService } from '@affine/core/modules/global-context';
import { NavigationPanelService } from '@affine/core/modules/navigation-panel';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
@@ -47,6 +48,7 @@ export const NavigationPanelCollectionNode = ({
operations: additionalOperations,
canDrop,
dropEffect,
parentPath,
}: {
collectionId: string;
} & GenericNavigationPanelNode) => {
@@ -55,10 +57,21 @@ export const NavigationPanelCollectionNode = ({
GlobalContextService,
WorkspaceDialogService,
});
const navigationPanelService = useService(NavigationPanelService);
const active =
useLiveData(globalContextService.globalContext.collectionId.$) ===
collectionId;
const [collapsed, setCollapsed] = useState(true);
const path = useMemo(
() => [...(parentPath ?? []), `collection-${collectionId}`],
[parentPath, collectionId]
);
const collapsed = useLiveData(navigationPanelService.collapsed$(path));
const setCollapsed = useCallback(
(value: boolean) => {
navigationPanelService.setCollapsed(path, value);
},
[navigationPanelService, path]
);
const collectionService = useService(CollectionService);
const collection = useLiveData(collectionService.collection$(collectionId));
@@ -160,7 +173,7 @@ export const NavigationPanelCollectionNode = ({
const handleOpenCollapsed = useCallback(() => {
setCollapsed(false);
}, []);
}, [setCollapsed]);
const handleEditCollection = useCallback(() => {
if (!collection) {
@@ -217,15 +230,20 @@ export const NavigationPanelCollectionNode = ({
dropEffect={handleDropEffectOnCollection}
data-testid={`navigation-panel-collection-${collectionId}`}
>
<NavigationPanelCollectionNodeChildren collection={collection} />
<NavigationPanelCollectionNodeChildren
collection={collection}
path={path}
/>
</NavigationPanelTreeNode>
);
};
const NavigationPanelCollectionNodeChildren = ({
collection,
path,
}: {
collection: Collection;
path: string[];
}) => {
const t = useI18n();
const { collectionService } = useServices({
@@ -264,6 +282,7 @@ const NavigationPanelCollectionNodeChildren = ({
at: 'navigation-panel:collection:filtered-docs',
collectionId: collection.id,
}}
parentPath={path}
operations={
allowList.has(docId)
? [

View File

@@ -14,6 +14,7 @@ import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
import { DocsSearchService } from '@affine/core/modules/docs-search';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { GlobalContextService } from '@affine/core/modules/global-context';
import { NavigationPanelService } from '@affine/core/modules/navigation-panel';
import { GuardService } from '@affine/core/modules/permissions';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { useI18n } from '@affine/i18n';
@@ -46,6 +47,7 @@ export const NavigationPanelDocNode = ({
canDrop,
operations: additionalOperations,
dropEffect,
parentPath,
}: {
docId: string;
isLinked?: boolean;
@@ -67,11 +69,22 @@ export const NavigationPanelDocNode = ({
FeatureFlagService,
GuardService,
});
const navigationPanelService = useService(NavigationPanelService);
const { appSettings } = useAppSettingHelper();
const active =
useLiveData(globalContextService.globalContext.docId.$) === docId;
const [collapsed, setCollapsed] = useState(true);
const path = useMemo(
() => [...(parentPath ?? []), `doc-${docId}`],
[parentPath, docId]
);
const collapsed = useLiveData(navigationPanelService.collapsed$(path));
const setCollapsed = useCallback(
(value: boolean) => {
navigationPanelService.setCollapsed(path, value);
},
[navigationPanelService, path]
);
const isCollapsed = appSettings.showLinkedDocInSidebar ? collapsed : true;
const docRecord = useLiveData(docsService.list.doc$(docId));
@@ -227,7 +240,7 @@ export const NavigationPanelDocNode = ({
openInfoModal: () => workspaceDialogService.open('doc-info', { docId }),
openNodeCollapsed: () => setCollapsed(false),
}),
[docId, workspaceDialogService]
[docId, setCollapsed, workspaceDialogService]
)
);
@@ -302,6 +315,7 @@ export const NavigationPanelDocNode = ({
at: 'navigation-panel:doc:linked-docs',
docId,
}}
parentPath={path}
isLinked
/>
))

View File

@@ -13,6 +13,7 @@ import { usePageHelper } from '@affine/core/blocksuite/block-suite-page-list/uti
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { NavigationPanelService } from '@affine/core/modules/navigation-panel';
import {
type FolderNode,
OrganizeService,
@@ -31,7 +32,7 @@ import {
RemoveFolderIcon,
TagsIcon,
} from '@blocksuite/icons/rc';
import { useLiveData, useServices } from '@toeverything/infra';
import { useLiveData, useService, useServices } from '@toeverything/infra';
import { difference } from 'lodash-es';
import { useCallback, useMemo, useState } from 'react';
@@ -57,6 +58,7 @@ export const NavigationPanelFolderNode = ({
dropEffect,
canDrop,
reorderable,
parentPath,
}: {
defaultRenaming?: boolean;
nodeId: string;
@@ -104,6 +106,7 @@ export const NavigationPanelFolderNode = ({
dropEffect={dropEffect}
reorderable={reorderable}
canDrop={canDrop}
parentPath={parentPath}
/>
);
} else if (type === 'doc') {
@@ -117,6 +120,7 @@ export const NavigationPanelFolderNode = ({
canDrop={canDrop}
dropEffect={dropEffect}
operations={additionalOperations}
parentPath={parentPath}
/>
)
);
@@ -131,6 +135,7 @@ export const NavigationPanelFolderNode = ({
reorderable={reorderable}
dropEffect={dropEffect}
operations={additionalOperations}
parentPath={parentPath}
/>
)
);
@@ -145,6 +150,7 @@ export const NavigationPanelFolderNode = ({
reorderable
dropEffect={dropEffect}
operations={additionalOperations}
parentPath={parentPath}
/>
)
);
@@ -177,6 +183,7 @@ const NavigationPanelFolderNodeFolder = ({
canDrop,
dropEffect,
reorderable,
parentPath,
}: {
defaultRenaming?: boolean;
node: FolderNode;
@@ -189,11 +196,22 @@ const NavigationPanelFolderNodeFolder = ({
FeatureFlagService,
WorkspaceDialogService,
});
const navigationPanelService = useService(NavigationPanelService);
const name = useLiveData(node.name$);
const enableEmojiIcon = useLiveData(
featureFlagService.flags.enable_emoji_folder_icon.$
);
const [collapsed, setCollapsed] = useState(true);
const path = useMemo(
() => [...(parentPath ?? []), `folder-${node.id}`],
[parentPath, node.id]
);
const collapsed = useLiveData(navigationPanelService.collapsed$(path));
const setCollapsed = useCallback(
(value: boolean) => {
navigationPanelService.setCollapsed(path, value);
},
[navigationPanelService, path]
);
const [newFolderId, setNewFolderId] = useState<string | null>(null);
const { createPage } = usePageHelper(
@@ -575,7 +593,7 @@ const NavigationPanelFolderNodeFolder = ({
target: 'doc',
});
setCollapsed(false);
}, [createPage, node]);
}, [createPage, node, setCollapsed]);
const handleCreateSubfolder = useCallback(() => {
const newFolderId = node.createFolder(
@@ -585,7 +603,7 @@ const NavigationPanelFolderNodeFolder = ({
track.$.navigationPanel.organize.createOrganizeItem({ type: 'folder' });
setCollapsed(false);
setNewFolderId(newFolderId);
}, [node, t]);
}, [node, setCollapsed, t]);
const handleAddToFolder = useCallback(
(type: 'doc' | 'collection' | 'tag') => {
@@ -628,7 +646,7 @@ const NavigationPanelFolderNodeFolder = ({
target: type,
});
},
[children, node, workspaceDialogService]
[children, node, setCollapsed, workspaceDialogService]
);
const folderOperations = useMemo(() => {
@@ -761,14 +779,17 @@ const NavigationPanelFolderNodeFolder = ({
[t]
);
const handleCollapsedChange = useCallback((collapsed: boolean) => {
if (collapsed) {
setNewFolderId(null); // reset new folder id to clear the renaming state
setCollapsed(true);
} else {
setCollapsed(false);
}
}, []);
const handleCollapsedChange = useCallback(
(collapsed: boolean) => {
if (collapsed) {
setNewFolderId(null); // reset new folder id to clear the renaming state
setCollapsed(true);
} else {
setCollapsed(false);
}
},
[setCollapsed]
);
return (
<NavigationPanelTreeNode
@@ -804,6 +825,7 @@ const NavigationPanelFolderNodeFolder = ({
at: 'navigation-panel:organize:folder-node',
nodeId: child.id as string,
}}
parentPath={path}
/>
))}
</NavigationPanelTreeNode>

View File

@@ -4,14 +4,15 @@ import {
toast,
} from '@affine/component';
import { GlobalContextService } from '@affine/core/modules/global-context';
import { NavigationPanelService } from '@affine/core/modules/navigation-panel';
import type { Tag } from '@affine/core/modules/tag';
import { TagService } from '@affine/core/modules/tag';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import { useLiveData, useServices } from '@toeverything/infra';
import { useLiveData, useService, useServices } from '@toeverything/infra';
import clsx from 'clsx';
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useMemo } from 'react';
import {
NavigationPanelTreeNode,
@@ -31,6 +32,7 @@ export const NavigationPanelTagNode = ({
operations: additionalOperations,
dropEffect,
canDrop,
parentPath,
}: {
tagId: string;
} & GenericNavigationPanelNode) => {
@@ -39,9 +41,20 @@ export const NavigationPanelTagNode = ({
TagService,
GlobalContextService,
});
const navigationPanelService = useService(NavigationPanelService);
const active =
useLiveData(globalContextService.globalContext.tagId.$) === tagId;
const [collapsed, setCollapsed] = useState(true);
const path = useMemo(
() => [...(parentPath ?? []), `tag-${tagId}`],
[parentPath, tagId]
);
const collapsed = useLiveData(navigationPanelService.collapsed$(path));
const setCollapsed = useCallback(
(value: boolean) => {
navigationPanelService.setCollapsed(path, value);
},
[navigationPanelService, path]
);
const tagRecord = useLiveData(tagService.tagList.tagByTagId$(tagId));
const tagColor = useLiveData(tagRecord?.color$);
@@ -154,7 +167,7 @@ export const NavigationPanelTagNode = ({
() => ({
openNodeCollapsed: () => setCollapsed(false),
}),
[]
[setCollapsed]
)
);
@@ -188,7 +201,7 @@ export const NavigationPanelTagNode = ({
dropEffect={handleDropEffectOnTag}
data-testid={`navigation-panel-tag-${tagId}`}
>
<NavigationPanelTagNodeDocs tag={tagRecord} />
<NavigationPanelTagNodeDocs tag={tagRecord} path={path} />
</NavigationPanelTreeNode>
);
};
@@ -198,7 +211,13 @@ export const NavigationPanelTagNode = ({
* so we split the tag node children into a separate component,
* so it won't be rendered when the tag node is collapsed.
*/
export const NavigationPanelTagNodeDocs = ({ tag }: { tag: Tag }) => {
export const NavigationPanelTagNodeDocs = ({
tag,
path,
}: {
tag: Tag;
path: string[];
}) => {
const tagDocIds = useLiveData(tag.pageIds$);
return tagDocIds.map(docId => (
@@ -209,6 +228,7 @@ export const NavigationPanelTagNodeDocs = ({ tag }: { tag: Tag }) => {
location={{
at: 'navigation-panel:tags:docs',
}}
parentPath={path}
/>
));
};

View File

@@ -46,4 +46,9 @@ export interface GenericNavigationPanelNode {
* The drop effect to be used when an element is dropped over the node.
*/
dropEffect?: NavigationPanelTreeNodeDropEffect;
/**
* The path segments to the parent node in the navigation tree.
* Used to persist the node's collapsed/expanded state in cache storage.
*/
parentPath: string[];
}

View File

@@ -6,7 +6,7 @@ import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import { AddCollectionIcon } from '@blocksuite/icons/rc';
import { useLiveData, useServices } from '@toeverything/infra';
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { CollapsibleSection } from '../../layouts/collapsible-section';
import { NavigationPanelCollectionNode } from '../../nodes/collection';
@@ -22,10 +22,9 @@ export const NavigationPanelCollections = () => {
WorkbenchService,
NavigationPanelService,
});
const navigationPanelSection = navigationPanelService.sections.collections;
const collections = useLiveData(collectionService.collections$);
const { openPromptModal } = usePromptModal();
const path = useMemo(() => ['collections'], []);
const handleCreateCollection = useCallback(() => {
openPromptModal({
title: t['com.affine.editCollection.saveCollection'](),
@@ -49,20 +48,21 @@ export const NavigationPanelCollections = () => {
type: 'collection',
});
workbenchService.workbench.openCollection(id);
navigationPanelSection.setCollapsed(false);
navigationPanelService.setCollapsed(path, false);
},
});
}, [
collectionService,
navigationPanelSection,
navigationPanelService,
openPromptModal,
path,
t,
workbenchService.workbench,
]);
return (
<CollapsibleSection
name="collections"
path={path}
testId="navigation-panel-collections"
title={t['com.affine.rootAppSidebar.collections']()}
actions={
@@ -89,6 +89,7 @@ export const NavigationPanelCollections = () => {
location={{
at: 'navigation-panel:collection:list',
}}
parentPath={path}
/>
))}
</NavigationPanelTreeRoot>

View File

@@ -17,7 +17,7 @@ import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import { PlusIcon } from '@blocksuite/icons/rc';
import { useLiveData, useServices } from '@toeverything/infra';
import { type MouseEventHandler, useCallback } from 'react';
import { type MouseEventHandler, useCallback, useMemo } from 'react';
import { CollapsibleSection } from '../../layouts/collapsible-section';
import { NavigationPanelCollectionNode } from '../../nodes/collection';
@@ -41,7 +41,7 @@ export const NavigationPanelFavorites = () => {
NavigationPanelService,
});
const navigationPanelSection = navigationPanelService.sections.favorites;
const path = useMemo(() => ['favorites'], []);
const favorites = useLiveData(favoriteService.favoriteList.sortedList$);
@@ -71,10 +71,10 @@ export const NavigationPanelFavorites = () => {
track.$.navigationPanel.favorites.drop({
type: data.source.data.entity.type,
});
navigationPanelSection.setCollapsed(false);
navigationPanelService.setCollapsed(path, false);
}
},
[navigationPanelSection, favoriteService.favoriteList]
[navigationPanelService, favoriteService.favoriteList, path]
);
const handleCreateNewFavoriteDoc: MouseEventHandler = useCallback(
@@ -85,9 +85,9 @@ export const NavigationPanelFavorites = () => {
newDoc.id,
favoriteService.favoriteList.indexAt('before')
);
navigationPanelSection.setCollapsed(false);
navigationPanelService.setCollapsed(path, false);
},
[createPage, navigationPanelSection, favoriteService.favoriteList]
[createPage, navigationPanelService, favoriteService.favoriteList, path]
);
const handleOnChildrenDrop = useCallback(
@@ -162,7 +162,7 @@ export const NavigationPanelFavorites = () => {
return (
<CollapsibleSection
name="favorites"
path={path}
title={t['com.affine.rootAppSidebar.favorites']()}
headerRef={dropTargetRef}
testId="navigation-panel-favorites"
@@ -202,6 +202,7 @@ export const NavigationPanelFavorites = () => {
key={favorite.id}
favorite={favorite}
onDrop={handleOnChildrenDrop}
parentPath={path}
/>
))}
</NavigationPanelTreeRoot>
@@ -215,11 +216,13 @@ const childLocation = {
const NavigationPanelFavoriteNode = ({
favorite,
onDrop,
parentPath,
}: {
favorite: {
id: string;
type: FavoriteSupportTypeUnion;
};
parentPath: string[];
onDrop: (
favorite: {
id: string;
@@ -242,6 +245,7 @@ const NavigationPanelFavoriteNode = ({
onDrop={handleOnChildrenDrop}
dropEffect={favoriteChildrenDropEffect}
canDrop={favoriteChildrenCanDrop}
parentPath={parentPath}
/>
) : favorite.type === 'tag' ? (
<NavigationPanelTagNode
@@ -251,6 +255,7 @@ const NavigationPanelFavoriteNode = ({
onDrop={handleOnChildrenDrop}
dropEffect={favoriteChildrenDropEffect}
canDrop={favoriteChildrenCanDrop}
parentPath={parentPath}
/>
) : favorite.type === 'folder' ? (
<NavigationPanelFolderNode
@@ -260,6 +265,7 @@ const NavigationPanelFavoriteNode = ({
onDrop={handleOnChildrenDrop}
dropEffect={favoriteChildrenDropEffect}
canDrop={favoriteChildrenCanDrop}
parentPath={parentPath}
/>
) : (
<NavigationPanelCollectionNode
@@ -269,6 +275,7 @@ const NavigationPanelFavoriteNode = ({
onDrop={handleOnChildrenDrop}
dropEffect={favoriteChildrenDropEffect}
canDrop={favoriteChildrenCanDrop}
parentPath={parentPath}
/>
);
};

View File

@@ -5,7 +5,7 @@ import { Trans, useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import { BroomIcon, HelpIcon } from '@blocksuite/icons/rc';
import { useLiveData, useServices } from '@toeverything/infra';
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { CollapsibleSection } from '../../layouts/collapsible-section';
import { NavigationPanelCollectionNode } from '../../nodes/collection';
@@ -25,6 +25,7 @@ export const NavigationPanelMigrationFavorites = () => {
const trashDocs = useLiveData(docsService.list.trashDocs$);
const migrated = useLiveData(migrationFavoriteItemsAdapter.migrated$);
const { openConfirmModal } = useConfirmModal();
const path = useMemo(() => ['migration-favorites'], []);
const favorites = useLiveData(
migrationFavoriteItemsAdapter.favorites$.map(favs => {
@@ -99,7 +100,7 @@ export const NavigationPanelMigrationFavorites = () => {
return (
<CollapsibleSection
name="migrationFavorites"
path={path}
className={styles.container}
title={t['com.affine.rootAppSidebar.migration-data']()}
actions={
@@ -126,6 +127,7 @@ export const NavigationPanelMigrationFavorites = () => {
<NavigationPanelMigrationFavoriteNode
key={favorite.id + ':' + i}
favorite={favorite}
parentPath={path}
/>
))}
</NavigationPanelTreeRoot>
@@ -138,11 +140,13 @@ const childLocation = {
};
const NavigationPanelMigrationFavoriteNode = ({
favorite,
parentPath,
}: {
favorite: {
id: string;
type: 'collection' | 'doc';
};
parentPath: string[];
}) => {
return favorite.type === 'doc' ? (
<NavigationPanelDocNode
@@ -151,6 +155,7 @@ const NavigationPanelMigrationFavoriteNode = ({
location={childLocation}
reorderable={false}
canDrop={false}
parentPath={parentPath}
/>
) : (
<NavigationPanelCollectionNode
@@ -159,6 +164,7 @@ const NavigationPanelMigrationFavoriteNode = ({
location={childLocation}
reorderable={false}
canDrop={false}
parentPath={parentPath}
/>
);
};

View File

@@ -27,10 +27,9 @@ export const NavigationPanelOrganize = () => {
OrganizeService,
NavigationPanelService,
});
const navigationPanelSection = navigationPanelService.sections.organize;
const collapsed = useLiveData(navigationPanelSection.collapsed$);
const path = useMemo(() => ['organize'], []);
const collapsed = useLiveData(navigationPanelService.collapsed$(path));
const [newFolderId, setNewFolderId] = useState<string | null>(null);
const t = useI18n();
const folderTree = organizeService.folderTree;
@@ -46,9 +45,9 @@ export const NavigationPanelOrganize = () => {
);
track.$.navigationPanel.organize.createOrganizeItem({ type: 'folder' });
setNewFolderId(newFolderId);
navigationPanelSection.setCollapsed(false);
navigationPanelService.setCollapsed(path, false);
return newFolderId;
}, [navigationPanelSection, rootFolder]);
}, [navigationPanelService, path, rootFolder]);
const handleOnChildrenDrop = useCallback(
(data: DropTargetDropEvent<AffineDNDData>, node?: FolderNode) => {
@@ -105,7 +104,7 @@ export const NavigationPanelOrganize = () => {
return (
<CollapsibleSection
name="organize"
path={path}
title={t['com.affine.rootAppSidebar.organize']()}
actions={
<IconButton
@@ -141,6 +140,7 @@ export const NavigationPanelOrganize = () => {
at: 'navigation-panel:organize:folder-node',
nodeId: child.id as string,
}}
parentPath={path}
/>
))}
</NavigationPanelTreeRoot>

View File

@@ -5,7 +5,7 @@ import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import { AddTagIcon } from '@blocksuite/icons/rc';
import { useLiveData, useServices } from '@toeverything/infra';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { CollapsibleSection } from '../../layouts/collapsible-section';
import { NavigationPanelTagNode } from '../../nodes/tag';
@@ -19,8 +19,8 @@ export const NavigationPanelTags = () => {
TagService,
NavigationPanelService,
});
const navigationPanelSection = navigationPanelService.sections.tags;
const collapsed = useLiveData(navigationPanelSection.collapsed$);
const path = useMemo(() => ['tags'], []);
const collapsed = useLiveData(navigationPanelService.collapsed$(path));
const [creating, setCreating] = useState(false);
const tags = useLiveData(tagService.tagList.tags$);
@@ -30,9 +30,9 @@ export const NavigationPanelTags = () => {
(name: string) => {
tagService.tagList.createTag(name, tagService.randomTagColor());
track.$.navigationPanel.organize.createOrganizeItem({ type: 'tag' });
navigationPanelSection.setCollapsed(false);
navigationPanelService.setCollapsed(path, false);
},
[navigationPanelSection, tagService]
[navigationPanelService, path, tagService]
);
useEffect(() => {
@@ -45,7 +45,7 @@ export const NavigationPanelTags = () => {
return (
<CollapsibleSection
name="tags"
path={path}
testId="navigation-panel-tags"
headerClassName={styles.draggedOverHighlight}
title={t['com.affine.rootAppSidebar.tags']()}
@@ -81,6 +81,7 @@ export const NavigationPanelTags = () => {
location={{
at: 'navigation-panel:tags:list',
}}
parentPath={path}
/>
))}
</NavigationPanelTreeRoot>

View File

@@ -147,9 +147,10 @@ export const Component = ({
}, [desktopApi]);
useEffect(() => {
if (listIsLoading || list.length > 0) {
if (listIsLoading || list.length > 0 || !enableLocalWorkspace) {
return;
}
createFirstAppData(workspacesService)
.then(createdWorkspace => {
if (createdWorkspace) {
@@ -177,6 +178,7 @@ export const Component = ({
loggedIn,
listIsLoading,
list,
enableLocalWorkspace,
]);
if (navigating || creating) {

View File

@@ -11,7 +11,10 @@ import { getViewManager } from '@affine/core/blocksuite/manager/view';
import { NotificationServiceImpl } from '@affine/core/blocksuite/view-extensions/editor-view/notification-service';
import { useAIChatConfig } from '@affine/core/components/hooks/affine/use-ai-chat-config';
import { useAISpecs } from '@affine/core/components/hooks/affine/use-ai-specs';
import { AIDraftService } from '@affine/core/modules/ai-button';
import {
AIDraftService,
AIToolsConfigService,
} from '@affine/core/modules/ai-button';
import {
EventSourceService,
FetchService,
@@ -223,6 +226,7 @@ export const Component = () => {
confirmModal.openConfirmModal
);
content.aiDraftService = framework.get(AIDraftService);
content.aiToolsConfigService = framework.get(AIToolsConfigService);
content.createSession = createSession;
content.onOpenDoc = onOpenDoc;

View File

@@ -4,7 +4,10 @@ import type { AffineEditorContainer } from '@affine/core/blocksuite/block-suite-
import { NotificationServiceImpl } from '@affine/core/blocksuite/view-extensions/editor-view/notification-service';
import { useAIChatConfig } from '@affine/core/components/hooks/affine/use-ai-chat-config';
import { useAISpecs } from '@affine/core/components/hooks/affine/use-ai-specs';
import { AIDraftService } from '@affine/core/modules/ai-button';
import {
AIDraftService,
AIToolsConfigService,
} from '@affine/core/modules/ai-button';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { AppThemeService } from '@affine/core/modules/theme';
@@ -97,6 +100,8 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel(
confirmModal.openConfirmModal
);
chatPanelRef.current.aiDraftService = framework.get(AIDraftService);
chatPanelRef.current.aiToolsConfigService =
framework.get(AIToolsConfigService);
containerRef.current?.append(chatPanelRef.current);
} else {

View File

@@ -1,7 +1,4 @@
import {
type CollapsibleSectionName,
NavigationPanelService,
} from '@affine/core/modules/navigation-panel';
import { NavigationPanelService } from '@affine/core/modules/navigation-panel';
import { ToggleRightIcon } from '@blocksuite/icons/rc';
import * as Collapsible from '@radix-ui/react-collapsible';
import { useLiveData, useService } from '@toeverything/infra';
@@ -22,7 +19,7 @@ import {
} from './collapsible-section.css';
interface CollapsibleSectionProps extends HTMLAttributes<HTMLDivElement> {
name: CollapsibleSectionName;
path: string[];
title: string;
actions?: ReactNode;
testId?: string;
@@ -76,7 +73,7 @@ const CollapsibleSectionTrigger = forwardRef<
});
export const CollapsibleSection = ({
name,
path,
title,
actions,
testId,
@@ -86,12 +83,12 @@ export const CollapsibleSection = ({
children,
...attrs
}: CollapsibleSectionProps) => {
const section = useService(NavigationPanelService).sections[name];
const collapsed = useLiveData(section.collapsed$);
const navigationPanelService = useService(NavigationPanelService);
const collapsed = useLiveData(navigationPanelService.collapsed$(path));
const setCollapsed = useCallback(
(v: boolean) => section.setCollapsed(v),
[section]
(v: boolean) => navigationPanelService.setCollapsed(path, v),
[navigationPanelService, path]
);
return (

View File

@@ -6,11 +6,12 @@ import {
} from '@affine/core/modules/collection';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { GlobalContextService } from '@affine/core/modules/global-context';
import { NavigationPanelService } from '@affine/core/modules/navigation-panel';
import { ShareDocsListService } from '@affine/core/modules/share-doc';
import { useI18n } from '@affine/i18n';
import track from '@affine/track';
import { FilterMinusIcon, ViewLayersIcon } from '@blocksuite/icons/rc';
import { useLiveData, useServices } from '@toeverything/infra';
import { useLiveData, useService, useServices } from '@toeverything/infra';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
@@ -26,9 +27,11 @@ const CollectionIcon = () => <ViewLayersIcon />;
export const NavigationPanelCollectionNode = ({
collectionId,
operations: additionalOperations,
parentPath,
}: {
collectionId: string;
operations?: NodeOperation[];
parentPath: string[];
}) => {
const t = useI18n();
const { globalContextService, collectionService, workspaceDialogService } =
@@ -37,17 +40,28 @@ export const NavigationPanelCollectionNode = ({
CollectionService,
WorkspaceDialogService,
});
const navigationPanelService = useService(NavigationPanelService);
const active =
useLiveData(globalContextService.globalContext.collectionId.$) ===
collectionId;
const [collapsed, setCollapsed] = useState(true);
const path = useMemo(
() => [...parentPath, `collection-${collectionId}`],
[parentPath, collectionId]
);
const collapsed = useLiveData(navigationPanelService.collapsed$(path));
const setCollapsed = useCallback(
(value: boolean) => {
navigationPanelService.setCollapsed(path, value);
},
[navigationPanelService, path]
);
const collection = useLiveData(collectionService.collection$(collectionId));
const name = useLiveData(collection?.name$);
const handleOpenCollapsed = useCallback(() => {
setCollapsed(false);
}, []);
}, [setCollapsed]);
const handleEditCollection = useCallback(() => {
if (!collection) {
@@ -95,6 +109,7 @@ export const NavigationPanelCollectionNode = ({
<NavigationPanelCollectionNodeChildren
collection={collection}
onAddDoc={handleAddDocToCollection}
path={path}
/>
</NavigationPanelTreeNode>
);
@@ -103,9 +118,11 @@ export const NavigationPanelCollectionNode = ({
const NavigationPanelCollectionNodeChildren = ({
collection,
onAddDoc,
path,
}: {
collection: Collection;
onAddDoc?: () => void;
path: string[];
}) => {
const t = useI18n();
const { shareDocsListService, collectionService } = useServices({
@@ -147,6 +164,7 @@ const NavigationPanelCollectionNodeChildren = ({
<NavigationPanelDocNode
key={docId}
docId={docId}
parentPath={path}
operations={
allowList
? [

View File

@@ -7,6 +7,7 @@ import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
import { DocsSearchService } from '@affine/core/modules/docs-search';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { GlobalContextService } from '@affine/core/modules/global-context';
import { NavigationPanelService } from '@affine/core/modules/navigation-panel';
import { useI18n } from '@affine/i18n';
import {
LiveData,
@@ -29,10 +30,12 @@ export const NavigationPanelDocNode = ({
docId,
isLinked,
operations: additionalOperations,
parentPath,
}: {
docId: string;
isLinked?: boolean;
operations?: NodeOperation[];
parentPath: string[];
}) => {
const t = useI18n();
const {
@@ -48,9 +51,20 @@ export const NavigationPanelDocNode = ({
DocDisplayMetaService,
FeatureFlagService,
});
const navigationPanelService = useService(NavigationPanelService);
const active =
useLiveData(globalContextService.globalContext.docId.$) === docId;
const [collapsed, setCollapsed] = useState(true);
const path = useMemo(
() => [...parentPath, `doc-${docId}`],
[parentPath, docId]
);
const collapsed = useLiveData(navigationPanelService.collapsed$(path));
const setCollapsed = useCallback(
(value: boolean) => {
navigationPanelService.setCollapsed(path, value);
},
[navigationPanelService, path]
);
const docRecord = useLiveData(docsService.list.doc$(docId));
const DocIcon = useLiveData(
@@ -103,7 +117,7 @@ export const NavigationPanelDocNode = ({
openInfoModal: () => workspaceDialogService.open('doc-info', { docId }),
openNodeCollapsed: () => setCollapsed(false),
}),
[docId, workspaceDialogService]
[docId, setCollapsed, workspaceDialogService]
);
const operations = useNavigationPanelDocNodeOperationsMenu(docId, option);
const { handleAddLinkedPage } = useNavigationPanelDocNodeOperations(
@@ -150,6 +164,7 @@ export const NavigationPanelDocNode = ({
key={`${child.docId}-${index}`}
docId={child.docId}
isLinked
parentPath={path}
/>
))
: null

View File

@@ -14,6 +14,7 @@ import type {
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/favorite';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { NavigationPanelService } from '@affine/core/modules/navigation-panel';
import {
type FolderNode,
OrganizeService,
@@ -31,9 +32,9 @@ import {
RemoveFolderIcon,
TagsIcon,
} from '@blocksuite/icons/rc';
import { useLiveData, useServices } from '@toeverything/infra';
import { useLiveData, useService, useServices } from '@toeverything/infra';
import { difference } from 'lodash-es';
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useMemo } from 'react';
import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
import { NavigationPanelTreeNode } from '../../tree/node';
@@ -46,11 +47,13 @@ import { FavoriteFolderOperation } from './operations';
export const NavigationPanelFolderNode = ({
nodeId,
operations,
parentPath,
}: {
nodeId: string;
operations?:
| NodeOperation[]
| ((type: string, node: FolderNode) => NodeOperation[]);
parentPath: string[];
}) => {
const { organizeService } = useServices({
OrganizeService,
@@ -78,24 +81,34 @@ export const NavigationPanelFolderNode = ({
<NavigationPanelFolderNodeFolder
node={node}
operations={additionalOperations}
parentPath={parentPath}
/>
);
}
if (!data) return null;
if (type === 'doc') {
return (
<NavigationPanelDocNode docId={data} operations={additionalOperations} />
<NavigationPanelDocNode
docId={data}
operations={additionalOperations}
parentPath={parentPath}
/>
);
} else if (type === 'collection') {
return (
<NavigationPanelCollectionNode
collectionId={data}
operations={additionalOperations}
parentPath={parentPath}
/>
);
} else if (type === 'tag') {
return (
<NavigationPanelTagNode tagId={data} operations={additionalOperations} />
<NavigationPanelTagNode
tagId={data}
operations={additionalOperations}
parentPath={parentPath}
/>
);
}
@@ -119,9 +132,11 @@ const NavigationPanelFolderIcon: NavigationPanelTreeNodeIcon = ({
const NavigationPanelFolderNodeFolder = ({
node,
operations: additionalOperations,
parentPath,
}: {
node: FolderNode;
operations?: NodeOperation[];
parentPath: string[];
}) => {
const t = useI18n();
const { workspaceService, featureFlagService, workspaceDialogService } =
@@ -135,7 +150,18 @@ const NavigationPanelFolderNodeFolder = ({
const enableEmojiIcon = useLiveData(
featureFlagService.flags.enable_emoji_folder_icon.$
);
const [collapsed, setCollapsed] = useState(true);
const navigationPanelService = useService(NavigationPanelService);
const path = useMemo(
() => [...parentPath, `folder-${node.id}`],
[parentPath, node.id]
);
const collapsed = useLiveData(navigationPanelService.collapsed$(path));
const setCollapsed = useCallback(
(value: boolean) => {
navigationPanelService.setCollapsed(path, value);
},
[navigationPanelService, path]
);
const { createPage } = usePageHelper(
workspaceService.workspace.docCollection
@@ -171,7 +197,7 @@ const NavigationPanelFolderNodeFolder = ({
target: 'doc',
});
setCollapsed(false);
}, [createPage, node]);
}, [createPage, node, setCollapsed]);
const handleCreateSubfolder = useCallback(
(name: string) => {
@@ -179,7 +205,7 @@ const NavigationPanelFolderNodeFolder = ({
track.$.navigationPanel.organize.createOrganizeItem({ type: 'folder' });
setCollapsed(false);
},
[node]
[node, setCollapsed]
);
const handleAddToFolder = useCallback(
@@ -223,7 +249,7 @@ const NavigationPanelFolderNodeFolder = ({
target: type,
});
},
[children, node, workspaceDialogService]
[children, node, setCollapsed, workspaceDialogService]
);
const createSubTipRenderer = useCallback(
@@ -388,13 +414,16 @@ const NavigationPanelFolderNodeFolder = ({
[t]
);
const handleCollapsedChange = useCallback((collapsed: boolean) => {
if (collapsed) {
setCollapsed(true);
} else {
setCollapsed(false);
}
}, []);
const handleCollapsedChange = useCallback(
(collapsed: boolean) => {
if (collapsed) {
setCollapsed(true);
} else {
setCollapsed(false);
}
},
[setCollapsed]
);
return (
<NavigationPanelTreeNode
@@ -413,6 +442,7 @@ const NavigationPanelFolderNodeFolder = ({
key={child.id}
nodeId={child.id as string}
operations={childrenOperations}
parentPath={path}
/>
))}
<AddItemPlaceholder

View File

@@ -1,11 +1,12 @@
import type { NodeOperation } from '@affine/core/desktop/components/navigation-panel';
import { GlobalContextService } from '@affine/core/modules/global-context';
import { NavigationPanelService } from '@affine/core/modules/navigation-panel';
import type { Tag } from '@affine/core/modules/tag';
import { TagService } from '@affine/core/modules/tag';
import { useI18n } from '@affine/i18n';
import { useLiveData, useServices } from '@toeverything/infra';
import { useLiveData, useService, useServices } from '@toeverything/infra';
import clsx from 'clsx';
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useMemo } from 'react';
import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
import { NavigationPanelTreeNode } from '../../tree/node';
@@ -19,18 +20,31 @@ import * as styles from './styles.css';
export const NavigationPanelTagNode = ({
tagId,
operations: additionalOperations,
parentPath,
}: {
tagId: string;
operations?: NodeOperation[];
parentPath: string[];
}) => {
const t = useI18n();
const { tagService, globalContextService } = useServices({
TagService,
GlobalContextService,
});
const navigationPanelService = useService(NavigationPanelService);
const active =
useLiveData(globalContextService.globalContext.tagId.$) === tagId;
const [collapsed, setCollapsed] = useState(true);
const path = useMemo(
() => [...parentPath, `tag-${tagId}`],
[parentPath, tagId]
);
const collapsed = useLiveData(navigationPanelService.collapsed$(path));
const setCollapsed = useCallback(
(value: boolean) => {
navigationPanelService.setCollapsed(path, value);
},
[navigationPanelService, path]
);
const tagRecord = useLiveData(tagService.tagList.tagByTagId$(tagId));
const tagColor = useLiveData(tagRecord?.color$);
@@ -57,7 +71,7 @@ export const NavigationPanelTagNode = ({
() => ({
openNodeCollapsed: () => setCollapsed(false),
}),
[]
[setCollapsed]
);
const operations = useNavigationPanelTagNodeOperationsMenu(tagId, option);
const { handleNewDoc } = useNavigationPanelTagNodeOperations(tagId, option);
@@ -86,7 +100,11 @@ export const NavigationPanelTagNode = ({
aria-label={tagName}
data-role="navigation-panel-tag"
>
<NavigationPanelTagNodeDocs tag={tagRecord} onNewDoc={handleNewDoc} />
<NavigationPanelTagNodeDocs
tag={tagRecord}
onNewDoc={handleNewDoc}
path={path}
/>
</NavigationPanelTreeNode>
);
};
@@ -99,9 +117,11 @@ export const NavigationPanelTagNode = ({
export const NavigationPanelTagNodeDocs = ({
tag,
onNewDoc,
path,
}: {
tag: Tag;
onNewDoc?: () => void;
path: string[];
}) => {
const t = useI18n();
const tagDocIds = useLiveData(tag.pageIds$);
@@ -109,7 +129,7 @@ export const NavigationPanelTagNodeDocs = ({
return (
<>
{tagDocIds.map(docId => (
<NavigationPanelDocNode key={docId} docId={docId} />
<NavigationPanelDocNode key={docId} docId={docId} parentPath={path} />
))}
<AddItemPlaceholder label={t['New Page']()} onClick={onNewDoc} />
</>

View File

@@ -7,7 +7,7 @@ import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import { AddCollectionIcon } from '@blocksuite/icons/rc';
import { useLiveData, useServices } from '@toeverything/infra';
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
import { CollapsibleSection } from '../../layouts/collapsible-section';
@@ -22,7 +22,7 @@ export const NavigationPanelCollections = () => {
WorkbenchService,
NavigationPanelService,
});
const navigationPanelSection = navigationPanelService.sections.collections;
const path = useMemo(() => ['collections'], []);
const collectionMetas = useLiveData(collectionService.collectionMetas$);
const { openPromptModal } = usePromptModal();
@@ -49,12 +49,13 @@ export const NavigationPanelCollections = () => {
type: 'collection',
});
workbenchService.workbench.openCollection(id);
navigationPanelSection.setCollapsed(false);
navigationPanelService.setCollapsed(path, false);
},
});
}, [
collectionService,
navigationPanelSection,
navigationPanelService,
path,
openPromptModal,
t,
workbenchService.workbench,
@@ -62,7 +63,7 @@ export const NavigationPanelCollections = () => {
return (
<CollapsibleSection
name="collections"
path={path}
testId="navigation-panel-collections"
title={t['com.affine.rootAppSidebar.collections']()}
>
@@ -71,6 +72,7 @@ export const NavigationPanelCollections = () => {
<NavigationPanelCollectionNode
key={collection.id}
collectionId={collection.id}
parentPath={path}
/>
))}
<AddItemPlaceholder

View File

@@ -6,7 +6,7 @@ import { NavigationPanelService } from '@affine/core/modules/navigation-panel';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n';
import { useLiveData, useServices } from '@toeverything/infra';
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
import { CollapsibleSection } from '../../layouts/collapsible-section';
@@ -24,7 +24,7 @@ export const NavigationPanelFavorites = () => {
});
const t = useI18n();
const navigationPanelSection = navigationPanelService.sections.favorites;
const path = useMemo(() => ['favorites'], []);
const favorites = useLiveData(favoriteService.favoriteList.sortedList$);
const isLoading = useLiveData(favoriteService.favoriteList.isLoading$);
const { createPage } = usePageHelper(
@@ -38,19 +38,23 @@ export const NavigationPanelFavorites = () => {
newDoc.id,
favoriteService.favoriteList.indexAt('before')
);
navigationPanelSection.setCollapsed(false);
}, [createPage, navigationPanelSection, favoriteService.favoriteList]);
navigationPanelService.setCollapsed(path, false);
}, [createPage, favoriteService.favoriteList, navigationPanelService, path]);
return (
<CollapsibleSection
name="favorites"
path={path}
title={t['com.affine.rootAppSidebar.favorites']()}
testId="navigation-panel-favorites"
headerTestId="navigation-panel-favorite-category-divider"
>
<NavigationPanelTreeRoot placeholder={isLoading ? 'Loading' : null}>
{favorites.map(favorite => (
<FavoriteNode key={favorite.id} favorite={favorite} />
<FavoriteNode
key={favorite.id}
favorite={favorite}
parentPath={path}
/>
))}
<AddItemPlaceholder
data-testid="navigation-panel-bar-add-favorite-button"
@@ -66,19 +70,24 @@ export const NavigationPanelFavorites = () => {
export const FavoriteNode = ({
favorite,
parentPath,
}: {
favorite: {
id: string;
type: FavoriteSupportTypeUnion;
};
parentPath: string[];
}) => {
return favorite.type === 'doc' ? (
<NavigationPanelDocNode docId={favorite.id} />
<NavigationPanelDocNode docId={favorite.id} parentPath={parentPath} />
) : favorite.type === 'tag' ? (
<NavigationPanelTagNode tagId={favorite.id} />
<NavigationPanelTagNode tagId={favorite.id} parentPath={parentPath} />
) : favorite.type === 'folder' ? (
<NavigationPanelFolderNode nodeId={favorite.id} />
<NavigationPanelFolderNode nodeId={favorite.id} parentPath={parentPath} />
) : (
<NavigationPanelCollectionNode collectionId={favorite.id} />
<NavigationPanelCollectionNode
collectionId={favorite.id}
parentPath={parentPath}
/>
);
};

View File

@@ -6,7 +6,7 @@ import { useI18n } from '@affine/i18n';
import track from '@affine/track';
import { AddOrganizeIcon } from '@blocksuite/icons/rc';
import { useLiveData, useServices } from '@toeverything/infra';
import { useCallback, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
import { CollapsibleSection } from '../../layouts/collapsible-section';
@@ -18,7 +18,7 @@ export const NavigationPanelOrganize = () => {
OrganizeService,
NavigationPanelService,
});
const navigationPanelSection = navigationPanelService.sections.organize;
const path = useMemo(() => ['organize'], []);
const [openNewFolderDialog, setOpenNewFolderDialog] = useState(false);
const t = useI18n();
@@ -36,15 +36,15 @@ export const NavigationPanelOrganize = () => {
rootFolder.indexAt('before')
);
track.$.navigationPanel.organize.createOrganizeItem({ type: 'folder' });
navigationPanelSection.setCollapsed(false);
navigationPanelService.setCollapsed(path, false);
return newFolderId;
},
[navigationPanelSection, rootFolder]
[navigationPanelService, path, rootFolder]
);
return (
<CollapsibleSection
name="organize"
path={path}
title={t['com.affine.rootAppSidebar.organize']()}
>
{/* TODO(@CatsJuice): Organize loading UI */}
@@ -53,6 +53,7 @@ export const NavigationPanelOrganize = () => {
<NavigationPanelFolderNode
key={child.id}
nodeId={child.id as string}
parentPath={path}
/>
))}
<AddItemPlaceholder

View File

@@ -5,7 +5,7 @@ import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import { AddTagIcon } from '@blocksuite/icons/rc';
import { useLiveData, useServices } from '@toeverything/infra';
import { useCallback, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
import { CollapsibleSection } from '../../layouts/collapsible-section';
@@ -25,7 +25,7 @@ export const NavigationPanelTags = () => {
TagService,
NavigationPanelService,
});
const navigationPanelSection = navigationPanelService.sections.tags;
const path = useMemo(() => ['tags'], []);
const tags = useLiveData(tagService.tagList.tags$);
const [showNewTagDialog, setShowNewTagDialog] = useState(false);
@@ -36,19 +36,23 @@ export const NavigationPanelTags = () => {
setShowNewTagDialog(false);
tagService.tagList.createTag(name, color);
track.$.navigationPanel.organize.createOrganizeItem({ type: 'tag' });
navigationPanelSection.setCollapsed(false);
navigationPanelService.setCollapsed(path, false);
},
[navigationPanelSection, tagService]
[navigationPanelService, path, tagService]
);
return (
<CollapsibleSection
name="tags"
path={path}
title={t['com.affine.rootAppSidebar.tags']()}
>
<NavigationPanelTreeRoot>
{tags.map(tag => (
<NavigationPanelTagNode key={tag.id} tagId={tag.id} />
<NavigationPanelTagNode
key={tag.id}
tagId={tag.id}
parentPath={path}
/>
))}
<AddItemPlaceholder
icon={<AddTagIcon />}

View File

@@ -24,7 +24,7 @@ export const RecentDocs = ({ max = 5 }: { max?: number }) => {
return (
<CollapsibleSection
name="recent"
path={['recent']}
title="Recent"
headerClassName={styles.header}
className={styles.recentSection}

View File

@@ -1,6 +1,10 @@
export { AIButtonProvider } from './provider/ai-button';
export { AIButtonService } from './services/ai-button';
export { AIDraftService } from './services/ai-draft';
export {
type AIToolsConfig,
AIToolsConfigService,
} from './services/tools-config';
import type { Framework } from '@toeverything/infra';
@@ -13,6 +17,7 @@ import { AIDraftService } from './services/ai-draft';
import { AINetworkSearchService } from './services/network-search';
import { AIPlaygroundService } from './services/playground';
import { AIReasoningService } from './services/reasoning';
import { AIToolsConfigService } from './services/tools-config';
export const configureAIButtonModule = (framework: Framework) => {
framework.service(AIButtonService, container => {
@@ -40,3 +45,7 @@ export function configureAIDraftModule(framework: Framework) {
.scope(WorkspaceScope)
.service(AIDraftService, [GlobalStateService, CacheStorage]);
}
export function configureAIToolsConfigModule(framework: Framework) {
framework.service(AIToolsConfigService, [GlobalStateService]);
}

View File

@@ -0,0 +1,50 @@
import {
createSignalFromObservable,
type Signal,
} from '@blocksuite/affine/shared/utils';
import { LiveData, Service } from '@toeverything/infra';
import { map } from 'rxjs';
import type { GlobalStateService } from '../../storage';
const AI_TOOLS_CONFIG_KEY = 'AIToolsConfig';
export interface AIToolsConfig {
searchWorkspace?: boolean;
readingDocs?: boolean;
}
export class AIToolsConfigService extends Service {
constructor(private readonly globalStateService: GlobalStateService) {
super();
const { signal, cleanup: enabledCleanup } =
createSignalFromObservable<AIToolsConfig>(this.config$, {
searchWorkspace: true,
readingDocs: true,
});
this.config = signal;
this.disposables.push(enabledCleanup);
}
config: Signal<AIToolsConfig>;
private readonly config$ = LiveData.from(
this.globalStateService.globalState.watch<AIToolsConfig>(
AI_TOOLS_CONFIG_KEY
),
undefined
).pipe(
map(config => ({
searchWorkspace: config?.searchWorkspace ?? true,
readingDocs: config?.readingDocs ?? true,
}))
);
setConfig = (data: Partial<AIToolsConfig>) => {
this.globalStateService.globalState.set(AI_TOOLS_CONFIG_KEY, {
...this.config.value,
...data,
});
};
}

View File

@@ -13,7 +13,7 @@ export const navWrapperStyle = style({
'&[data-has-border=true]': {
borderRight: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
},
'&[data-is-floating="true"]': {
'&[data-is-floating="true"], &[data-is-electron="false"]': {
backgroundColor: cssVarV2('layer/background/primary'),
},
},

View File

@@ -66,7 +66,7 @@ export class Collection extends Entity<{ id: string }> {
},
],
})
.pipe(map(result => result.groups.map(group => group.items).flat()));
.pipe(map(result => result.groups.flatMap(group => group.items)));
})
);
}

View File

@@ -7,6 +7,7 @@ import {
configureAINetworkSearchModule,
configureAIPlaygroundModule,
configureAIReasoningModule,
configureAIToolsConfigModule,
} from './ai-button';
import { configureAppSidebarModule } from './app-sidebar';
import { configAtMenuConfigModule } from './at-menu-config';
@@ -112,6 +113,7 @@ export function configureCommonModules(framework: Framework) {
configureAIPlaygroundModule(framework);
configureAIButtonModule(framework);
configureAIDraftModule(framework);
configureAIToolsConfigModule(framework);
configureTemplateDocModule(framework);
configureBlobManagementModule(framework);
configureMediaModule(framework);

View File

@@ -1,39 +0,0 @@
import { Entity, LiveData } from '@toeverything/infra';
import { map } from 'rxjs';
import type { GlobalCache } from '../../storage';
import type { CollapsibleSectionName } from '../types';
const DEFAULT_COLLAPSABLE_STATE: Record<CollapsibleSectionName, boolean> = {
recent: true,
favorites: false,
organize: false,
collections: true,
tags: true,
favoritesOld: true,
migrationFavorites: true,
others: false,
};
export class NavigationPanelSection extends Entity<{
name: CollapsibleSectionName;
}> {
name: CollapsibleSectionName = this.props.name;
key = `explorer.section.${this.name}`;
defaultValue = DEFAULT_COLLAPSABLE_STATE[this.name];
constructor(private readonly globalCache: GlobalCache) {
super();
}
collapsed$ = LiveData.from(
this.globalCache
.watch<boolean>(this.key)
.pipe(map(v => v ?? this.defaultValue)),
this.defaultValue
);
setCollapsed(collapsed: boolean) {
this.globalCache.set(this.key, collapsed);
}
}

View File

@@ -1,15 +1,12 @@
import { type Framework } from '@toeverything/infra';
import { GlobalCache } from '../storage';
import { WorkspaceScope } from '../workspace';
import { NavigationPanelSection } from './entities/navigation-panel-section';
import { WorkspaceScope, WorkspaceService } from '../workspace';
import { NavigationPanelService } from './services/navigation-panel';
export { NavigationPanelService } from './services/navigation-panel';
export type { CollapsibleSectionName } from './types';
export function configureNavigationPanelModule(framework: Framework) {
framework
.scope(WorkspaceScope)
.service(NavigationPanelService)
.entity(NavigationPanelSection, [GlobalCache]);
.service(NavigationPanelService, [GlobalCache, WorkspaceService]);
}

View File

@@ -1,25 +1,47 @@
import { Service } from '@toeverything/infra';
import { LiveData, Service } from '@toeverything/infra';
import { NavigationPanelSection } from '../entities/navigation-panel-section';
import type { CollapsibleSectionName } from '../types';
import type { GlobalCache } from '../../storage/providers/global';
import type { WorkspaceService } from '../../workspace';
const allSectionName: Array<CollapsibleSectionName> = [
'recent', // mobile only
'favorites',
'organize',
'collections',
'tags',
'favoritesOld',
'migrationFavorites',
'others',
];
const DEFAULT_COLLAPSABLE_STATE: Record<string, boolean> = {
recent: true,
favorites: false,
organize: false,
collections: true,
tags: true,
favoritesOld: true,
migrationFavorites: true,
others: false,
};
export class NavigationPanelService extends Service {
readonly sections = allSectionName.reduce(
(prev, name) =>
Object.assign(prev, {
[name]: this.framework.createEntity(NavigationPanelSection, { name }),
}),
{} as Record<CollapsibleSectionName, NavigationPanelSection>
);
constructor(
private readonly globalCache: GlobalCache,
private readonly workspaceService: WorkspaceService
) {
super();
}
private readonly collapsedCache = new Map<string, LiveData<boolean>>();
collapsed$(path: string[]) {
const pathKey = path.join(':');
const key = `navigation:${this.workspaceService.workspace.id}:${pathKey}`;
const cached$ = this.collapsedCache.get(key);
if (!cached$) {
const liveData$ = LiveData.from(
this.globalCache.watch<boolean>(key),
undefined
).map(v => v ?? DEFAULT_COLLAPSABLE_STATE[pathKey] ?? true);
this.collapsedCache.set(key, liveData$);
return liveData$;
}
return cached$;
}
setCollapsed(path: string[], collapsed: boolean) {
const pathKey = path.join(':');
const key = `navigation:${this.workspaceService.workspace.id}:${pathKey}`;
this.globalCache.set(key, collapsed);
}
}

View File

@@ -1,9 +0,0 @@
export type CollapsibleSectionName =
| 'recent'
| 'collections'
| 'favorites'
| 'tags'
| 'organize'
| 'favoritesOld'
| 'migrationFavorites'
| 'others';

View File

@@ -2,6 +2,10 @@ import { toReactNode } from '@affine/component';
import { AIChatBlockPeekViewTemplate } from '@affine/core/blocksuite/ai';
import type { AIChatBlockModel } from '@affine/core/blocksuite/ai/blocks/ai-chat-block/model/ai-chat-model';
import { useAIChatConfig } from '@affine/core/components/hooks/affine/use-ai-chat-config';
import {
AIDraftService,
AIToolsConfigService,
} from '@affine/core/modules/ai-button';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import type { EditorHost } from '@blocksuite/affine/std';
@@ -27,6 +31,8 @@ export const AIChatBlockPeekView = ({
const framework = useFramework();
const affineFeatureFlagService = framework.get(FeatureFlagService);
const affineWorkspaceDialogService = framework.get(WorkspaceDialogService);
const aiDraftService = framework.get(AIDraftService);
const aiToolsConfigService = framework.get(AIToolsConfigService);
return useMemo(() => {
const template = AIChatBlockPeekViewTemplate(
@@ -37,7 +43,9 @@ export const AIChatBlockPeekView = ({
networkSearchConfig,
reasoningConfig,
affineFeatureFlagService,
affineWorkspaceDialogService
affineWorkspaceDialogService,
aiDraftService,
aiToolsConfigService
);
return toReactNode(template);
}, [
@@ -49,5 +57,7 @@ export const AIChatBlockPeekView = ({
reasoningConfig,
affineFeatureFlagService,
affineWorkspaceDialogService,
aiDraftService,
aiToolsConfigService,
]);
};

View File

@@ -335,6 +335,10 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
const localData = (await docStorage.getDoc(id))?.bin;
const cloudData = (await cloudStorage.getDoc(id))?.bin;
const isEmpty = isEmptyUpdate(localData) && isEmptyUpdate(cloudData);
console.log('isEmpty', isEmpty, localData, cloudData);
docStorage.connection.disconnect();
const info = await this.getWorkspaceInfo(id, signal);
@@ -344,6 +348,7 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
isOwner: info.workspace.role === Permission.Owner,
isAdmin: info.workspace.role === Permission.Admin,
isTeam: info.workspace.team,
isEmpty,
};
}
@@ -360,8 +365,10 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
isOwner: info.workspace.role === Permission.Owner,
isAdmin: info.workspace.role === Permission.Admin,
isTeam: info.workspace.team,
isEmpty,
};
}
async getWorkspaceBlob(id: string, blob: string): Promise<Blob | null> {
const storage = new this.BlobStorageType({
id: id,
@@ -659,3 +666,13 @@ export class CloudWorkspaceFlavoursProvider
}
);
}
export function isEmptyUpdate(binary: Uint8Array | undefined) {
if (!binary) {
return true;
}
return (
binary.byteLength === 0 ||
(binary.byteLength === 2 && binary[0] === 0 && binary[1] === 0)
);
}

View File

@@ -24,6 +24,7 @@ export interface WorkspaceProfileInfo {
isOwner?: boolean;
isAdmin?: boolean;
isTeam?: boolean;
isEmpty?: boolean;
}
/**
@@ -61,6 +62,7 @@ export class WorkspaceProfile extends Entity<{ metadata: WorkspaceMetadata }> {
}
private setProfile(info: WorkspaceProfileInfo) {
console.log('setProfile', info, isEqual(this.profile$.value, info));
if (isEqual(this.profile$.value, info)) {
return;
}

View File

@@ -19,13 +19,7 @@ export class WorkspaceProfileCacheStore extends Store {
}
const info = data as WorkspaceProfileInfo;
return {
avatar: info.avatar,
name: info.name,
isOwner: info.isOwner,
isAdmin: info.isAdmin,
isTeam: info.isTeam,
};
return info;
})
);
}

View File

@@ -51,8 +51,7 @@ import stickerContent${id} from './stickers/${category}/Content/${sticker}';`,
}
const importStatements = Object.values(data)
.map(v => Object.values(v).map(v => v.importStatement))
.flat()
.flatMap(v => Object.values(v).map(v => v.importStatement))
.join('\n');
const templates = `const templates = {

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