Compare commits

...

64 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
DarkSky
c4cf5799d4 fix(server): exclude outdated doc id style in embedding count (#13269)
fix AI-392
fix AI-393

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

## Summary by CodeRabbit

* **New Features**
* Improved filtering of outdated document ID styles in embedding status
reporting, ensuring more accurate counts of embedded documents.
* Stricter rate limiting applied to workspace embedding status queries
for enhanced system reliability.

* **Bug Fixes**
* Resolved issues with duplicate or outdated document IDs affecting
embedding status totals.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-21 10:58:29 +00:00
德布劳外 · 贾贵
b53b4884cf refactor(core): align markdown conversion logic (#13254)
## Refactor

Align the Markdown conversion logic across all business modules:
1. frontend/backend apply: doc to markdown
2. insert/import markdown: use `markdownAdapter.toDoc`

> CLOSE AI-328 AI-379 AI-380

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

* **Documentation**
* Clarified instructions and provided an explicit example for correct
list item formatting in the markdown editing tool.

* **Bug Fixes**
* Improved markdown parsing for lists, ensuring correct indentation and
handling of trailing newlines.
* Cleaned up markdown snapshot test files by removing redundant blank
lines for better readability.

* **Refactor**
* Updated markdown conversion logic to use a new parsing approach for
improved reliability and maintainability.
* Enhanced markdown generation method for document snapshots with
improved error handling.
* Refined markdown-to-snapshot conversion with more robust document
handling and snapshot extraction.

* **Chores**
* Added a new workspace dependency for enhanced markdown parsing
capabilities.
* Updated project references and workspace dependencies to include the
new markdown parsing package.

* **Tests**
* Temporarily disabled two markdown-related tests due to parse errors in
test mode.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-21 10:35:13 +00:00
EYHN
0525c499a1 feat(core): enable two step journal by default (#13283)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* The journal confirmation flow is now always enabled when creating or
opening journals across all platforms.

* **Refactor**
* Removed the two-step journal confirmation feature flag and all related
conditional logic.
* Simplified journal navigation and creation flows for a more consistent
user experience.

* **Chores**
* Cleaned up unused components and imports related to the removed
feature flag.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-21 10:24:33 +00:00
EYHN
43f8d852d8 feat(ios): ai button feature flag (#13280)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
  * Added a global API to check the AI button feature flag status.

* **Bug Fixes**
* Improved handling for the AI button feature: the app now checks the
feature flag before proceeding and provides a clear error if the feature
is disabled.

* **Refactor**
  * Removed all AI button presentation and dismissal logic from the app.
* Deleted unused plugin interfaces and registration related to the AI
button feature.

* **Chores**
  * Updated project metadata and build configuration for iOS.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-21 10:07:32 +00:00
DarkSky
06eb17387a chore(server): relax list session permission (#13268)
fix AI-326

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

## Summary by CodeRabbit

* **Bug Fixes**
* Adjusted permission checks for viewing histories and chats to require
read access instead of update access on documents.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-21 10:02:51 +00:00
EYHN
436d5e5079 fix(core): allow mobile connect selfhost without https (#13279)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Chores**
* Updated Android and iOS app configurations to allow non-HTTPS
(cleartext) network traffic.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-21 05:57:25 +00:00
Cats Juice
52e69e0dde feat(mobile): add two step confirmation for mobile journal (#13266)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Introduced a new mobile journals page with a two-step confirmation
flow, allowing users to select a date and confirm before creating or
opening a journal.
  * Added a dedicated route for journals on mobile devices.
* Implemented a placeholder view when no journal exists for a selected
date on both desktop and mobile.

* **Enhancements**
* Improved mobile and desktop styling for journals pages, including
responsive adjustments for mobile layouts.
* Updated journal navigation behavior based on a feature flag, enabling
or disabling the two-step confirmation flow.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-21 05:30:24 +00:00
德布劳外 · 贾贵
612c73cab1 fix(core): code-edit param maybe json string (#13278)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved input handling for code editing by allowing the tool to
accept both arrays and JSON string representations for the `code_edit`
parameter, ensuring more robust and flexible input validation.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-21 03:38:21 +00:00
Lakr
b7c026bbe8 feat: ai now working again (#13196)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added support for displaying title and summary fields in workspace
pages.
* Introduced a menu in the chat header with a "Clear History" option to
remove chat history.

* **Improvements**
* Enhanced chat message handling with asynchronous context preparation
and improved markdown processing.
* Simplified chat input and assistant message rendering for better
performance and maintainability.
* Updated dependency versions for improved stability and compatibility.

* **Bug Fixes**
* Ensured chat features are available in all build configurations, not
just debug mode.

* **Chores**
* Removed unused dependencies and internal code, and disabled certain
function bar options.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-21 02:49:20 +00:00
DarkSky
013a6ceb7e feat(server): add compatibility for ios client (#13263)
fix AI-355

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

## Summary by CodeRabbit

* **New Features**
* Added support for uploading a single file as an attachment when
creating chat messages, in addition to existing multiple file uploads.

* **Tests**
* Expanded test coverage to verify message creation with both single and
multiple file attachments.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-18 08:31:26 +00:00
Yii
fa42e3619f ci: adjuest minimal approves of image release job 2025-07-18 15:33:18 +08:00
Peng Xiao
edd97ae73b fix(core): share page should have basename correctly set (#13256)
fix AF-2760

#### PR Dependency Tree


* **PR #13256** 👈

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

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

## Summary by CodeRabbit

* **New Features**
* Improved synchronization of workspace information with the URL path,
ensuring the displayed workspace name stays up-to-date when navigating
within the workspace share page.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-17 14:01:38 +00:00
Wu Yue
0770b109cb feat(core): add ai draft service (#13252)
Close [AI-244](https://linear.app/affine-design/issue/AI-244)

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

* **New Features**
* Added AI chat draft persistence, allowing your chat input, quotes,
markdown, and images to be automatically saved and restored across
sessions.
* Drafts are now synchronized across chat components, so you won’t lose
your progress if you navigate away or refresh the page.

* **Improvements**
* Enhanced chat experience with seamless restoration of previously
entered content and attachments.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-17 09:42:01 +00:00
Cats Juice
4018b3aeca fix(component): mobile menu bottom padding not work (#13249)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved safe area styling by ensuring a default padding is applied
when certain variables are not set, resulting in more consistent layout
spacing across different scenarios.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-17 09:23:04 +00:00
Cats Juice
c90d511251 feat(core): server version check for selfhost login (#13247)
close AF-2752;

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

* **New Features**
* Added a version compatibility check for self-hosted environments
during sign-in, displaying a clear error message and upgrade
instructions if the server version is outdated.
* **Style**
* Updated the appearance of the notification icon in the mobile header
for improved visual consistency.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-17 09:21:51 +00:00
DarkSky
bdf1389258 feat(server): improve transcript (#13253)
fix AF-2758
fix AF-2759
2025-07-17 09:20:14 +00:00
德布劳外 · 贾贵
dc68c2385d fix(core): ai apply ui opt (#13238)
> CLOSE AI-377 AI-372 AI-373 AI-381 AI-378 AI-374 AI-382 AI-375

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

## Summary by CodeRabbit

* **Style**
* Improved button styling for tool controls, including hover effects and
consistent padding.
* Updated background colors and border placements for result cards and
headers.

* **New Features**
  * Added tooltips to control buttons for enhanced user guidance.

* **Bug Fixes**
* Improved accessibility by replacing clickable spans with button
elements.
* Updated loading indicators to use a spinner icon for clearer feedback
during actions.

* **Refactor**
* Simplified layout and reduced unnecessary wrapper elements for cleaner
rendering.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-17 09:16:26 +00:00
Cats Juice
07f2f7b5a8 fix(core): hide intelligence entrance when ai is disabled (#13251)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* The AI Chat button is now only visible when AI features are enabled
and supported by the server, ensuring users see it only when available.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-17 08:14:36 +00:00
Peng Xiao
38107910f9 fix(core): comment action button bg color (#13250)
fix BS-3623

Also use enter instead of enter+CMD/CTRL to commit comment/reply

#### PR Dependency Tree


* **PR #13250** 👈

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**
* Updated comment editor so comments are now submitted by pressing Enter
(without CMD or CTRL).
* **Style**
* Improved visual styling for action buttons in the comment sidebar for
a more consistent appearance.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-17 07:23:40 +00:00
Cats Juice
ea21de8311 feat(core): add flag for two-step journal conformation (#13246)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Introduced a feature flag to control a two-step journal confirmation
process.
* Users may now experience either an immediate journal opening or a
confirmation step before journal creation, depending on the feature flag
status.

* **Chores**
* Added a new feature flag for two-step journal confirmation,
configurable in canary builds.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-17 07:03:32 +00:00
L-Sun
21360591a9 chore(editor): add table and callout entries for mobile (#13245)
Close
[AF-2755](https://linear.app/affine-design/issue/AF-2755/table-block支持)

#### PR Dependency Tree


* **PR #13245** 👈

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 "Table" and "Callout" options to the keyboard toolbar, allowing
users to insert table and callout blocks directly from the toolbar when
available.

* **Chores**
* Updated internal dependencies to support new block types and maintain
compatibility.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-17 04:17:00 +00:00
L-Sun
5300eff8f1 fix(editor): at-menu boundary in chat pannel (#13241)
Close
[BS-3621](https://linear.app/affine-design/issue/BS-3621/comment-menu-需要规避边缘)

#### PR Dependency Tree


* **PR #13241** 👈

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

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

* **Bug Fixes**
* Improved the positioning of the linked document popover to ensure it
displays correctly, even when its width is not initially rendered.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->


#### PR Dependency Tree


* **PR #13241** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)
2025-07-17 02:47:45 +00:00
Cats Juice
46a2ad750f feat(core): add a two-step confirm page to create new journal (#13240)
close AF-2750;

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

* **New Features**
* Introduced a new workspace journals page with date-based navigation,
placeholder UI, and the ability to create daily journals directly from
the page.
* Added a "Today" button for quick navigation to the current day's
journal when viewing other dates.

* **Improvements**
* Enhanced the journal document title display with improved date
formatting and flexible styling.
* Expanded the active state for the journal sidebar button to cover all
journal-related routes.
* Updated journal navigation to open existing entries directly or
navigate to filtered journal listings.

* **Bug Fixes**
* Improved date handling and navigation logic for journal entries to
ensure accurate redirection and creation flows.

* **Style**
* Added new styles for the workspace journals page, including headers,
placeholders, and buttons.

* **Localization**
* Added English translations for journal placeholder text and create
journal prompts.

* **Tests**
* Added confirmation steps in journal creation flows to improve test
reliability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-17 02:32:01 +00:00
Kieran Cui
3949714618 fix(core): optimize settings dialog's left sidebar scroll style (#13237)
change from scrolling the entire left side to scrolling menu items

**before**


https://github.com/user-attachments/assets/85d5c518-5160-493e-9010-431e6f0ed51b



**after**


https://github.com/user-attachments/assets/2efcdfde-7005-4d38-8dfb-2aef5e123946




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

## Summary by CodeRabbit

* **New Features**
* Added vertical scrolling with a visible scrollbar to the settings
sidebar for easier navigation of setting groups.

* **Style**
* Updated sidebar padding and spacing for improved layout and
appearance.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-16 07:16:55 +00:00
德布劳外 · 贾贵
7b9e0a215d fix(core): css var for apply delete diff (#13235)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Style**
* Updated the background color variable for deleted blocks to improve
consistency with the latest theme settings. No visible changes expected
unless custom theme variables are in use.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-16 06:55:21 +00:00
德布劳外 · 贾贵
b93d5d5e86 fix(core): apply insert in same position not refresh (#13210)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Refactor**
* Improved the rendering process for block inserts, resulting in more
efficient and streamlined updates when viewing block differences. No
changes to user-facing features or behaviors.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-16 06:54:06 +00:00
Cats Juice
c8dc51ccae feat(core): highlight active session in history (#13212)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added visual highlighting for the selected session in the session
history list.
* Improved accessibility by indicating the selected session for
assistive technologies.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-16 06:53:35 +00:00
EYHN
cdff5c3117 feat(core): add context menu for navigation and explorer (#13216)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Introduced a customizable context menu component for desktop
interfaces, enabling right-click menus in various UI elements.
* Added context menu support to document list items and navigation tree
nodes, allowing users to access additional operations via right-click.
* **Improvements**
* Enhanced submenu and menu item components to support both dropdown and
context menu variants based on context.
* Updated click handling in workbench links to prevent unintended
actions on non-left mouse button clicks.
* **Chores**
* Added `@radix-ui/react-context-menu` as a dependency to relevant
frontend packages.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-16 04:40:10 +00:00
EYHN
d44771dfe9 feat(electron): add global context menu (#13218)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added automatic synchronization of language settings between the
desktop app and the system environment.
* Context menu actions (Cut, Copy, Paste) in the desktop app are now
localized according to the selected language.

* **Improvements**
* Context menu is always available with standard editing actions,
regardless of spell check settings.

* **Localization**
* Added translations for "Cut", "Copy", and "Paste" in the context menu.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-16 04:37:38 +00:00
Yii
45b05f06b3 fix(core): demo workspace (#13234)
do not show demo workspace before config fetched for selfhost instances

fixes https://github.com/toeverything/AFFiNE/issues/13219

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

## Summary by CodeRabbit

* **Refactor**
* Updated the list of available features for self-hosted server
configurations. No visible changes to exported interfaces or public
APIs.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-16 04:16:03 +00:00
Peng Xiao
04e002eb77 feat(core): optimize artifact preview loading (#13224)
fix AI-369

#### PR Dependency Tree


* **PR #13224** 👈

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

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

## Summary by CodeRabbit

* **New Features**
* Introduced a loading skeleton component for artifact previews,
providing a smoother visual experience during loading states.
* Artifact loading skeleton is now globally available as a custom
element.

* **Refactor**
* Streamlined icon and loading state handling in AI tools, centralizing
logic and removing redundant loading indicators.
* Simplified card metadata by removing loading and icon properties from
card meta methods.

* **Chores**
* Improved resource management for code block highlighting, ensuring
efficient disposal and avoiding unnecessary operations.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-16 02:08:32 +00:00
DarkSky
a444941b79 fix(server): delay send mail if retry many times (#13225)
fix AF-2748

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

* **New Features**
* Improved mail sending job with adaptive retry delays based on elapsed
time, enhancing reliability of email delivery.

* **Chores**
* Updated job payload to include a start time for better retry
management.
* Added an internal delay utility to support asynchronous pause in
processes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-15 12:21:42 +00:00
Cats Juice
39e0ec37fd fix(core): prevent reload pinned chat infinitely (#13226)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved chat stability by centralizing and simplifying the logic for
resetting chat content, reducing unnecessary reloads and preventing
infinite loading cycles.

* **Refactor**
* Streamlined internal chat content management for more reliable session
handling and smoother user experience.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-15 12:03:41 +00:00
DarkSky
cc1d5b497a feat(server): cleanup trashed doc's embedding (#13201)
fix AI-359

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

* **New Features**
* Added automated cleanup of embeddings for documents deleted or trashed
from workspaces.
* Introduced a new job to schedule and perform this cleanup per
workspace daily and on demand.
  * Added new GraphQL mutation to manually trigger the cleanup process.
* Added the ability to list workspaces with flexible filtering and
selection options.

* **Improvements**
* Enhanced document status handling to more accurately reflect embedding
presence.
* Refined internal methods for managing and checking document
embeddings.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-15 12:00:33 +00:00
Wu Yue
a4b535a42a feat(core): support lazy load for ai session history (#13221)
Close [AI-331](https://linear.app/affine-design/issue/AI-331)

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

## Summary by CodeRabbit

* **New Features**
* Added infinite scroll and incremental loading for AI session history,
allowing users to load more sessions as they scroll.

* **Refactor**
* Improved session history component with better state management and
modular rendering for loading, empty, and history states.

* **Bug Fixes**
* Enhanced handling of absent or uninitialized chat sessions, reducing
potential errors when session data is missing.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-15 11:43:36 +00:00
DarkSky
c797cac87d feat(server): clear semantic search metadata (#13197)
fix AI-360

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

* **New Features**
* Search results now display document metadata enriched with author
information.

* **Improvements**
* Search result content is cleaner, with leading metadata lines (such as
titles and creation dates) removed from document excerpts.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-15 11:16:34 +00:00
Cats Juice
339ecab00f fix(core): the down arrow may show when showLinkedDoc not configured (#13220)
The original setting object on user's device not defined, so the default
value `true` won't work.

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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved reliability of sidebar and appearance settings by ensuring
toggle switches consistently reflect the correct on/off state.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-15 09:32:10 +00:00
DarkSky
8e374f5517 feat(server): skip embedding for deprecated doc ids & empty docs (#13211)
fix AI-367

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

* **Bug Fixes**
* Improved document filtering to exclude settings documents and empty
blobs from embedding and status calculations.
* Enhanced embedding jobs to skip processing deprecated documents if a
newer version exists, ensuring only up-to-date documents are embedded.
* **New Features**
* Added a mutation to trigger the cron job for generating missing
titles.
* **Tests**
* Added test to verify exclusion of documents with empty content from
embedding.
* Updated embedding-related tests to toggle embedding state during
attachment upload under simulated network conditions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-15 08:50:48 +00:00
Cats Juice
cd91bea5c1 feat(core): open doc in semantic and keyword result (#13217)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added clickable document titles in AI chat search results, allowing
users to open documents directly from chat interactions.
* Enhanced interactivity in AI chat by making relevant search result
titles visually indicate clickability (pointer cursor).

* **Style**
* Updated styles to visually highlight clickable search result titles in
AI chat results.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-15 08:06:17 +00:00
L-Sun
613597e642 feat(core): notification entry for mobile (#13214)
#### PR Dependency Tree


* **PR #13214** 👈

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 notification icon with a live badge displaying the
notification count in the mobile home header. The badge dynamically
adjusts and caps the count at "99+".
* Introduced a notification menu in the mobile header, allowing users to
view their notifications directly.

* **Style**
* Improved notification list responsiveness on mobile by making it full
width.
* Enhanced the appearance of the notification badge for better
visibility.
* Updated the app fallback UI to display skeleton placeholders for both
notification and settings icons.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-15 07:45:37 +00:00
Cats Juice
a597bdcdf6 fix(core): sidebar ai layout (#13215)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Style**
* Improved chat panel layout with flexible vertical sizing and
alignment.
* Updated padding for chat panel titles to ensure consistent appearance
even if CSS variables are missing.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-15 07:27:55 +00:00
EYHN
316c671c92 fix(core): error when delete tags (#13207)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Refactor**
* Adjusted the placement of a conditional check to improve code
organization. No changes to user-facing functionality.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-15 06:50:54 +00:00
Yii
95a97b793c ci: release tag should start with 'v' 2025-07-15 15:07:16 +08:00
Yii
eb24074871 ci: manually approve ci requires issue wirte permission 2025-07-15 14:57:47 +08:00
Peng Xiao
2a8f18504b fix(core): electron storage sync (#13213)
#### PR Dependency Tree


* **PR #13213** 👈

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 version tracking for global state and cache updates, enabling
synchronized updates across multiple windows.
* Introduced a unique client identifier to prevent processing
self-originated updates.
* **Refactor**
* Improved event broadcasting for global state and cache changes,
ensuring more reliable and efficient update propagation.
* **Chores**
* Updated internal logic to support structured event formats and
revision management for shared storage.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-15 06:45:05 +00:00
235 changed files with 4286 additions and 1589 deletions

View File

@@ -465,7 +465,7 @@ jobs:
name: ${{ env.RELEASE_VERSION }}
draft: ${{ inputs.build-type == 'stable' }}
prerelease: ${{ inputs.build-type != 'stable' }}
tag_name: ${{ env.RELEASE_VERSION}}
tag_name: v${{ env.RELEASE_VERSION}}
files: |
./release/*
./release/.env.example

View File

@@ -34,6 +34,7 @@ permissions:
packages: write
security-events: write
attestations: write
issues: write
jobs:
prepare:
@@ -74,6 +75,7 @@ jobs:
with:
secret: ${{ secrets.GITHUB_TOKEN }}
approvers: forehalo,fengmk2
minimum-approvals: 1
fail-on-denial: true
issue-title: Please confirm to release docker image
issue-body: |

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

@@ -39,6 +39,13 @@ export class CodeBlockHighlighter extends LifeCycleWatcher {
private readonly _loadTheme = async (
highlighter: HighlighterCore
): Promise<void> => {
// It is possible that by the time the highlighter is ready all instances
// have already been unmounted. In that case there is no need to load
// themes or update state.
if (CodeBlockHighlighter._refCount === 0) {
return;
}
const config = this.std.getOptional(CodeBlockConfigExtension.identifier);
const darkTheme = config?.theme?.dark ?? CODE_BLOCK_DEFAULT_DARK_THEME;
const lightTheme = config?.theme?.light ?? CODE_BLOCK_DEFAULT_LIGHT_THEME;
@@ -78,14 +85,27 @@ export class CodeBlockHighlighter extends LifeCycleWatcher {
override unmounted(): void {
CodeBlockHighlighter._refCount--;
// Only dispose the shared highlighter when no instances are using it
if (
CodeBlockHighlighter._refCount === 0 &&
CodeBlockHighlighter._sharedHighlighter
) {
CodeBlockHighlighter._sharedHighlighter.dispose();
// Dispose the shared highlighter **after** any in-flight creation finishes.
if (CodeBlockHighlighter._refCount !== 0) {
return;
}
const doDispose = (highlighter: HighlighterCore | null) => {
if (highlighter) {
highlighter.dispose();
}
CodeBlockHighlighter._sharedHighlighter = null;
CodeBlockHighlighter._highlighterPromise = null;
};
if (CodeBlockHighlighter._sharedHighlighter) {
// Highlighter already created dispose immediately.
doDispose(CodeBlockHighlighter._sharedHighlighter);
} else if (CodeBlockHighlighter._highlighterPromise) {
// Highlighter still being created wait for it, then dispose.
CodeBlockHighlighter._highlighterPromise
.then(doDispose)
.catch(console.error);
}
}
}

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

@@ -20,6 +20,7 @@
"@blocksuite/affine-block-paragraph": "workspace:*",
"@blocksuite/affine-block-surface": "workspace:*",
"@blocksuite/affine-block-surface-ref": "workspace:*",
"@blocksuite/affine-block-table": "workspace:*",
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-ext-loader": "workspace:*",
"@blocksuite/affine-fragment-doc-title": "workspace:*",

View File

@@ -18,6 +18,7 @@ import {
} from '@blocksuite/affine-block-paragraph';
import { DefaultTool, getSurfaceBlock } from '@blocksuite/affine-block-surface';
import { insertSurfaceRefBlockCommand } from '@blocksuite/affine-block-surface-ref';
import { insertTableBlockCommand } from '@blocksuite/affine-block-table';
import { toggleEmbedCardCreateModal } from '@blocksuite/affine-components/embed-card-modal';
import { toast } from '@blocksuite/affine-components/toast';
import { insertInlineLatex } from '@blocksuite/affine-inline-latex';
@@ -40,14 +41,20 @@ import {
deleteSelectedModelsCommand,
draftSelectedModelsCommand,
duplicateSelectedModelsCommand,
focusBlockEnd,
getBlockSelectionsCommand,
getSelectedModelsCommand,
getTextSelectionCommand,
} from '@blocksuite/affine-shared/commands';
import { REFERENCE_NODE } from '@blocksuite/affine-shared/consts';
import {
FeatureFlagService,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import type { AffineTextStyleAttributes } from '@blocksuite/affine-shared/types';
import {
createDefaultDoc,
isInsideBlockByFlavour,
openSingleFileWith,
type Signal,
} from '@blocksuite/affine-shared/utils';
@@ -87,6 +94,7 @@ import {
RedoIcon,
RightTabIcon,
StrikeThroughIcon,
TableIcon,
TeXIcon,
TextIcon,
TodayIcon,
@@ -258,6 +266,62 @@ const textToolActionItems: KeyboardToolbarActionItem[] = [
.run();
},
},
{
name: 'Table',
icon: TableIcon(),
showWhen: ({ std, rootComponent: { model } }) =>
std.store.schema.flavourSchemaMap.has('affine:table') &&
!isInsideBlockByFlavour(std.store, model, 'affine:edgeless-text'),
action: ({ std }) => {
std.command
.chain()
.pipe(getSelectedModelsCommand)
.pipe(insertTableBlockCommand, {
place: 'after',
removeEmptyLine: true,
})
.pipe(({ insertedTableBlockId }) => {
if (insertedTableBlockId) {
const telemetry = std.getOptional(TelemetryProvider);
telemetry?.track('BlockCreated', {
blockType: 'affine:table',
});
}
})
.run();
},
},
{
name: 'Callout',
icon: FontIcon(),
showWhen: ({ std, rootComponent: { model } }) => {
return (
std.get(FeatureFlagService).getFlag('enable_callout') &&
!isInsideBlockByFlavour(model.store, model, 'affine:edgeless-text')
);
},
action: ({ rootComponent: { model }, std }) => {
const { store } = model;
const parent = store.getParent(model);
if (!parent) return;
const index = parent.children.indexOf(model);
if (index === -1) return;
const calloutId = store.addBlock('affine:callout', {}, parent, index + 1);
if (!calloutId) return;
const paragraphId = store.addBlock('affine:paragraph', {}, calloutId);
if (!paragraphId) return;
std.host.updateComplete
.then(() => {
const paragraph = std.view.getBlock(paragraphId);
if (!paragraph) return;
std.command.exec(focusBlockEnd, {
focusBlock: paragraph,
});
})
.catch(console.error);
},
},
];
const listToolActionItems: KeyboardToolbarActionItem[] = [

View File

@@ -17,6 +17,7 @@
{ "path": "../../blocks/paragraph" },
{ "path": "../../blocks/surface" },
{ "path": "../../blocks/surface-ref" },
{ "path": "../../blocks/table" },
{ "path": "../../components" },
{ "path": "../../ext-loader" },
{ "path": "../../fragments/doc-title" },

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() {
@@ -343,7 +341,18 @@ export class LinkedDocPopover extends SignalWatcher(
override willUpdate() {
if (!this.hasUpdated) {
const updatePosition = throttle(() => {
this._position = getPopperPosition(this, this.context.startNativeRange);
this._position = getPopperPosition(
{
getBoundingClientRect: () => {
return {
...this.getBoundingClientRect(),
// Workaround: the width of the popover is zero when it is not rendered
width: 280,
};
},
},
this.context.startNativeRange
);
}, 10);
this.disposables.addFromEvent(window, 'resize', updatePosition);

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

@@ -396,6 +396,15 @@ Generated by [AVA](https://avajs.dev).
},
],
},
{
args: [
'copilot.workspace.cleanupTrashedDocEmbeddings',
{},
{
jobId: 'daily-copilot-cleanup-trashed-doc-embeddings',
},
],
},
]
> cleanup empty sessions calls

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

@@ -461,6 +461,29 @@ test('should create message correctly', async t => {
sessionId,
undefined,
undefined,
new File([new Uint8Array(pngData)], '1.png', { type: 'image/png' })
);
t.truthy(messageId, 'should be able to create message with blob');
}
// with attachments
{
const { id } = await createWorkspace(app);
const sessionId = await createCopilotSession(
app,
id,
randomUUID(),
textPromptName
);
const smallestPng =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII';
const pngData = await fetch(smallestPng).then(res => res.arrayBuffer());
const messageId = await createCopilotMessage(
app,
sessionId,
undefined,
undefined,
undefined,
[new File([new Uint8Array(pngData)], '1.png', { type: 'image/png' })]
);
t.truthy(messageId, 'should be able to create message with blobs');

View File

@@ -13,74 +13,45 @@ Generated by [AVA](https://avajs.dev).
# You own your data, with no compromises␊
## Local-first & Real-time collaborative␊
We love the idea proposed by Ink & Switch in the famous article about you owning your data, despite the cloud. Furthermore, AFFiNE is the first all-in-one workspace that keeps your data ownership with no compromises on real-time collaboration and editing experience.␊
AFFiNE is a local-first application upon CRDTs with real-time collaboration support. Your data is always stored locally while multiple nodes remain synced in real-time.␊
### Blocks that assemble your next docs, tasks kanban or whiteboard␊
There is a large overlap of their atomic "building blocks" between these apps. They are neither open source nor have a plugin system like VS Code for contributors to customize. We want to have something that contains all the features we love and goes one step further.␊
We are building AFFiNE to be a fundamental open source platform that contains all the building blocks for docs, task management and visual collaboration, hoping you can shape your next workflow with us that can make your life better and also connect others, too.␊
If you want to learn more about the product design of AFFiNE, here goes the concepts:␊
To Shape, not to adapt. AFFiNE is built for individuals & teams who care about their data, who refuse vendor lock-in, and who want to have control over their essential tools.␊
## A true canvas for blocks in any form␊
[Many editor apps](http://notion.so) claimed to be a canvas for productivity. Since _the Mother of All Demos,_ Douglas Engelbart, a creative and programable digital workspace has been a pursuit and an ultimate mission for generations of tool makers.␊
"We shape our tools and thereafter our tools shape us”. A lot of pioneers have inspired us a long the way, e.g.:␊
* Quip & Notion with their great concept of "everything is a block"␊
* Trello with their Kanban␊
* Airtable & Miro with their no-code programable datasheets␊
* Miro & Whimiscal with their edgeless visual whiteboard␊
* Remnote & Capacities with their object-based tag system␊
For more details, please refer to our [RoadMap](https://docs.affine.pro/docs/core-concepts/roadmap)␊
## Self Host␊
Self host AFFiNE␊
||Title|Tag|␊
|---|---|---|␊
|Affine Development|Affine Development|<span data-affine-option data-value="AxSe-53xjX" data-option-color="var(--affine-tag-pink)">AFFiNE</span>|␊
@@ -91,16 +62,12 @@ Generated by [AVA](https://avajs.dev).
|Miro & Whimiscal with their edgeless visual whiteboard|Miro & Whimiscal with their edgeless visual whiteboard|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|␊
|Remnote & Capacities with their object-based tag system|Remnote & Capacities with their object-based tag system||␊
## Affine Development␊
For developer or installation guides, please go to [AFFiNE Development](https://docs.affine.pro/docs/development/quick-start)␊
`,
title: 'Write, Draw, Plan all at Once.',
}

View File

@@ -89,3 +89,19 @@ Generated by [AVA](https://avajs.dev).
> should not find docs to embed
0
## should filter outdated doc id style in embedding status
> should include modern doc format
{
embedded: 0,
total: 1,
}
> should count docs after filtering outdated
{
embedded: 1,
total: 1,
}

View File

@@ -164,11 +164,14 @@ test('should insert embedding by doc id', async t => {
);
{
const ret = await t.context.copilotContext.hasWorkspaceEmbedding(
const ret = await t.context.copilotContext.listWorkspaceEmbedding(
workspace.id,
[docId]
);
t.true(ret.has(docId), 'should return doc id when embedding is inserted');
t.true(
ret.includes(docId),
'should return doc id when embedding is inserted'
);
}
{
@@ -317,8 +320,8 @@ test('should merge doc status correctly', async t => {
const hasEmbeddingStub = Sinon.stub(
t.context.copilotContext,
'hasWorkspaceEmbedding'
).resolves(new Set<string>());
'listWorkspaceEmbedding'
).resolves([]);
const stubResult = await t.context.copilotContext.mergeDocStatus(
workspace.id,

View File

@@ -214,6 +214,21 @@ test('should insert and search embedding', async t => {
);
t.false(results.includes(docId), 'docs containing `$` should be excluded');
}
{
const docId = 'empty_doc';
await t.context.doc.upsert({
spaceId: workspace.id,
docId: docId,
blob: Uint8Array.from([0, 0]),
timestamp: Date.now(),
editorId: user.id,
});
const results = await t.context.copilotWorkspace.findDocsToEmbed(
workspace.id
);
t.false(results.includes(docId), 'empty documents should be excluded');
}
});
test('should check need to be embedded', async t => {
@@ -291,3 +306,50 @@ test('should check embedding table', async t => {
// t.false(ret, 'should return false when embedding table is not available');
// }
});
test('should filter outdated doc id style in embedding status', async t => {
const docId = randomUUID();
const outdatedDocId = `${workspace.id}:space:${docId}`;
await t.context.doc.upsert({
spaceId: workspace.id,
docId,
blob: Uint8Array.from([1, 2, 3]),
timestamp: Date.now(),
editorId: user.id,
});
await t.context.doc.upsert({
spaceId: workspace.id,
docId: outdatedDocId,
blob: Uint8Array.from([1, 2, 3]),
timestamp: Date.now(),
editorId: user.id,
});
{
const status = await t.context.copilotWorkspace.getEmbeddingStatus(
workspace.id
);
t.snapshot(status, 'should include modern doc format');
}
{
await t.context.copilotContext.insertWorkspaceEmbedding(
workspace.id,
docId,
[
{
index: 0,
content: 'content',
embedding: Array.from({ length: 1024 }, () => 1),
},
]
);
const status = await t.context.copilotWorkspace.getEmbeddingStatus(
workspace.id
);
t.snapshot(status, 'should count docs after filtering outdated');
}
});

View File

@@ -554,52 +554,73 @@ export async function createCopilotMessage(
sessionId: string,
content?: string,
attachments?: string[],
blob?: File,
blobs?: File[],
params?: Record<string, string>
): Promise<string> {
let resp = app
.POST('/graphql')
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.field(
'operations',
JSON.stringify({
query: `
const gql = {
query: `
mutation createCopilotMessage($options: CreateChatMessageInput!) {
createCopilotMessage(options: $options)
}
`,
variables: {
options: { sessionId, content, attachments, blobs: [], params },
},
})
)
.field(
'map',
JSON.stringify(
Array.from<any>({ length: blobs?.length ?? 0 }).reduce(
(acc, _, idx) => {
acc[idx.toString()] = [`variables.options.blobs.${idx}`];
return acc;
},
{}
)
)
);
if (blobs && blobs.length) {
for (const [idx, file] of blobs.entries()) {
resp = resp.attach(
idx.toString(),
Buffer.from(await file.arrayBuffer()),
{
filename: file.name || `file${idx}`,
contentType: file.type || 'application/octet-stream',
}
variables: {
options: {
sessionId,
content,
attachments,
blob: null,
blobs: [],
params,
},
},
};
let resp = app
.POST('/graphql')
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' });
if (blob || blobs) {
resp = resp.field('operations', JSON.stringify(gql));
if (blob) {
resp = resp.field(
'map',
JSON.stringify({ '0': ['variables.options.blob'] })
);
resp = resp.attach('0', Buffer.from(await blob.arrayBuffer()), {
filename: blob.name || 'file',
contentType: blob.type || 'application/octet-stream',
});
} else if (blobs && blobs.length) {
resp = resp.field(
'map',
JSON.stringify(
Array.from<any>({ length: blobs?.length ?? 0 }).reduce(
(acc, _, idx) => {
acc[idx.toString()] = [`variables.options.blobs.${idx}`];
return acc;
},
{}
)
)
);
for (const [idx, file] of blobs.entries()) {
resp = resp.attach(
idx.toString(),
Buffer.from(await file.arrayBuffer()),
{
filename: file.name || `file${idx}`,
contentType: file.type || 'application/octet-stream',
}
);
}
}
} else {
resp = resp.send(gql);
}
const res = await resp.expect(200);
console.log('createCopilotMessage', res.body);
return res.body.data.createCopilotMessage;
}

View File

@@ -1,3 +1,5 @@
import { setTimeout } from 'node:timers/promises';
import { defer as rxjsDefer, retry } from 'rxjs';
export class RetryablePromise<T> extends Promise<T> {
@@ -48,3 +50,7 @@ export function defer(dispose: () => Promise<void>) {
[Symbol.asyncDispose]: dispose,
};
}
export function sleep(ms: number): Promise<void> {
return setTimeout(ms);
}

View File

@@ -13,74 +13,45 @@ Generated by [AVA](https://avajs.dev).
# You own your data, with no compromises␊
## Local-first & Real-time collaborative␊
We love the idea proposed by Ink & Switch in the famous article about you owning your data, despite the cloud. Furthermore, AFFiNE is the first all-in-one workspace that keeps your data ownership with no compromises on real-time collaboration and editing experience.␊
AFFiNE is a local-first application upon CRDTs with real-time collaboration support. Your data is always stored locally while multiple nodes remain synced in real-time.␊
### Blocks that assemble your next docs, tasks kanban or whiteboard␊
There is a large overlap of their atomic "building blocks" between these apps. They are neither open source nor have a plugin system like VS Code for contributors to customize. We want to have something that contains all the features we love and goes one step further.␊
We are building AFFiNE to be a fundamental open source platform that contains all the building blocks for docs, task management and visual collaboration, hoping you can shape your next workflow with us that can make your life better and also connect others, too.␊
If you want to learn more about the product design of AFFiNE, here goes the concepts:␊
To Shape, not to adapt. AFFiNE is built for individuals & teams who care about their data, who refuse vendor lock-in, and who want to have control over their essential tools.␊
## A true canvas for blocks in any form␊
[Many editor apps](http://notion.so) claimed to be a canvas for productivity. Since _the Mother of All Demos,_ Douglas Engelbart, a creative and programable digital workspace has been a pursuit and an ultimate mission for generations of tool makers.␊
"We shape our tools and thereafter our tools shape us”. A lot of pioneers have inspired us a long the way, e.g.:␊
* Quip & Notion with their great concept of "everything is a block"␊
* Trello with their Kanban␊
* Airtable & Miro with their no-code programable datasheets␊
* Miro & Whimiscal with their edgeless visual whiteboard␊
* Remnote & Capacities with their object-based tag system␊
For more details, please refer to our [RoadMap](https://docs.affine.pro/docs/core-concepts/roadmap)␊
## Self Host␊
Self host AFFiNE␊
||Title|Tag|␊
|---|---|---|␊
|Affine Development|Affine Development|<span data-affine-option data-value="AxSe-53xjX" data-option-color="var(--affine-tag-pink)">AFFiNE</span>|␊
@@ -91,16 +62,12 @@ Generated by [AVA](https://avajs.dev).
|Miro & Whimiscal with their edgeless visual whiteboard|Miro & Whimiscal with their edgeless visual whiteboard|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|␊
|Remnote & Capacities with their object-based tag system|Remnote & Capacities with their object-based tag system||␊
## Affine Development␊
For developer or installation guides, please go to [AFFiNE Development](https://docs.affine.pro/docs/development/quick-start)␊
`,
title: 'Write, Draw, Plan all at Once.',
}

View File

@@ -13,74 +13,45 @@ Generated by [AVA](https://avajs.dev).
# You own your data, with no compromises␊
## Local-first & Real-time collaborative␊
We love the idea proposed by Ink & Switch in the famous article about you owning your data, despite the cloud. Furthermore, AFFiNE is the first all-in-one workspace that keeps your data ownership with no compromises on real-time collaboration and editing experience.␊
AFFiNE is a local-first application upon CRDTs with real-time collaboration support. Your data is always stored locally while multiple nodes remain synced in real-time.␊
### Blocks that assemble your next docs, tasks kanban or whiteboard␊
There is a large overlap of their atomic "building blocks" between these apps. They are neither open source nor have a plugin system like VS Code for contributors to customize. We want to have something that contains all the features we love and goes one step further.␊
We are building AFFiNE to be a fundamental open source platform that contains all the building blocks for docs, task management and visual collaboration, hoping you can shape your next workflow with us that can make your life better and also connect others, too.␊
If you want to learn more about the product design of AFFiNE, here goes the concepts:␊
To Shape, not to adapt. AFFiNE is built for individuals & teams who care about their data, who refuse vendor lock-in, and who want to have control over their essential tools.␊
## A true canvas for blocks in any form␊
[Many editor apps](http://notion.so) claimed to be a canvas for productivity. Since _the Mother of All Demos,_ Douglas Engelbart, a creative and programable digital workspace has been a pursuit and an ultimate mission for generations of tool makers.␊
"We shape our tools and thereafter our tools shape us”. A lot of pioneers have inspired us a long the way, e.g.:␊
* Quip & Notion with their great concept of "everything is a block"␊
* Trello with their Kanban␊
* Airtable & Miro with their no-code programable datasheets␊
* Miro & Whimiscal with their edgeless visual whiteboard␊
* Remnote & Capacities with their object-based tag system␊
For more details, please refer to our [RoadMap](https://docs.affine.pro/docs/core-concepts/roadmap)␊
## Self Host␊
Self host AFFiNE␊
||Title|Tag|␊
|---|---|---|␊
|Affine Development|Affine Development|<span data-affine-option data-value="AxSe-53xjX" data-option-color="var(--affine-tag-pink)">AFFiNE</span>|␊
@@ -91,16 +62,12 @@ Generated by [AVA](https://avajs.dev).
|Miro & Whimiscal with their edgeless visual whiteboard|Miro & Whimiscal with their edgeless visual whiteboard|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|␊
|Remnote & Capacities with their object-based tag system|Remnote & Capacities with their object-based tag system||␊
## Affine Development␊
For developer or installation guides, please go to [AFFiNE Development](https://docs.affine.pro/docs/development/quick-start)␊
`,
title: 'Write, Draw, Plan all at Once.',
}

View File

@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { getStreamAsBuffer } from 'get-stream';
import { JOB_SIGNAL, OnJob } from '../../base';
import { JOB_SIGNAL, OnJob, sleep } from '../../base';
import { type MailName, MailProps, Renderers } from '../../mails';
import { UserProps, WorkspaceProps } from '../../mails/components';
import { Models } from '../../models';
@@ -34,7 +34,7 @@ type SendMailJob<Mail extends MailName = MailName, Props = MailProps<Mail>> = {
declare global {
interface Jobs {
'notification.sendMail': {
'notification.sendMail': { startTime: number } & {
[K in MailName]: SendMailJob<K>;
}[MailName];
}
@@ -50,7 +50,12 @@ export class MailJob {
) {}
@OnJob('notification.sendMail')
async sendMail({ name, to, props }: Jobs['notification.sendMail']) {
async sendMail({
startTime,
name,
to,
props,
}: Jobs['notification.sendMail']) {
let options: Partial<SendOptions> = {};
for (const key in props) {
@@ -100,8 +105,15 @@ export class MailJob {
)),
...options,
});
if (result === false) {
// wait for a while before retrying
const elapsed = Date.now() - startTime;
const retryDelay = Math.min(30 * 1000, Math.round(elapsed / 2000) * 1000);
await sleep(retryDelay);
return JOB_SIGNAL.Retry;
}
return result === false ? JOB_SIGNAL.Retry : undefined;
return undefined;
}
private async fetchWorkspaceProps(workspaceId: string) {

View File

@@ -15,11 +15,14 @@ export class Mailer {
*
* @note never throw
*/
async trySend(command: Jobs['notification.sendMail']) {
async trySend(command: Omit<Jobs['notification.sendMail'], 'startTime'>) {
return this.send(command, true);
}
async send(command: Jobs['notification.sendMail'], suppressError = false) {
async send(
command: Omit<Jobs['notification.sendMail'], 'startTime'>,
suppressError = false
) {
if (!this.sender.configured) {
if (suppressError) {
return false;
@@ -28,7 +31,12 @@ export class Mailer {
}
try {
await this.queue.add('notification.sendMail', command);
await this.queue.add(
'notification.sendMail',
Object.assign({}, command, {
startTime: Date.now(),
}) as Jobs['notification.sendMail']
);
return true;
} catch {
return false;

View File

@@ -1376,74 +1376,45 @@ Generated by [AVA](https://avajs.dev).
# You own your data, with no compromises␊
## Local-first & Real-time collaborative␊
We love the idea proposed by Ink & Switch in the famous article about you owning your data, despite the cloud. Furthermore, AFFiNE is the first all-in-one workspace that keeps your data ownership with no compromises on real-time collaboration and editing experience.␊
AFFiNE is a local-first application upon CRDTs with real-time collaboration support. Your data is always stored locally while multiple nodes remain synced in real-time.␊
### Blocks that assemble your next docs, tasks kanban or whiteboard␊
There is a large overlap of their atomic "building blocks" between these apps. They are neither open source nor have a plugin system like VS Code for contributors to customize. We want to have something that contains all the features we love and goes one step further.␊
We are building AFFiNE to be a fundamental open source platform that contains all the building blocks for docs, task management and visual collaboration, hoping you can shape your next workflow with us that can make your life better and also connect others, too.␊
If you want to learn more about the product design of AFFiNE, here goes the concepts:␊
To Shape, not to adapt. AFFiNE is built for individuals & teams who care about their data, who refuse vendor lock-in, and who want to have control over their essential tools.␊
## A true canvas for blocks in any form␊
[Many editor apps](http://notion.so) claimed to be a canvas for productivity. Since _the Mother of All Demos,_ Douglas Engelbart, a creative and programable digital workspace has been a pursuit and an ultimate mission for generations of tool makers.␊
"We shape our tools and thereafter our tools shape us”. A lot of pioneers have inspired us a long the way, e.g.:␊
* Quip & Notion with their great concept of "everything is a block"␊
* Trello with their Kanban␊
* Airtable & Miro with their no-code programable datasheets␊
* Miro & Whimiscal with their edgeless visual whiteboard␊
* Remnote & Capacities with their object-based tag system␊
For more details, please refer to our [RoadMap](https://docs.affine.pro/docs/core-concepts/roadmap)␊
## Self Host␊
Self host AFFiNE␊
||Title|Tag|␊
|---|---|---|␊
|Affine Development|Affine Development|<span data-affine-option data-value="AxSe-53xjX" data-option-color="var(--affine-tag-pink)">AFFiNE</span>|␊
@@ -1454,16 +1425,12 @@ Generated by [AVA](https://avajs.dev).
|Miro & Whimiscal with their edgeless visual whiteboard|Miro & Whimiscal with their edgeless visual whiteboard|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|␊
|Remnote & Capacities with their object-based tag system|Remnote & Capacities with their object-based tag system||␊
## Affine Development␊
For developer or installation guides, please go to [AFFiNE Development](https://docs.affine.pro/docs/development/quick-start)␊
`,
title: 'Write, Draw, Plan all at Once.',
}
@@ -1476,113 +1443,80 @@ Generated by [AVA](https://avajs.dev).
markdown: `<!-- block_id=FoPQcAyV_m flavour=affine:paragraph -->␊
AFFiNE is an open source all in one workspace, an operating system for all the building blocks of your team wiki, knowledge management and digital assets and a better alternative to Notion and Miro.␊
<!-- block_id=oz48nn_zp8 flavour=affine:paragraph -->␊
<!-- block_id=g8a-D9-jXS flavour=affine:paragraph -->␊
# You own your data, with no compromises␊
<!-- block_id=J8lHN1GR_5 flavour=affine:paragraph -->␊
## Local-first & Real-time collaborative␊
<!-- block_id=xCuWdM0VLz flavour=affine:paragraph -->␊
We love the idea proposed by Ink & Switch in the famous article about you owning your data, despite the cloud. Furthermore, AFFiNE is the first all-in-one workspace that keeps your data ownership with no compromises on real-time collaboration and editing experience.␊
<!-- block_id=zElMi0tViK flavour=affine:paragraph -->␊
AFFiNE is a local-first application upon CRDTs with real-time collaboration support. Your data is always stored locally while multiple nodes remain synced in real-time.␊
<!-- block_id=Z4rK0OF9Wk flavour=affine:paragraph -->␊
<!-- block_id=DQ0Ryb-SpW flavour=affine:paragraph -->␊
### Blocks that assemble your next docs, tasks kanban or whiteboard␊
<!-- block_id=HAZC3URZp_ flavour=affine:paragraph -->␊
There is a large overlap of their atomic "building blocks" between these apps. They are neither open source nor have a plugin system like VS Code for contributors to customize. We want to have something that contains all the features we love and goes one step further.␊
<!-- block_id=0H87ypiuv8 flavour=affine:paragraph -->␊
We are building AFFiNE to be a fundamental open source platform that contains all the building blocks for docs, task management and visual collaboration, hoping you can shape your next workflow with us that can make your life better and also connect others, too.␊
<!-- block_id=Sp4G1KD0Wn flavour=affine:paragraph -->␊
If you want to learn more about the product design of AFFiNE, here goes the concepts:␊
<!-- block_id=RsUhDuEqXa flavour=affine:paragraph -->␊
To Shape, not to adapt. AFFiNE is built for individuals & teams who care about their data, who refuse vendor lock-in, and who want to have control over their essential tools.␊
<!-- block_id=Z2HibKzAr- flavour=affine:paragraph -->␊
## A true canvas for blocks in any form␊
<!-- block_id=UwvWddamzM flavour=affine:paragraph -->␊
[Many editor apps](http://notion.so) claimed to be a canvas for productivity. Since _the Mother of All Demos,_ Douglas Engelbart, a creative and programable digital workspace has been a pursuit and an ultimate mission for generations of tool makers.␊
<!-- block_id=g9xKUjhJj1 flavour=affine:paragraph -->␊
<!-- block_id=wDTn4YJ4pm flavour=affine:paragraph -->␊
"We shape our tools and thereafter our tools shape us”. A lot of pioneers have inspired us a long the way, e.g.:␊
<!-- block_id=xFrrdiP3-V flavour=affine:list -->␊
* Quip & Notion with their great concept of "everything is a block"␊
<!-- block_id=Tp9xyN4Okl flavour=affine:list -->␊
* Trello with their Kanban␊
<!-- block_id=K_4hUzKZFQ flavour=affine:list -->␊
* Airtable & Miro with their no-code programable datasheets␊
<!-- block_id=QwMzON2s7x flavour=affine:list -->␊
* Miro & Whimiscal with their edgeless visual whiteboard␊
<!-- block_id=FFVmit6u1T flavour=affine:list -->␊
* Remnote & Capacities with their object-based tag system␊
<!-- block_id=YqnG5O6AE6 flavour=affine:paragraph -->␊
For more details, please refer to our [RoadMap](https://docs.affine.pro/docs/core-concepts/roadmap)␊
<!-- block_id=sbDTmZMZcq flavour=affine:paragraph -->␊
## Self Host␊
<!-- block_id=QVvitesfbj flavour=affine:paragraph -->␊
Self host AFFiNE␊
<!-- block_id=U_GoHFD9At flavour=affine:database placeholder -->␊
<!-- block_id=NyHXrMX3R1 flavour=affine:paragraph -->␊
## Affine Development␊
<!-- block_id=9-K49otbCv flavour=affine:paragraph -->␊
For developer or installation guides, please go to [AFFiNE Development](https://docs.affine.pro/docs/development/quick-start)␊
<!-- block_id=faFteK9eG- flavour=affine:paragraph -->␊
`,
title: 'Write, Draw, Plan all at Once.',
}

View File

@@ -84,11 +84,17 @@ export class CopilotContextModel extends BaseModel {
}
async mergeDocStatus(workspaceId: string, docs: ContextDoc[]) {
const docIds = Array.from(new Set(docs.map(doc => doc.id)));
const finishedDoc = await this.hasWorkspaceEmbedding(workspaceId, docIds);
const canEmbedding = await this.checkEmbeddingAvailable();
const finishedDoc = canEmbedding
? await this.listWorkspaceEmbedding(
workspaceId,
Array.from(new Set(docs.map(doc => doc.id)))
)
: [];
const finishedDocSet = new Set(finishedDoc);
for (const doc of docs) {
const status = finishedDoc.has(doc.id)
const status = finishedDocSet.has(doc.id)
? ContextEmbedStatus.finished
: undefined;
// NOTE: when the document has not been synchronized to the server or is in the embedding queue
@@ -120,24 +126,17 @@ export class CopilotContextModel extends BaseModel {
return Number(count) === 2;
}
async hasWorkspaceEmbedding(workspaceId: string, docIds: string[]) {
const canEmbedding = await this.checkEmbeddingAvailable();
if (!canEmbedding) {
return new Set();
}
async listWorkspaceEmbedding(workspaceId: string, docIds?: string[]) {
const existsIds = await this.db.aiWorkspaceEmbedding
.findMany({
.groupBy({
where: {
workspaceId,
docId: { in: docIds },
},
select: {
docId: true,
docId: docIds ? { in: docIds } : undefined,
},
by: ['docId'],
})
.then(r => r.map(r => r.docId));
return new Set(existsIds);
return existsIds;
}
private processEmbeddings(

View File

@@ -58,10 +58,12 @@ export class CopilotWorkspaceConfigModel extends BaseModel {
ON id.workspace_id = s.workspace_id
AND id.doc_id = s.guid
WHERE s.workspace_id = ${workspaceId}
AND s.guid != s.workspace_id
AND s.guid <> s.workspace_id
AND s.guid NOT LIKE '%$%'
AND s.guid NOT LIKE '%:settings:%'
AND e.doc_id IS NULL
AND id.doc_id IS NULL;`;
AND id.doc_id IS NULL
AND s.blob <> E'\\\\x0000';`;
return docIds.map(r => r.id);
}
@@ -150,7 +152,7 @@ export class CopilotWorkspaceConfigModel extends BaseModel {
}
@Transactional()
async getWorkspaceEmbeddingStatus(workspaceId: string) {
async getEmbeddingStatus(workspaceId: string) {
const ignoredDocIds = (await this.listIgnoredDocIds(workspaceId)).map(
d => d.docId
);
@@ -160,13 +162,19 @@ export class CopilotWorkspaceConfigModel extends BaseModel {
{ id: { notIn: ignoredDocIds } },
{ id: { not: workspaceId } },
{ id: { not: { contains: '$' } } },
{ id: { not: { contains: ':settings:' } } },
{ blob: { not: new Uint8Array([0, 0]) } },
],
};
const [docTotal, docEmbedded, fileTotal, fileEmbedded] = await Promise.all([
this.db.snapshot.count({ where: snapshotCondition }),
this.db.snapshot.count({
this.db.snapshot.findMany({
where: snapshotCondition,
select: { id: true },
}),
this.db.snapshot.findMany({
where: { ...snapshotCondition, embedding: { some: {} } },
select: { id: true },
}),
this.db.aiWorkspaceFiles.count({ where: { workspaceId } }),
this.db.aiWorkspaceFiles.count({
@@ -174,9 +182,23 @@ export class CopilotWorkspaceConfigModel extends BaseModel {
}),
]);
const docTotalIds = docTotal.map(d => d.id);
const docTotalSet = new Set(docTotalIds);
const outdatedDocPrefix = `${workspaceId}:space:`;
const duplicateOutdatedDocSet = new Set(
docTotalIds
.filter(id => id.startsWith(outdatedDocPrefix))
.filter(id => docTotalSet.has(id.slice(outdatedDocPrefix.length)))
);
return {
total: docTotal + fileTotal,
embedded: docEmbedded + fileEmbedded,
total:
docTotalIds.filter(id => !duplicateOutdatedDocSet.has(id)).length +
fileTotal,
embedded:
docEmbedded
.map(d => d.id)
.filter(id => !duplicateOutdatedDocSet.has(id)).length + fileEmbedded,
};
}

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import { type Workspace } from '@prisma/client';
import { Prisma, type Workspace } from '@prisma/client';
import { EventBus } from '../base';
import { BaseModel } from './base';
@@ -93,6 +93,19 @@ export class WorkspaceModel extends BaseModel {
});
}
async list<S extends Prisma.WorkspaceSelect>(
where: Prisma.WorkspaceWhereInput = {},
select?: S
) {
return (await this.db.workspace.findMany({
where,
select,
orderBy: {
sid: 'asc',
},
})) as Prisma.WorkspaceGetPayload<{ select: S }>[];
}
async delete(workspaceId: string) {
const rawResult = await this.db.workspace.deleteMany({
where: {

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

@@ -356,6 +356,7 @@ export class CopilotContextRootResolver {
return false;
}
@Throttle('strict')
@Query(() => ContextWorkspaceEmbeddingStatus, {
description: 'query workspace embedding status',
})
@@ -372,9 +373,7 @@ export class CopilotContextRootResolver {
if (this.context.canEmbedding) {
const { total, embedded } =
await this.models.copilotWorkspace.getWorkspaceEmbeddingStatus(
workspaceId
);
await this.models.copilotWorkspace.getEmbeddingStatus(workspaceId);
return { total, embedded };
}

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,13 +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': {
nextSid?: number;
};
}
}
@@ -20,6 +25,14 @@ export class CopilotCronJobs {
private readonly jobs: JobQueue
) {}
async triggerCleanupTrashedDocEmbeddings() {
await this.jobs.add(
'copilot.workspace.cleanupTrashedDocEmbeddings',
{},
{ jobId: 'daily-copilot-cleanup-trashed-doc-embeddings' }
);
}
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async dailyCleanupJob() {
await this.jobs.add(
@@ -33,6 +46,12 @@ export class CopilotCronJobs {
{},
{ jobId: 'daily-copilot-generate-missing-titles' }
);
await this.jobs.add(
'copilot.workspace.cleanupTrashedDocEmbeddings',
{},
{ jobId: 'daily-copilot-cleanup-trashed-doc-embeddings' }
);
}
async triggerGenerateMissingTitles() {
@@ -68,4 +87,27 @@ export class CopilotCronJobs {
`Scheduled title generation for ${sessions.length} sessions`
);
}
@OnJob('copilot.workspace.cleanupTrashedDocEmbeddings')
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',
{ workspaceId },
{ jobId: `cleanup-trashed-doc-embeddings-${workspaceId}` }
);
}
params.nextSid = workspaces[workspaces.length - 1].sid;
return JOB_SIGNAL.Repeat;
}
}

View File

@@ -12,6 +12,7 @@ import {
OnJob,
} from '../../../base';
import { DocReader } from '../../../core/doc';
import { readAllDocIdsFromWorkspaceSnapshot } from '../../../core/utils/blocksuite';
import { Models } from '../../../models';
import { CopilotStorage } from '../storage';
import { readStream } from '../utils';
@@ -134,10 +135,30 @@ export class CopilotEmbeddingJob {
if (enableDocEmbedding) {
const toBeEmbedDocIds =
await this.models.copilotWorkspace.findDocsToEmbed(workspaceId);
if (!toBeEmbedDocIds.length) {
return;
}
// filter out trashed docs
const rootSnapshot = await this.models.doc.getSnapshot(
workspaceId,
workspaceId
);
if (!rootSnapshot) {
this.logger.warn(
`Root snapshot for workspace ${workspaceId} not found, skipping embedding.`
);
return;
}
const allDocIds = new Set(
readAllDocIdsFromWorkspaceSnapshot(rootSnapshot.blob)
);
this.logger.log(
`Trigger embedding for ${toBeEmbedDocIds.length} docs in workspace ${workspaceId}`
);
for (const docId of toBeEmbedDocIds) {
const finalToBeEmbedDocIds = toBeEmbedDocIds.filter(docId =>
allDocIds.has(docId)
);
for (const docId of finalToBeEmbedDocIds) {
await this.queue.add(
'copilot.embedding.docs',
{
@@ -337,6 +358,10 @@ export class CopilotEmbeddingJob {
const signal = this.getWorkspaceSignal(workspaceId);
try {
const hasNewDoc = await this.models.doc.exists(
workspaceId,
docId.split(':space:')[1] || ''
);
const needEmbedding =
await this.models.copilotWorkspace.checkDocNeedEmbedded(
workspaceId,
@@ -352,8 +377,11 @@ export class CopilotEmbeddingJob {
);
return;
}
const fragment = await this.getDocFragment(workspaceId, docId);
if (fragment) {
// if doc id deprecated, skip embedding and fulfill empty embedding
const fragment = !hasNewDoc
? await this.getDocFragment(workspaceId, docId)
: undefined;
if (!hasNewDoc && fragment) {
// fast fall for empty doc, journal is easily to create a empty doc
if (fragment.summary.trim()) {
const embeddings = await this.embeddingClient.getFileEmbeddings(
@@ -382,7 +410,7 @@ export class CopilotEmbeddingJob {
);
await this.fulfillEmptyEmbedding(workspaceId, docId);
}
} else if (contextId) {
} else {
this.logger.warn(
`Doc ${docId} in workspace ${workspaceId} has no fragment, fulfilling empty embedding.`
);
@@ -415,4 +443,39 @@ export class CopilotEmbeddingJob {
);
}
}
@OnJob('copilot.embedding.cleanupTrashedDocEmbeddings')
async cleanupTrashedDocEmbeddings({
workspaceId,
}: Jobs['copilot.embedding.cleanupTrashedDocEmbeddings']) {
const workspace = await this.models.workspace.get(workspaceId);
if (!workspace) {
this.logger.warn(`workspace ${workspaceId} not found`);
return;
}
const snapshot = await this.models.doc.getSnapshot(
workspaceId,
workspaceId
);
if (!snapshot) {
this.logger.warn(`workspace snapshot ${workspaceId} not found`);
return;
}
const docIdsInWorkspace = readAllDocIdsFromWorkspaceSnapshot(snapshot.blob);
const docIdsInEmbedding =
await this.models.copilotContext.listWorkspaceEmbedding(workspaceId);
const docIdsInWorkspaceSet = new Set(docIdsInWorkspace);
const deletedDocIds = docIdsInEmbedding.filter(
docId => !docIdsInWorkspaceSet.has(docId)
);
for (const docId of deletedDocIds) {
await this.models.copilotContext.deleteWorkspaceEmbedding(
workspaceId,
docId
);
}
}
}

View File

@@ -61,6 +61,10 @@ declare global {
fileId: string;
fileName: string;
};
'copilot.embedding.cleanupTrashedDocEmbeddings': {
workspaceId: string;
};
}
}

View File

@@ -64,8 +64,8 @@ import {
// context
CopilotContextResolver,
CopilotContextService,
// jobs
CopilotEmbeddingJob,
// cron jobs
CopilotCronJobs,
// transcription
CopilotTranscriptionService,

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

@@ -303,7 +303,7 @@ const textActions: Prompt[] = [
{
name: 'Transcript audio',
action: 'Transcript audio',
model: 'gemini-2.5-pro',
model: 'gemini-2.5-flash',
optionalModels: ['gemini-2.5-flash', 'gemini-2.5-pro'],
messages: [
{
@@ -334,6 +334,7 @@ Convert a multi-speaker audio recording into a structured JSON format by transcr
config: {
requireContent: false,
requireAttachment: true,
maxRetries: 1,
},
},
{
@@ -1467,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[] = [
@@ -1810,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
@@ -1819,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>
@@ -1889,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}}
@@ -1903,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}}
@@ -1923,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

@@ -129,7 +129,16 @@ export abstract class GeminiProvider<T> extends CopilotProvider<T> {
system,
messages: msgs,
schema,
providerOptions: {
google: {
thinkingConfig: {
thinkingBudget: -1,
includeThoughts: false,
},
},
},
abortSignal: options.signal,
maxRetries: options.maxRetries || 3,
experimental_repairText: async ({ text, error }) => {
if (error instanceof JSONParseError) {
// strange fixed response, temporarily replace it
@@ -246,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

@@ -37,7 +37,7 @@ import {
import { CurrentUser } from '../../core/auth';
import { Admin } from '../../core/common';
import { DocReader } from '../../core/doc';
import { AccessController } from '../../core/permission';
import { AccessController, DocAction } from '../../core/permission';
import { UserType } from '../../core/user';
import type { ListSessionOptions, UpdateChatSession } from '../../models';
import { CopilotCronJobs } from './cron';
@@ -143,6 +143,9 @@ class CreateChatMessageInput implements Omit<SubmittedMessage, 'content'> {
@Field(() => [String], { nullable: true, deprecationReason: 'use blobs' })
attachments!: string[] | undefined;
@Field(() => GraphQLUpload, { nullable: true })
blob!: Promise<FileUpload> | undefined;
@Field(() => [GraphQLUpload], { nullable: true })
blobs!: Promise<FileUpload>[] | undefined;
@@ -417,7 +420,8 @@ export class CopilotResolver {
private async assertPermission(
user: CurrentUser,
options: { workspaceId?: string | null; docId?: string | null }
options: { workspaceId?: string | null; docId?: string | null },
fallbackAction?: DocAction
) {
const { workspaceId, docId } = options;
if (!workspaceId) {
@@ -428,7 +432,7 @@ export class CopilotResolver {
.user(user.id)
.doc({ workspaceId, docId })
.allowLocal()
.assert('Doc.Update');
.assert(fallbackAction ?? 'Doc.Update');
} else {
await this.ac
.user(user.id)
@@ -507,7 +511,7 @@ export class CopilotResolver {
if (!workspaceId) {
return [];
} else {
await this.assertPermission(user, { workspaceId, docId });
await this.assertPermission(user, { workspaceId, docId }, 'Doc.Read');
}
const histories = await this.chatSession.list(
@@ -537,7 +541,7 @@ export class CopilotResolver {
if (!workspaceId) {
return paginate([], 'updatedAt', pagination, 0);
} else {
await this.assertPermission(user, { workspaceId, docId });
await this.assertPermission(user, { workspaceId, docId }, 'Doc.Read');
}
const finalOptions = Object.assign(
@@ -703,10 +707,13 @@ export class CopilotResolver {
}
const attachments: PromptMessage['attachments'] = options.attachments || [];
if (options.blobs) {
if (options.blob || options.blobs) {
const { workspaceId } = session.config;
const blobs = await Promise.all(options.blobs);
const blobs = await Promise.all(
options.blob ? [options.blob] : options.blobs || []
);
delete options.blob;
delete options.blobs;
for (const blob of blobs) {
@@ -844,7 +851,7 @@ export class PromptsManagementResolver {
private readonly promptService: PromptService
) {}
@Query(() => Boolean, {
@Mutation(() => Boolean, {
description: 'Trigger generate missing titles cron job',
})
async triggerGenerateTitleCron() {
@@ -852,6 +859,14 @@ export class PromptsManagementResolver {
return true;
}
@Mutation(() => Boolean, {
description: 'Trigger cleanup of trashed doc embeddings',
})
async triggerCleanupTrashedDocEmbeddings() {
await this.cron.triggerCleanupTrashedDocEmbeddings();
return true;
}
@Query(() => [CopilotPromptType], {
description: 'List all copilot prompts',
})

View File

@@ -51,7 +51,15 @@ Important Instructions:
- When inserting, follow the same format as a replacement, but ensure the new block_id does not conflict with existing IDs.
- When replacing content, always keep the original block_id unchanged.
- When deleting content, only use the format <!-- delete block_id=xxx -->, and only for valid block_id present in the original <code> content.
- Each list item should be a block.
- Each top-level list item should be a block. Like this:
\`\`\`markdown
<!-- block_id=001 flavour=affine:list -->
* Item 1
* SubItem 1
<!-- block_id=002 flavour=affine:list -->
1. Item 1
1. SubItem 1
\`\`\`
- Your task is to return a list of block-level changes needed to fulfill the user's intent.
- **Each change in code_edit must be completely independent: each code_edit entry should only perform a single, isolated change, and must not include the effects of other changes. For example, the updates for a delete operation should only show the context related to the deletion, and must not include any content modified by other operations (such as bolding or insertion). This ensures that each change can be applied independently and in any order.**
@@ -142,24 +150,33 @@ You should specify the following arguments before the others: [doc_id], [origin_
'A short, first-person description of the intended edit, clearly summarizing what I will change. For example: "I will translate the steps into English and delete the paragraph explaining the delay." This helps the downstream system understand the purpose of the changes.'
),
code_edit: z
.array(
z.object({
op: z
.string()
.describe(
'A short description of the change, such as "Bold intro name"'
),
updates: z
.string()
.describe(
'Markdown block fragments that represent the change, including the block_id and type'
),
})
)
.describe(
'An array of independent semantic changes to apply to the document.'
),
code_edit: z.preprocess(
val => {
// BACKGROUND: LLM sometimes returns a JSON string instead of an array.
if (typeof val === 'string') {
return JSON.parse(val);
}
return val;
},
z
.array(
z.object({
op: z
.string()
.describe(
'A short description of the change, such as "Bold intro name"'
),
updates: z
.string()
.describe(
'Markdown block fragments that represent the change, including the block_id and type'
),
})
)
.describe(
'An array of independent semantic changes to apply to the document.'
)
),
}),
execute: async ({ doc_id, origin_content, code_edit }) => {
try {

View File

@@ -1,4 +1,5 @@
import { tool } from 'ai';
import { omit } from 'lodash-es';
import { z } from 'zod';
import type { AccessController } from '../../../core/permission';
@@ -8,6 +9,32 @@ import type { ContextSession } from '../context/session';
import type { CopilotChatOptions } from '../providers';
import { toolError } from './error';
const FILTER_PREFIX = [
'Title: ',
'Created at: ',
'Updated at: ',
'Created by: ',
'Updated by: ',
];
function clearEmbeddingChunk(chunk: ChunkSimilarity): ChunkSimilarity {
if (chunk.content) {
const lines = chunk.content.split('\n');
let maxLines = 5;
while (maxLines > 0 && lines.length > 0) {
if (FILTER_PREFIX.some(prefix => lines[0].startsWith(prefix))) {
lines.shift();
maxLines--;
} else {
// only process consecutive metadata rows
break;
}
}
return { ...chunk, content: lines.join('\n') };
}
return chunk;
}
export const buildDocSearchGetter = (
ac: AccessController,
context: CopilotContextService,
@@ -47,18 +74,37 @@ export const buildDocSearchGetter = (
if (!docChunks.length && !fileChunks.length)
return `No results found for "${query}".`;
const docIds = docChunks.map(c => ({
// oxlint-disable-next-line no-non-null-assertion
workspaceId: options.workspace!,
docId: c.docId,
}));
const docAuthors = await models.doc
.findAuthors(docIds)
.then(
docs =>
new Map(
docs
.filter(d => !!d)
.map(doc => [doc.id, omit(doc, ['id', 'workspaceId'])])
)
);
const docMetas = await models.doc
.findAuthors(
docChunks.map(c => ({
// oxlint-disable-next-line no-non-null-assertion
workspaceId: options.workspace!,
docId: c.docId,
}))
)
.then(docs => new Map(docs.filter(d => !!d).map(doc => [doc.id, doc])));
.findMetas(docIds, { select: { title: true } })
.then(
docs =>
new Map(
docs
.filter(d => !!d)
.map(doc => [
doc.docId,
Object.assign({}, doc, docAuthors.get(doc.docId)),
])
)
);
return [
...fileChunks,
...fileChunks.map(clearEmbeddingChunk),
...docChunks.map(c => ({
...c,
...docMetas.get(c.docId),

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

@@ -15,7 +15,6 @@ import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import {
CopilotTranscriptionAudioNotProvided,
CopilotTranscriptionJobNotFound,
type FileUpload,
} from '../../../base';
import { CurrentUser } from '../../../core/auth';
@@ -74,7 +73,7 @@ const FinishedStatus: Set<AiJobStatus> = new Set([
export class CopilotTranscriptionResolver {
constructor(
private readonly ac: AccessController,
private readonly service: CopilotTranscriptionService
private readonly transcript: CopilotTranscriptionService
) {}
private handleJobResult(
@@ -122,7 +121,7 @@ export class CopilotTranscriptionResolver {
throw new CopilotTranscriptionAudioNotProvided();
}
const jobResult = await this.service.submitTranscriptionJob(
const jobResult = await this.transcript.submitJob(
user.id,
workspaceId,
blobId,
@@ -144,19 +143,11 @@ export class CopilotTranscriptionResolver {
.allowLocal()
.assert('Workspace.Copilot');
const job = await this.service.queryTranscriptionJob(
const jobResult = await this.transcript.retryJob(
user.id,
workspaceId,
jobId
);
if (!job || !job.infos) {
throw new CopilotTranscriptionJobNotFound();
}
const jobResult = await this.service.executeTranscriptionJob(
job.id,
job.infos
);
return this.handleJobResult(jobResult);
}
@@ -166,7 +157,7 @@ export class CopilotTranscriptionResolver {
@CurrentUser() user: CurrentUser,
@Args('jobId') jobId: string
): Promise<TranscriptionResultType | null> {
const job = await this.service.claimTranscriptionJob(user.id, jobId);
const job = await this.transcript.claimJob(user.id, jobId);
return this.handleJobResult(job);
}
@@ -190,7 +181,7 @@ export class CopilotTranscriptionResolver {
.allowLocal()
.assert('Workspace.Copilot');
const job = await this.service.queryTranscriptionJob(
const job = await this.transcript.queryJob(
user.id,
copilot.workspaceId,
jobId,

View File

@@ -49,7 +49,17 @@ export class CopilotTranscriptionService {
private readonly providerFactory: CopilotProviderFactory
) {}
async submitTranscriptionJob(
private async getModel(userId: string) {
const prompt = await this.prompt.get('Transcript audio');
const hasAccess = await this.models.userFeature.has(
userId,
'unlimited_copilot'
);
// choose the pro model if user has copilot plan
return prompt?.optionalModels[hasAccess ? 1 : 0];
}
async submitJob(
userId: string,
workspaceId: string,
blobId: string,
@@ -78,12 +88,26 @@ export class CopilotTranscriptionService {
infos.push({ url, mimeType: blob.mimetype });
}
return await this.executeTranscriptionJob(jobId, infos);
const model = await this.getModel(userId);
return await this.executeJob(jobId, infos, model);
}
async executeTranscriptionJob(
async retryJob(userId: string, workspaceId: string, jobId: string) {
const job = await this.queryJob(userId, workspaceId, jobId);
if (!job || !job.infos) {
throw new CopilotTranscriptionJobNotFound();
}
const model = await this.getModel(userId);
const jobResult = await this.executeJob(job.id, job.infos, model);
return jobResult;
}
async executeJob(
jobId: string,
infos: AudioBlobInfos
infos: AudioBlobInfos,
modelId?: string
): Promise<TranscriptionJob> {
const status = AiJobStatus.running;
const success = await this.models.copilotJob.update(jobId, {
@@ -98,12 +122,13 @@ export class CopilotTranscriptionService {
await this.job.add('copilot.transcript.submit', {
jobId,
infos,
modelId,
});
return { id: jobId, status };
}
async claimTranscriptionJob(
async claimJob(
userId: string,
jobId: string
): Promise<TranscriptionJob | null> {
@@ -118,7 +143,7 @@ export class CopilotTranscriptionService {
return null;
}
async queryTranscriptionJob(
async queryJob(
userId: string,
workspaceId: string,
jobId?: string,
@@ -181,14 +206,20 @@ export class CopilotTranscriptionService {
promptName: string,
message: Partial<PromptMessage>,
schema?: ZodType<any>,
prefer?: CopilotProviderType
prefer?: CopilotProviderType,
modelId?: string
): Promise<string> {
const prompt = await this.prompt.get(promptName);
if (!prompt) {
throw new CopilotPromptNotFound({ name: promptName });
}
const cond = { modelId: prompt.model };
const cond = {
modelId:
modelId && prompt.optionalModels.includes(modelId)
? modelId
: prompt.model,
};
const msg = { role: 'user' as const, content: '', ...message };
const config = Object.assign({}, prompt.config);
if (schema) {
@@ -231,13 +262,19 @@ export class CopilotTranscriptionService {
return `${hoursStr}:${minutesStr}:${secondsStr}`;
}
private async callTranscript(url: string, mimeType: string, offset: number) {
private async callTranscript(
url: string,
mimeType: string,
offset: number,
modelId?: string
) {
// NOTE: Vertex provider not support transcription yet, we always use Gemini here
const result = await this.chatWithPrompt(
'Transcript audio',
{ attachments: [url], params: { mimetype: mimeType } },
TranscriptionResponseSchema,
CopilotProviderType.Gemini
CopilotProviderType.Gemini,
modelId
);
const transcription = TranscriptionResponseSchema.parse(
@@ -256,6 +293,7 @@ export class CopilotTranscriptionService {
async transcriptAudio({
jobId,
infos,
modelId,
// @deprecated
url,
mimeType,
@@ -264,7 +302,7 @@ export class CopilotTranscriptionService {
const blobInfos = this.mergeInfos(infos, url, mimeType);
const transcriptions = await Promise.all(
Array.from(blobInfos.entries()).map(([idx, { url, mimeType }]) =>
this.callTranscript(url, mimeType, idx * 10 * 60)
this.callTranscript(url, mimeType, idx * 10 * 60, modelId)
)
);

View File

@@ -56,6 +56,7 @@ declare global {
'copilot.transcript.submit': {
jobId: string;
infos?: AudioBlobInfos;
modelId?: string;
/// @deprecated use `infos` instead
url?: string;
/// @deprecated use `infos` instead

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

@@ -103,6 +103,7 @@ export class CopilotWorkspaceEmbeddingConfigResolver {
return ignoredDocs;
}
@Mutation(() => Number, {
name: 'updateWorkspaceEmbeddingIgnoredDocs',
complexity: 2,

View File

@@ -457,6 +457,7 @@ type CopilotWorkspaceIgnoredDocTypeEdge {
input CreateChatMessageInput {
attachments: [String!]
blob: Upload
blobs: [Upload!]
content: String
params: JSON
@@ -1297,6 +1298,12 @@ type Mutation {
setBlob(blob: Upload!, workspaceId: String!): String!
submitAudioTranscription(blob: Upload, blobId: String!, blobs: [Upload!], workspaceId: String!): TranscriptionResultType
"""Trigger cleanup of trashed doc embeddings"""
triggerCleanupTrashedDocEmbeddings: Boolean!
"""Trigger generate missing titles cron job"""
triggerGenerateTitleCron: Boolean!
"""update app configuration"""
updateAppConfig(updates: [UpdateAppConfigInput!]!): JSONObject!

View File

@@ -569,6 +569,7 @@ export interface CopilotWorkspaceIgnoredDocTypeEdge {
export interface CreateChatMessageInput {
attachments?: InputMaybe<Array<Scalars['String']['input']>>;
blob?: InputMaybe<Scalars['Upload']['input']>;
blobs?: InputMaybe<Array<Scalars['Upload']['input']>>;
content?: InputMaybe<Scalars['String']['input']>;
params?: InputMaybe<Scalars['JSON']['input']>;
@@ -1440,6 +1441,10 @@ export interface Mutation {
sendVerifyEmail: Scalars['Boolean']['output'];
setBlob: Scalars['String']['output'];
submitAudioTranscription: Maybe<TranscriptionResultType>;
/** Trigger cleanup of trashed doc embeddings */
triggerCleanupTrashedDocEmbeddings: Scalars['Boolean']['output'];
/** Trigger generate missing titles cron job */
triggerGenerateTitleCron: Scalars['Boolean']['output'];
/** update app configuration */
updateAppConfig: Scalars['JSONObject']['output'];
/** Update a comment content */

View File

@@ -58,74 +58,45 @@ exports[`should parse page doc work 1`] = `
# You own your data, with no compromises
## Local-first & Real-time collaborative
We love the idea proposed by Ink & Switch in the famous article about you owning your data, despite the cloud. Furthermore, AFFiNE is the first all-in-one workspace that keeps your data ownership with no compromises on real-time collaboration and editing experience.
AFFiNE is a local-first application upon CRDTs with real-time collaboration support. Your data is always stored locally while multiple nodes remain synced in real-time.
### Blocks that assemble your next docs, tasks kanban or whiteboard
There is a large overlap of their atomic "building blocks" between these apps. They are neither open source nor have a plugin system like VS Code for contributors to customize. We want to have something that contains all the features we love and goes one step further.
We are building AFFiNE to be a fundamental open source platform that contains all the building blocks for docs, task management and visual collaboration, hoping you can shape your next workflow with us that can make your life better and also connect others, too.
If you want to learn more about the product design of AFFiNE, here goes the concepts:
To Shape, not to adapt. AFFiNE is built for individuals & teams who care about their data, who refuse vendor lock-in, and who want to have control over their essential tools.
## A true canvas for blocks in any form
[Many editor apps](http://notion.so) claimed to be a canvas for productivity. Since _the Mother of All Demos,_ Douglas Engelbart, a creative and programable digital workspace has been a pursuit and an ultimate mission for generations of tool makers.
"We shape our tools and thereafter our tools shape us”. A lot of pioneers have inspired us a long the way, e.g.:
* Quip & Notion with their great concept of "everything is a block"
* Trello with their Kanban
* Airtable & Miro with their no-code programable datasheets
* Miro & Whimiscal with their edgeless visual whiteboard
* Remnote & Capacities with their object-based tag system
For more details, please refer to our [RoadMap](https://docs.affine.pro/docs/core-concepts/roadmap)
## Self Host
Self host AFFiNE
||Title|Tag|
|---|---|---|
|Affine Development|Affine Development|<span data-affine-option data-value="AxSe-53xjX" data-option-color="var(--affine-tag-pink)">AFFiNE</span>|
@@ -136,16 +107,12 @@ Self host AFFiNE
|Miro & Whimiscal with their edgeless visual whiteboard|Miro & Whimiscal with their edgeless visual whiteboard|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|
|Remnote & Capacities with their object-based tag system|Remnote & Capacities with their object-based tag system||
## Affine Development
For developer or installation guides, please go to [AFFiNE Development](https://docs.affine.pro/docs/development/quick-start)
",
"parsedBlock": {
"children": [
@@ -322,7 +289,6 @@ For developer or installation guides, please go to [AFFiNE Development](https://
{
"children": [],
"content": "* Quip & Notion with their great concept of "everything is a block"
",
"flavour": "affine:list",
"id": "xFrrdiP3-V",
@@ -331,7 +297,6 @@ For developer or installation guides, please go to [AFFiNE Development](https://
{
"children": [],
"content": "* Trello with their Kanban
",
"flavour": "affine:list",
"id": "Tp9xyN4Okl",
@@ -340,7 +305,6 @@ For developer or installation guides, please go to [AFFiNE Development](https://
{
"children": [],
"content": "* Airtable & Miro with their no-code programable datasheets
",
"flavour": "affine:list",
"id": "K_4hUzKZFQ",
@@ -349,7 +313,6 @@ For developer or installation guides, please go to [AFFiNE Development](https://
{
"children": [],
"content": "* Miro & Whimiscal with their edgeless visual whiteboard
",
"flavour": "affine:list",
"id": "QwMzON2s7x",
@@ -358,7 +321,6 @@ For developer or installation guides, please go to [AFFiNE Development](https://
{
"children": [],
"content": "* Remnote & Capacities with their object-based tag system
",
"flavour": "affine:list",
"id": "FFVmit6u1T",
@@ -427,77 +389,63 @@ For developer or installation guides, please go to [AFFiNE Development](https://
"Tag": "<span data-affine-option data-value="AxSe-53xjX" data-option-color="var(--affine-tag-pink)">AFFiNE</span>",
"Title": "Affine Development
",
"undefined": "Affine Development
",
},
{
"Tag": "<span data-affine-option data-value="0jh9gNw4Yl" data-option-color="var(--affine-tag-orange)">Developers</span>",
"Title": "For developers or installations guides, please go to AFFiNE Doc
",
"undefined": "For developers or installations guides, please go to AFFiNE Doc
",
},
{
"Tag": "<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>",
"Title": "Quip & Notion with their great concept of "everything is a block"
",
"undefined": "Quip & Notion with their great concept of "everything is a block"
",
},
{
"Tag": "<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>",
"Title": "Trello with their Kanban
",
"undefined": "Trello with their Kanban
",
},
{
"Tag": "<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>",
"Title": "Airtable & Miro with their no-code programable datasheets
",
"undefined": "Airtable & Miro with their no-code programable datasheets
",
},
{
"Tag": "<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>",
"Title": "Miro & Whimiscal with their edgeless visual whiteboard
",
"undefined": "Miro & Whimiscal with their edgeless visual whiteboard
",
},
{
"Tag": "",
"Title": "Remnote & Capacities with their object-based tag system
",
"undefined": "Remnote & Capacities with their object-based tag system
",
},
],
@@ -559,113 +507,80 @@ exports[`should parse page doc work with ai editable 1`] = `
"<!-- block_id=FoPQcAyV_m flavour=affine:paragraph -->
AFFiNE is an open source all in one workspace, an operating system for all the building blocks of your team wiki, knowledge management and digital assets and a better alternative to Notion and Miro.
<!-- block_id=oz48nn_zp8 flavour=affine:paragraph -->
<!-- block_id=g8a-D9-jXS flavour=affine:paragraph -->
# You own your data, with no compromises
<!-- block_id=J8lHN1GR_5 flavour=affine:paragraph -->
## Local-first & Real-time collaborative
<!-- block_id=xCuWdM0VLz flavour=affine:paragraph -->
We love the idea proposed by Ink & Switch in the famous article about you owning your data, despite the cloud. Furthermore, AFFiNE is the first all-in-one workspace that keeps your data ownership with no compromises on real-time collaboration and editing experience.
<!-- block_id=zElMi0tViK flavour=affine:paragraph -->
AFFiNE is a local-first application upon CRDTs with real-time collaboration support. Your data is always stored locally while multiple nodes remain synced in real-time.
<!-- block_id=Z4rK0OF9Wk flavour=affine:paragraph -->
<!-- block_id=DQ0Ryb-SpW flavour=affine:paragraph -->
### Blocks that assemble your next docs, tasks kanban or whiteboard
<!-- block_id=HAZC3URZp_ flavour=affine:paragraph -->
There is a large overlap of their atomic "building blocks" between these apps. They are neither open source nor have a plugin system like VS Code for contributors to customize. We want to have something that contains all the features we love and goes one step further.
<!-- block_id=0H87ypiuv8 flavour=affine:paragraph -->
We are building AFFiNE to be a fundamental open source platform that contains all the building blocks for docs, task management and visual collaboration, hoping you can shape your next workflow with us that can make your life better and also connect others, too.
<!-- block_id=Sp4G1KD0Wn flavour=affine:paragraph -->
If you want to learn more about the product design of AFFiNE, here goes the concepts:
<!-- block_id=RsUhDuEqXa flavour=affine:paragraph -->
To Shape, not to adapt. AFFiNE is built for individuals & teams who care about their data, who refuse vendor lock-in, and who want to have control over their essential tools.
<!-- block_id=Z2HibKzAr- flavour=affine:paragraph -->
## A true canvas for blocks in any form
<!-- block_id=UwvWddamzM flavour=affine:paragraph -->
[Many editor apps](http://notion.so) claimed to be a canvas for productivity. Since _the Mother of All Demos,_ Douglas Engelbart, a creative and programable digital workspace has been a pursuit and an ultimate mission for generations of tool makers.
<!-- block_id=g9xKUjhJj1 flavour=affine:paragraph -->
<!-- block_id=wDTn4YJ4pm flavour=affine:paragraph -->
"We shape our tools and thereafter our tools shape us”. A lot of pioneers have inspired us a long the way, e.g.:
<!-- block_id=xFrrdiP3-V flavour=affine:list -->
* Quip & Notion with their great concept of "everything is a block"
<!-- block_id=Tp9xyN4Okl flavour=affine:list -->
* Trello with their Kanban
<!-- block_id=K_4hUzKZFQ flavour=affine:list -->
* Airtable & Miro with their no-code programable datasheets
<!-- block_id=QwMzON2s7x flavour=affine:list -->
* Miro & Whimiscal with their edgeless visual whiteboard
<!-- block_id=FFVmit6u1T flavour=affine:list -->
* Remnote & Capacities with their object-based tag system
<!-- block_id=YqnG5O6AE6 flavour=affine:paragraph -->
For more details, please refer to our [RoadMap](https://docs.affine.pro/docs/core-concepts/roadmap)
<!-- block_id=sbDTmZMZcq flavour=affine:paragraph -->
## Self Host
<!-- block_id=QVvitesfbj flavour=affine:paragraph -->
Self host AFFiNE
<!-- block_id=U_GoHFD9At flavour=affine:database placeholder -->
<!-- block_id=NyHXrMX3R1 flavour=affine:paragraph -->
## Affine Development
<!-- block_id=9-K49otbCv flavour=affine:paragraph -->
For developer or installation guides, please go to [AFFiNE Development](https://docs.affine.pro/docs/development/quick-start)
<!-- block_id=faFteK9eG- flavour=affine:paragraph -->
"
`;
@@ -673,122 +588,74 @@ exports[`should parse page full doc work with ai editable 1`] = `
"<!-- block_id=T4qSXc13wz flavour=affine:paragraph -->
# H1 text
<!-- block_id=F5eByK8Fx_ flavour=affine:paragraph -->
List all flavours in one document.
<!-- block_id=6_-Ta2Hpsg flavour=affine:paragraph -->
## H2 ~ H6
<!-- block_id=QLH8pCeJwr flavour=affine:paragraph -->
### H3
<!-- block_id=eRseB5ilzP flavour=affine:paragraph -->
#### H4 with emoji 😄
<!-- block_id=xSEIo9I5jQ flavour=affine:paragraph -->
##### H5
<!-- block_id=h4Fozi-Mvv flavour=affine:paragraph -->
###### H6
<!-- block_id=U-Hd9O6FEZ flavour=affine:paragraph -->
max is H6
<!-- block_id=z2aCxUDpOc flavour=affine:paragraph -->
## List
<!-- block_id=z5Zw7lMlD7 flavour=affine:list -->
* item 1
<!-- block_id=Opmt3x2Ao0 flavour=affine:list -->
* item 2
* sub item 1
* sub item 2
* super sub item 1
* sub item 3
* sub item 1
* sub item 2
* super sub item 1
* sub item 3
<!-- block_id=_EF3g4194w flavour=affine:list -->
* item 3
<!-- block_id=5u-T48lLVF flavour=affine:paragraph -->
<!-- block_id=7urxrvhr-p flavour=affine:paragraph -->
<!-- block_id=U-96XKGGz7 flavour=affine:paragraph -->
<!-- block_id=hOvvRmDGqN flavour=affine:paragraph -->
sort list
<!-- block_id=hcqkMyvKnx flavour=affine:list -->
1. item 1
<!-- block_id=xUsDktnmuD flavour=affine:list -->
1. item 2
<!-- block_id=xa5tsLHHJN flavour=affine:list -->
1. item 3
1. sub item 1
1. sub item 2
1. super item 1
1. super item 2
1. sub item 3
1. sub item 1
1. sub item 2
1. super item 1
1. super item 2
1. sub item 3
<!-- block_id=BX05mQdxJ0 flavour=affine:list -->
1. item 4
<!-- block_id=VYzM3O17th flavour=affine:paragraph -->
<!-- block_id=epKYpKt5vo flavour=affine:paragraph -->
<!-- block_id=5Ghem19uGh flavour=affine:paragraph -->
Table
<!-- block_id=OXvH-s1Jx4 flavour=affine:table -->
|c1|c2|c3|c4|
|---|---|---|---|
@@ -796,176 +663,129 @@ Table
||||v4|
||v6||v5|
<!-- block_id=j2F2hQ3zy9 flavour=affine:paragraph -->
<!-- block_id=jLCRD2G_BC flavour=affine:paragraph -->
<!-- block_id=794ZoPeBJM flavour=affine:paragraph -->
Database
<!-- block_id=xQ7rA57Qxz flavour=affine:database placeholder -->
<!-- block_id=RbMSmluZYK flavour=affine:paragraph -->
Code
<!-- block_id=cJ6CMeUWMg flavour=affine:code -->
\`\`\`javascript
console.log('hello world');
\`\`\`
<!-- block_id=y1xVwkxlDm flavour=affine:paragraph -->
<!-- block_id=BKy3zmm8SE flavour=affine:paragraph -->
Image
<!-- block_id=WFftQ-qXzr flavour=affine:image -->
![-HjsQksaVuEaM0KeTEbBYDgWVOmoVzrAeVK0kn4Jfr0=](blob://-HjsQksaVuEaM0KeTEbBYDgWVOmoVzrAeVK0kn4Jfr0=)
<!-- block_id=F-RKpfxL1z flavour=affine:paragraph -->
<!-- block_id=G3LSqjKv8M flavour=affine:paragraph -->
File
<!-- block_id=pO8JCsiK4z flavour=affine:attachment -->
![IrsiJ9XonFXF8fw9tLQXbSsBbUYFfzLgoFpodeidQOU=](blob://IrsiJ9XonFXF8fw9tLQXbSsBbUYFfzLgoFpodeidQOU=)
<!-- block_id=dTKFqQhJuA flavour=affine:paragraph -->
<!-- block_id=nwld7RMYvp flavour=affine:paragraph -->
> foo bar quote text
<!-- block_id=MwBD3BhRnf flavour=affine:paragraph -->
<!-- block_id=pakOSAm6EU flavour=affine:paragraph -->
<!-- block_id=95-NxAyFuo flavour=affine:divider -->
---
<!-- block_id=r9EllTNiN1 flavour=affine:paragraph -->
<!-- block_id=OpxZ1kYM40 flavour=affine:paragraph -->
TeX
<!-- block_id=gjFqI97IRc flavour=affine:paragraph -->
<!-- block_id=KXBZ1_Pfdw flavour=affine:paragraph -->
<!-- block_id=VHj5gMaGa7 flavour=affine:paragraph -->
2025-06-18 13:15
<!-- block_id=JwaUwzuQEH flavour=affine:paragraph -->
<!-- block_id=_zu2kl56FY flavour=affine:database placeholder -->
<!-- block_id=Kcbp6BLA-y flavour=affine:paragraph -->
Mind Map
<!-- block_id=R_g1tzqzAU flavour=affine:paragraph -->
<!-- block_id=C8G82uLCz1 flavour=affine:paragraph -->
<!-- block_id=J6gfR8YMGy flavour=affine:paragraph -->
A Link
<!-- block_id=yHky0s_H1v flavour=affine:embed-linked-doc -->
[null](doc://FmHFPAPzp51JjFP89aZ-b)
<!-- block_id=P7w3ka4Amo flavour=affine:paragraph -->
Todo List
<!-- block_id=WbeCXu6fcA flavour=affine:list -->
- [ ] abc
<!-- block_id=X_F5fw-MEn flavour=affine:list -->
- [ ] edf
- [x] done1
- [x] done1
<!-- block_id=sdw-couBVA flavour=affine:list -->
- [ ] end
<!-- block_id=COJiWGOVJu flavour=affine:paragraph -->
<!-- block_id=shK7TY-Q3F flavour=affine:paragraph -->
~~delete text~~
<!-- block_id=_NIj4pT_Iy flavour=affine:paragraph -->
<!-- block_id=CaXXPfEt62 flavour=affine:paragraph -->
**Bold text**
<!-- block_id=1WFCwn1708 flavour=affine:paragraph -->
<!-- block_id=25f19QUjQI flavour=affine:paragraph -->
Underline
<!-- block_id=GrS-y17iiw flavour=affine:paragraph -->
<!-- block_id=dJm5C8KsEg flavour=affine:paragraph -->
Youtube
<!-- block_id=epfNja2Txk flavour=affine:embed-youtube -->
<iframe
@@ -979,23 +799,18 @@ Youtube
credentialless>
</iframe>
<!-- block_id=wNb6ZRJKMt flavour=affine:paragraph -->
<!-- block_id=HqKjEGWF_s flavour=affine:paragraph -->
## end
<!-- block_id=FOh_TJmcF1 flavour=affine:paragraph -->
this is end
<!-- block_id=ImCJN2Xint flavour=affine:paragraph -->
"
`;

View File

@@ -22,9 +22,10 @@ export const parseBlockToMd = (
block.content
.split('\n')
.map(line => padding + line)
.slice(0, -1)
.join('\n') +
'\n' +
block.children.map(b => parseBlockToMd(b, padding + ' ')).join('')
block.children.map(b => parseBlockToMd(b, padding + ' ')).join('')
);
} else {
return block.children.map(b => parseBlockToMd(b, padding)).join('');
@@ -109,7 +110,7 @@ export function parseBlock(
const checked = yBlock.get('prop:checked') as boolean;
prefix = checked ? '- [x] ' : '- [ ] ';
}
result.content = prefix + toMd() + '\n';
result.content = prefix + toMd();
break;
}
case 'affine:code': {

View File

@@ -14,6 +14,7 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/AppTheme">
<activity

View File

@@ -10,6 +10,7 @@ import { Suspense } from 'react';
import { RouterProvider } from 'react-router-dom';
import { setupEffects } from './effects';
import { DesktopLanguageSync } from './language-sync';
import { DesktopThemeSync } from './theme-sync';
const { frameworkProvider } = setupEffects();
@@ -46,6 +47,7 @@ export function App() {
<I18nProvider>
<AffineContext store={getCurrentStore()}>
<DesktopThemeSync />
<DesktopLanguageSync />
<RouterProvider
fallbackElement={<AppContainer fallback />}
router={router}

View File

@@ -0,0 +1,18 @@
import { DesktopApiService } from '@affine/core/modules/desktop-api';
import { I18nService } from '@affine/core/modules/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useEffect } from 'react';
export const DesktopLanguageSync = () => {
const i18nService = useService(I18nService);
const currentLanguage = useLiveData(i18nService.i18n.currentLanguageKey$);
const handler = useService(DesktopApiService).api.handler;
useEffect(() => {
handler.i18n.changeLanguage(currentLanguage ?? 'en').catch(err => {
console.error(err);
});
}, [currentLanguage, handler]);
return null;
};

View File

@@ -33,6 +33,7 @@
},
"devDependencies": {
"@affine-tools/utils": "workspace:*",
"@affine/i18n": "workspace:*",
"@affine/native": "workspace:*",
"@affine/nbstore": "workspace:*",
"@electron-forge/cli": "^7.6.0",

View File

@@ -1,3 +1,4 @@
import { I18n } from '@affine/i18n';
import { ipcMain } from 'electron';
import { AFFINE_API_CHANNEL_NAME } from '../shared/type';
@@ -21,6 +22,12 @@ export const debugHandlers = {
},
};
export const i18nHandlers = {
changeLanguage: async (_: Electron.IpcMainInvokeEvent, language: string) => {
return I18n.changeLanguage(language);
},
};
// Note: all of these handlers will be the single-source-of-truth for the apis exposed to the renderer process
export const allHandlers = {
debug: debugHandlers,
@@ -33,6 +40,7 @@ export const allHandlers = {
worker: workerHandlers,
recording: recordingHandlers,
popup: popupHandlers,
i18n: i18nHandlers,
};
export const registerHandlers = () => {

View File

@@ -1,25 +1,17 @@
import type { MainEventRegister } from '../type';
import { globalCacheStorage, globalStateStorage } from './storage';
import { globalCacheUpdates$, globalStateUpdates$ } from './handlers';
export const sharedStorageEvents = {
onGlobalStateChanged: (
fn: (state: Record<string, unknown | undefined>) => void
) => {
const subscription = globalStateStorage.watchAll().subscribe(updates => {
fn(updates);
});
return () => {
subscription.unsubscribe();
};
const subscription = globalStateUpdates$.subscribe(fn);
return () => subscription.unsubscribe();
},
onGlobalCacheChanged: (
fn: (state: Record<string, unknown | undefined>) => void
) => {
const subscription = globalCacheStorage.watchAll().subscribe(updates => {
fn(updates);
});
return () => {
subscription.unsubscribe();
};
const subscription = globalCacheUpdates$.subscribe(fn);
return () => subscription.unsubscribe();
},
} satisfies Record<string, MainEventRegister>;

View File

@@ -1,6 +1,22 @@
import { Subject } from 'rxjs';
import type { NamespaceHandlers } from '../type';
import { globalCacheStorage, globalStateStorage } from './storage';
// Subjects used by shared-storage/events.ts to broadcast updates to all renderer processes
export const globalStateUpdates$ = new Subject<Record<string, any>>();
export const globalCacheUpdates$ = new Subject<Record<string, any>>();
// Revision maps; main generates the next value each time
const globalStateRevisions = new Map<string, number>();
const globalCacheRevisions = new Map<string, number>();
function nextRev(revisions: Map<string, number>, key: string) {
const r = (revisions.get(key) ?? 0) + 1;
revisions.set(key, r);
return r;
}
export const sharedStorageHandlers = {
getAllGlobalState: async () => {
return globalStateStorage.all();
@@ -8,22 +24,36 @@ export const sharedStorageHandlers = {
getAllGlobalCache: async () => {
return globalCacheStorage.all();
},
setGlobalState: async (_, key: string, value: any) => {
return globalStateStorage.set(key, value);
setGlobalState: async (_e, key: string, value: any, sourceId?: string) => {
const rev = nextRev(globalStateRevisions, key);
globalStateStorage.set(key, value);
globalStateUpdates$.next({ [key]: { v: value, r: rev, s: sourceId } });
},
delGlobalState: async (_, key: string) => {
return globalStateStorage.del(key);
delGlobalState: async (_e, key: string, sourceId?: string) => {
const rev = nextRev(globalStateRevisions, key);
globalStateStorage.del(key);
globalStateUpdates$.next({ [key]: { v: undefined, r: rev, s: sourceId } });
},
clearGlobalState: async () => {
return globalStateStorage.clear();
clearGlobalState: async (_e, sourceId?: string) => {
globalStateRevisions.clear();
globalStateStorage.clear();
globalStateUpdates$.next({ '*': { v: undefined, r: 0, s: sourceId } });
},
setGlobalCache: async (_, key: string, value: any) => {
return globalCacheStorage.set(key, value);
setGlobalCache: async (_e, key: string, value: any, sourceId?: string) => {
const rev = nextRev(globalCacheRevisions, key);
globalCacheStorage.set(key, value);
globalCacheUpdates$.next({ [key]: { v: value, r: rev, s: sourceId } });
},
delGlobalCache: async (_, key: string) => {
return globalCacheStorage.del(key);
delGlobalCache: async (_e, key: string, sourceId?: string) => {
const rev = nextRev(globalCacheRevisions, key);
globalCacheStorage.del(key);
globalCacheUpdates$.next({ [key]: { v: undefined, r: rev, s: sourceId } });
},
clearGlobalCache: async () => {
return globalCacheStorage.clear();
clearGlobalCache: async (_e, sourceId?: string) => {
globalCacheRevisions.clear();
globalCacheStorage.clear();
globalCacheUpdates$.next({ '*': { v: undefined, r: 0, s: sourceId } });
},
} satisfies NamespaceHandlers;

View File

@@ -1,5 +1,6 @@
import { join } from 'node:path';
import { I18n } from '@affine/i18n';
import {
app,
BrowserWindow,
@@ -822,42 +823,53 @@ export class WebContentViewsManager {
},
});
if (spellCheckSettings.enabled) {
view.webContents.on('context-menu', (_event, params) => {
const shouldShow =
params.misspelledWord && params.dictionarySuggestions.length > 0;
view.webContents.on('context-menu', (_event, params) => {
const menu = Menu.buildFromTemplate([
{
id: 'cut',
label: I18n['com.affine.context-menu.cut'](),
role: 'cut',
enabled: params.editFlags.canCut,
},
{
id: 'copy',
label: I18n['com.affine.context-menu.copy'](),
role: 'copy',
enabled: params.editFlags.canCopy,
},
{
id: 'paste',
label: I18n['com.affine.context-menu.paste'](),
role: 'paste',
enabled: params.editFlags.canPaste,
},
]);
if (!shouldShow) {
return;
}
const menu = new Menu();
// Add each spelling suggestion
for (const suggestion of params.dictionarySuggestions) {
menu.append(
new MenuItem({
label: suggestion,
click: () => view.webContents.replaceMisspelling(suggestion),
})
);
}
// Add each spelling suggestion
for (const suggestion of params.dictionarySuggestions) {
menu.append(
new MenuItem({
label: suggestion,
click: () => view.webContents.replaceMisspelling(suggestion),
})
);
}
// Allow users to add the misspelled word to the dictionary
if (params.misspelledWord) {
menu.append(
new MenuItem({
label: 'Add to dictionary', // TODO: i18n
click: () =>
view.webContents.session.addWordToSpellCheckerDictionary(
params.misspelledWord
),
})
);
}
// Allow users to add the misspelled word to the dictionary
if (params.misspelledWord) {
menu.append(
new MenuItem({
label: 'Add to dictionary', // TODO: i18n
click: () =>
view.webContents.session.addWordToSpellCheckerDictionary(
params.misspelledWord
),
})
);
}
menu.popup();
});
}
menu.popup();
});
this.webViewsMap$.next(this.tabViewsMap.set(viewId, view));
let unsub = () => {};

View File

@@ -6,6 +6,7 @@ import {
AFFINE_EVENT_CHANNEL_NAME,
} from '../shared/type';
// Load persisted data from main process synchronously at preload time
const initialGlobalState = ipcRenderer.sendSync(
AFFINE_API_CHANNEL_NAME,
'sharedStorage:getAllGlobalState'
@@ -15,6 +16,9 @@ const initialGlobalCache = ipcRenderer.sendSync(
'sharedStorage:getAllGlobalCache'
);
// Unique id for this renderer instance, used to ignore self-originated broadcasts
const CLIENT_ID: string = Math.random().toString(36).slice(2);
function invokeWithCatch(key: string, ...args: any[]) {
ipcRenderer.invoke(AFFINE_API_CHANNEL_NAME, key, ...args).catch(err => {
console.error(`Failed to invoke ${key}`, err);
@@ -34,7 +38,23 @@ function createSharedStorageApi(
memory.setAll(init);
ipcRenderer.on(AFFINE_EVENT_CHANNEL_NAME, (_event, channel, updates) => {
if (channel === `sharedStorage:${event}`) {
for (const [key, value] of Object.entries(updates)) {
for (const [key, raw] of Object.entries(updates)) {
// support both legacy plain value and new { v, r, s } structure
let value: any;
let source: string | undefined;
if (raw && typeof raw === 'object' && 'v' in raw) {
value = (raw as any).v;
source = (raw as any).s;
} else {
value = raw;
}
// Ignore our own broadcasts
if (source && source === CLIENT_ID) {
continue;
}
if (value === undefined) {
memory.del(key);
} else {
@@ -47,11 +67,11 @@ function createSharedStorageApi(
return {
del(key: string) {
memory.del(key);
invokeWithCatch(`sharedStorage:${api.del}`, key);
invokeWithCatch(`sharedStorage:${api.del}`, key, CLIENT_ID);
},
clear() {
memory.clear();
invokeWithCatch(`sharedStorage:${api.clear}`);
invokeWithCatch(`sharedStorage:${api.clear}`, CLIENT_ID);
},
get<T>(key: string): T | undefined {
return memory.get(key);
@@ -61,7 +81,7 @@ function createSharedStorageApi(
},
set(key: string, value: unknown) {
memory.set(key, value);
invokeWithCatch(`sharedStorage:${api.set}`, key, value);
invokeWithCatch(`sharedStorage:${api.set}`, key, value, CLIENT_ID);
},
watch<T>(key: string, cb: (i: T | undefined) => void): () => void {
const subscription = memory.watch(key).subscribe(i => cb(i as T));

View File

@@ -8,6 +8,7 @@
"include": ["./src"],
"references": [
{ "path": "../../../../tools/utils" },
{ "path": "../../i18n" },
{ "path": "../../native" },
{ "path": "../../../common/nbstore" },
{ "path": "../../../common/infra" }

View File

@@ -27,15 +27,6 @@
"version" : "1.1.6"
}
},
{
"identity" : "litext",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Lakr233/Litext",
"state" : {
"revision" : "c37f3ab5826659854311e20d6c3942d4905b00b6",
"version" : "0.5.0"
}
},
{
"identity" : "lrucache",
"kind" : "remoteSourceControl",
@@ -50,8 +41,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/Lakr233/MarkdownView",
"state" : {
"revision" : "29a9da19d6dc21af4e629c423961b0f453ffe192",
"version" : "2.3.8"
"revision" : "446dba45be81c67d0717d19277367dcbe5b2fb12",
"version" : "3.1.9"
}
},
{
@@ -68,8 +59,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/Lakr233/Splash",
"state" : {
"revision" : "4d997712fe07f75695aacdf287aeb3b1f2c6ab88",
"version" : "0.17.0"
"revision" : "de9cde249fdb7a173a6e6b950ab18b11f6c2a557",
"version" : "0.18.0"
}
},
{

View File

@@ -13,23 +13,5 @@ extension AFFiNEViewController: IntelligentsButtonDelegate {
// if it shows up then we are ready to go
let controller = IntelligentsController()
self.present(controller, animated: true)
// IntelligentContext.shared.webView = webView
// button.beginProgress()
// IntelligentContext.shared.preparePresent { result in
// DispatchQueue.main.async {
// button.stopProgress()
// switch result {
// case .success:
// case let .failure(failure):
// let alert = UIAlertController(
// title: "Error",
// message: failure.localizedDescription,
// preferredStyle: .alert
// )
// alert.addAction(UIAlertAction(title: "OK", style: .default))
// self.present(alert, animated: true)
// }
// }
// }
}
}

View File

@@ -64,12 +64,7 @@ class AFFiNEViewController: CAPBridgeViewController {
switch result {
case .failure: break
case .success:
#if DEBUG
// only show the button in debug mode before we get done
self.presentIntelligentsButton()
#else
break
#endif
}
}
}

View File

@@ -69,5 +69,10 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict>
</plist>

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: true } ) { __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,11 +69,15 @@ 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": true
"withMessages": false
]
]),
] }

View File

@@ -7,7 +7,7 @@ public class GetWorkspacePageByIdQuery: GraphQLQuery {
public static let operationName: String = "getWorkspacePageById"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query getWorkspacePageById($workspaceId: String!, $pageId: String!) { workspace(id: $workspaceId) { __typename doc(docId: $pageId) { __typename id mode defaultRole public } } }"#
#"query getWorkspacePageById($workspaceId: String!, $pageId: String!) { workspace(id: $workspaceId) { __typename doc(docId: $pageId) { __typename id mode defaultRole public title summary } } }"#
))
public var workspaceId: String
@@ -68,12 +68,16 @@ public class GetWorkspacePageByIdQuery: GraphQLQuery {
.field("mode", GraphQLEnum<AffineGraphQL.PublicDocMode>.self),
.field("defaultRole", GraphQLEnum<AffineGraphQL.DocRole>.self),
.field("public", Bool.self),
.field("title", String?.self),
.field("summary", String?.self),
] }
public var id: String { __data["id"] }
public var mode: GraphQLEnum<AffineGraphQL.PublicDocMode> { __data["mode"] }
public var defaultRole: GraphQLEnum<AffineGraphQL.DocRole> { __data["defaultRole"] }
public var `public`: Bool { __data["public"] }
public var title: String? { __data["title"] }
public var summary: String? { __data["summary"] }
}
}
}

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

@@ -21,7 +21,7 @@ let package = Package(
.package(url: "https://github.com/SwifterSwift/SwifterSwift.git", from: "6.0.0"),
.package(url: "https://github.com/Recouse/EventSource", from: "0.1.4"),
.package(url: "https://github.com/Lakr233/ListViewKit", from: "1.1.6"),
.package(url: "https://github.com/Lakr233/MarkdownView", exact: "2.3.8"),
.package(url: "https://github.com/Lakr233/MarkdownView", from: "3.1.9"),
],
targets: [
.target(name: "Intelligents", dependencies: [

View File

@@ -0,0 +1,33 @@
//
// ChatManager+CURD.swift
// Intelligents
//
// Created by on 7/14/25.
//
import AffineGraphQL
import Apollo
import ApolloAPI
import EventSource
import Foundation
import MarkdownParser
import MarkdownView
extension ChatManager {
func clearCurrentSession() {
guard let session = IntelligentContext.shared.currentSession else {
print("[-] no current session to clear")
return
}
let mutation = CleanupCopilotSessionMutation(input: .init(
docId: session.docId ?? "",
sessionIds: [session.id],
workspaceId: session.workspaceId
))
QLService.shared.client.perform(mutation: mutation) { result in
print("[+] cleanup session result: \(result)")
}
}
}

View File

@@ -24,53 +24,172 @@ private extension InputBoxData {
}
}
extension ChatManager {
public func startUserRequest(
content: String,
inputBoxData: InputBoxData,
sessionId: String
) {
public extension ChatManager {
func startUserRequest(editorData: InputBoxData, sessionId: String) {
append(sessionId: sessionId, UserMessageCellViewModel(
id: .init(),
content: inputBoxData.text,
content: editorData.text,
timestamp: .init()
))
append(sessionId: sessionId, UserHintCellViewModel(
id: .init(),
timestamp: .init(),
imageAttachments: inputBoxData.imageAttachments,
fileAttachments: inputBoxData.fileAttachments,
docAttachments: inputBoxData.documentAttachments
imageAttachments: editorData.imageAttachments,
fileAttachments: editorData.fileAttachments,
docAttachments: editorData.documentAttachments
))
let viewModelId = append(sessionId: sessionId, AssistantMessageCellViewModel(
id: .init(),
content: "...",
timestamp: .init()
))
scrollToBottomPublisher.send(sessionId)
guard let workspaceId = IntelligentContext.shared.currentWorkspaceId,
!workspaceId.isEmpty
else {
report(sessionId, ChatError.unknownError)
assertionFailure("Invalid workspace ID")
return
}
DispatchQueue.global().async {
self.prepareContext(
workspaceId: workspaceId,
sessionId: sessionId,
editorData: editorData,
viewModelId: viewModelId
)
}
}
}
private extension ChatManager {
func prepareContext(
workspaceId: String,
sessionId: String,
editorData: InputBoxData,
viewModelId: UUID
) {
assert(!Thread.isMainThread)
let createContext = CreateCopilotContextMutation(
workspaceId: workspaceId,
sessionId: sessionId
)
QLService.shared.client.perform(mutation: createContext) { result in
DispatchQueue.main.async {
switch result {
case let .success(graphQLResult):
guard let contextId = graphQLResult.data?.createCopilotContext else {
self.report(sessionId, ChatError.invalidResponse)
return
}
print("[+] copilot context created: \(contextId)")
DispatchQueue.global().async {
let docAttachGroup = DispatchGroup()
for docAttach in editorData.documentAttachments {
let addDoc = AddContextDocMutation(
options: .init(
contextId: contextId,
docId: docAttach.documentID
)
)
docAttachGroup.enter()
QLService.shared.client.perform(mutation: addDoc) { result in
switch result {
case .success:
print("[+] doc \(docAttach.documentID) added to context")
case let .failure(error):
print("[-] addContextDoc failed: \(error)")
}
docAttachGroup.leave()
}
}
docAttachGroup.notify(queue: .global()) {
var contextSnippet = ""
if !editorData.documentAttachments.isEmpty {
let sem = DispatchSemaphore(value: 0)
let matchQuery = MatchContextQuery(
contextId: .some(contextId),
workspaceId: .some(workspaceId),
content: editorData.text,
limit: .none,
scopedThreshold: .none,
threshold: .none
)
QLService.shared.client.fetch(query: matchQuery) { result in
switch result {
case let .success(queryResult):
let matches = queryResult.data?.currentUser?.copilot.contexts ?? []
let matchDocs = matches.compactMap(\.matchWorkspaceDocs).flatMap(\.self)
for context in matchDocs {
contextSnippet += "<file docId=\"\(context.docId)\" chunk=\"\(context.chunk)\">\(context.content)</file>\n"
}
case let .failure(error):
print("[-] matchContext failed: \(error)")
// self.report(sessionId, error)
}
sem.signal()
}
sem.wait()
}
print("[+] context snippet prepared: \(contextSnippet)")
self.startCopilotResponse(
editorData: editorData,
contextSnippet: contextSnippet,
sessionId: sessionId,
viewModelId: viewModelId
)
}
}
case let .failure(error):
self.report(sessionId, error)
return
}
}
}
}
func startCopilotResponse(
editorData: InputBoxData,
contextSnippet: String,
sessionId: String,
viewModelId: UUID
) {
assert(!Thread.isMainThread)
let messageParameters: [String: AnyHashable] = [
// packages/frontend/core/src/blocksuite/ai/provider/setup-provider.tsx
"docs": inputBoxData.documentAttachments.map(\.documentID), // affine doc
"docs": editorData.documentAttachments.map(\.documentID), // affine doc
"files": [String](), // attachment in context, keep nil for now
"searchMode": inputBoxData.isSearchEnabled ? "MUST" : "AUTO",
"searchMode": editorData.isSearchEnabled ? "MUST" : "AUTO",
]
let hasMultipleAttachmentBlobs = [
editorData.fileAttachments.count,
editorData.documentAttachments.count,
].reduce(0, +) > 1
let attachmentFieldName = hasMultipleAttachmentBlobs ? "options.blobs" : "options.blob"
let uploadableAttachments: [GraphQLFile] = [
inputBoxData.fileAttachments.map { file -> GraphQLFile in
.init(
fieldName: file.name,
originalName: file.name,
data: file.data ?? .init()
)
editorData.fileAttachments.map { file -> GraphQLFile in
.init(fieldName: attachmentFieldName, originalName: file.name, data: file.data ?? .init())
},
inputBoxData.imageAttachments.map { image -> GraphQLFile in
.init(
fieldName: image.hashValue.description,
originalName: "image.jpg",
data: image.imageData
)
editorData.imageAttachments.map { image -> GraphQLFile in
.init(fieldName: attachmentFieldName, originalName: "image.jpg", data: image.imageData)
},
].flatMap(\.self)
assert(uploadableAttachments.allSatisfy { !($0.data?.isEmpty ?? true) })
guard let input = try? CreateChatMessageInput(
content: .some(content),
attachments: [],
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
) else {
report(sessionId, ChatError.unknownError)
assertionFailure() // very unlikely to happen
return
}
@@ -83,11 +202,6 @@ extension ChatManager {
self.report(sessionId, ChatError.invalidResponse)
return
}
let viewModelId = self.append(sessionId: sessionId, AssistantMessageCellViewModel(
id: .init(),
content: .init(),
timestamp: .init()
))
self.startStreamingResponse(
sessionId: sessionId,
messageId: messageIdentifier,
@@ -99,8 +213,10 @@ extension ChatManager {
}
}
}
}
private func startStreamingResponse(sessionId: String, messageId: String, applyingTo vmId: UUID) {
private extension ChatManager {
func startStreamingResponse(sessionId: String, messageId: String, applyingTo vmId: UUID) {
let base = IntelligentContext.shared.webViewMetadata[.currentServerBaseUrl] as? String
guard let base, let url = URL(string: base) else {
report(sessionId, ChatError.invalidServerConfiguration)
@@ -164,24 +280,11 @@ extension ChatManager {
vmId: UUID
) {
let result = MarkdownParser().parse(document)
var renderedContexts: [String: RenderedItem] = [:]
for (key, value) in result.mathContext {
let image = MathRenderer.renderToImage(
latex: value,
fontSize: MarkdownTheme.default.fonts.body.pointSize,
textColor: MarkdownTheme.default.colors.body
)?.withRenderingMode(.alwaysTemplate)
let renderedContext = RenderedItem(
image: image,
text: value
)
renderedContexts["math://\(key)"] = renderedContext
}
let content = MarkdownTextView.PreprocessContent(parserResult: result, theme: .default)
with(sessionId: sessionId, vmId: vmId) { (viewModel: inout AssistantMessageCellViewModel) in
viewModel.content = document
viewModel.documentBlocks = result.document
viewModel.documentRenderedContent = renderedContexts
viewModel.preprocessedContent = content
}
}
}

View File

@@ -33,6 +33,12 @@ public class ChatManager: ObservableObject, @unchecked Sendable {
closable.removeAll()
}
public func clearAll() {
assert(Thread.isMainThread)
closeAll()
viewModels.removeAll()
}
public func with(sessionId: String, _ action: (inout OrderedDictionary<MessageID, any ChatCellViewModel>) -> Void) {
if Thread.isMainThread {
if var sessionViewModels = viewModels[sessionId] {
@@ -59,8 +65,6 @@ public class ChatManager: ObservableObject, @unchecked Sendable {
return
}
sessionViewModels[vmId] = vm
} else {
assertionFailure()
}
}
}

View File

@@ -17,6 +17,7 @@ enum BridgedWindowScript: String {
case getCurrentServerBaseUrl = "window.getCurrentServerBaseUrl()"
case getCurrentWorkspaceId = "window.getCurrentWorkspaceId();"
case getCurrentDocId = "window.getCurrentDocId();"
case getAiButtonFeatureFlag = "window.getAiButtonFeatureFlag();"
case getCurrentI18nLocale = "window.getCurrentI18nLocale();"
case createNewDocByMarkdownInCurrentWorkspace = "return await window.createNewDocByMarkdownInCurrentWorkspace(markdown, title);"

View File

@@ -17,6 +17,7 @@ extension IntelligentContext {
(.currentWorkspaceId, .getCurrentWorkspaceId),
(.currentServerBaseUrl, .getCurrentServerBaseUrl),
(.currentI18nLocale, .getCurrentI18nLocale),
(.currentAiButtonFeatureFlag, .getAiButtonFeatureFlag),
]
for (key, script) in keysAndScripts {
DispatchQueue.main.async {

View File

@@ -40,6 +40,7 @@ public class IntelligentContext {
case currentWorkspaceId
case currentServerBaseUrl
case currentI18nLocale
case currentAiButtonFeatureFlag
}
@Published public private(set) var currentSession: ChatSessionObject?
@@ -53,6 +54,7 @@ public class IntelligentContext {
public enum IntelligentError: Error, LocalizedError {
case loginRequired(String)
case sessionCreationFailed(String)
case featureClosed
public var errorDescription: String? {
switch self {
@@ -60,6 +62,8 @@ public class IntelligentContext {
"Login required: \(reason)"
case let .sessionCreationFailed(reason):
"Session creation failed: \(reason)"
case .featureClosed:
"Intelligent feature closed"
}
}
}
@@ -81,6 +85,11 @@ public class IntelligentContext {
}
webViewGroup.wait()
webViewMetadata = webViewMetadataResult
if webViewMetadataResult[.currentAiButtonFeatureFlag] as? Bool == false {
completion(.failure(IntelligentError.featureClosed))
return
}
// Check required webView metadata
guard let baseUrlString = webViewMetadataResult[.currentServerBaseUrl] as? String,

View File

@@ -85,11 +85,7 @@ extension MainViewController: InputBoxDelegate {
}
ChatManager.shared.closeAll()
ChatManager.shared.startUserRequest(
content: inputData.text,
inputBoxData: inputData,
sessionId: currentSession.id
)
ChatManager.shared.startUserRequest(editorData: inputData, sessionId: currentSession.id)
}
private func showAlert(title: String, message: String) {

View File

@@ -21,11 +21,6 @@ class AssistantMessageCell: ChatBaseCell {
contentView.addSubview(markdownView)
}
override func prepareForReuse() {
super.prepareForReuse()
markdownView.prepareForReuse()
}
override func configure(with viewModel: any ChatCellViewModel) {
super.configure(with: viewModel)
@@ -33,10 +28,7 @@ class AssistantMessageCell: ChatBaseCell {
assertionFailure()
return
}
markdownView.setMarkdown(
vm.documentBlocks,
renderedContent: vm.documentRenderedContent
)
markdownView.setMarkdown(vm.preprocessedContent)
}
override func layoutContentView(bounds: CGRect) {
@@ -53,10 +45,7 @@ class AssistantMessageCell: ChatBaseCell {
markdownViewForSizeCalculation.frame = .init(
x: 0, y: 0, width: width, height: .greatestFiniteMagnitude
)
markdownViewForSizeCalculation.setMarkdown(
vm.documentBlocks,
renderedContent: vm.documentRenderedContent
)
markdownViewForSizeCalculation.setMarkdownManually(vm.preprocessedContent)
let boundingSize = markdownViewForSizeCalculation.boundingSize(for: width)
return ceil(boundingSize.height)
}

View File

@@ -38,8 +38,7 @@ struct AssistantMessageCellViewModel: ChatCellViewModel {
var citations: [CitationViewModel]?
var actions: [MessageActionViewModel]?
var documentBlocks: [MarkdownBlockNode]
var documentRenderedContent: RenderContext
var preprocessedContent: MarkdownTextView.PreprocessContent
init(
id: UUID,
@@ -53,7 +52,7 @@ struct AssistantMessageCellViewModel: ChatCellViewModel {
actions: [MessageActionViewModel]? = nil
) {
// time expensive rendering should not happen here
assert(!Thread.isMainThread || content.isEmpty)
assert(!Thread.isMainThread || content.count < 10) // allow placeholder content
self.id = id
self.content = content
@@ -67,21 +66,10 @@ struct AssistantMessageCellViewModel: ChatCellViewModel {
let parser = MarkdownParser()
let parserResult = parser.parse(content)
documentBlocks = parserResult.document
var renderedContexts: [String: RenderedItem] = [:]
for (key, value) in parserResult.mathContext {
let image = MathRenderer.renderToImage(
latex: value,
fontSize: MarkdownTheme.default.fonts.body.pointSize,
textColor: MarkdownTheme.default.colors.body
)?.withRenderingMode(.alwaysTemplate)
let renderedContext = RenderedItem(
image: image,
text: value
)
renderedContexts["math://\(key)"] = renderedContext
}
documentRenderedContent = renderedContexts
preprocessedContent = MarkdownTextView.PreprocessContent(
parserResult: parserResult,
theme: .default,
)
}
}

View File

@@ -6,8 +6,8 @@ private let unselectedColor: UIColor = .affineIconPrimary
private let selectedColor: UIColor = .affineIconActivated
private let configurableOptions: [ConfigurableOptions] = [
.networking,
.reasoning,
// .networking,
// .reasoning,
]
enum ConfigurableOptions {
case tool

View File

@@ -22,11 +22,20 @@ class MainHeaderView: UIView {
$0.textAlignment = .center
}
private lazy var modelMenu = UIDeferredMenuElement.uncached { completion in
completion([])
}
private lazy var dropdownButton = UIButton(type: .system).then {
$0.imageView?.contentMode = .scaleAspectFit
$0.setImage(UIImage.affineArrowDown, for: .normal)
$0.tintColor = UIColor.affineIconPrimary
$0.addTarget(self, action: #selector(dropdownButtonTapped), for: .touchUpInside)
$0.showsMenuAsPrimaryAction = true
$0.menu = UIMenu(options: [.displayInline], children: [
modelMenu,
])
$0.isHidden = true
}
private lazy var centerStackView = UIStackView().then {
@@ -45,6 +54,13 @@ class MainHeaderView: UIView {
$0.layer.cornerRadius = 8
$0.addTarget(self, action: #selector(menuButtonTapped), for: .touchUpInside)
$0.setContentHuggingPriority(.required, for: .horizontal)
$0.showsMenuAsPrimaryAction = true
$0.menu = .init(options: [.displayInline], children: [
UIAction(title: "Clear History", image: .affineBroom, handler: { _ in
ChatManager.shared.clearCurrentSession()
ChatManager.shared.clearAll()
}),
])
}
private lazy var leftSpacerView = UIView()

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