Compare commits

...

135 Commits

Author SHA1 Message Date
liuyi
171a974904 fix(server): use timestamp with timezone (#7847) 2024-08-13 15:32:24 +08:00
forehalo
0ec1995add fix(admin): organize admin panel (#7840) 2024-08-13 14:51:54 +08:00
JimmFly
6dea831d8a fix(admin): handle error login status (#7646)
Fix unhandled error login status, modify style

https://github.com/user-attachments/assets/0b40807d-e17a-4d23-a168-4894adfa5998
2024-08-13 14:51:54 +08:00
JimmFly
b214003968 feat(admin): add prompt management page (#7611)
close AF-907

Supports online modification of prompt, but does not support custom ai key yet

![CleanShot 2024-07-29 at 22 12 39@2x](https://github.com/user-attachments/assets/c67ad0d0-3e5b-44ff-b7db-d07dd11c19e2)
2024-08-13 14:51:54 +08:00
JimmFly
bf6e36de37 feat(admin): add server runtime config settings (#7618) 2024-08-13 14:51:31 +08:00
JimmFly
7f7c0519a0 feat(admin): add config page to admin (#7619) 2024-08-13 14:38:39 +08:00
liuyi
83a9beed83 fix(electron): app got deleted when auto update on windows (#7820) 2024-08-13 14:26:26 +08:00
EYHN
1db6b9fe3b refactor(infra): remove setimmediate (#7821) 2024-08-13 14:25:33 +08:00
JimmFly
ccf225c8f9 feat(admin): add self-host setup and user management page (#7537) 2024-08-13 06:11:03 +00:00
Lye Hongtao
dc519348c5 feat: bump bs (#7836) 2024-08-13 14:05:49 +08:00
renovate
10f4eaf2bd chore: bump up vitest-mock-extended version to v2 (#7584)
[![Mend Renovate](https://app.renovatebot.com/images/banner.svg)](https://renovatebot.com)

This PR contains the following updates:

| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
| [vitest-mock-extended](https://togithub.com/eratio08/vitest-mock-extended) | [`^1.3.1` -> `^2.0.0`](https://renovatebot.com/diffs/npm/vitest-mock-extended/1.3.2/2.0.0) | [![age](https://developer.mend.io/api/mc/badges/age/npm/vitest-mock-extended/2.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/vitest-mock-extended/2.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/vitest-mock-extended/1.3.2/2.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vitest-mock-extended/1.3.2/2.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) |

---

### Release Notes

<details>
<summary>eratio08/vitest-mock-extended (vitest-mock-extended)</summary>

### [`v2.0.0`](https://togithub.com/eratio08/vitest-mock-extended/releases/tag/v2.0.0)

[Compare Source](https://togithub.com/eratio08/vitest-mock-extended/compare/v1.3.2...v2.0.0)

##### chore

-   Adjust package version to 2.0 ([b867078](b86707812b))

##### BREAKING CHANGES

-   Require at least vitest 2.0 as peer dependency

</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.

🔕 **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://www.mend.io/free-developer-tools/renovate/). View the [repository job log](https://developer.mend.io/github/toeverything/AFFiNE).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNy40MzguMCIsInVwZGF0ZWRJblZlciI6IjM4LjIwLjEiLCJ0YXJnZXRCcmFuY2giOiJjYW5hcnkiLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->
2024-08-13 04:03:07 +00:00
renovate
d365494fef chore: bump up vite-plugin-dts version to v4 (#7762)
[![Mend Renovate](https://app.renovatebot.com/images/banner.svg)](https://renovatebot.com)

This PR contains the following updates:

| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
| [vite-plugin-dts](https://togithub.com/qmhc/vite-plugin-dts) | [`3.9.1` -> `4.0.2`](https://renovatebot.com/diffs/npm/vite-plugin-dts/3.9.1/4.0.2) | [![age](https://developer.mend.io/api/mc/badges/age/npm/vite-plugin-dts/4.0.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/npm/vite-plugin-dts/4.0.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/npm/vite-plugin-dts/3.9.1/4.0.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vite-plugin-dts/3.9.1/4.0.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) |

---

### Release Notes

<details>
<summary>qmhc/vite-plugin-dts (vite-plugin-dts)</summary>

### [`v4.0.2`](https://togithub.com/qmhc/vite-plugin-dts/blob/HEAD/CHANGELOG.md#402-2024-08-09)

[Compare Source](https://togithub.com/qmhc/vite-plugin-dts/compare/v4.0.1...v4.0.2)

##### Bug Fixes

-   ensure inserted index file be a module ([f93e98c](f93e98cd84)), closes [#&#8203;365](https://togithub.com/qmhc/vite-plugin-dts/issues/365)

### [`v4.0.1`](https://togithub.com/qmhc/vite-plugin-dts/blob/HEAD/CHANGELOG.md#401-2024-08-07)

[Compare Source](https://togithub.com/qmhc/vite-plugin-dts/compare/v4.0.0...v4.0.1)

##### Bug Fixes

-   correctly match normal export ([589901f](589901fead)), closes [#&#8203;362](https://togithub.com/qmhc/vite-plugin-dts/issues/362)

### [`v4.0.0`](https://togithub.com/qmhc/vite-plugin-dts/blob/HEAD/CHANGELOG.md#400-2024-08-06)

[Compare Source](https://togithub.com/qmhc/vite-plugin-dts/compare/v3.9.1...v4.0.0)

##### Bug Fixes

-   remove global types for vue declaration files ([e873107](e8731077f3)), closes [#&#8203;354](https://togithub.com/qmhc/vite-plugin-dts/issues/354)
-   resolve module preserve to esnext for rollup ([710400a](710400a276)), closes [#&#8203;358](https://togithub.com/qmhc/vite-plugin-dts/issues/358)
-   sync diff line to mappings after transform ([cd5ba32](cd5ba32148)), closes [#&#8203;356](https://togithub.com/qmhc/vite-plugin-dts/issues/356)
-   typescript lib path resolution for rollup in monorepo ([#&#8203;360](https://togithub.com/qmhc/vite-plugin-dts/issues/360)) ([da4af65](da4af6542e))

</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.

🔕 **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://www.mend.io/free-developer-tools/renovate/). View the [repository job log](https://developer.mend.io/github/toeverything/AFFiNE).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzOC4xOC4xNyIsInVwZGF0ZWRJblZlciI6IjM4LjIwLjEiLCJ0YXJnZXRCcmFuY2giOiJjYW5hcnkiLCJsYWJlbHMiOlsiZGVwZW5kZW5jaWVzIl19-->
2024-08-13 03:48:23 +00:00
forehalo
69c64b2fc2 fix(core): checkout event (#7844) 2024-08-13 03:35:38 +00:00
forehalo
dc41ffbe2f chore(core): enable mixpanel ignore_dnt flag (#7841) 2024-08-13 03:35:37 +00:00
JimmFly
9037e6695e feat(core): add configuration for experimental features (#7699)
close AF-1218 AF-1219

Added configuration for experimental features

Example:
```
const blocksuiteFeatureFlags = {
  ...
  enable_expand_database_block: {
    displayName: 'Enable Expand Database Block',
    description: 'Allows expanding database blocks for better view and management.',
    feedbackType: 'discord',
    displayChannel: ['stable', 'beta', 'canary', 'internal'],
    restrictedPlatform: 'client'
  },
    enable_ai_onboarding: {
    displayName: 'AI Onboarding',
    description: 'Enables AI onboarding.',
    displayChannel: [],
    defaultState: true,
  },
  ...
}

```

![CleanShot 2024-08-02 at 12 26 36@2x](https://github.com/user-attachments/assets/98b1e8e7-cd8b-4309-8063-323b2f3b5a94)
2024-08-13 02:26:05 +00:00
CatsJuice
6228b27271 feat(core): new theme editor poc (#7810) 2024-08-12 04:12:51 +00:00
CatsJuice
75e02bb088 feat(core): rewrite page-mode-switch with RadioGroup, bind hotkey with cmdk (#7758)
close AF-1170

- bump `@toeverything/theme`
- refactor page-mode-switch
  - use global `<RadioGroup />`
  - reuse for doc history
  - remove `styled` usage
  - bind hotkey via cmdk
- Update `<RadioGroup />` color scheme with latest design system
- Update right sidebar header tab style
- Update tooltip with shortcut for app nav button
2024-08-12 03:56:56 +00:00
hwangdev97
4ac9bd7790 feat(i18n): fix i18n en-Us & en json english style (#7834) 2024-08-12 03:19:13 +00:00
pengx17
a6169ab26a fix: do not use globalShortcut for tab switching (#7827)
fix #7826
2024-08-11 07:56:47 +00:00
pengx17
d82f4b5461 fix: center peek responsiveness update (#7814)
fix PD-1407
2024-08-09 11:48:50 +00:00
EYHN
a579cc7716 fix(core): better search result (#7819) 2024-08-09 10:37:27 +00:00
EYHN
b993ab04df fix(core): some doc missing in search result (#7818) 2024-08-09 10:37:24 +00:00
forehalo
eef9afd3ed chore: bump base version to 0.16.0 2024-08-09 18:30:07 +08:00
Cats Juice
06d5d9719c fix(core): wrong color of ai-subscribe button (#7816) 2024-08-09 09:44:09 +00:00
Cats Juice
f8e51112aa fix(core): sidebar renaming menu pos (#7798) 2024-08-09 17:06:55 +08:00
Cats Juice
e8d5692062 fix(core): sidebar unauthorized user avatar should center vertically (#7812) 2024-08-09 16:52:29 +08:00
EYHN
d2b0ee40a8 fix(core): disable blocksuite indexer (#7813) 2024-08-09 08:24:44 +00:00
EYHN
3ad5170b71 fix(core): hidden open in split view in browser (#7811) 2024-08-09 07:50:07 +00:00
pengx17
8209e84842 chore(electron): disable parallel execution of electron tests (#7789) 2024-08-09 07:33:16 +00:00
pengx17
fc19180451 fix(electron): missing collection name in tab header (#7807)
fix AF-1177
2024-08-09 07:02:38 +00:00
pengx17
009b5353b1 fix(electron): shell should import renderer css in dev (#7805) 2024-08-09 07:02:34 +00:00
EYHN
4beedaa22c fix(core): delete from folder not work (#7806) 2024-08-09 06:49:20 +00:00
CatsJuice
26fd9a4a1c feat(component): add autoFocusConfirmButton for confirm-modal (#7801)
close #5813

<div class='graphite__hidden'>
          <div>🎥 Video uploaded on Graphite:</div>
            <a href="https://app.graphite.dev/media/video/LakojjjzZNf6ogjOVwKE/aff35b76-9f73-4d15-b2cb-c25b03e2e2c3.mp4">
              <img src="https://app.graphite.dev/api/v1/graphite/video/thumbnail/LakojjjzZNf6ogjOVwKE/aff35b76-9f73-4d15-b2cb-c25b03e2e2c3.mp4">
            </a>
          </div>
<video src="https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/LakojjjzZNf6ogjOVwKE/aff35b76-9f73-4d15-b2cb-c25b03e2e2c3.mp4">CleanShot 2024-08-09 at 11.25.46.mp4</video>
2024-08-09 05:50:22 +00:00
EYHN
b2c00a2618 fix(core): typo in migration text (#7804) 2024-08-09 05:23:52 +00:00
JimmFly
85637156f6 chore: adjust i18n (#7800) 2024-08-09 04:10:18 +00:00
EYHN
c006f3f0af fix(core): reduce indexer performance impact (#7803) 2024-08-09 11:57:06 +08:00
EYHN
7efc87b6d3 chore(core): adjust migration text (#7802) 2024-08-09 11:50:52 +08:00
Tasnim Tantawi
450106ea54 feat(i18n): add Arabic (#7795) 2024-08-09 10:08:52 +08:00
EYHN
ffc12176c9 fix(electron): fix electron global state sync (#7793) 2024-08-08 12:02:10 +00:00
L-Sun
3d4fbcaebc fix(core): can not get chrome version in desktop mode in iOS (#7791) 2024-08-08 18:37:25 +08:00
pengx17
8db37e9bbf feat: cmd click support for journal sidebar (#7792)
fix AF-1214

The titles are also corrected:
![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/T2klNLEk0wxLh4NRDzhk/b7cd888f-b080-4800-a868-c37cbb0b9cbb.png)
2024-08-08 10:22:11 +00:00
pengx17
7fca13076a feat: mid click links to open in new tab (#7784)
fix AF-1200
2024-08-08 09:43:35 +00:00
EYHN
fd6e198295 chore: bump blocksuite (#7788) 2024-08-08 09:27:44 +00:00
pengx17
b71945c29f chore: tracking events for app tabs header (#7778)
fix AF-1194
2024-08-08 09:14:47 +00:00
EYHN
6ef5675be1 feat(core): better search result (#7787) 2024-08-08 08:56:55 +00:00
EYHN
c7aabd3a8d feat(core): highlight doc title in search result (#7786) 2024-08-08 08:56:51 +00:00
CatsJuice
03fd23de39 fix(core): cloud s subscription resume button's content is blank (#7783) 2024-08-08 08:43:05 +00:00
forehalo
f2eafc374c feat(server): authenticate user before ws connected (#7777) 2024-08-08 08:30:56 +00:00
EYHN
83244f0201 fix(core): trash doc in search result (#7785) 2024-08-08 08:17:48 +00:00
pengx17
f62d30527b fix: onboarding stage not shown (#7782)
fix AF-1199
2024-08-08 03:58:11 +00:00
donteatfriedrice
025abc6169 fix: ai chat block center peek animation (#7781) 2024-08-08 02:17:06 +00:00
L-Sun
58b43582e1 chore: bump blocksuite (#7779)
## Features
- https://github.com/toeverything/BlockSuite/pull/7870 @L-Sun

## Bugfix
- https://github.com/toeverything/BlockSuite/pull/7856 @Flrande
- https://github.com/toeverything/BlockSuite/pull/7868 @doouding
- https://github.com/toeverything/BlockSuite/pull/7869 @Flrande
- https://github.com/toeverything/BlockSuite/pull/7866 @doouding
- https://github.com/toeverything/BlockSuite/pull/7867 @Saul-Mirone
- https://github.com/toeverything/BlockSuite/pull/7872 @L-Sun
- https://github.com/toeverything/BlockSuite/pull/7873 @doouding
- https://github.com/toeverything/BlockSuite/pull/7871 @fundon

## Misc
- https://github.com/toeverything/BlockSuite/pull/7874 @Saul-Mirone
2024-08-07 13:45:40 +00:00
L-Sun
ff68efb206 chore: bump blocksuite (#7776)
## Features
- https://github.com/toeverything/BlockSuite/pull/7859 @akumatus
- https://github.com/toeverything/BlockSuite/pull/7855 @darkskygit
- https://github.com/toeverything/BlockSuite/pull/7858 @donteatfriedrice

## Bugfix
- https://github.com/toeverything/BlockSuite/pull/7843 @doouding
- https://github.com/toeverything/BlockSuite/pull/7863 @L-Sun
- https://github.com/toeverything/BlockSuite/pull/7865 @Saul-Mirone
- https://github.com/toeverything/BlockSuite/pull/7860 @doouding
- https://github.com/toeverything/BlockSuite/pull/7857 @fundon

## Refactor
- https://github.com/toeverything/BlockSuite/pull/7862 @L-Sun

## Misc
- https://github.com/toeverything/BlockSuite/pull/7833 @L-Sun
- https://github.com/toeverything/BlockSuite/pull/7864 @Saul-Mirone
- https://github.com/toeverything/BlockSuite/pull/7861 @Saul-Mirone
- https://github.com/toeverything/BlockSuite/pull/7849 @Saul-Mirone
2024-08-07 09:45:09 +00:00
CatsJuice
c8f4766ceb fix(component): center button's icon vertically (#7775)
close AF-1211
2024-08-07 09:31:23 +00:00
EYHN
d968cfe425 fix(infra): better search result (#7774) 2024-08-07 09:16:43 +00:00
Ikko Eltociear Ashimine
2f0e39b702 fix(core): update en.json (#7765) 2024-08-07 17:16:14 +08:00
JimmFly
4e03edba44 feat(i18n): add Spanish(Argentina) and Urdu (#7771)
Support for various languages has been updated, and `Spanish (Argentina)` and `Urdu` have been added. Many thanks to the community contributors for their ongoing translation efforts.
2024-08-07 08:57:23 +00:00
pengx17
00ee2a8852 fix(electron): always show traffic light for mac (#7773)
fix AF-1209, fix PD-1550
2024-08-07 08:44:35 +00:00
CatsJuice
75a308ac79 fix(core): optimize explorer's dnd behaviors (#7769)
close AF-1198, AF-1169, AF-1204, AF-1167, AF-1168

- **fix**: empty favorite cannot be dropped(AF-1198)
- **fix**: folder close animation has a unexpected delay
- **fix**: mount explorer's DropEffect to body to avoid clipping(AF-1169)
- **feat**: drop on empty organize to create folder and put item into it(AF-1204)
- **feat**: only show explorer section's action when hovered(AF-1168)
- **feat**: animate folder icon when opened(AF-1167)
- **chore**: extract dnd related `dropEffect`, `canDrop` functions outside component
2024-08-07 08:29:19 +00:00
donteatfriedrice
f35dc744dd fix: render ai chat block in embed doc and surface ref (#7747)
[BS-1017](https://linear.app/affine-design/issue/BS-1017/含有chat-block的页面被embed时,embed预览不会渲染chatblock)

related: https://github.com/toeverything/blocksuite/pull/7845
2024-08-07 06:55:51 +00:00
pengx17
ae9381c36d feat: allow opening new tab for some navigation buttons (#7764)
fix AF-1010
2024-08-07 06:43:10 +00:00
donteatfriedrice
e1087a0c7b fix: remove chat block button flag (#7767) 2024-08-07 06:31:04 +00:00
JimmFly
eb01e76426 feat(core): add track events for cmdk (#7668) 2024-08-07 05:52:42 +00:00
JimmFly
67dce9c97a feat(core): add track events for page info (#7667) 2024-08-07 05:52:41 +00:00
JimmFly
7edd78884e feat(core): add track events for page option (#7664) 2024-08-07 05:52:41 +00:00
JimmFly
74025fc85e feat(core): add track events for editor header (#7661)
close AF-1054
2024-08-07 05:52:40 +00:00
pengx17
b5e543c406 feat(electron): mouse middle click to close tab (#7759)
fix AF-1200
2024-08-07 05:19:45 +00:00
donteatfriedrice
352ceca94b fix: chat action button style (#7766)
[PD-1547](https://linear.app/affine-design/issue/PD-1547/ui-bug-chat-block-面板生成结果的-action-按钮的高度不对)
2024-08-07 04:59:26 +00:00
akumatus
f3855c57b4 fix: can not create a new edgeless doc with "@" on edgeless (#7772)
Close issue [BS-935](https://linear.app/affine-design/issue/BS-935).
2024-08-07 04:46:13 +00:00
L-Sun
f6279ee47f chore(core): remove outline viewer feature flag (#7770) 2024-08-07 03:46:15 +00:00
EYHN
aee24ffb31 fix(core): migration favorite appear again (#7768) 2024-08-07 03:31:06 +00:00
EYHN
96fed60655 chore: bump blocksuite (#7751)
## Features
- https://github.com/toeverything/BlockSuite/pull/7850 @donteatfriedrice
- https://github.com/toeverything/BlockSuite/pull/7848 @L-Sun

## Bugfix
- https://github.com/toeverything/BlockSuite/pull/7853 @doouding
- https://github.com/toeverything/BlockSuite/pull/7838 @fundon
- https://github.com/toeverything/BlockSuite/pull/7851 @donteatfriedrice

## Refactor

## Misc
2024-08-07 02:52:44 +00:00
EYHN
dd74cfea14 chore(core): remove old favorite (#7743)
closes AF-1203
2024-08-07 02:19:53 +00:00
pengx17
c2cf331ff7 fix(electron): fix tab view blink issue on open new tab (#7748)
fix AF-1197
2024-08-06 15:57:40 +00:00
darkskygit
744cc542de feat: handle copilot error (#7760)
fix BS-729 CLOUD-32 PD-1529
2024-08-06 11:19:35 +00:00
fundon
601f5fef95 chore(core): theme v1.0.2 (#7746) 2024-08-06 10:54:09 +00:00
darkskygit
14669b9ced feat: improve continue to chat compatibility (#7757)
fix AF-1152
2024-08-06 09:15:42 +00:00
darkskygit
5872b884a5 fix: increase image limit of copilot (#7756)
fix AF-1080 AF-1154
2024-08-06 09:15:40 +00:00
liuyi
d0f1bb24fd chore(core): replace with new track impl (#7735) 2024-08-06 09:15:15 +00:00
renovate[bot]
7373e174db chore: bump up fast-xml-parser version to v4.4.1 [SECURITY] (#7752)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-08-06 16:54:24 +08:00
forehalo
cc09085dc2 feat(core): make event track great again (#7695)
![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/5c0ymolP9B7QStCsS1RP/6a3941d9-4409-4eda-987b-88ae41bd72d4.png)

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/5c0ymolP9B7QStCsS1RP/3e9cedec-5457-4e7a-9125-63aab7247cd2.png)
2024-08-06 08:39:29 +00:00
darkskygit
f93743dae6 fix: reset height after send (#7755)
fix PD-1427
2024-08-06 08:25:28 +00:00
darkskygit
de7933c1dd fix: dont limit text block height in chat panel (#7754)
fix PD-1368
2024-08-06 08:25:27 +00:00
pengx17
ca7c221d23 fix(electron): onboarding not shown (#7753)
fix AF-1199
2024-08-06 07:54:32 +00:00
EYHN
873e6faef2 chore: bump @blocksuite/icons (#7749) 2024-08-06 05:32:19 +00:00
CatsJuice
5938d8b259 feat(core): add tooltip and toast for organize operations (#7725)
close AF-1138, AF-1139, AF-1165
2024-08-06 02:07:10 +00:00
EYHN
cd4e462d8c fix(core): transform workspace db when enable cloud (#7744) 2024-08-05 15:17:19 +00:00
pengx17
a03831f2a2 fix(electron): whitescreen issue (#7742)
1. non-blurred mode whitescreen issue
2. should not close tab with cmd+w for pinned tabs
2024-08-05 14:09:22 +00:00
pengx17
0d7de67e01 refactor(electron): reduce the number of listeners for ipc (#7740)
previously there are quite a lot of api/events handlers registered on ipcMain/ipcRenderer. After this PR, the number should be significantly reduced, which will benefit performance.
2024-08-05 13:33:31 +00:00
darkskygit
0acc1bd9e8 chore: cleanup outdated model & upgrade model (#7739) 2024-08-05 10:13:33 +00:00
EYHN
e6e9f7d4c7 feat(core): enable feature flag for release (#7738) 2024-08-05 09:53:11 +00:00
pengx17
9f57ed5e84 fix(electron): find in page input border blink issue (#7737) 2024-08-05 09:27:21 +00:00
JimmFly
9cc976ce2e fix(core): canvas text adapts to input scrolling (#7733)
close PD-1539
Fixed the problem that the input text in find in page cannot be scrolled correctly.
2024-08-05 09:27:18 +00:00
CatsJuice
6d253c0600 fix(core): add favorite folder in menu, adjust empty-page new page button (#7730)
close AF-1150, AF-1128, AF-1131
- Replace favorite migration related copy
- Adjust empty page's "New Page" button
  ![CleanShot 2024-08-05 at 15.16.06@2x.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/LakojjjzZNf6ogjOVwKE/1cf7d75a-a33a-4eec-9dc1-87d50d9526f1.png)
- Add toggle favorite to folder menu
  ![CleanShot 2024-08-05 at 15.17.50@2x.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/LakojjjzZNf6ogjOVwKE/af6116b5-47d1-49a6-9660-41c0d7cd8fd3.png)
- Adjust `Button`
  - add `withoutHover` state
  - remove cursor: not-allowed when disabled
2024-08-05 09:15:17 +00:00
darkskygit
73a6723d15 fix: use correct user id in forked session (#7710) 2024-08-05 09:03:11 +00:00
pengx17
5050418c1a fix(electron): app ghosting issue when quickly opening new tabs (#7736)
fix PD-1519
2024-08-05 08:51:11 +00:00
pengx17
5ab1210c9c fix(electron): drop indicator position (#7734) 2024-08-05 08:03:13 +00:00
pengx17
51848ff6c3 fix(electron): allow close pinned tab (#7732) 2024-08-05 08:03:12 +00:00
pengx17
5f52547d9e fix(electron): tab title/icon default state (#7731) 2024-08-05 07:39:51 +00:00
pengx17
561fa46232 fix(electron): add i18n setup for shell (#7728)
![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/T2klNLEk0wxLh4NRDzhk/1ef2a050-c372-4b8f-8bf0-b1e557e11f29.png)
2024-08-05 06:52:14 +00:00
renovate[bot]
7a66212568 chore: bump up oxlint version to v0.7.0 (#7727) 2024-08-05 14:46:59 +08:00
pengx17
51ca7657d8 feat(electron): new tab/split view entries (#7708)
fix AF-1146
2024-08-05 05:37:50 +00:00
L-Sun
bd31c8388c fix(core): update outline viewer style (#7641)
## What changes
- Update responsive style and fix some bug of outline viewer (https://github.com/toeverything/blocksuite/pull/7759)
- Change left and right padding of full-width editor from `15px` to `72px`
- Hide outline viewer when side outline panel is opened ([BS-987](https://linear.app/affine-design/issue/BS-987/逻辑-bug-toc-入口和-toc-侧边栏共存))
- Add entries of outline panel and frame panel in more menu of detail page header ( [BS-996](https://linear.app/affine-design/issue/BS-996/page-mode-下的-page-option-缺少-view-table-of-contents-的入口) , [BS-1006](https://linear.app/affine-design/issue/BS-1006/edgeless-mode-的-page-options-里缺少-view-all-frames))
- Add outline viewer to dock peek preview ( [BS-995](https://linear.app/affine-design/issue/BS-995/center-peek-里缺少-quick-toc-的入口) )
- Add more e2e tests for outline viewer
2024-08-05 03:57:48 +00:00
L-Sun
545bd032a7 fix(core): app height exceeds viewport of mobile (#7706)
TL;DR
use `100dvh` instead of `100vh`.

https://stackoverflow.com/a/72245072

PS: The `100dvh` is tested in Firefox in macOS

## Before
iPad
<img src="https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/MyRfgiN4RuBxJfrza3SG/c81548ed-7ca0-4f88-af7c-cce498958a28.png" width="250">

Phone
<img src="https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/MyRfgiN4RuBxJfrza3SG/4559554d-6f3f-445f-82c1-39a0dc2eb664.png" width="250">

## After
iPad
<img src="https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/MyRfgiN4RuBxJfrza3SG/51fe97f9-f488-432c-9866-20524efd08de.png" width="250">

Phone
<img src="https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/MyRfgiN4RuBxJfrza3SG/a05cce56-38a5-47df-a0c6-f757d94ef6b8.png" width="250">
2024-08-05 03:42:15 +00:00
pengx17
e3878ae8bf build(electron): nightly build issue for windows (#7649)
ref https://github.com/toeverything/AFFiNE/actions/runs/10156494874/job/28085093900
2024-08-05 03:28:02 +00:00
pengx17
c0c5c83dad fix(electron): should activate the target tab when closing other tabs (#7704)
fix PD-1520
2024-08-05 03:11:55 +00:00
pengx17
cbdcfdc2d8 fix(electron): duplicate tab views issue (#7703)
fix PD-1523
2024-08-05 03:11:52 +00:00
pengx17
741ff2379e fix(electron): reload view in tab context menu issue (#7702)
fix PD-1524
2024-08-05 03:11:50 +00:00
pengx17
9307acf0de fix(core): ctrl/cmd + click on add page button opens in new tab (#7701)
fix PD-1521
2024-08-05 03:11:47 +00:00
pengx17
0468355593 test(electron): adjust expect timeout for CI (#7707) 2024-08-05 03:11:44 +00:00
CatsJuice
249f3471c9 feat(component): shortcut style for tooltip (#7721)
![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/LakojjjzZNf6ogjOVwKE/2e68337c-91f1-4ea7-8426-7fb33be02081.png)

- New `shortcut` prop for `<Tooltip />`
  - single key
      ```tsx
      <Tooltip shortcut="T" />
      ```
  - multiple
      ```tsx
      <Tooltip shortcut={["⌘",  "K"]} />
      ```
- Round tooltip's arrow
- Use new design system colors
- Replace some usage
  - App sidebar switch
  - Editor mode switch
  - New tab (new)
2024-08-05 02:57:24 +00:00
CatsJuice
3d855647c7 refactor(component): refactor the implementation of Button and IconButton (#7716)
## Button
- Remove props withoutHoverStyle
   refactor hover impl with independent layer, so that hover-color won't affect the background even if is overridden outside
- Update `type` (renamed to `variant`):
  - remove `processing` and `warning`
  - rename `default` with `secondary`
- Remove `shape` props
- Remove `icon` and `iconPosition`, replaced with `prefix: ReactNode` and `suffix: ReactNode`
- Integrate tooltip for more convenient usage
- New Storybook document
- Focus style

## IconButton
- A Wrapper base on `<Button />`
- Override Button size and variant
  - size: `'12' | '14' | '16' | '20' | '24' | number`
     These presets size are referenced from the design system.
  - variant:  `'plain' | 'solid' | 'danger' | 'custom'`
- Inset icon via Button 's prefix

## Fix
- fix some button related issues
- close AF-1159, AF-1160, AF-1161, AF-1162, AF-1163, AF-1158, AF-1157

## Storybook

![CleanShot 2024-08-03 at 14.57.20@2x.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/LakojjjzZNf6ogjOVwKE/f5a76110-35d0-4082-a940-efc12bed87b0.png)
2024-08-05 02:57:23 +00:00
donteatfriedrice
10deed94e3 fix: center peek message role (#7723) 2024-08-05 02:43:58 +00:00
EYHN
f108b95704 feat(core): tuning for better search (#7713) 2024-08-05 02:29:50 +00:00
EYHN
ad26102815 fix(core): fix scroll block into view (#7712) 2024-08-05 02:29:49 +00:00
EYHN
05448f50af fix(core): wrong display of 404 page (#7711) 2024-08-02 10:40:58 +00:00
EYHN
e54be7dc02 feat(core): loading ui for favorite and organize (#7700) 2024-08-02 07:17:01 +00:00
donteatfriedrice
94c5effdd5 fix: should save chat to block when doc mode (#7697)
[BS-1033](https://linear.app/affine-design/issue/BS-1033/在page模式下尝试存chat-block,被告知失败,预期是切换到白板模式直接操作成功)
2024-08-02 13:14:15 +08:00
donteatfriedrice
62fc7e2f4d fix: support chat in different doc (#7693)
fix:
[BS-990](https://linear.app/affine-design/issue/BS-990/避免centerpeek中发起的会话,等待ai返回时,页面失去响应)
[BS-1005](https://linear.app/affine-design/issue/BS-1005/chat-block无法被copy-paste到别的文档中,duplicate全篇文档后,可以center-peek)
2024-08-02 13:14:15 +08:00
donteatfriedrice
f7798a00c1 feat: patch edgeless clipboard to support cuntom block copy paste (#7689)
fix: [BS-1005](https://linear.app/affine-design/issue/BS-1005/chat-block无法被copy-paste到别的文档中,duplicate全篇文档后,可以center-peek)

related: https://github.com/toeverything/blocksuite/pull/7797
2024-08-02 13:14:14 +08:00
pengx17
854718db0e test(electron): enable trace file for desktop tests (#7692)
<div class='graphite__hidden'>
          <div>🎥 Video uploaded on Graphite:</div>
            <a href="https://app.graphite.dev/media/video/T2klNLEk0wxLh4NRDzhk/e8d87ee7-cea6-4188-80a7-1a64f6c74eca.webm">
              <img src="https://app.graphite.dev/api/v1/graphite/video/thumbnail/T2klNLEk0wxLh4NRDzhk/e8d87ee7-cea6-4188-80a7-1a64f6c74eca.webm">
            </a>
          </div>
<video src="https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/T2klNLEk0wxLh4NRDzhk/e8d87ee7-cea6-4188-80a7-1a64f6c74eca.webm">e02e866a6496b210b8883798d82783d8.webm</video>

one failed run but no valuable result
2024-08-02 04:07:33 +00:00
donteatfriedrice
2cfe9e8b9e feat: bump blocksuite (#7698)
## Features
- https://github.com/toeverything/BlockSuite/pull/7801 @akumatus

## Bugfix
- https://github.com/toeverything/BlockSuite/pull/7811 @donteatfriedrice
- https://github.com/toeverything/BlockSuite/pull/7808 @doouding
- https://github.com/toeverything/BlockSuite/pull/7813 @L-Sun
- https://github.com/toeverything/BlockSuite/pull/7803 @fundon
- https://github.com/toeverything/BlockSuite/pull/7809 @Flrande

## Refactor

## Misc
- https://github.com/toeverything/BlockSuite/pull/7815 @doodlewind
2024-08-02 03:20:08 +00:00
pengx17
bfff10e25e feat(electron): app tabs dnd (#7684)
<div class='graphite__hidden'>
          <div>🎥 Video uploaded on Graphite:</div>
            <a href="https://app.graphite.dev/media/video/T2klNLEk0wxLh4NRDzhk/cd84e155-9f2e-4d12-a933-8673eb6bc6cb.mp4">
              <img src="https://app.graphite.dev/api/v1/graphite/video/thumbnail/T2klNLEk0wxLh4NRDzhk/cd84e155-9f2e-4d12-a933-8673eb6bc6cb.mp4">
            </a>
          </div>
<video src="https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/T2klNLEk0wxLh4NRDzhk/cd84e155-9f2e-4d12-a933-8673eb6bc6cb.mp4">Kapture 2024-07-31 at 19.39.30.mp4</video>

fix AF-1149
fix PD-1513
fix PD-1515
2024-08-02 02:02:03 +00:00
L-Sun
4719ffadc6 chore: bump blocksuite (#7696)
## Features
- https://github.com/toeverything/BlockSuite/pull/7807 @zzj3720
- https://github.com/toeverything/BlockSuite/pull/7786 @zzj3720
- https://github.com/toeverything/BlockSuite/pull/7797 @donteatfriedrice

## Bugfix
- https://github.com/toeverything/BlockSuite/pull/7814 @fundon
- https://github.com/toeverything/BlockSuite/pull/7812 @L-Sun
- https://github.com/toeverything/BlockSuite/pull/7792 @fundon
- https://github.com/toeverything/BlockSuite/pull/7788 @fundon
- https://github.com/toeverything/BlockSuite/pull/7805 @doouding
- https://github.com/toeverything/BlockSuite/pull/7810 @zzj3720
- https://github.com/toeverything/BlockSuite/pull/7802 @L-Sun
- https://github.com/toeverything/BlockSuite/pull/7804 @L-Sun
- https://github.com/toeverything/BlockSuite/pull/7799 @Saul-Mirone
- https://github.com/toeverything/BlockSuite/pull/7753 @CatsJuice
- https://github.com/toeverything/BlockSuite/pull/7798 @zzj3720
- https://github.com/toeverything/BlockSuite/pull/7796 @Saul-Mirone
- https://github.com/toeverything/BlockSuite/pull/7793 @doouding
- https://github.com/toeverything/BlockSuite/pull/7795 @Saul-Mirone
- https://github.com/toeverything/BlockSuite/pull/7791 @fundon
- https://github.com/toeverything/BlockSuite/pull/7747 @doouding
- https://github.com/toeverything/BlockSuite/pull/7785 @fundon
- https://github.com/toeverything/BlockSuite/pull/7784 @akumatus

## Misc
- https://github.com/toeverything/BlockSuite/pull/7800 @Saul-Mirone
- https://github.com/toeverything/BlockSuite/pull/7790 @fourdim
2024-08-02 01:29:10 +00:00
pengx17
07409b8a91 refactor(electron): tab title/icon update logic (#7675)
fix AF-1122
fix AF-1136
2024-08-01 16:43:18 +00:00
CatsJuice
e60b2d64e5 fix(core): new no children status for explorer (#7686)
close AF-1140
2024-08-01 09:41:13 +00:00
CatsJuice
8816d2a639 feat(core): adjust explorer section style, persist collapsable state (#7679)
close AF-1124,AF-1129,AF-1134,AF-1144
2024-08-01 09:41:09 +00:00
EYHN
553fbed60f feat(core): add globalcontext info to mixpanel track (#7681) 2024-08-01 09:29:31 +00:00
EYHN
bb767a6cdc fix(component): modal overlap issue (#7691) 2024-08-01 08:03:21 +00:00
L-Sun
33fc00f8c7 chore(core): set read-only mode on mobile device (#7651)
Close [BS-795](https://linear.app/affine-design/issue/BS-795/affine-mobile-设置只读模式)

- Set read-only mode on mobile device
- Add mobile only support read-only warning toast
- remove `user-select: none` so that user can select text in read-only mode
2024-08-01 05:22:50 +00:00
donteatfriedrice
3a0241340c fix: optimize ai chat block position calculation (#7683)
[BS-977](https://linear.app/affine-design/issue/BS-977/新生成的分支chat-block应该采用类似mindmap新节点的的定位方式,避免互相遮盖)
2024-08-01 01:37:23 +00:00
L-Sun
2093685385 chore: bump blocksuite (#7680) 2024-07-31 21:57:51 +08:00
pengx17
10e78d617e build(electron): re-enable windows signing (#7682)
ref https://github.com/toeverything/AFFiNE/pull/7645
2024-07-31 10:00:19 +00:00
darkskygit
49529b7e63 fix: make chat button click event work fine (#7658)
fix PD-1504
2024-07-31 09:24:54 +00:00
fundon
48e17fad02 feat(core): experience function color picker (#7677) 2024-07-31 09:07:29 +00:00
619 changed files with 17059 additions and 8164 deletions

View File

@@ -247,7 +247,8 @@ const config = {
'react-hooks/exhaustive-deps': [
'warn',
{
additionalHooks: '(useAsyncCallback|useDraggable|useDropTarget)',
additionalHooks:
'(useAsyncCallback|useCatchEventCallback|useDraggable|useDropTarget)',
},
],
},

View File

@@ -17,7 +17,7 @@ runs:
PACKAGE_VERSION=$(node -p "require('./package.json').version")
TIME_VERSION=$(date +%Y%m%d%H%M)
GIT_SHORT_HASH=$(git rev-parse --short HEAD)
APP_VERSION=$PACKAGE_VERSION-$GIT_SHORT_HASH
APP_VERSION=$PACKAGE_VERSION-nightly-$GIT_SHORT_HASH
fi
echo $APP_VERSION
echo "APP_VERSION=$APP_VERSION" >> "$GITHUB_OUTPUT"

View File

@@ -3,4 +3,4 @@ name: affine
description: AFFiNE cloud chart
type: application
version: 0.0.0
appVersion: "0.15.0"
appVersion: "0.16.0"

View File

@@ -3,7 +3,7 @@ name: graphql
description: AFFiNE GraphQL server
type: application
version: 0.0.0
appVersion: "0.15.0"
appVersion: "0.16.0"
dependencies:
- name: gcloud-sql-proxy
version: 0.0.0

View File

@@ -3,7 +3,7 @@ name: sync
description: AFFiNE Sync Server
type: application
version: 0.0.0
appVersion: "0.15.0"
appVersion: "0.16.0"
dependencies:
- name: gcloud-sql-proxy
version: 0.0.0

View File

@@ -181,7 +181,7 @@ jobs:
name: affine-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}-builds
path: builds
make-distribution-windows-skip-signing:
package-distribution-windows:
strategy:
matrix:
spec:
@@ -191,6 +191,8 @@ jobs:
target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.spec.runner }}
needs: before-make
outputs:
FILES_TO_BE_SIGNED: ${{ steps.get_files_to_be_signed.outputs.FILES_TO_BE_SIGNED }}
env:
SKIP_GENERATE_ASSETS: 1
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
@@ -230,12 +232,111 @@ jobs:
SKIP_WEB_BUILD: 1
HOIST_NODE_MODULES: 1
- name: get all files to be signed
id: get_files_to_be_signed
run: |
Set-Variable -Name FILES_TO_BE_SIGNED -Value ((Get-ChildItem -Path packages/frontend/electron/out -Recurse -File | Where-Object { $_.Extension -in @(".exe", ".node", ".dll", ".msi") } | ForEach-Object { '"' + $_.FullName.Replace((Get-Location).Path + '\packages\frontend\electron\out\', '') + '"' }) -join ' ')
"FILES_TO_BE_SIGNED=$FILES_TO_BE_SIGNED" >> $env:GITHUB_OUTPUT
echo $FILES_TO_BE_SIGNED
- name: Zip artifacts for faster upload
run: Compress-Archive -CompressionLevel Fastest -Path packages/frontend/electron/out/* -DestinationPath archive.zip
- name: Save packaged artifacts for signing
uses: actions/upload-artifact@v4
with:
name: packaged-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
path: |
archive.zip
!**/*.map
sign-packaged-artifacts-windows:
needs: package-distribution-windows
uses: ./.github/workflows/windows-signer.yml
with:
files: ${{ needs.package-distribution-windows.outputs.FILES_TO_BE_SIGNED }}
artifact-name: packaged-win32-x64
make-windows-installer:
needs: sign-packaged-artifacts-windows
strategy:
matrix:
spec:
- runner: windows-latest
platform: win32
arch: x64
target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.spec.runner }}
outputs:
FILES_TO_BE_SIGNED: ${{ steps.get_files_to_be_signed.outputs.FILES_TO_BE_SIGNED }}
steps:
- uses: actions/checkout@v4
- name: Setup Version
id: version
uses: ./.github/actions/setup-version
- name: Setup Node.js
timeout-minutes: 10
uses: ./.github/actions/setup-node
with:
extra-flags: workspaces focus @affine/electron @affine/monorepo
hard-link-nm: false
nmHoistingLimits: workspaces
- name: Download and overwrite packaged artifacts
uses: actions/download-artifact@v4
with:
name: signed-packaged-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
path: .
- name: unzip file
run: Expand-Archive -Path signed.zip -DestinationPath packages/frontend/electron/out
- name: Make squirrel.windows installer
run: yarn workspace @affine/electron make-squirrel --platform=${{ matrix.spec.platform }} --arch=${{ matrix.spec.arch }}
- name: Make nsis.windows installer
run: yarn workspace @affine/electron make-nsis --platform=${{ matrix.spec.platform }} --arch=${{ matrix.spec.arch }}
- name: Zip artifacts for faster upload
run: Compress-Archive -CompressionLevel Fastest -Path packages/frontend/electron/out/${{ env.BUILD_TYPE }}/make/* -DestinationPath archive.zip
- name: get all files to be signed
id: get_files_to_be_signed
run: |
Set-Variable -Name FILES_TO_BE_SIGNED -Value ((Get-ChildItem -Path packages/frontend/electron/out/${{ env.BUILD_TYPE }}/make -Recurse -File | Where-Object { $_.Extension -in @(".exe", ".node", ".dll", ".msi") } | ForEach-Object { '"' + $_.FullName.Replace((Get-Location).Path + '\packages\frontend\electron\out\${{ env.BUILD_TYPE }}\make\', '') + '"' }) -join ' ')
"FILES_TO_BE_SIGNED=$FILES_TO_BE_SIGNED" >> $env:GITHUB_OUTPUT
echo $FILES_TO_BE_SIGNED
- name: Save installer for signing
uses: actions/upload-artifact@v4
with:
name: installer-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
path: archive.zip
sign-installer-artifacts-windows:
needs: make-windows-installer
uses: ./.github/workflows/windows-signer.yml
with:
files: ${{ needs.make-windows-installer.outputs.FILES_TO_BE_SIGNED }}
artifact-name: installer-win32-x64
finalize-installer-windows:
needs: [sign-installer-artifacts-windows, before-make]
strategy:
matrix:
spec:
- runner: windows-latest
platform: win32
arch: x64
target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.spec.runner }}
steps:
- name: Download and overwrite installer artifacts
uses: actions/download-artifact@v4
with:
name: signed-installer-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
path: .
- name: unzip file
run: Expand-Archive -Path signed.zip -DestinationPath packages/frontend/electron/out/${{ env.BUILD_TYPE }}/make
- name: Save artifacts
run: |
mkdir -p builds
@@ -256,180 +357,8 @@ jobs:
name: affine-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}-builds
path: builds
# package-distribution-windows:
# strategy:
# matrix:
# spec:
# - runner: windows-latest
# platform: win32
# arch: x64
# target: x86_64-pc-windows-msvc
# runs-on: ${{ matrix.spec.runner }}
# needs: before-make
# outputs:
# FILES_TO_BE_SIGNED: ${{ steps.get_files_to_be_signed.outputs.FILES_TO_BE_SIGNED }}
# env:
# SKIP_GENERATE_ASSETS: 1
# SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
# SENTRY_PROJECT: 'affine'
# SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
# SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
# MIXPANEL_TOKEN: ${{ secrets.MIXPANEL_TOKEN }}
# steps:
# - uses: actions/checkout@v4
# - name: Setup Version
# id: version
# uses: ./.github/actions/setup-version
# - name: Setup Node.js
# timeout-minutes: 10
# uses: ./.github/actions/setup-node
# with:
# extra-flags: workspaces focus @affine/electron @affine/monorepo
# hard-link-nm: false
# nmHoistingLimits: workspaces
# - name: Build AFFiNE native
# uses: ./.github/actions/build-rust
# with:
# target: ${{ matrix.spec.target }}
# package: '@affine/native'
# nx_token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
# - uses: actions/download-artifact@v4
# with:
# name: web
# path: packages/frontend/electron/resources/web-static
# - name: Build Desktop Layers
# run: yarn workspace @affine/electron build
# - name: package
# run: yarn workspace @affine/electron package --platform=${{ matrix.spec.platform }} --arch=${{ matrix.spec.arch }}
# env:
# SKIP_WEB_BUILD: 1
# HOIST_NODE_MODULES: 1
# - name: get all files to be signed
# id: get_files_to_be_signed
# run: |
# Set-Variable -Name FILES_TO_BE_SIGNED -Value ((Get-ChildItem -Path packages/frontend/electron/out -Recurse -File | Where-Object { $_.Extension -in @(".exe", ".node", ".dll", ".msi") } | ForEach-Object { '"' + $_.FullName.Replace((Get-Location).Path + '\packages\frontend\electron\out\', '') + '"' }) -join ' ')
# "FILES_TO_BE_SIGNED=$FILES_TO_BE_SIGNED" >> $env:GITHUB_OUTPUT
# echo $FILES_TO_BE_SIGNED
# - name: Zip artifacts for faster upload
# run: Compress-Archive -CompressionLevel Fastest -Path packages/frontend/electron/out/* -DestinationPath archive.zip
# - name: Save packaged artifacts for signing
# uses: actions/upload-artifact@v4
# with:
# name: packaged-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
# path: |
# archive.zip
# !**/*.map
# sign-packaged-artifacts-windows:
# needs: package-distribution-windows
# uses: ./.github/workflows/windows-signer.yml
# with:
# files: ${{ needs.package-distribution-windows.outputs.FILES_TO_BE_SIGNED }}
# artifact-name: packaged-win32-x64
# make-windows-installer:
# needs: sign-packaged-artifacts-windows
# strategy:
# matrix:
# spec:
# - runner: windows-latest
# platform: win32
# arch: x64
# target: x86_64-pc-windows-msvc
# runs-on: ${{ matrix.spec.runner }}
# outputs:
# FILES_TO_BE_SIGNED: ${{ steps.get_files_to_be_signed.outputs.FILES_TO_BE_SIGNED }}
# steps:
# - uses: actions/checkout@v4
# - name: Setup Version
# id: version
# uses: ./.github/actions/setup-version
# - name: Setup Node.js
# timeout-minutes: 10
# uses: ./.github/actions/setup-node
# with:
# extra-flags: workspaces focus @affine/electron @affine/monorepo
# hard-link-nm: false
# nmHoistingLimits: workspaces
# - name: Download and overwrite packaged artifacts
# uses: actions/download-artifact@v4
# with:
# name: signed-packaged-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
# path: .
# - name: unzip file
# run: Expand-Archive -Path signed.zip -DestinationPath packages/frontend/electron/out
# - name: Make squirrel.windows installer
# run: yarn workspace @affine/electron make-squirrel --platform=${{ matrix.spec.platform }} --arch=${{ matrix.spec.arch }}
# - name: Make nsis.windows installer
# run: yarn workspace @affine/electron make-nsis --platform=${{ matrix.spec.platform }} --arch=${{ matrix.spec.arch }}
# - name: Zip artifacts for faster upload
# run: Compress-Archive -CompressionLevel Fastest -Path packages/frontend/electron/out/${{ env.BUILD_TYPE }}/make/* -DestinationPath archive.zip
# - name: get all files to be signed
# id: get_files_to_be_signed
# run: |
# Set-Variable -Name FILES_TO_BE_SIGNED -Value ((Get-ChildItem -Path packages/frontend/electron/out/${{ env.BUILD_TYPE }}/make -Recurse -File | Where-Object { $_.Extension -in @(".exe", ".node", ".dll", ".msi") } | ForEach-Object { '"' + $_.FullName.Replace((Get-Location).Path + '\packages\frontend\electron\out\${{ env.BUILD_TYPE }}\make\', '') + '"' }) -join ' ')
# "FILES_TO_BE_SIGNED=$FILES_TO_BE_SIGNED" >> $env:GITHUB_OUTPUT
# echo $FILES_TO_BE_SIGNED
# - name: Save installer for signing
# uses: actions/upload-artifact@v4
# with:
# name: installer-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
# path: archive.zip
# sign-installer-artifacts-windows:
# needs: make-windows-installer
# uses: ./.github/workflows/windows-signer.yml
# with:
# files: ${{ needs.make-windows-installer.outputs.FILES_TO_BE_SIGNED }}
# artifact-name: installer-win32-x64
# finalize-installer-windows:
# needs: [sign-installer-artifacts-windows, before-make]
# strategy:
# matrix:
# spec:
# - runner: windows-latest
# platform: win32
# arch: x64
# target: x86_64-pc-windows-msvc
# runs-on: ${{ matrix.spec.runner }}
# steps:
# - name: Download and overwrite installer artifacts
# uses: actions/download-artifact@v4
# with:
# name: signed-installer-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}
# path: .
# - name: unzip file
# run: Expand-Archive -Path signed.zip -DestinationPath packages/frontend/electron/out/${{ env.BUILD_TYPE }}/make
# - name: Save artifacts
# run: |
# mkdir -p builds
# mv packages/frontend/electron/out/*/make/zip/win32/x64/AFFiNE*-win32-x64-*.zip ./builds/affine-${{ needs.before-make.outputs.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-windows-x64.zip
# mv packages/frontend/electron/out/*/make/squirrel.windows/x64/*.exe ./builds/affine-${{ needs.before-make.outputs.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-windows-x64.exe
# mv packages/frontend/electron/out/*/make/nsis.windows/x64/*.exe ./builds/affine-${{ needs.before-make.outputs.RELEASE_VERSION }}-${{ env.BUILD_TYPE }}-windows-x64.nsis.exe
# - name: Upload Artifact
# uses: actions/upload-artifact@v4
# with:
# name: affine-${{ matrix.spec.platform }}-${{ matrix.spec.arch }}-builds
# path: builds
release:
needs:
- before-make
- make-distribution
- make-distribution-windows-skip-signing
needs: [before-make, make-distribution, finalize-installer-windows]
runs-on: ubuntu-latest
steps:

View File

@@ -23,7 +23,7 @@
<div align="center">
<a href="https://affine.pro">Home Page</a> |
<a href="https://discord.com/invite/yz6tGVsf5p">Discord</a> |
<a href="https://discord.gg/whd5mjYqVw">Discord</a> |
<a href="https://app.affine.pro">Live Demo</a> |
<a href="https://affine.pro/blog/">Blog</a> |
<a href="https://docs.affine.pro/docs/">Documentation</a>

View File

@@ -19,5 +19,5 @@
],
"ext": "ts,md,json"
},
"version": "0.15.0"
"version": "0.16.0"
}

View File

@@ -1,6 +1,6 @@
{
"name": "@affine/monorepo",
"version": "0.15.0",
"version": "0.16.0",
"private": true,
"author": "toeverything",
"license": "MIT",
@@ -95,7 +95,7 @@
"nanoid": "^5.0.7",
"nx": "^19.0.0",
"nyc": "^17.0.0",
"oxlint": "0.6.1",
"oxlint": "0.7.0",
"prettier": "^3.2.5",
"semver": "^7.6.0",
"serve": "^14.2.1",
@@ -108,7 +108,7 @@
"vite-plugin-static-copy": "^1.0.2",
"vitest": "1.6.0",
"vitest-fetch-mock": "^0.3.0",
"vitest-mock-extended": "^1.3.1"
"vitest-mock-extended": "^2.0.0"
},
"packageManager": "yarn@4.3.1",
"resolutions": {

View File

@@ -1,6 +1,6 @@
{
"name": "@affine/server-native",
"version": "0.15.0",
"version": "0.16.0",
"engines": {
"node": ">= 10.16.0 < 11 || >= 11.8.0"
},

View File

@@ -0,0 +1,95 @@
/*
Warnings:
- The primary key for the `snapshot_histories` table will be changed. If it partially fails, the table could be left without primary key constraint.
*/
-- AlterTable
ALTER TABLE "_data_migrations" ALTER COLUMN "started_at" SET DATA TYPE TIMESTAMPTZ(3),
ALTER COLUMN "finished_at" SET DATA TYPE TIMESTAMPTZ(3);
-- AlterTable
ALTER TABLE "ai_prompts_messages" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3);
-- AlterTable
ALTER TABLE "ai_prompts_metadata" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3);
-- AlterTable
ALTER TABLE "ai_sessions_messages" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3),
ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMPTZ(3);
-- AlterTable
ALTER TABLE "ai_sessions_metadata" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3),
ALTER COLUMN "deleted_at" SET DATA TYPE TIMESTAMPTZ(3);
-- AlterTable
ALTER TABLE "app_runtime_settings" ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMPTZ(3),
ALTER COLUMN "deleted_at" SET DATA TYPE TIMESTAMPTZ(3);
-- AlterTable
ALTER TABLE "features" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3);
-- AlterTable
ALTER TABLE "multiple_users_sessions" ALTER COLUMN "expires_at" SET DATA TYPE TIMESTAMPTZ(3),
ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3);
-- AlterTable
ALTER TABLE "snapshot_histories" ALTER COLUMN "timestamp" SET DATA TYPE TIMESTAMPTZ(3),
ALTER COLUMN "expired_at" SET DATA TYPE TIMESTAMPTZ(3);
-- AlterTable
ALTER TABLE "snapshots" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3),
ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMPTZ(3);
-- AlterTable
ALTER TABLE "updates" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3);
-- AlterTable
ALTER TABLE "user_connected_accounts" ALTER COLUMN "expires_at" SET DATA TYPE TIMESTAMPTZ(3),
ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3),
ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMPTZ(3);
-- AlterTable
ALTER TABLE "user_features" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3),
ALTER COLUMN "expired_at" SET DATA TYPE TIMESTAMPTZ(3);
-- AlterTable
ALTER TABLE "user_invoices" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3),
ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMPTZ(3);
-- AlterTable
ALTER TABLE "user_sessions" ALTER COLUMN "expires_at" SET DATA TYPE TIMESTAMPTZ(3),
ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3);
-- AlterTable
ALTER TABLE "user_stripe_customers" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3);
-- AlterTable
ALTER TABLE "user_subscriptions" ALTER COLUMN "start" SET DATA TYPE TIMESTAMPTZ(3),
ALTER COLUMN "end" SET DATA TYPE TIMESTAMPTZ(3),
ALTER COLUMN "next_bill_at" SET DATA TYPE TIMESTAMPTZ(3),
ALTER COLUMN "canceled_at" SET DATA TYPE TIMESTAMPTZ(3),
ALTER COLUMN "trial_start" SET DATA TYPE TIMESTAMPTZ(3),
ALTER COLUMN "trial_end" SET DATA TYPE TIMESTAMPTZ(3),
ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3),
ALTER COLUMN "updated_at" SET DATA TYPE TIMESTAMPTZ(3);
-- AlterTable
ALTER TABLE "users" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3),
ALTER COLUMN "email_verified" SET DATA TYPE TIMESTAMPTZ(3);
-- AlterTable
ALTER TABLE "verification_tokens" ALTER COLUMN "expiresAt" SET DATA TYPE TIMESTAMPTZ(3);
-- AlterTable
ALTER TABLE "workspace_features" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3),
ALTER COLUMN "expired_at" SET DATA TYPE TIMESTAMPTZ(3);
-- AlterTable
ALTER TABLE "workspace_page_user_permissions" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3);
-- AlterTable
ALTER TABLE "workspace_user_permissions" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3);
-- AlterTable
ALTER TABLE "workspaces" ALTER COLUMN "created_at" SET DATA TYPE TIMESTAMPTZ(3);

View File

@@ -1,7 +1,7 @@
{
"name": "@affine/server",
"private": true,
"version": "0.15.0",
"version": "0.16.0",
"description": "Affine Node.js server",
"type": "module",
"bin": {

View File

@@ -13,9 +13,9 @@ model User {
id String @id @default(uuid()) @db.VarChar
name String @db.VarChar
email String @unique @db.VarChar
emailVerifiedAt DateTime? @map("email_verified") @db.Timestamp(3)
emailVerifiedAt DateTime? @map("email_verified") @db.Timestamptz(3)
avatarUrl String? @map("avatar_url") @db.VarChar
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
/// Not available if user signed up through OAuth providers
password String? @db.VarChar
/// Indicate whether the user finished the signup progress.
@@ -45,9 +45,9 @@ model ConnectedAccount {
scope String? @db.Text
accessToken String? @map("access_token") @db.Text
refreshToken String? @map("refresh_token") @db.Text
expiresAt DateTime? @map("expires_at") @db.Timestamp(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(3)
expiresAt DateTime? @map("expires_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -58,8 +58,8 @@ model ConnectedAccount {
model Session {
id String @id @default(uuid()) @db.VarChar
expiresAt DateTime? @map("expires_at") @db.Timestamp(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
expiresAt DateTime? @map("expires_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
userSessions UserSession[]
@@ -70,8 +70,8 @@ model UserSession {
id String @id @default(uuid()) @db.VarChar
sessionId String @map("session_id") @db.VarChar
userId String @map("user_id") @db.VarChar
expiresAt DateTime? @map("expires_at") @db.Timestamp(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
expiresAt DateTime? @map("expires_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -84,7 +84,7 @@ model VerificationToken {
token String @db.VarChar
type Int @db.SmallInt
credential String? @db.Text
expiresAt DateTime @db.Timestamp(3)
expiresAt DateTime @db.Timestamptz(3)
@@unique([type, token])
@@map("verification_tokens")
@@ -93,7 +93,7 @@ model VerificationToken {
model Workspace {
id String @id @default(uuid()) @db.VarChar
public Boolean
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
pages WorkspacePage[]
permissions WorkspaceUserPermission[]
@@ -129,7 +129,7 @@ model WorkspaceUserPermission {
type Int @db.SmallInt
/// Whether the permission invitation is accepted by the user
accepted Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@ -147,7 +147,7 @@ model WorkspacePageUserPermission {
type Int @db.SmallInt
/// Whether the permission invitation is accepted by the user
accepted Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@ -170,9 +170,9 @@ model UserFeature {
// - pro_plan_v1: "user buy the pro plan"
reason String @db.VarChar
// record the quota enabled time
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
// record the quota expired time, pay plan is a subscription, so it will expired
expiredAt DateTime? @map("expired_at") @db.Timestamp(3)
expiredAt DateTime? @map("expired_at") @db.Timestamptz(3)
// whether the feature is activated
// for example:
// - if we switch the user to another plan, we will set the old plan to deactivated, but dont delete it
@@ -198,9 +198,9 @@ model WorkspaceFeature {
// - copilet_v1: "owner buy the copilet feature package"
reason String @db.VarChar
// record the feature enabled time
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
// record the quota expired time, pay plan is a subscription, so it will expired
expiredAt DateTime? @map("expired_at") @db.Timestamp(3)
expiredAt DateTime? @map("expired_at") @db.Timestamptz(3)
// whether the feature is activated
// for example:
// - if owner unsubscribe a feature package, we will set the feature to deactivated, but dont delete it
@@ -220,7 +220,7 @@ model Feature {
type Int @db.Integer
// configs, define by feature conntroller
configs Json @db.Json
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
UserFeatureGates UserFeature[]
WorkspaceFeatures WorkspaceFeature[]
@@ -237,10 +237,10 @@ model Snapshot {
blob Bytes @db.ByteA
seq Int @default(0) @db.Integer
state Bytes? @db.ByteA
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
// the `updated_at` field will not record the time of record changed,
// but the created time of last seen update that has been merged into snapshot.
updatedAt DateTime @map("updated_at") @db.Timestamp(3)
updatedAt DateTime @map("updated_at") @db.Timestamptz(3)
@@id([id, workspaceId])
@@map("snapshots")
@@ -251,7 +251,7 @@ model Update {
id String @map("guid") @db.VarChar
seq Int @db.Integer
blob Bytes @db.ByteA
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
@@id([workspaceId, id, seq])
@@map("updates")
@@ -260,10 +260,10 @@ model Update {
model SnapshotHistory {
workspaceId String @map("workspace_id") @db.VarChar
id String @map("guid") @db.VarChar
timestamp DateTime @db.Timestamp(3)
timestamp DateTime @db.Timestamptz(3)
blob Bytes @db.ByteA
state Bytes? @db.ByteA
expiredAt DateTime @map("expired_at") @db.Timestamp(3)
expiredAt DateTime @map("expired_at") @db.Timestamptz(3)
@@id([workspaceId, id, timestamp])
@@map("snapshot_histories")
@@ -272,7 +272,7 @@ model SnapshotHistory {
model UserStripeCustomer {
userId String @id @map("user_id") @db.VarChar
stripeCustomerId String @unique @map("stripe_customer_id") @db.VarChar
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -290,21 +290,21 @@ model UserSubscription {
// subscription.status, active/past_due/canceled/unpaid...
status String @db.VarChar(20)
// subscription.current_period_start
start DateTime @map("start") @db.Timestamp(3)
start DateTime @map("start") @db.Timestamptz(3)
// subscription.current_period_end, null for lifetime payment
end DateTime? @map("end") @db.Timestamp(3)
end DateTime? @map("end") @db.Timestamptz(3)
// subscription.billing_cycle_anchor
nextBillAt DateTime? @map("next_bill_at") @db.Timestamp(3)
nextBillAt DateTime? @map("next_bill_at") @db.Timestamptz(3)
// subscription.canceled_at
canceledAt DateTime? @map("canceled_at") @db.Timestamp(3)
canceledAt DateTime? @map("canceled_at") @db.Timestamptz(3)
// subscription.trial_start
trialStart DateTime? @map("trial_start") @db.Timestamp(3)
trialStart DateTime? @map("trial_start") @db.Timestamptz(3)
// subscription.trial_end
trialEnd DateTime? @map("trial_end") @db.Timestamp(3)
trialEnd DateTime? @map("trial_end") @db.Timestamptz(3)
stripeScheduleId String? @map("stripe_schedule_id") @db.VarChar
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, plan])
@@ -321,8 +321,8 @@ model UserInvoice {
status String @db.VarChar(20)
plan String @db.VarChar(20)
recurring String @db.VarChar(20)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
// billing reason
reason String @db.VarChar
lastPaymentError String? @map("last_payment_error") @db.Text
@@ -350,7 +350,7 @@ model AiPromptMessage {
content String @db.Text
attachments Json? @db.Json
params Json? @db.Json
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
prompt AiPrompt @relation(fields: [promptId], references: [id], onDelete: Cascade)
@@ -366,7 +366,7 @@ model AiPrompt {
action String? @db.VarChar
model String @db.VarChar
config Json? @db.Json
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
messages AiPromptMessage[]
sessions AiSession[]
@@ -381,8 +381,8 @@ model AiSessionMessage {
content String @db.Text
attachments Json? @db.Json
params Json? @db.Json
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
session AiSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
@@ -399,8 +399,8 @@ model AiSession {
parentSessionId String? @map("parent_session_id") @db.VarChar
messageCost Int @default(0)
tokenCost Int @default(0)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(3)
deletedAt DateTime? @map("deleted_at") @db.Timestamp(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
prompt AiPrompt @relation(fields: [promptName], references: [name], onDelete: Cascade)
@@ -412,8 +412,8 @@ model AiSession {
model DataMigration {
id String @id @default(uuid()) @db.VarChar
name String @db.VarChar
startedAt DateTime @default(now()) @map("started_at") @db.Timestamp(3)
finishedAt DateTime? @map("finished_at") @db.Timestamp(3)
startedAt DateTime @default(now()) @map("started_at") @db.Timestamptz(3)
finishedAt DateTime? @map("finished_at") @db.Timestamptz(3)
@@map("_data_migrations")
}
@@ -433,8 +433,8 @@ model RuntimeConfig {
key String @db.VarChar
value Json @db.Json
description String @db.Text
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(3)
deletedAt DateTime? @map("deleted_at") @db.Timestamp(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
lastUpdatedBy String? @map("last_updated_by") @db.VarChar
lastUpdatedByUser User? @relation(fields: [lastUpdatedBy], references: [id])

View File

@@ -152,14 +152,16 @@ function buildAppModule() {
factor
// common fundamental modules
.use(...FunctionalityModules)
.useIf(config => config.flavor.sync, WebSocketModule)
// auth
.use(AuthModule)
.use(UserModule, AuthModule)
// business modules
.use(DocModule)
// sync server only
.useIf(config => config.flavor.sync, WebSocketModule, SyncModule)
.useIf(config => config.flavor.sync, SyncModule)
// graphql server only
.useIf(
@@ -167,7 +169,6 @@ function buildAppModule() {
ServerConfigModule,
GqlModule,
StorageModule,
UserModule,
WorkspaceModule,
FeatureModule,
QuotaModule

View File

@@ -29,7 +29,7 @@ export async function createApp() {
graphqlUploadExpress({
// TODO(@darkskygit): dynamic limit by quota maybe?
maxFileSize: 100 * 1024 * 1024,
maxFiles: 5,
maxFiles: 32,
})
);

View File

@@ -1,6 +1,6 @@
import type { ExecutionContext } from '@nestjs/common';
import { createParamDecorator } from '@nestjs/common';
import { User } from '@prisma/client';
import { User, UserSession } from '@prisma/client';
import { getRequestResponseFromContext } from '../../fundamentals';
@@ -53,3 +53,5 @@ export interface CurrentUser
hasPassword: boolean | null;
emailVerified: boolean;
}
export { type UserSession };

View File

@@ -1,15 +1,22 @@
import type {
CanActivate,
ExecutionContext,
FactoryProvider,
OnModuleInit,
} from '@nestjs/common';
import { Injectable, SetMetadata, UseGuards } from '@nestjs/common';
import { ModuleRef, Reflector } from '@nestjs/core';
import type { Request } from 'express';
import {
AuthenticationRequired,
Config,
getRequestResponseFromContext,
mapAnyError,
parseCookies,
} from '../../fundamentals';
import { WEBSOCKET_OPTIONS } from '../../fundamentals/websocket';
import { CurrentUser, UserSession } from './current-user';
import { AuthService, parseAuthUserSeqNum } from './service';
function extractTokenFromHeader(authorization: string) {
@@ -38,37 +45,9 @@ export class AuthGuard implements CanActivate, OnModuleInit {
async canActivate(context: ExecutionContext) {
const { req, res } = getRequestResponseFromContext(context);
// check cookie
let sessionToken: string | undefined =
req.cookies[AuthService.sessionCookieName];
if (!sessionToken && req.headers.authorization) {
sessionToken = extractTokenFromHeader(req.headers.authorization);
}
if (sessionToken) {
const userSeq = parseAuthUserSeqNum(
req.headers[AuthService.authUserSeqHeaderName]
);
const { user, expiresAt } = await this.auth.getUser(
sessionToken,
userSeq
);
if (res && user && expiresAt) {
await this.auth.refreshUserSessionIfNeeded(
req,
res,
sessionToken,
user.id,
expiresAt
);
}
if (user) {
req.sid = sessionToken;
req.user = user;
}
const userSession = await this.signIn(req);
if (res && userSession && userSession.session.expiresAt) {
await this.auth.refreshUserSessionIfNeeded(req, res, userSession.session);
}
// api is public
@@ -84,9 +63,44 @@ export class AuthGuard implements CanActivate, OnModuleInit {
if (!req.user) {
throw new AuthenticationRequired();
}
return true;
}
async signIn(
req: Request
): Promise<{ user: CurrentUser; session: UserSession } | null> {
if (req.user && req.session) {
return {
user: req.user,
session: req.session,
};
}
parseCookies(req);
let sessionToken: string | undefined =
req.cookies[AuthService.sessionCookieName];
if (!sessionToken && req.headers.authorization) {
sessionToken = extractTokenFromHeader(req.headers.authorization);
}
if (sessionToken) {
const userSeq = parseAuthUserSeqNum(
req.headers[AuthService.authUserSeqHeaderName]
);
const userSession = await this.auth.getUserSession(sessionToken, userSeq);
if (userSession) {
req.session = userSession.session;
req.user = userSession.user;
}
return userSession;
}
return null;
}
}
/**
@@ -111,3 +125,35 @@ export const Auth = () => {
// api is public accessible
export const Public = () => SetMetadata(PUBLIC_ENTRYPOINT_SYMBOL, true);
export const AuthWebsocketOptionsProvider: FactoryProvider = {
provide: WEBSOCKET_OPTIONS,
useFactory: (config: Config, guard: AuthGuard) => {
return {
...config.websocket,
allowRequest: async (
req: any,
pass: (err: string | null | undefined, success: boolean) => void
) => {
if (!config.websocket.requireAuthentication) {
return pass(null, true);
}
try {
const authentication = await guard.signIn(req);
if (authentication) {
return pass(null, true);
} else {
return pass('unauthenticated', false);
}
} catch (e) {
const error = mapAnyError(e);
error.log('Websocket');
return pass('unauthenticated', false);
}
},
};
},
inject: [Config, AuthGuard],
};

View File

@@ -6,15 +6,21 @@ import { FeatureModule } from '../features';
import { QuotaModule } from '../quota';
import { UserModule } from '../user';
import { AuthController } from './controller';
import { AuthGuard } from './guard';
import { AuthGuard, AuthWebsocketOptionsProvider } from './guard';
import { AuthResolver } from './resolver';
import { AuthService } from './service';
import { TokenService, TokenType } from './token';
@Module({
imports: [FeatureModule, UserModule, QuotaModule],
providers: [AuthService, AuthResolver, TokenService, AuthGuard],
exports: [AuthService, AuthGuard],
providers: [
AuthService,
AuthResolver,
TokenService,
AuthGuard,
AuthWebsocketOptionsProvider,
],
exports: [AuthService, AuthGuard, AuthWebsocketOptionsProvider],
controllers: [AuthController],
})
export class AuthModule {}

View File

@@ -21,6 +21,7 @@ import {
Throttle,
URLHelper,
} from '../../fundamentals';
import { Admin } from '../common';
import { UserService } from '../user';
import { UserType } from '../user/types';
import { validators } from '../utils/validators';
@@ -291,4 +292,19 @@ export class AuthResolver {
return emailVerifiedAt !== null;
}
@Admin()
@Mutation(() => String, {
description: 'Create change password url',
})
async createChangePasswordUrl(
@Args('userId') userId: string,
@Args('callbackUrl') callbackUrl: string
): Promise<string> {
const token = await this.token.createToken(
TokenType.ChangePassword,
userId
);
return this.url.link(callbackUrl, { token });
}
}

View File

@@ -1,9 +1,9 @@
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import type { User } from '@prisma/client';
import type { User, UserSession } from '@prisma/client';
import { PrismaClient } from '@prisma/client';
import type { CookieOptions, Request, Response } from 'express';
import { assign, omit } from 'lodash-es';
import { assign, pick } from 'lodash-es';
import { Config, EmailAlreadyUsed, MailService } from '../../fundamentals';
import { FeatureManagementService } from '../features/management';
@@ -41,13 +41,11 @@ export function sessionUser(
'id' | 'email' | 'avatarUrl' | 'name' | 'emailVerifiedAt'
> & { password?: string | null }
): CurrentUser {
return assign(
omit(user, 'password', 'registered', 'emailVerifiedAt', 'createdAt'),
{
hasPassword: user.password !== null,
emailVerified: user.emailVerifiedAt !== null,
}
);
// use pick to avoid unexpected fields
return assign(pick(user, 'id', 'email', 'avatarUrl', 'name'), {
hasPassword: user.password !== null,
emailVerified: user.emailVerifiedAt !== null,
});
}
@Injectable()
@@ -121,27 +119,27 @@ export class AuthService implements OnApplicationBootstrap {
return sessionUser(user);
}
async getUser(
async getUserSession(
token: string,
seq = 0
): Promise<{ user: CurrentUser | null; expiresAt: Date | null }> {
): Promise<{ user: CurrentUser; session: UserSession } | null> {
const session = await this.getSession(token);
// no such session
if (!session) {
return { user: null, expiresAt: null };
return null;
}
const userSession = session.userSessions.at(seq);
// no such user session
if (!userSession) {
return { user: null, expiresAt: null };
return null;
}
// user session expired
if (userSession.expiresAt && userSession.expiresAt <= new Date()) {
return { user: null, expiresAt: null };
return null;
}
const user = await this.db.user.findUnique({
@@ -149,10 +147,10 @@ export class AuthService implements OnApplicationBootstrap {
});
if (!user) {
return { user: null, expiresAt: null };
return null;
}
return { user: sessionUser(user), expiresAt: userSession.expiresAt };
return { user: sessionUser(user), session: userSession };
}
async getUserList(token: string) {
@@ -251,12 +249,13 @@ export class AuthService implements OnApplicationBootstrap {
async refreshUserSessionIfNeeded(
_req: Request,
res: Response,
sessionId: string,
userId: string,
expiresAt: Date,
session: UserSession,
ttr = this.config.auth.session.ttr
): Promise<boolean> {
if (expiresAt && expiresAt.getTime() - Date.now() > ttr * 1000) {
if (
session.expiresAt &&
session.expiresAt.getTime() - Date.now() > ttr * 1000
) {
// no need to refresh
return false;
}
@@ -267,17 +266,14 @@ export class AuthService implements OnApplicationBootstrap {
await this.db.userSession.update({
where: {
sessionId_userId: {
sessionId,
userId,
},
id: session.id,
},
data: {
expiresAt: newExpiresAt,
},
});
res.cookie(AuthService.sessionCookieName, sessionId, {
res.cookie(AuthService.sessionCookieName, session.sessionId, {
expires: newExpiresAt,
...this.cookieOptions,
});

View File

@@ -16,5 +16,5 @@ import {
],
})
export class ServerConfigModule {}
export { ADD_ENABLED_FEATURES, ServerConfigType } from './resolver';
export { ADD_ENABLED_FEATURES } from './server-feature';
export { ServerFeature } from './types';

View File

@@ -12,24 +12,14 @@ import {
import { PrismaClient, RuntimeConfig, RuntimeConfigType } from '@prisma/client';
import { GraphQLJSON, GraphQLJSONObject } from 'graphql-scalars';
import { Config, DeploymentType, URLHelper } from '../../fundamentals';
import { Config, URLHelper } from '../../fundamentals';
import { Public } from '../auth';
import { Admin } from '../common';
import { FeatureType } from '../features';
import { AvailableUserFeatureConfig } from '../features/resolver';
import { ServerFlags } from './config';
import { ServerFeature } from './types';
const ENABLED_FEATURES: Set<ServerFeature> = new Set();
export function ADD_ENABLED_FEATURES(feature: ServerFeature) {
ENABLED_FEATURES.add(feature);
}
registerEnumType(ServerFeature, {
name: 'ServerFeature',
});
registerEnumType(DeploymentType, {
name: 'ServerDeploymentType',
});
import { ENABLED_FEATURES } from './server-feature';
import { ServerConfigType } from './types';
@ObjectType()
export class PasswordLimitsType {
@@ -45,36 +35,6 @@ export class CredentialsRequirementType {
password!: PasswordLimitsType;
}
@ObjectType()
export class ServerConfigType {
@Field({
description:
'server identical name could be shown as badge on user interface',
})
name!: string;
@Field({ description: 'server version' })
version!: string;
@Field({ description: 'server base url' })
baseUrl!: string;
@Field(() => DeploymentType, { description: 'server type' })
type!: DeploymentType;
/**
* @deprecated
*/
@Field({ description: 'server flavor', deprecationReason: 'use `features`' })
flavor!: string;
@Field(() => [ServerFeature], { description: 'enabled server features' })
features!: ServerFeature[];
@Field({ description: 'enable telemetry' })
enableTelemetry!: boolean;
}
registerEnumType(RuntimeConfigType, {
name: 'RuntimeConfigType',
});
@@ -175,6 +135,20 @@ export class ServerConfigResolver {
}
}
@Resolver(() => ServerConfigType)
export class ServerFeatureConfigResolver extends AvailableUserFeatureConfig {
constructor(config: Config) {
super(config);
}
@ResolveField(() => [FeatureType], {
description: 'Features for user that can be configured',
})
override availableUserFeatures() {
return super.availableUserFeatures();
}
}
@ObjectType()
class ServerServiceConfig {
@Field()

View File

@@ -0,0 +1,7 @@
import { ServerFeature } from './types';
export const ENABLED_FEATURES: Set<ServerFeature> = new Set();
export function ADD_ENABLED_FEATURES(feature: ServerFeature) {
ENABLED_FEATURES.add(feature);
}
export { ServerFeature };

View File

@@ -1,5 +1,47 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { DeploymentType } from '../../fundamentals';
export enum ServerFeature {
Copilot = 'copilot',
Payment = 'payment',
OAuth = 'oauth',
}
registerEnumType(ServerFeature, {
name: 'ServerFeature',
});
registerEnumType(DeploymentType, {
name: 'ServerDeploymentType',
});
@ObjectType()
export class ServerConfigType {
@Field({
description:
'server identical name could be shown as badge on user interface',
})
name!: string;
@Field({ description: 'server version' })
version!: string;
@Field({ description: 'server base url' })
baseUrl!: string;
@Field(() => DeploymentType, { description: 'server type' })
type!: DeploymentType;
/**
* @deprecated
*/
@Field({ description: 'server flavor', deprecationReason: 'use `features`' })
flavor!: string;
@Field(() => [ServerFeature], { description: 'enabled server features' })
features!: ServerFeature[];
@Field({ description: 'enable telemetry' })
enableTelemetry!: boolean;
}

View File

@@ -2,7 +2,10 @@ import { Module } from '@nestjs/common';
import { UserModule } from '../user';
import { EarlyAccessType, FeatureManagementService } from './management';
import { FeatureManagementResolver } from './resolver';
import {
AdminFeatureManagementResolver,
FeatureManagementResolver,
} from './resolver';
import { FeatureService } from './service';
/**
@@ -17,6 +20,7 @@ import { FeatureService } from './service';
FeatureService,
FeatureManagementService,
FeatureManagementResolver,
AdminFeatureManagementResolver,
],
exports: [FeatureService, FeatureManagementService],
})

View File

@@ -1,21 +1,18 @@
import {
Args,
Context,
Int,
Mutation,
Parent,
Query,
registerEnumType,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { difference } from 'lodash-es';
import { UserNotFound } from '../../fundamentals';
import { sessionUser } from '../auth/service';
import { Config } from '../../fundamentals';
import { Admin } from '../common';
import { UserService } from '../user/service';
import { UserType } from '../user/types';
import { EarlyAccessType, FeatureManagementService } from './management';
import { FeatureService } from './service';
import { FeatureType } from './types';
registerEnumType(EarlyAccessType, {
@@ -24,10 +21,7 @@ registerEnumType(EarlyAccessType, {
@Resolver(() => UserType)
export class FeatureManagementResolver {
constructor(
private readonly users: UserService,
private readonly feature: FeatureManagementService
) {}
constructor(private readonly feature: FeatureManagementService) {}
@ResolveField(() => [FeatureType], {
name: 'features',
@@ -36,58 +30,48 @@ export class FeatureManagementResolver {
async userFeatures(@Parent() user: UserType) {
return this.feature.getActivatedUserFeatures(user.id);
}
}
@Admin()
@Mutation(() => Int)
async addToEarlyAccess(
@Args('email') email: string,
@Args({ name: 'type', type: () => EarlyAccessType }) type: EarlyAccessType
): Promise<number> {
const user = await this.users.findUserByEmail(email);
if (user) {
return this.feature.addEarlyAccess(user.id, type);
} else {
const user = await this.users.createUser({
email,
registered: false,
});
return this.feature.addEarlyAccess(user.id, type);
}
}
export class AvailableUserFeatureConfig {
constructor(private readonly config: Config) {}
@Admin()
@Mutation(() => Int)
async removeEarlyAccess(@Args('email') email: string): Promise<number> {
const user = await this.users.findUserByEmail(email);
if (!user) {
throw new UserNotFound();
}
return this.feature.removeEarlyAccess(user.id);
}
@Admin()
@Query(() => [UserType])
async earlyAccessUsers(
@Context() ctx: { isAdminQuery: boolean }
): Promise<UserType[]> {
// allow query other user's subscription
ctx.isAdminQuery = true;
return this.feature.listEarlyAccess().then(users => {
return users.map(sessionUser);
});
}
@Admin()
@Mutation(() => Boolean)
async addAdminister(@Args('email') email: string): Promise<boolean> {
const user = await this.users.findUserByEmail(email);
if (!user) {
throw new UserNotFound();
}
await this.feature.addAdmin(user.id);
return true;
async availableUserFeatures() {
return this.config.isSelfhosted
? [FeatureType.Admin]
: [FeatureType.EarlyAccess, FeatureType.AIEarlyAccess, FeatureType.Admin];
}
}
@Admin()
@Resolver(() => Boolean)
export class AdminFeatureManagementResolver extends AvailableUserFeatureConfig {
constructor(
config: Config,
private readonly feature: FeatureService
) {
super(config);
}
@Mutation(() => [FeatureType], {
description: 'update user enabled feature',
})
async updateUserFeatures(
@Args('id') id: string,
@Args({ name: 'features', type: () => [FeatureType] })
features: FeatureType[]
) {
const configurableFeatures = await this.availableUserFeatures();
const removed = difference(configurableFeatures, features);
await Promise.all(
features.map(feature =>
this.feature.addUserFeature(id, feature, 'admin panel')
)
);
await Promise.all(
removed.map(feature => this.feature.removeUserFeature(id, feature))
);
return features;
}
}

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { CannotDeleteAllAdminAccount } from '../../fundamentals';
import { WorkspaceType } from '../workspaces/types';
import { FeatureConfigType, getFeature } from './feature';
import { FeatureKind, FeatureType } from './types';
@@ -81,6 +82,9 @@ export class FeatureService {
}
async removeUserFeature(userId: string, feature: FeatureType) {
if (feature === FeatureType.Admin) {
await this.ensureNotLastAdmin(userId);
}
return this.prisma.userFeature
.updateMany({
where: {
@@ -98,6 +102,20 @@ export class FeatureService {
.then(r => r.count);
}
async ensureNotLastAdmin(userId: string) {
const count = await this.prisma.userFeature.count({
where: {
userId: { not: userId },
feature: { feature: FeatureType.Admin, type: FeatureKind.Feature },
activated: true,
},
});
if (count === 0) {
throw new CannotDeleteAllAdminAccount();
}
}
/**
* get user's features, will included inactivated features
* @param userId user id

View File

@@ -50,12 +50,7 @@ function Awareness(workspaceId: string): `${string}:awareness` {
return `${workspaceId}:awareness`;
}
@WebSocketGateway({
cors: !AFFiNE.node.prod,
transports: ['websocket'],
// see: https://socket.io/docs/v4/server-options/#maxhttpbuffersize
maxHttpBufferSize: 1e8, // 100 MB
})
@WebSocketGateway()
export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
protected logger = new Logger(EventsGateway.name);
private connectionCount = 0;

View File

@@ -12,7 +12,12 @@ import { PrismaClient } from '@prisma/client';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { isNil, omitBy } from 'lodash-es';
import { type FileUpload, Throttle, UserNotFound } from '../../fundamentals';
import {
CannotDeleteOwnAccount,
type FileUpload,
Throttle,
UserNotFound,
} from '../../fundamentals';
import { CurrentUser } from '../auth/current-user';
import { Public } from '../auth/guard';
import { sessionUser } from '../auth/service';
@@ -22,6 +27,7 @@ import { validators } from '../utils/validators';
import { UserService } from './service';
import {
DeleteAccount,
ManageUserInput,
RemoveAvatar,
UpdateUserInput,
UserOrLimitedUser,
@@ -161,9 +167,6 @@ class CreateUserInput {
@Field(() => String, { nullable: true })
name!: string | null;
@Field(() => String, { nullable: true })
password!: string | null;
}
@Admin()
@@ -174,6 +177,13 @@ export class UserManagementResolver {
private readonly user: UserService
) {}
@Query(() => Int, {
description: 'Get users count',
})
async usersCount(): Promise<number> {
return this.db.user.count();
}
@Query(() => [UserType], {
description: 'List registered users',
})
@@ -208,6 +218,26 @@ export class UserManagementResolver {
return sessionUser(user);
}
@Query(() => UserType, {
name: 'userByEmail',
description: 'Get user by email for admin',
nullable: true,
})
async getUserByEmail(@Args('email') email: string) {
const user = await this.db.user.findUnique({
select: { ...this.user.defaultUserSelect, password: true },
where: {
email,
},
});
if (!user) {
return null;
}
return sessionUser(user);
}
@Mutation(() => UserType, {
description: 'Create a new user',
})
@@ -216,7 +246,6 @@ export class UserManagementResolver {
) {
const { id } = await this.user.createUser({
email: input.email,
password: input.password,
registered: true,
});
@@ -227,8 +256,42 @@ export class UserManagementResolver {
@Mutation(() => DeleteAccount, {
description: 'Delete a user account',
})
async deleteUser(@Args('id') id: string): Promise<DeleteAccount> {
async deleteUser(
@CurrentUser() user: CurrentUser,
@Args('id') id: string
): Promise<DeleteAccount> {
if (user.id === id) {
throw new CannotDeleteOwnAccount();
}
await this.user.deleteUser(id);
return { success: true };
}
@Mutation(() => UserType, {
description: 'Update a user',
})
async updateUser(
@Args('id') id: string,
@Args('input') input: ManageUserInput
): Promise<UserType> {
const user = await this.db.user.findUnique({
where: { id },
});
if (!user) {
throw new UserNotFound();
}
input = omitBy(input, isNil);
if (Object.keys(input).length === 0) {
return sessionUser(user);
}
return sessionUser(
await this.user.updateUser(user.id, {
email: input.email,
name: input.name,
})
);
}
}

View File

@@ -194,9 +194,7 @@ export class UserService {
async updateUser(
id: string,
data: Omit<Prisma.UserUpdateInput, 'password'> & {
password?: string | null;
},
data: Omit<Partial<Prisma.UserCreateInput>, 'id'>,
select: Prisma.UserSelect = this.defaultUserSelect
) {
if (data.password) {
@@ -211,6 +209,23 @@ export class UserService {
data.password = await this.crypto.encryptPassword(data.password);
}
if (data.email) {
validators.assertValidEmail(data.email);
const emailTaken = await this.prisma.user.count({
where: {
email: data.email,
id: {
not: id,
},
},
});
if (emailTaken) {
throw new EmailAlreadyUsed();
}
}
const user = await this.prisma.user.update({ where: { id }, data, select });
this.emitter.emit('user.updated', user);

View File

@@ -83,6 +83,15 @@ export class UpdateUserInput implements Partial<User> {
name?: string;
}
@InputType()
export class ManageUserInput {
@Field({ description: 'User email', nullable: true })
email?: string;
@Field({ description: 'User name', nullable: true })
name?: string;
}
declare module '../../fundamentals/event/def' {
interface UserEvents {
admin: {

View File

@@ -498,4 +498,12 @@ export const USER_FRIENDLY_ERRORS = {
type: 'internal_server_error',
message: 'Mailer service is not configured.',
},
cannot_delete_all_admin_account: {
type: 'action_forbidden',
message: 'Cannot delete all admin accounts.',
},
cannot_delete_own_account: {
type: 'action_forbidden',
message: 'Cannot delete own account.',
},
} satisfies Record<string, UserFriendlyErrorOptions>;

View File

@@ -487,6 +487,18 @@ export class MailerServiceIsNotConfigured extends UserFriendlyError {
super('internal_server_error', 'mailer_service_is_not_configured', message);
}
}
export class CannotDeleteAllAdminAccount extends UserFriendlyError {
constructor(message?: string) {
super('action_forbidden', 'cannot_delete_all_admin_account', message);
}
}
export class CannotDeleteOwnAccount extends UserFriendlyError {
constructor(message?: string) {
super('action_forbidden', 'cannot_delete_own_account', message);
}
}
export enum ErrorNames {
INTERNAL_SERVER_ERROR,
TOO_MANY_REQUEST,
@@ -551,7 +563,9 @@ export enum ErrorNames {
COPILOT_QUOTA_EXCEEDED,
RUNTIME_CONFIG_NOT_FOUND,
INVALID_RUNTIME_CONFIG_TYPE,
MAILER_SERVICE_IS_NOT_CONFIGURED
MAILER_SERVICE_IS_NOT_CONFIGURED,
CANNOT_DELETE_ALL_ADMIN_ACCOUNT,
CANNOT_DELETE_OWN_ACCOUNT
}
registerEnumType(ErrorNames, {
name: 'ErrorNames'

View File

@@ -36,5 +36,6 @@ export {
getRequestFromHost,
getRequestResponseFromContext,
getRequestResponseFromHost,
parseCookies,
} from './utils/request';
export type * from './utils/types';

View File

@@ -148,7 +148,7 @@ export const emailTemplate = ({
</a>
</td>
<td style="padding: 0 10px">
<a href="https://discord.gg/Arn7TqJBvG" target="_blank"
<a href="https://discord.gg/whd5mjYqVw" target="_blank"
><img
src="https://cdn.affine.pro/mail/2023-8-9/Discord.png"
alt="AFFiNE discord link"

View File

@@ -2,8 +2,10 @@ import { ArgumentsHost, Catch, Logger } from '@nestjs/common';
import { BaseExceptionFilter } from '@nestjs/core';
import { GqlContextType } from '@nestjs/graphql';
import { ThrottlerException } from '@nestjs/throttler';
import { BaseWsExceptionFilter } from '@nestjs/websockets';
import { Response } from 'express';
import { of } from 'rxjs';
import { Socket } from 'socket.io';
import {
InternalServerError,
@@ -44,6 +46,20 @@ export class GlobalExceptionFilter extends BaseExceptionFilter {
}
}
export class GlobalWsExceptionFilter extends BaseWsExceptionFilter {
// @ts-expect-error satisfies the override
override handleError(client: Socket, exception: any): void {
const error = mapAnyError(exception);
error.log('Websocket');
metrics.socketio
.counter('unhandled_error')
.add(1, { status: error.status });
client.emit('error', {
error: toWebsocketError(error),
});
}
}
/**
* Only exists for websocket error body backward compatibility
*

View File

@@ -57,7 +57,7 @@ export class CloudThrottlerGuard extends ThrottlerGuard {
override getTracker(req: Request): Promise<string> {
return Promise.resolve(
// ↓ prefer session id if available
`throttler:${req.sid ?? req.get('CF-Connecting-IP') ?? req.get('CF-ray') ?? req.ip}`
`throttler:${req.session?.sessionId ?? req.get('CF-Connecting-IP') ?? req.get('CF-ray') ?? req.ip}`
// ^ throttler prefix make the key in store recognizable
);
}

View File

@@ -66,3 +66,29 @@ export function getRequestFromHost(host: ArgumentsHost) {
export function getRequestResponseFromContext(ctx: ExecutionContext) {
return getRequestResponseFromHost(ctx);
}
/**
* simple patch for request not protected by `cookie-parser`
* only take effect if `req.cookies` is not defined
*/
export function parseCookies(req: Request) {
if (req.cookies) {
return;
}
const cookieStr = req?.headers?.cookie ?? '';
req.cookies = cookieStr.split(';').reduce(
(cookies, cookie) => {
const [key, val] = cookie.split('=');
if (key) {
cookies[decodeURIComponent(key.trim())] = val
? decodeURIComponent(val.trim())
: val;
}
return cookies;
},
{} as Record<string, string>
);
}

View File

@@ -0,0 +1,20 @@
import { GatewayMetadata } from '@nestjs/websockets';
import { defineStartupConfig, ModuleConfig } from '../config';
declare module '../config' {
interface AppConfig {
websocket: ModuleConfig<
GatewayMetadata & {
requireAuthentication?: boolean;
}
>;
}
}
defineStartupConfig('websocket', {
// see: https://socket.io/docs/v4/server-options/#maxhttpbuffersize
transports: ['websocket'],
maxHttpBufferSize: 1e8, // 100 MB
requireAuthentication: true,
});

View File

@@ -1,17 +1,46 @@
import { Module, Provider } from '@nestjs/common';
import './config';
import {
FactoryProvider,
INestApplicationContext,
Module,
Provider,
} from '@nestjs/common';
import { IoAdapter } from '@nestjs/platform-socket.io';
import { Server } from 'socket.io';
import { Config } from '../config';
export const SocketIoAdapterImpl = Symbol('SocketIoAdapterImpl');
export class SocketIoAdapter extends IoAdapter {}
export class SocketIoAdapter extends IoAdapter {
constructor(protected readonly app: INestApplicationContext) {
super(app);
}
override createIOServer(port: number, options?: any): Server {
const config = this.app.get(WEBSOCKET_OPTIONS);
return super.createIOServer(port, { ...config, ...options });
}
}
const SocketIoAdapterImplProvider: Provider = {
provide: SocketIoAdapterImpl,
useValue: SocketIoAdapter,
};
export const WEBSOCKET_OPTIONS = Symbol('WEBSOCKET_OPTIONS');
export const websocketOptionsProvider: FactoryProvider = {
provide: WEBSOCKET_OPTIONS,
useFactory: (config: Config) => {
return config.websocket;
},
inject: [Config],
};
@Module({
providers: [SocketIoAdapterImplProvider],
exports: [SocketIoAdapterImplProvider],
providers: [SocketIoAdapterImplProvider, websocketOptionsProvider],
exports: [SocketIoAdapterImplProvider, websocketOptionsProvider],
})
export class WebSocketModule {}

View File

@@ -1,7 +1,7 @@
declare namespace Express {
interface Request {
user?: import('./core/auth/current-user').CurrentUser;
sid?: string;
session?: import('./core/auth/current-user').UserSession;
}
}

View File

@@ -278,18 +278,6 @@ const workflows: Prompt[] = [
];
const actions: Prompt[] = [
{
name: 'debug:action:gpt4',
action: 'text',
model: 'gpt-4o',
messages: [],
},
{
name: 'debug:action:vision4',
action: 'text',
model: 'gpt-4o',
messages: [],
},
{
name: 'debug:action:dalle3',
action: 'image',
@@ -302,12 +290,6 @@ const actions: Prompt[] = [
model: 'lcm-sd15-i2i',
messages: [],
},
{
name: 'debug:action:fal-sdturbo',
action: 'image',
model: 'fast-turbo-diffusion',
messages: [],
},
{
name: 'debug:action:fal-upscaler',
action: 'Clearer',
@@ -332,14 +314,14 @@ const actions: Prompt[] = [
messages: [],
},
{
name: 'debug:action:fal-summary-caption',
name: 'Generate a caption',
action: 'Generate a caption',
model: 'llava-next',
model: 'gpt-4o-mini',
messages: [
{
role: 'user',
content:
'Please understand this image and generate a short caption. Limit it to 20 words. {{content}}',
'Please understand this image and generate a short caption that can summarize the content of the image. Limit it to up 20 words. {{content}}',
},
],
},
@@ -393,7 +375,7 @@ content: {{content}}`,
{
name: 'Explain this image',
action: 'Explain this image',
model: 'gpt-4-vision-preview',
model: 'gpt-4o',
messages: [
{
role: 'user',
@@ -692,7 +674,7 @@ content: {{content}}`,
{
name: 'Make it real',
action: 'Make it real',
model: 'gpt-4-vision-preview',
model: 'gpt-4o',
messages: [
{
role: 'user',
@@ -731,7 +713,7 @@ content: {{content}}`,
{
name: 'Make it real with text',
action: 'Make it real with text',
model: 'gpt-4-vision-preview',
model: 'gpt-4o',
messages: [
{
role: 'user',

View File

@@ -43,9 +43,6 @@ export class OpenAIProvider
// text to text
'gpt-4o',
'gpt-4o-mini',
'gpt-4-vision-preview',
'gpt-4-turbo-preview',
'gpt-3.5-turbo',
// embeddings
'text-embedding-3-large',
'text-embedding-3-small',
@@ -203,7 +200,7 @@ export class OpenAIProvider
// ====== text to text ======
async generateText(
messages: PromptMessage[],
model: string = 'gpt-3.5-turbo',
model: string = 'gpt-4o-mini',
options: CopilotChatOptions = {}
): Promise<string> {
this.checkParams({ messages, model, options });
@@ -232,10 +229,11 @@ export class OpenAIProvider
async *generateTextStream(
messages: PromptMessage[],
model: string = 'gpt-3.5-turbo',
model: string = 'gpt-4o-mini',
options: CopilotChatOptions = {}
): AsyncIterable<string> {
this.checkParams({ messages, model, options });
try {
const result = await this.instance.chat.completions.create(
{

View File

@@ -4,6 +4,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
import {
Args,
Field,
Float,
ID,
InputType,
Mutation,
@@ -205,16 +206,16 @@ class CopilotPromptConfigType {
@Field(() => Boolean, { nullable: true })
jsonMode!: boolean | null;
@Field(() => Number, { nullable: true })
@Field(() => Float, { nullable: true })
frequencyPenalty!: number | null;
@Field(() => Number, { nullable: true })
@Field(() => Float, { nullable: true })
presencePenalty!: number | null;
@Field(() => Number, { nullable: true })
@Field(() => Float, { nullable: true })
temperature!: number | null;
@Field(() => Number, { nullable: true })
@Field(() => Float, { nullable: true })
topP!: number | null;
}
@@ -238,8 +239,8 @@ class CopilotPromptType {
@Field(() => String)
name!: string;
@Field(() => AvailableModels)
model!: AvailableModels;
@Field(() => String)
model!: string;
@Field(() => String, { nullable: true })
action!: string | null;

View File

@@ -283,7 +283,13 @@ export class ChatSessionService {
docId: true,
parentSessionId: true,
messages: {
select: { id: true, role: true, content: true, createdAt: true },
select: {
id: true,
role: true,
content: true,
attachments: true,
createdAt: true,
},
orderBy: { createdAt: 'asc' },
},
promptName: true,
@@ -571,6 +577,7 @@ export class ChatSessionService {
const forkedState = {
...state,
userId: options.userId,
sessionId: randomUUID(),
messages: [],
parentSessionId: options.sessionId,

View File

@@ -8,9 +8,7 @@ import type { ChatPrompt } from './prompt';
export enum AvailableModels {
// text to text
Gpt4Omni = 'gpt-4o',
Gpt4VisionPreview = 'gpt-4-vision-preview',
Gpt4TurboPreview = 'gpt-4-turbo-preview',
Gpt35Turbo = 'gpt-3.5-turbo',
Gpt4OmniMini = 'gpt-4o-mini',
// embeddings
TextEmbedding3Large = 'text-embedding-3-large',
TextEmbedding3Small = 'text-embedding-3-small',
@@ -34,7 +32,8 @@ export function getTokenEncoder(model?: string | null): Tokenizer | null {
// dalle don't need to calc the token
return null;
} else {
return fromModelName('gpt-4-turbo-preview');
// c100k based model
return fromModelName('gpt-4');
}
}

View File

@@ -1,6 +1,6 @@
import { registerEnumType, ResolveField, Resolver } from '@nestjs/graphql';
import { ServerConfigType } from '../../core/config';
import { ServerConfigType } from '../../core/config/types';
import { OAuthProviderName } from './config';
import { OAuthProviderFactory } from './register';

View File

@@ -18,7 +18,7 @@ export function createSockerIoAdapterImpl(
console.error(err);
});
const server = super.createIOServer(port, options) as Server;
const server = super.createIOServer(port, options);
server.adapter(createAdapter(pubClient, subClient));
return server;
}

View File

@@ -52,9 +52,7 @@ type CopilotMessageNotFoundDataType {
enum CopilotModels {
DallE3
Gpt4Omni
Gpt4TurboPreview
Gpt4VisionPreview
Gpt35Turbo
Gpt4OmniMini
TextEmbedding3Large
TextEmbedding3Small
TextEmbeddingAda002
@@ -63,19 +61,19 @@ enum CopilotModels {
}
input CopilotPromptConfigInput {
frequencyPenalty: Int
frequencyPenalty: Float
jsonMode: Boolean
presencePenalty: Int
temperature: Int
topP: Int
presencePenalty: Float
temperature: Float
topP: Float
}
type CopilotPromptConfigType {
frequencyPenalty: Int
frequencyPenalty: Float
jsonMode: Boolean
presencePenalty: Int
temperature: Int
topP: Int
presencePenalty: Float
temperature: Float
topP: Float
}
input CopilotPromptMessageInput {
@@ -104,7 +102,7 @@ type CopilotPromptType {
action: String
config: CopilotPromptConfigType
messages: [CopilotPromptMessageType!]!
model: CopilotModels!
model: String!
name: String!
}
@@ -154,7 +152,6 @@ input CreateCopilotPromptInput {
input CreateUserInput {
email: String!
name: String
password: String
}
type CredentialsRequirementType {
@@ -198,11 +195,6 @@ type DocNotFoundDataType {
workspaceId: String!
}
enum EarlyAccessType {
AI
App
}
union ErrorDataUnion = BlobNotFoundDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderSideErrorDataType | DocAccessDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | InvalidHistoryTimestampDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MissingOauthQueryParameterDataType | NotInWorkspaceDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | VersionRejectedDataType | WorkspaceAccessDeniedDataType | WorkspaceNotFoundDataType | WorkspaceOwnerNotFoundDataType
enum ErrorNames {
@@ -211,6 +203,8 @@ enum ErrorNames {
AUTHENTICATION_REQUIRED
BLOB_NOT_FOUND
BLOB_QUOTA_EXCEEDED
CANNOT_DELETE_ALL_ADMIN_ACCOUNT
CANNOT_DELETE_OWN_ACCOUNT
CANT_CHANGE_WORKSPACE_OWNER
CANT_UPDATE_LIFETIME_SUBSCRIPTION
COPILOT_ACTION_TAKEN
@@ -398,14 +392,20 @@ input ListUserInput {
skip: Int = 0
}
input ManageUserInput {
"""User email"""
email: String
"""User name"""
name: String
}
type MissingOauthQueryParameterDataType {
name: String!
}
type Mutation {
acceptInviteById(inviteId: String!, sendAcceptMail: Boolean, workspaceId: String!): Boolean!
addAdminister(email: String!): Boolean!
addToEarlyAccess(email: String!, type: EarlyAccessType!): Int!
addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int!
cancelSubscription(idempotencyKey: String!, plan: SubscriptionPlan = Pro): UserSubscription!
changeEmail(email: String!, token: String!): UserType!
@@ -414,6 +414,9 @@ type Mutation {
"""Cleanup sessions"""
cleanupCopilotSession(options: DeleteSessionInput!): [String!]!
"""Create change password url"""
createChangePasswordUrl(callbackUrl: String!, userId: String!): String!
"""Create a subscription checkout link of stripe"""
createCheckoutSession(input: CreateCheckoutSessionInput!): String!
@@ -450,7 +453,6 @@ type Mutation {
"""Remove user avatar"""
removeAvatar: RemoveAvatar!
removeEarlyAccess(email: String!): Int!
removeWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int!
resumeSubscription(idempotencyKey: String!, plan: SubscriptionPlan = Pro): UserSubscription!
revoke(userId: String!, workspaceId: String!): Boolean!
@@ -476,6 +478,12 @@ type Mutation {
updateRuntimeConfigs(updates: JSONObject!): [ServerRuntimeConfigType!]!
updateSubscriptionRecurring(idempotencyKey: String!, plan: SubscriptionPlan = Pro, recurring: SubscriptionRecurring!): UserSubscription!
"""Update a user"""
updateUser(id: String!, input: ManageUserInput!): UserType!
"""update user enabled feature"""
updateUserFeatures(features: [FeatureType!]!, id: String!): [FeatureType!]!
"""Update workspace"""
updateWorkspace(input: UpdateWorkspaceInput!): WorkspaceType!
@@ -519,7 +527,6 @@ type Query {
"""Get current user"""
currentUser: UserType
earlyAccessUsers: [UserType!]!
error(name: ErrorNames!): ErrorDataUnion!
"""send workspace invitation"""
@@ -546,12 +553,18 @@ type Query {
"""Get user by email"""
user(email: String!): UserOrLimitedUser
"""Get user by email for admin"""
userByEmail(email: String!): UserType
"""Get user by id"""
userById(id: String!): UserType!
"""List registered users"""
users(filter: ListUserInput!): [UserType!]!
"""Get users count"""
usersCount: Int!
"""Get workspace by id"""
workspace(id: String!): WorkspaceType!

View File

@@ -69,7 +69,7 @@ test('should be able to visit public api if signed in', async t => {
const { app, auth } = t.context;
// @ts-expect-error mock
auth.getUser.resolves({ user: { id: '1' } });
auth.getUserSession.resolves({ user: { id: '1' }, session: { id: '1' } });
const res = await request(app.getHttpServer())
.get('/public')
@@ -100,7 +100,7 @@ test('should be able to visit private api if signed in', async t => {
const { app, auth } = t.context;
// @ts-expect-error mock
auth.getUser.resolves({ user: { id: '1' } });
auth.getUserSession.resolves({ user: { id: '1' }, session: { id: '1' } });
const res = await request(app.getHttpServer())
.get('/private')
@@ -114,26 +114,26 @@ test('should be able to parse session cookie', async t => {
const { app, auth } = t.context;
// @ts-expect-error mock
auth.getUser.resolves({ user: { id: '1' } });
auth.getUserSession.resolves({ user: { id: '1' }, session: { id: '1' } });
await request(app.getHttpServer())
.get('/public')
.set('cookie', `${AuthService.sessionCookieName}=1`)
.expect(200);
t.deepEqual(auth.getUser.firstCall.args, ['1', 0]);
t.deepEqual(auth.getUserSession.firstCall.args, ['1', 0]);
});
test('should be able to parse bearer token', async t => {
const { app, auth } = t.context;
// @ts-expect-error mock
auth.getUser.resolves({ user: { id: '1' } });
auth.getUserSession.resolves({ user: { id: '1' }, session: { id: '1' } });
await request(app.getHttpServer())
.get('/public')
.auth('1', { type: 'bearer' })
.expect(200);
t.deepEqual(auth.getUser.firstCall.args, ['1', 0]);
t.deepEqual(auth.getUserSession.firstCall.args, ['1', 0]);
});

View File

@@ -157,10 +157,10 @@ test('should be able to get user from session', async t => {
const session = await auth.createUserSession(u1);
const { user } = await auth.getUser(session.sessionId);
const userSession = await auth.getUserSession(session.sessionId);
t.not(user, null);
t.is(user!.id, u1.id);
t.not(userSession, null);
t.is(userSession!.user.id, u1.id);
});
test('should be able to sign out session', async t => {
@@ -203,19 +203,19 @@ test('should be able to signout multi accounts session', async t => {
t.not(signedOutSession, null);
const { user: signedU2 } = await auth.getUser(session.sessionId, 0);
const { user: noUser } = await auth.getUser(session.sessionId, 1);
const userSession1 = await auth.getUserSession(session.sessionId, 0);
const userSession2 = await auth.getUserSession(session.sessionId, 1);
t.is(noUser, null);
t.not(signedU2, null);
t.is(userSession2, null);
t.not(userSession1, null);
t.is(signedU2!.id, u2.id);
t.is(userSession1!.user.id, u2.id);
// sign out user at seq(0)
signedOutSession = await auth.signOut(session.sessionId);
t.is(signedOutSession, null);
const { user: noUser2 } = await auth.getUser(session.sessionId, 0);
t.is(noUser2, null);
const userSession3 = await auth.getUserSession(session.sessionId, 0);
t.is(userSession3, null);
});

View File

@@ -246,14 +246,16 @@ test('should be able to manage chat session', async t => {
const s1 = (await session.get(sessionId))!;
t.deepEqual(
// @ts-expect-error
s1.finish(params).map(({ id: _, createdAt: __, ...m }) => m),
s1
.finish(params)
// @ts-expect-error
.map(({ id: _, attachments: __, createdAt: ___, ...m }) => m),
finalMessages,
'should same as before message'
);
t.deepEqual(
// @ts-expect-error
s1.finish({}).map(({ id: _, createdAt: __, ...m }) => m),
s1.finish({}).map(({ id: _, attachments: __, createdAt: ___, ...m }) => m),
[
{ content: 'hello ', params: {}, role: 'system' },
{ content: 'hello', role: 'user' },
@@ -273,7 +275,7 @@ test('should be able to manage chat session', async t => {
});
test('should be able to fork chat session', async t => {
const { prompt, session } = t.context;
const { auth, prompt, session } = t.context;
await prompt.set('prompt', 'model', [
{ role: 'system', content: 'hello {{word}}' },
@@ -305,8 +307,10 @@ test('should be able to fork chat session', async t => {
...commonParams,
});
t.not(sessionId, forkedSessionId1, 'should fork a new session');
const newUser = await auth.signUp('test', 'darksky.1@affine.pro', '123456');
const forkedSessionId2 = await session.fork({
userId,
userId: newUser.id,
sessionId,
latestMessageId,
...commonParams,
@@ -323,7 +327,7 @@ test('should be able to fork chat session', async t => {
const finalMessages = s2
.finish(params) // @ts-expect-error
.map(({ id: _, createdAt: __, ...m }) => m);
.map(({ id: _, attachments: __, createdAt: ___, ...m }) => m);
t.deepEqual(
finalMessages,
[
@@ -335,13 +339,16 @@ test('should be able to fork chat session', async t => {
);
}
// check second times forked session messages
// check second times forked session
{
const s2 = (await session.get(forkedSessionId2))!;
// should overwrite user id
t.is(s2.config.userId, newUser.id, 'should have same user id');
const finalMessages = s2
.finish(params) // @ts-expect-error
.map(({ id: _, createdAt: __, ...m }) => m);
.map(({ id: _, attachments: __, createdAt: ___, ...m }) => m);
t.deepEqual(
finalMessages,
[
@@ -359,7 +366,7 @@ test('should be able to fork chat session', async t => {
const finalMessages = s3
.finish(params) // @ts-expect-error
.map(({ id: _, createdAt: __, ...m }) => m);
.map(({ id: _, attachments: __, createdAt: ___, ...m }) => m);
t.deepEqual(
finalMessages,
[

View File

@@ -341,8 +341,10 @@ test('should throw if oauth account already connected', async t => {
},
});
// @ts-expect-error mock
Sinon.stub(auth, 'getUser').resolves({ user: { id: 'u2-id' } });
Sinon.stub(auth, 'getUserSession').resolves({
user: { id: 'u2-id' },
session: {},
} as any);
mockOAuthProvider(app, 'u2@affine.pro');
@@ -363,8 +365,10 @@ test('should throw if oauth account already connected', async t => {
test('should be able to connect oauth account', async t => {
const { app, u1, auth, db } = t.context;
// @ts-expect-error mock
Sinon.stub(auth, 'getUser').resolves({ user: { id: u1.id } });
Sinon.stub(auth, 'getUserSession').resolves({
user: { id: u1.id },
session: {},
} as any);
mockOAuthProvider(app, u1.email);

View File

@@ -91,7 +91,7 @@ export class MockCopilotTestProvider
override async *generateTextStream(
messages: PromptMessage[],
model: string = 'gpt-3.5-turbo',
model: string = 'gpt-4o-mini',
options: CopilotChatOptions = {}
): AsyncIterable<string> {
this.checkParams({ messages, model, options });

View File

@@ -9,5 +9,5 @@
"@types/debug": "^4.1.12",
"vitest": "1.6.0"
},
"version": "0.15.0"
"version": "0.16.0"
}

View File

@@ -3,8 +3,8 @@
"private": true,
"type": "module",
"devDependencies": {
"@blocksuite/global": "0.16.0-canary-202407301803-87f6a75",
"@blocksuite/store": "0.16.0-canary-202407301803-87f6a75",
"@blocksuite/global": "0.17.0-canary-202408121434-ff85a54",
"@blocksuite/store": "0.17.0-canary-202408121434-ff85a54",
"react": "18.3.1",
"react-dom": "18.3.1",
"vitest": "1.6.0"
@@ -26,5 +26,5 @@
"lit": "^3.1.2",
"zod": "^3.22.4"
},
"version": "0.15.0"
"version": "0.16.0"
}

View File

@@ -31,13 +31,7 @@ export const runtimeFlagsSchema = z.object({
enableExperimentalFeature: z.boolean(),
enableInfoModal: z.boolean(),
enableOrganize: z.boolean(),
// show the new favorite, which exclusive to each user
enableNewFavorite: z.boolean(),
// show the old favorite
enableOldFavorite: z.boolean(),
// before 0.16, enableNewFavorite = false and enableOldFavorite = true
// after 0.16, enableNewFavorite = true and enableOldFavorite = false
// for debug purpose, we can enable both
enableThemeEditor: z.boolean(),
});
export type RuntimeConfig = z.infer<typeof runtimeFlagsSchema>;

View File

@@ -1,5 +1,3 @@
import { assertExists } from '@blocksuite/global/utils';
export class UaHelper {
private readonly uaMap;
public isLinux = false;
@@ -12,8 +10,14 @@ export class UaHelper {
public isIOS = false;
getChromeVersion = (): number => {
const raw = this.navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./);
assertExists(raw);
let raw = this.navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./);
if (!raw) {
raw = this.navigator.userAgent.match(/(CriOS)\/([0-9]+)/);
}
if (!raw) {
console.error('Cannot get chrome version');
return 0;
}
return parseInt(raw[2], 10);
};

View File

@@ -14,10 +14,10 @@
"@affine/debug": "workspace:*",
"@affine/env": "workspace:*",
"@affine/templates": "workspace:*",
"@blocksuite/blocks": "0.16.0-canary-202407301803-87f6a75",
"@blocksuite/global": "0.16.0-canary-202407301803-87f6a75",
"@blocksuite/presets": "0.16.0-canary-202407301803-87f6a75",
"@blocksuite/store": "0.16.0-canary-202407301803-87f6a75",
"@blocksuite/blocks": "0.17.0-canary-202408121434-ff85a54",
"@blocksuite/global": "0.17.0-canary-202408121434-ff85a54",
"@blocksuite/presets": "0.17.0-canary-202408121434-ff85a54",
"@blocksuite/store": "0.17.0-canary-202408121434-ff85a54",
"@datastructures-js/binary-search-tree": "^5.3.2",
"foxact": "^0.2.33",
"fuse.js": "^7.0.0",
@@ -34,15 +34,15 @@
"devDependencies": {
"@affine-test/fixtures": "workspace:*",
"@affine/templates": "workspace:*",
"@blocksuite/block-std": "0.16.0-canary-202407301803-87f6a75",
"@blocksuite/presets": "0.16.0-canary-202407301803-87f6a75",
"@blocksuite/block-std": "0.17.0-canary-202408121434-ff85a54",
"@blocksuite/presets": "0.17.0-canary-202408121434-ff85a54",
"@testing-library/react": "^16.0.0",
"async-call-rpc": "^6.4.0",
"fake-indexeddb": "^6.0.0",
"react": "^18.2.0",
"rxjs": "^7.8.1",
"vite": "^5.2.8",
"vite-plugin-dts": "3.9.1",
"vite-plugin-dts": "4.0.2",
"vitest": "1.6.0"
},
"peerDependencies": {
@@ -73,5 +73,5 @@
"optional": true
}
},
"version": "0.15.0"
"version": "0.16.0"
}

View File

@@ -33,7 +33,6 @@ export type AppSetting = {
autoDownloadUpdate: boolean;
enableMultiView: boolean;
enableTelemetry: boolean;
enableOutlineViewer: boolean;
editorFlags: Partial<Omit<BlockSuiteFlags, 'readonly'>>;
};
export const windowFrameStyleOptions: AppSetting['windowFrameStyle'][] = [
@@ -75,7 +74,6 @@ const appSettingBaseAtom = atomWithStorage<AppSetting>('affine-settings', {
autoDownloadUpdate: true,
enableTelemetry: true,
enableMultiView: false,
enableOutlineViewer: false,
editorFlags: {},
});
@@ -93,8 +91,14 @@ export function setupEditorFlags(docCollection: DocCollection) {
// override this flag in app settings
// TODO(@eyhn): need a better way to manage block suite flags
docCollection.awarenessStore.setFlag('enable_synced_doc_block', true);
docCollection.awarenessStore.setFlag('enable_edgeless_text', true);
Object.entries(blocksuiteFeatureFlags).forEach(([key, value]) => {
if (value.defaultState !== undefined) {
docCollection.awarenessStore.setFlag(
key as keyof BlockSuiteFlags,
value.defaultState
);
}
});
} catch (err) {
logger.error('syncEditorFlags', err);
}
@@ -139,3 +143,89 @@ export const appSettingAtom = atom<
});
}
);
export type BuildChannel = 'stable' | 'beta' | 'canary' | 'internal';
export type FeedbackType = 'discord' | 'email' | 'github';
export type PreconditionType = () => boolean | undefined;
export type Flag<K extends string> = Partial<{
[key in K]: {
displayName: string;
description?: string;
precondition?: PreconditionType;
defaultState?: boolean; // default to open and not controlled by user
feedbackType?: FeedbackType;
};
}>;
const isNotStableBuild: PreconditionType = () => {
return runtimeConfig.appBuildType !== 'stable';
};
const isDesktopEnvironment: PreconditionType = () => environment.isDesktop;
const neverShow: PreconditionType = () => false;
export const blocksuiteFeatureFlags: Flag<keyof BlockSuiteFlags> = {
enable_database_attachment_note: {
displayName: 'Database Attachment Note',
description: 'Allows adding notes to database attachments.',
precondition: isNotStableBuild,
},
enable_database_statistics: {
displayName: 'Database Block Statistics',
description: 'Shows statistics for database blocks.',
precondition: isNotStableBuild,
},
enable_block_query: {
displayName: 'Todo Block Query',
description: 'Enables querying of todo blocks.',
precondition: isNotStableBuild,
},
enable_synced_doc_block: {
displayName: 'Synced Doc Block',
description: 'Enables syncing of doc blocks.',
precondition: neverShow,
defaultState: true,
},
enable_edgeless_text: {
displayName: 'Edgeless Text',
description: 'Enables edgeless text blocks.',
precondition: neverShow,
defaultState: true,
},
enable_color_picker: {
displayName: 'Color Picker',
description: 'Enables color picker blocks.',
precondition: neverShow,
defaultState: true,
},
enable_ai_chat_block: {
displayName: 'AI Chat Block',
description: 'Enables AI chat blocks.',
precondition: neverShow,
defaultState: true,
},
enable_ai_onboarding: {
displayName: 'AI Onboarding',
description: 'Enables AI onboarding.',
precondition: neverShow,
defaultState: true,
},
enable_expand_database_block: {
displayName: 'Expand Database Block',
description: 'Enables expanding of database blocks.',
precondition: neverShow,
defaultState: true,
},
};
export const affineFeatureFlags: Flag<keyof AppSetting> = {
enableMultiView: {
displayName: 'Split View',
description:
'The Split View feature in AFFiNE allows users to divide their workspace into multiple sections, enabling simultaneous viewing and editing of different documents.The Split View feature in AFFiNE allows users to divide their workspace into multiple sections, enabling simultaneous viewing and editing of different documents.',
feedbackType: 'discord',
precondition: isDesktopEnvironment,
},
};

View File

@@ -495,7 +495,8 @@ export class LiveData<T = unknown>
throw this.poisonedError;
}
this.ops$.next('watch');
setImmediate(() => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises -- never throw
Promise.resolve().then(() => {
this.ops$.next('unwatch');
});
return this.raw$.value;

View File

@@ -0,0 +1,28 @@
import { Entity } from '../../../framework';
import type { DBSchemaBuilder, TableMap } from '../../../orm';
import { Table } from './table';
export class DB<Schema extends DBSchemaBuilder> extends Entity<{
db: TableMap<Schema>;
schema: Schema;
storageDocId: (tableName: string) => string;
}> {
readonly db = this.props.db;
constructor() {
super();
Object.entries(this.props.schema).forEach(([tableName]) => {
const table = this.framework.createEntity(Table, {
table: this.db[tableName],
storageDocId: this.props.storageDocId(tableName),
});
Object.defineProperty(this, tableName, {
get: () => table,
});
});
}
}
export type DBWithTables<Schema extends DBSchemaBuilder> = DB<Schema> & {
[K in keyof Schema]: Table<Schema[K]>;
};

View File

@@ -0,0 +1,33 @@
import { Entity } from '../../../framework';
import type { Table as OrmTable, TableSchemaBuilder } from '../../../orm/core';
import type { WorkspaceService } from '../../workspace';
export class Table<Schema extends TableSchemaBuilder> extends Entity<{
table: OrmTable<Schema>;
storageDocId: string;
}> {
readonly table = this.props.table;
constructor(private readonly workspaceService: WorkspaceService) {
super();
}
isSyncing$ = this.workspaceService.workspace.engine.doc
.docState$(this.props.storageDocId)
.map(docState => docState.syncing);
isLoading$ = this.workspaceService.workspace.engine.doc
.docState$(this.props.storageDocId)
.map(docState => docState.loading);
create = this.table.create.bind(this.table);
update = this.table.update.bind(this.table);
get = this.table.get.bind(this.table);
// eslint-disable-next-line rxjs/finnish
get$ = this.table.get$.bind(this.table);
find = this.table.find.bind(this.table);
// eslint-disable-next-line rxjs/finnish
find$ = this.table.find$.bind(this.table);
keys = this.table.keys.bind(this.table);
delete = this.table.delete.bind(this.table);
}

View File

@@ -1,12 +1,17 @@
import type { Framework } from '../../framework';
import { WorkspaceScope, WorkspaceService } from '../workspace';
import { DB } from './entities/db';
import { Table } from './entities/table';
import { WorkspaceDBService } from './services/db';
export { AFFiNE_WORKSPACE_DB_SCHEMA } from './schema';
export { WorkspaceDBService } from './services/db';
export { transformWorkspaceDBLocalToCloud } from './services/db';
export function configureWorkspaceDBModule(framework: Framework) {
framework
.scope(WorkspaceScope)
.service(WorkspaceDBService, [WorkspaceService]);
.service(WorkspaceDBService, [WorkspaceService])
.entity(DB)
.entity(Table, [WorkspaceService]);
}

View File

@@ -1,9 +1,11 @@
import { Doc as YDoc } from 'yjs';
import { Service } from '../../../framework';
import { createORMClient, type TableMap, YjsDBAdapter } from '../../../orm';
import { createORMClient, YjsDBAdapter } from '../../../orm';
import type { DocStorage } from '../../../sync';
import { ObjectPool } from '../../../utils';
import type { WorkspaceService } from '../../workspace';
import { DB, type DBWithTables } from '../entities/db';
import { AFFiNE_WORKSPACE_DB_SCHEMA } from '../schema';
import { AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA } from '../schema/schema';
@@ -11,11 +13,13 @@ const WorkspaceDBClient = createORMClient(AFFiNE_WORKSPACE_DB_SCHEMA);
const WorkspaceUserdataDBClient = createORMClient(
AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA
);
type WorkspaceUserdataDBClient = InstanceType<typeof WorkspaceUserdataDBClient>;
export class WorkspaceDBService extends Service {
db: TableMap<AFFiNE_WORKSPACE_DB_SCHEMA>;
userdataDBPool = new ObjectPool<string, WorkspaceUserdataDBClient>({
db: DBWithTables<AFFiNE_WORKSPACE_DB_SCHEMA>;
userdataDBPool = new ObjectPool<
string,
DB<AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA>
>({
onDangling() {
return false; // never release
},
@@ -23,19 +27,27 @@ export class WorkspaceDBService extends Service {
constructor(private readonly workspaceService: WorkspaceService) {
super();
this.db = new WorkspaceDBClient(
new YjsDBAdapter(AFFiNE_WORKSPACE_DB_SCHEMA, {
getDoc: guid => {
const ydoc = new YDoc({
// guid format: db${workspaceId}${guid}
guid: `db$${this.workspaceService.workspace.id}$${guid}`,
});
this.workspaceService.workspace.engine.doc.addDoc(ydoc, false);
this.workspaceService.workspace.engine.doc.setPriority(ydoc.guid, 50);
return ydoc;
},
})
);
this.db = this.framework.createEntity(DB<AFFiNE_WORKSPACE_DB_SCHEMA>, {
db: new WorkspaceDBClient(
new YjsDBAdapter(AFFiNE_WORKSPACE_DB_SCHEMA, {
getDoc: guid => {
const ydoc = new YDoc({
// guid format: db${workspaceId}${guid}
guid: `db$${this.workspaceService.workspace.id}$${guid}`,
});
this.workspaceService.workspace.engine.doc.addDoc(ydoc, false);
this.workspaceService.workspace.engine.doc.setPriority(
ydoc.guid,
50
);
return ydoc;
},
})
),
schema: AFFiNE_WORKSPACE_DB_SCHEMA,
storageDocId: tableName =>
`db$${this.workspaceService.workspace.id}$${tableName}`,
}) as DBWithTables<AFFiNE_WORKSPACE_DB_SCHEMA>;
}
// eslint-disable-next-line @typescript-eslint/ban-types
@@ -43,28 +55,65 @@ export class WorkspaceDBService extends Service {
// __local__ for local workspace
const userdataDb = this.userdataDBPool.get(userId);
if (userdataDb) {
return userdataDb.obj;
return userdataDb.obj as DBWithTables<AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA>;
}
const newDB = new WorkspaceUserdataDBClient(
new YjsDBAdapter(AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA, {
getDoc: guid => {
const ydoc = new YDoc({
// guid format: userdata${userId}${workspaceId}${guid}
guid: `userdata$${userId}$${this.workspaceService.workspace.id}$${guid}`,
});
this.workspaceService.workspace.engine.doc.addDoc(ydoc, false);
this.workspaceService.workspace.engine.doc.setPriority(ydoc.guid, 50);
return ydoc;
},
})
const newDB = this.framework.createEntity(
DB<AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA>,
{
db: new WorkspaceUserdataDBClient(
new YjsDBAdapter(AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA, {
getDoc: guid => {
const ydoc = new YDoc({
// guid format: userdata${userId}${workspaceId}${guid}
guid: `userdata$${userId}$${this.workspaceService.workspace.id}$${guid}`,
});
this.workspaceService.workspace.engine.doc.addDoc(ydoc, false);
this.workspaceService.workspace.engine.doc.setPriority(
ydoc.guid,
50
);
return ydoc;
},
})
),
schema: AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA,
storageDocId: tableName =>
`userdata$${userId}$${this.workspaceService.workspace.id}$${tableName}`,
}
);
this.userdataDBPool.put(userId, newDB);
return newDB;
return newDB as DBWithTables<AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA>;
}
static isDBDocId(docId: string) {
return docId.startsWith('db$') || docId.startsWith('userdata$');
}
}
export async function transformWorkspaceDBLocalToCloud(
localWorkspaceId: string,
cloudWorkspaceId: string,
localDocStorage: DocStorage,
cloudDocStorage: DocStorage,
accountId: string
) {
for (const tableName of Object.keys(AFFiNE_WORKSPACE_DB_SCHEMA)) {
const localDocName = `db$${localWorkspaceId}$${tableName}`;
const localDoc = await localDocStorage.doc.get(localDocName);
if (localDoc) {
const cloudDocName = `db$${cloudWorkspaceId}$${tableName}`;
await cloudDocStorage.doc.set(cloudDocName, localDoc);
}
}
for (const tableName of Object.keys(AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA)) {
const localDocName = `userdata$__local__$${localWorkspaceId}$${tableName}`;
const localDoc = await localDocStorage.doc.get(localDocName);
if (localDoc) {
const cloudDocName = `userdata$${accountId}$${cloudWorkspaceId}$${tableName}`;
await cloudDocStorage.doc.set(cloudDocName, localDoc);
}
}
}

View File

@@ -8,19 +8,36 @@ export class GlobalContext extends Entity {
workspaceId = this.define<string>('workspaceId');
/**
* is in doc page
*/
isDoc = this.define<boolean>('isDoc');
isTrashDoc = this.define<boolean>('isTrashDoc');
docId = this.define<string>('docId');
docMode = this.define<DocMode>('docMode');
/**
* is in collection page
*/
isCollection = this.define<boolean>('isCollection');
collectionId = this.define<string>('collectionId');
/**
* is in trash page
*/
isTrash = this.define<boolean>('isTrash');
docMode = this.define<DocMode>('docMode');
/**
* is in tag page
*/
isTag = this.define<boolean>('isTag');
tagId = this.define<string>('tagId');
/**
* is in all docs page
*/
isAllDocs = this.define<boolean>('isAllDocs');
define<T>(key: string) {
this.memento.set(key, null);
const livedata$ = LiveData.from(this.memento.watch<T>(key), null);

View File

@@ -34,6 +34,8 @@ export class Workspace extends Entity {
},
idGenerator: () => nanoid(),
schema: globalBlockSuiteSchema,
disableBacklinkIndex: true,
disableSearchIndex: true,
});
}
return this._docCollection;

View File

@@ -28,7 +28,8 @@ export interface WorkspaceFlavourProvider {
createWorkspace(
initial: (
docCollection: DocCollection,
blobStorage: BlobStorage
blobStorage: BlobStorage,
docStorage: DocStorage
) => Promise<void>
): Promise<WorkspaceMetadata>;

View File

@@ -2,7 +2,7 @@ import type { WorkspaceFlavour } from '@affine/env/workspace';
import type { DocCollection } from '@blocksuite/store';
import { Service } from '../../../framework';
import type { BlobStorage } from '../../../sync';
import type { BlobStorage, DocStorage } from '../../../sync';
import type { WorkspaceFlavourProvider } from '../providers/flavour';
export class WorkspaceFactoryService extends Service {
@@ -20,7 +20,8 @@ export class WorkspaceFactoryService extends Service {
flavour: WorkspaceFlavour,
initial: (
docCollection: DocCollection,
blobStorage: BlobStorage
blobStorage: BlobStorage,
docStorage: DocStorage
) => Promise<void> = () => Promise.resolve()
) => {
const provider = this.providers.find(x => x.flavour === flavour);

View File

@@ -3,6 +3,7 @@ import { assertEquals } from '@blocksuite/global/utils';
import { applyUpdate, encodeStateAsUpdate } from 'yjs';
import { Service } from '../../../framework';
import { transformWorkspaceDBLocalToCloud } from '../../db';
import type { Workspace } from '../entities/workspace';
import type { WorkspaceMetadata } from '../metadata';
import type { WorkspaceDestroyService } from './destroy';
@@ -18,9 +19,12 @@ export class WorkspaceTransformService extends Service {
/**
* helper function to transform local workspace to cloud workspace
*
* @param accountId - all local user data will be transformed to this account
*/
transformLocalToCloud = async (
local: Workspace
local: Workspace,
accountId: string
): Promise<WorkspaceMetadata> => {
assertEquals(local.flavour, WorkspaceFlavour.LOCAL);
@@ -28,23 +32,35 @@ export class WorkspaceTransformService extends Service {
const newMetadata = await this.factory.create(
WorkspaceFlavour.AFFINE_CLOUD,
async (ws, bs) => {
applyUpdate(ws.doc, encodeStateAsUpdate(local.docCollection.doc));
async (docCollection, blobStorage, docStorage) => {
applyUpdate(
docCollection.doc,
encodeStateAsUpdate(local.docCollection.doc)
);
for (const subdoc of local.docCollection.doc.getSubdocs()) {
for (const newSubdoc of ws.doc.getSubdocs()) {
for (const newSubdoc of docCollection.doc.getSubdocs()) {
if (newSubdoc.guid === subdoc.guid) {
applyUpdate(newSubdoc, encodeStateAsUpdate(subdoc));
}
}
}
// transform db
await transformWorkspaceDBLocalToCloud(
local.id,
docCollection.id,
local.engine.doc.storage.behavior,
docStorage,
accountId
);
const blobList = await local.engine.blob.list();
for (const blobKey of blobList) {
const blob = await local.engine.blob.get(blobKey);
if (blob) {
await bs.set(blobKey, blob);
await blobStorage.set(blobKey, blob);
}
}
}

View File

@@ -6,7 +6,11 @@ import { applyUpdate, encodeStateAsUpdate } from 'yjs';
import { Service } from '../../../framework';
import { LiveData } from '../../../livedata';
import { wrapMemento } from '../../../storage';
import { type BlobStorage, MemoryDocStorage } from '../../../sync';
import {
type BlobStorage,
type DocStorage,
MemoryDocStorage,
} from '../../../sync';
import { MemoryBlobStorage } from '../../../sync/blob/blob';
import type { GlobalState } from '../../storage';
import type { WorkspaceProfileInfo } from '../entities/profile';
@@ -39,7 +43,8 @@ export class TestingWorkspaceLocalProvider
async createWorkspace(
initial: (
docCollection: DocCollection,
blobStorage: BlobStorage
blobStorage: BlobStorage,
docStorage: DocStorage
) => Promise<void>
): Promise<WorkspaceMetadata> {
const id = nanoid();
@@ -56,10 +61,12 @@ export class TestingWorkspaceLocalProvider
blobSources: {
main: blobStorage,
},
disableBacklinkIndex: true,
disableSearchIndex: true,
});
// apply initial state
await initial(docCollection, blobStorage);
await initial(docCollection, blobStorage, this.docStorage);
// save workspace to storage
await this.docStorage.doc.set(id, encodeStateAsUpdate(docCollection.doc));
@@ -90,6 +97,8 @@ export class TestingWorkspaceLocalProvider
const bs = new DocCollection({
id,
schema: globalBlockSuiteSchema,
disableBacklinkIndex: true,
disableSearchIndex: true,
});
applyUpdate(bs.doc, data);

View File

@@ -5,7 +5,7 @@ import { validators } from './validators';
export class ORMClient {
static hooksMap: Map<string, Hook<any>[]> = new Map();
private readonly tables = new Map<string, Table<any>>();
readonly tables = new Map<string, Table<any>>();
constructor(
protected readonly db: DBSchemaBuilder,
protected readonly adapter: DBAdapter

View File

@@ -22,6 +22,25 @@ export {
ReadonlyStorage as ReadonlyDocStorage,
} from './storage';
export interface DocEngineDocState {
/**
* is syncing with the server
*/
syncing: boolean;
/**
* is saving to local storage
*/
saving: boolean;
/**
* is loading from local storage
*/
loading: boolean;
retrying: boolean;
ready: boolean;
errorMessage: string | null;
serverClock: number | null;
}
export class DocEngine {
readonly clientId: string;
localPart: DocEngineLocalPart;
@@ -53,13 +72,14 @@ export class DocEngine {
docState$(docId: string) {
const localState$ = this.localPart.docState$(docId);
const remoteState$ = this.remotePart?.docState$(docId);
return LiveData.computed(get => {
return LiveData.computed<DocEngineDocState>(get => {
const localState = get(localState$);
const remoteState = remoteState$ ? get(remoteState$) : null;
if (remoteState) {
return {
syncing: remoteState.syncing,
saving: localState.syncing,
loading: localState.syncing,
retrying: remoteState.retrying,
ready: localState.ready,
errorMessage: remoteState.errorMessage,
@@ -69,6 +89,7 @@ export class DocEngine {
return {
syncing: localState.syncing,
saving: localState.syncing,
loading: localState.syncing,
ready: localState.ready,
retrying: false,
errorMessage: null,

View File

@@ -40,6 +40,7 @@ export interface LocalEngineState {
export interface LocalDocState {
ready: boolean;
loading: boolean;
syncing: boolean;
}
@@ -81,6 +82,7 @@ export class DocEngineLocalPart {
const next = () => {
subscribe.next({
ready: this.status.readyDocs.has(docId) ?? false,
loading: this.status.connectedDocs.has(docId),
syncing:
(this.status.jobMap.get(docId)?.length ?? 0) > 0 ||
this.status.currentJob?.docId === docId,
@@ -91,7 +93,7 @@ export class DocEngineLocalPart {
if (updatedId === docId) next();
});
}),
{ ready: false, syncing: false }
{ ready: false, loading: false, syncing: false }
);
}

View File

@@ -6,4 +6,5 @@ test('bm25', () => {
expect(bm25(1, 1, 10, 10, 15)).toEqual(3.2792079793859643);
expect(bm25(2, 1, 10, 10, 15) > bm25(1, 1, 10, 10, 15)).toBeTruthy();
expect(bm25(1, 1, 10, 10, 15) > bm25(2, 1, 10, 100, 15)).toBeTruthy();
expect(bm25(1, 1, 10, 10, 15) > bm25(1, 1, 10, 100, 15)).toBeTruthy();
});

View File

@@ -57,6 +57,6 @@ export const bm25 = (
invDocFreq *
(d +
(termFreq * (k + 1)) /
(termFreq + k * (1 - b + (b * fieldLength) / avgFieldLength)))
(termFreq + k * (1 - b + b * (fieldLength / avgFieldLength))))
);
};

View File

@@ -178,10 +178,13 @@ export class FullTextInvertedIndex implements InvertedIndex {
const queryTokens = new GeneralTokenizer().tokenize(term);
const matched = new Map<
number,
{
score: number[];
positions: Map<number, [number, number][]>;
}
Map<
number, // index
{
score: number;
ranges: [number, number][];
}
>
>();
for (const token of queryTokens) {
const key = InvertedIndexKey.forString(this.fieldKey, token.term);
@@ -244,30 +247,48 @@ export class FullTextInvertedIndex implements InvertedIndex {
// normalize score
const maxScore = submatched.reduce((acc, s) => Math.max(acc, s.score), 0);
const minScore = submatched.reduce((acc, s) => Math.min(acc, s.score), 1);
const minScore = submatched.reduce((acc, s) => Math.min(acc, s.score), 0);
for (const { nid, score, position } of submatched) {
const normalizedScore = (score - minScore) / (maxScore - minScore);
const match = matched.get(nid) || {
score: [] as number[],
positions: new Map(),
const normalizedScore =
maxScore === minScore
? score
: (score - minScore) / (maxScore - minScore);
const match =
matched.get(nid) ??
new Map<
number, // index
{
score: number;
ranges: [number, number][];
}
>();
const item = match.get(position.index) || {
score: 0,
ranges: [],
};
match.score.push(normalizedScore);
const ranges = match.positions.get(position.index) || [];
ranges.push(...position.ranges);
match.positions.set(position.index, ranges);
item.score += normalizedScore;
item.ranges.push(...position.ranges);
match.set(position.index, item);
matched.set(nid, match);
}
}
const match = new Match();
for (const [nid, { score, positions }] of matched) {
match.addScore(
nid,
score.reduce((acc, s) => acc + s, 0)
);
for (const [index, ranges] of positions) {
match.addHighlighter(nid, this.fieldKey, index, ranges);
for (const [nid, items] of matched) {
if (items.size === 0) {
break;
}
let highestScore = -1;
let highestIndex = -1;
let highestRanges: [number, number][] = [];
for (const [index, { score, ranges }] of items) {
if (score > highestScore) {
highestScore = score;
highestIndex = index;
highestRanges = ranges;
}
}
match.addScore(nid, highestScore);
match.addHighlighter(nid, this.fieldKey, highestIndex, highestRanges);
}
return match;
}

View File

@@ -1,6 +1,6 @@
{
"name": "@affine/admin",
"version": "0.15.0",
"version": "0.16.0",
"private": true,
"dependencies": {
"@affine/core": "workspace:*",
@@ -34,6 +34,7 @@
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.1",
"@sentry/react": "^8.9.0",
"@tanstack/react-table": "^8.19.3",
"cmdk": "^1.0.0",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.1.5",

View File

@@ -23,8 +23,8 @@ const Redirect = function Redirect() {
const location = useLocation();
const navigate = useNavigate();
useEffect(() => {
if (!location.pathname.startsWith('/admin')) {
navigate('/admin', { replace: true });
if (!location.pathname.startsWith('/admin/accounts')) {
navigate('/admin/accounts', { replace: true });
}
}, [location, navigate]);
return null;
@@ -41,15 +41,31 @@ export const router = _createBrowserRouter(
children: [
{
path: '',
lazy: () => import('./modules/home'),
element: <Redirect />,
},
{
path: '/admin/accounts',
lazy: () => import('./modules/accounts'),
},
{
path: '/admin/auth',
lazy: () => import('./modules/auth'),
},
{
path: '/admin/users',
lazy: () => import('./modules/users'),
path: '/admin/ai',
lazy: () => import('./modules/ai'),
},
{
path: '/admin/setup',
lazy: () => import('./modules/setup'),
},
{
path: '/admin/config',
lazy: () => import('./modules/config'),
},
{
path: '/admin/settings',
lazy: () => import('./modules/settings'),
},
],
},

View File

@@ -52,23 +52,30 @@ interface SheetContentProps
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = 'right', className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
));
SheetContentProps & { withoutCloseButton?: boolean }
>(
(
{ side = 'right', className, children, withoutCloseButton, ...props },
ref
) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
{!withoutCloseButton && (
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
);
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({

View File

@@ -0,0 +1,117 @@
import {
Avatar,
AvatarFallback,
AvatarImage,
} from '@affine/admin/components/ui/avatar';
import type { UserType } from '@affine/graphql';
import { FeatureType } from '@affine/graphql';
import type { ColumnDef } from '@tanstack/react-table';
import clsx from 'clsx';
import {
LockIcon,
MailIcon,
MailWarningIcon,
UnlockIcon,
UserIcon,
} from 'lucide-react';
import type { ReactNode } from 'react';
import { DataTableRowActions } from './data-table-row-actions';
const StatusItem = ({
condition,
IconTrue,
IconFalse,
textTrue,
textFalse,
}: {
condition: boolean | null;
IconTrue: ReactNode;
IconFalse: ReactNode;
textTrue: string;
textFalse: string;
}) => (
<div
className={clsx(
'flex gap-2 items-center',
!condition ? 'text-red-500 opacity-100' : 'opacity-25'
)}
>
{condition ? (
<>
{IconTrue}
{textTrue}
</>
) : (
<>
{IconFalse}
{textFalse}
</>
)}
</div>
);
export const columns: ColumnDef<UserType>[] = [
{
accessorKey: 'info',
cell: ({ row }) => (
<div className="flex gap-3 items-center max-w-[50vw] overflow-hidden">
<Avatar className="w-10 h-10">
<AvatarImage src={row.original.avatarUrl ?? undefined} />
<AvatarFallback>
<UserIcon size={20} />
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-1 max-w-full overflow-hidden">
<div className="text-sm font-medium max-w-full overflow-hidden gap-[6px]">
<span>{row.original.name}</span>{' '}
{row.original.features.includes(FeatureType.Admin) && (
<span
className="rounded p-1 text-xs"
style={{
backgroundColor: 'rgba(30, 150, 235, 0.20)',
color: 'rgba(30, 150, 235, 1)',
}}
>
Admin
</span>
)}
</div>
<div className="text-xs font-medium opacity-50 max-w-full overflow-hidden">
{row.original.email}
</div>
</div>
</div>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: 'property',
cell: ({ row: { original: user } }) => (
<div className="flex items-center gap-2">
<div className="flex flex-col gap-2 text-xs max-md:hidden">
<div className="flex justify-end opacity-25">{user.id}</div>
<div className="flex gap-3 items-center justify-end">
<StatusItem
condition={user.hasPassword}
IconTrue={<LockIcon size={10} />}
IconFalse={<UnlockIcon size={10} />}
textTrue="Password Set"
textFalse="No Password"
/>
<StatusItem
condition={user.emailVerified}
IconTrue={<MailIcon size={10} />}
IconFalse={<MailWarningIcon size={10} />}
textTrue="Email Verified"
textFalse="Email Not Verified"
/>
</div>
</div>
<DataTableRowActions user={user} />
</div>
),
},
];

View File

@@ -0,0 +1,125 @@
import { Button } from '@affine/admin/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@affine/admin/components/ui/select';
import type { Table } from '@tanstack/react-table';
import {
ChevronLeftIcon,
ChevronRightIcon,
ChevronsLeftIcon,
ChevronsRightIcon,
} from 'lucide-react';
import { useCallback, useTransition } from 'react';
interface DataTablePaginationProps<TData> {
table: Table<TData>;
}
export function DataTablePagination<TData>({
table,
}: DataTablePaginationProps<TData>) {
const [, startTransition] = useTransition();
// to handle the error: a component suspended while responding to synchronous input.
// This will cause the UI to be replaced with a loading indicator.
// To fix, updates that suspend should be wrapped with startTransition.
const onPageSizeChange = useCallback(
(value: string) => {
startTransition(() => {
table.setPageSize(Number(value));
});
},
[table]
);
const handleFirstPage = useCallback(() => {
startTransition(() => {
table.setPageIndex(0);
});
}, [startTransition, table]);
const handlePreviousPage = useCallback(() => {
startTransition(() => {
table.previousPage();
});
}, [startTransition, table]);
const handleNextPage = useCallback(() => {
startTransition(() => {
table.nextPage();
});
}, [startTransition, table]);
const handleLastPage = useCallback(() => {
startTransition(() => {
table.setPageIndex(table.getPageCount() - 1);
});
}, [startTransition, table]);
return (
<div className="flex items-center justify-between md:px-2">
<div className="flex items-center md:space-x-2">
<p className="text-sm font-medium max-md:hidden">Rows per page</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={onPageSizeChange}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 40, 80].map(pageSize => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-6 lg:space-x-8">
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of{' '}
{table.getPageCount()}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={handleFirstPage}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<ChevronsLeftIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={handlePreviousPage}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<ChevronLeftIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={handleNextPage}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<ChevronRightIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={handleLastPage}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<ChevronsRightIcon className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,175 @@
import { Button } from '@affine/admin/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@affine/admin/components/ui/dropdown-menu';
import {
LockIcon,
MoreVerticalIcon,
SettingsIcon,
TrashIcon,
} from 'lucide-react';
import { useCallback, useState } from 'react';
import { toast } from 'sonner';
import { useRightPanel } from '../../layout';
import type { UserType } from '../schema';
import { DeleteAccountDialog } from './delete-account';
import { DiscardChanges } from './discard-changes';
import { ResetPasswordDialog } from './reset-password';
import { useDeleteUser, useResetUserPassword } from './use-user-management';
import { UpdateUserForm } from './user-form';
interface DataTableRowActionsProps {
user: UserType;
}
export function DataTableRowActions({ user }: DataTableRowActionsProps) {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false);
const [discardDialogOpen, setDiscardDialogOpen] = useState(false);
const { setRightPanelContent, openPanel, isOpen, closePanel } =
useRightPanel();
const deleteUser = useDeleteUser();
const { resetPasswordLink, onResetPassword } = useResetUserPassword();
const openResetPasswordDialog = useCallback(() => {
onResetPassword(user.id, () => setResetPasswordDialogOpen(true));
}, [onResetPassword, user.id]);
const handleCopy = useCallback(() => {
navigator.clipboard
.writeText(resetPasswordLink)
.then(() => {
toast('Reset password link copied to clipboard');
setResetPasswordDialogOpen(false);
})
.catch(e => {
toast.error('Failed to copy reset password link: ' + e.message);
});
}, [resetPasswordLink]);
const onDeleting = useCallback(() => {
if (isOpen) {
closePanel();
}
setDeleteDialogOpen(false);
}, [closePanel, isOpen]);
const handleDelete = useCallback(() => {
deleteUser(user.id, onDeleting);
}, [deleteUser, onDeleting, user.id]);
const openDeleteDialog = useCallback(() => {
setDeleteDialogOpen(true);
}, []);
const closeDeleteDialog = useCallback(() => {
setDeleteDialogOpen(false);
}, []);
const handleDiscardChangesCancel = useCallback(() => {
setDiscardDialogOpen(false);
}, []);
const handleConfirm = useCallback(() => {
setRightPanelContent(
<UpdateUserForm
user={user}
onComplete={closePanel}
onResetPassword={openResetPasswordDialog}
onDeleteAccount={openDeleteDialog}
/>
);
if (discardDialogOpen) {
handleDiscardChangesCancel();
}
if (!isOpen) {
openPanel();
}
}, [
closePanel,
discardDialogOpen,
handleDiscardChangesCancel,
isOpen,
openDeleteDialog,
openPanel,
openResetPasswordDialog,
setRightPanelContent,
user,
]);
const handleEdit = useCallback(() => {
if (isOpen) {
setDiscardDialogOpen(true);
} else {
handleConfirm();
}
}, [handleConfirm, isOpen]);
return (
<div className="flex justify-end items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="flex h-8 w-8 p-0 data-[state=open]:bg-muted"
>
<MoreVerticalIcon size={20} />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[214px] p-[5px] gap-2">
<div className="px-2 py-[6px] text-sm font-semibold overflow-hidden text-ellipsis text-nowrap">
{user.name}
</div>
<DropdownMenuSeparator />
<DropdownMenuItem
className="px-2 py-[6px] text-sm font-medium gap-2 cursor-pointer"
onSelect={openResetPasswordDialog}
>
<LockIcon size={16} /> Reset Password
</DropdownMenuItem>
<DropdownMenuItem
onSelect={handleEdit}
className="px-2 py-[6px] text-sm font-medium gap-2 cursor-pointer"
>
<SettingsIcon size={16} /> Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="px-2 py-[6px] text-sm font-medium gap-2 text-red-500 cursor-pointer focus:text-red-500"
onSelect={openDeleteDialog}
>
<TrashIcon size={16} /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DeleteAccountDialog
email={user.email}
open={deleteDialogOpen}
onClose={closeDeleteDialog}
onOpenChange={setDeleteDialogOpen}
onDelete={handleDelete}
/>
<ResetPasswordDialog
link={resetPasswordLink}
open={resetPasswordDialogOpen}
onOpenChange={setResetPasswordDialogOpen}
onCopy={handleCopy}
/>
<DiscardChanges
open={discardDialogOpen}
onOpenChange={setDiscardDialogOpen}
onClose={handleDiscardChangesCancel}
onConfirm={handleConfirm}
/>
</div>
);
}

View File

@@ -0,0 +1,117 @@
import { Button } from '@affine/admin/components/ui/button';
import { Input } from '@affine/admin/components/ui/input';
import { useQuery } from '@affine/core/hooks/use-query';
import { getUserByEmailQuery } from '@affine/graphql';
import { PlusIcon } from 'lucide-react';
import type { SetStateAction } from 'react';
import { startTransition, useCallback, useEffect, useState } from 'react';
import { useRightPanel } from '../../layout';
import { DiscardChanges } from './discard-changes';
import { CreateUserForm } from './user-form';
interface DataTableToolbarProps<TData> {
data: TData[];
setDataTable: (data: TData[]) => void;
}
function useDebouncedValue<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
export function DataTableToolbar<TData>({
data,
setDataTable,
}: DataTableToolbarProps<TData>) {
const [value, setValue] = useState('');
const [dialogOpen, setDialogOpen] = useState(false);
const debouncedValue = useDebouncedValue(value, 500);
const { setRightPanelContent, openPanel, closePanel, isOpen } =
useRightPanel();
const handleConfirm = useCallback(() => {
setRightPanelContent(<CreateUserForm onComplete={closePanel} />);
if (dialogOpen) {
setDialogOpen(false);
}
if (!isOpen) {
openPanel();
}
}, [setRightPanelContent, closePanel, dialogOpen, isOpen, openPanel]);
const result = useQuery({
query: getUserByEmailQuery,
variables: {
email: value,
},
}).data.userByEmail;
useEffect(() => {
startTransition(() => {
if (!debouncedValue) {
setDataTable(data);
} else if (result) {
setDataTable([result as TData]);
} else {
setDataTable([]);
}
});
}, [data, debouncedValue, result, setDataTable, value]);
const onValueChange = useCallback(
(e: { currentTarget: { value: SetStateAction<string> } }) => {
startTransition(() => {
setValue(e.currentTarget.value);
});
},
[]
);
const handleCancel = useCallback(() => {
setDialogOpen(false);
}, []);
const handleOpenConfirm = useCallback(() => {
if (isOpen) {
return setDialogOpen(true);
}
return handleConfirm();
}, [handleConfirm, isOpen]);
return (
<div className="flex items-center justify-between">
<div className="flex flex-1 items-center space-x-2">
<Input
placeholder="Search Email"
value={value}
onChange={onValueChange}
className="h-10 w-full mr-[10px]"
/>
</div>
<Button
className="px-4 py-2 space-x-[10px] text-sm font-medium"
onClick={handleOpenConfirm}
>
<PlusIcon size={20} /> <span>Add User</span>
</Button>
<DiscardChanges
open={dialogOpen}
onOpenChange={setDialogOpen}
onClose={handleCancel}
onConfirm={handleConfirm}
/>
</div>
);
}

View File

@@ -0,0 +1,102 @@
import { ScrollArea } from '@affine/admin/components/ui/scroll-area';
import {
Table,
TableBody,
TableCell,
TableRow,
} from '@affine/admin/components/ui/table';
import { useQuery } from '@affine/core/hooks/use-query';
import { getUsersCountQuery } from '@affine/graphql';
import type { ColumnDef, PaginationState } from '@tanstack/react-table';
import {
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
import { DataTablePagination } from './data-table-pagination';
import { DataTableToolbar } from './data-table-toolbar';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
pagination: PaginationState;
onPaginationChange: Dispatch<
SetStateAction<{
pageIndex: number;
pageSize: number;
}>
>;
}
export function DataTable<TData, TValue>({
columns,
data,
pagination,
onPaginationChange,
}: DataTableProps<TData, TValue>) {
const {
data: { usersCount },
} = useQuery({
query: getUsersCountQuery,
});
const [tableData, setTableData] = useState(data);
const table = useReactTable({
data: tableData,
columns,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
rowCount: usersCount,
enableFilters: true,
onPaginationChange: onPaginationChange,
state: {
pagination,
},
});
useEffect(() => {
setTableData(data);
}, [data]);
return (
<div className="flex flex-col gap-4 py-5 px-6 h-full">
<DataTableToolbar setDataTable={setTableData} data={data} />
<ScrollArea className="rounded-md border max-h-[75vh] h-full">
<Table>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map(row => (
<TableRow
key={row.id}
className="flex items-center justify-between"
>
{row.getVisibleCells().map(cell => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</ScrollArea>
<DataTablePagination table={table} />
</div>
);
}

View File

@@ -0,0 +1,76 @@
import { Button } from '@affine/admin/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@affine/admin/components/ui/dialog';
import { Input } from '@affine/admin/components/ui/input';
import { useCallback, useEffect, useState } from 'react';
export const DeleteAccountDialog = ({
email,
open,
onClose,
onDelete,
onOpenChange,
}: {
email: string;
open: boolean;
onClose: () => void;
onDelete: () => void;
onOpenChange: (open: boolean) => void;
}) => {
const [input, setInput] = useState('');
const handleInput = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setInput(event.target.value);
},
[setInput]
);
useEffect(() => {
if (!open) {
setInput('');
}
}, [open]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[460px]">
<DialogHeader>
<DialogTitle>Delete Account ?</DialogTitle>
<DialogDescription>
<span className="font-bold">{email}</span> will be permanently
deleted. This operation is irreversible. Please proceed with
caution.
</DialogDescription>
</DialogHeader>
<Input
type="text"
value={input}
onChange={handleInput}
placeholder="Please type email to confirm"
className="placeholder:opacity-50"
/>
<DialogFooter>
<div className="flex justify-between items-center w-full">
<Button type="button" variant="outline" size="sm" onClick={onClose}>
Cancel
</Button>
<Button
type="button"
onClick={onDelete}
size="sm"
variant="destructive"
>
Delete
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,44 @@
import { Button } from '@affine/admin/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@affine/admin/components/ui/dialog';
export const DiscardChanges = ({
open,
onClose,
onConfirm,
onOpenChange,
}: {
open: boolean;
onClose: () => void;
onConfirm: () => void;
onOpenChange: (open: boolean) => void;
}) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:w-[460px]">
<DialogHeader>
<DialogTitle className="leading-7">Discard Changes</DialogTitle>
<DialogDescription className="leading-6">
Changes to this user will not be saved.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<div className="flex justify-end items-center w-full space-x-4">
<Button type="button" onClick={onClose} variant="outline">
<span>Cancel</span>
</Button>
<Button type="button" onClick={onConfirm} variant="destructive">
<span>Discard</span>
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,16 @@
export const Logo = () => {
return (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18.6172 16.2657C18.314 15.7224 17.8091 14.8204 17.3102 13.9295C17.1589 13.6591 17.0086 13.3904 16.8644 13.1326C16.5679 12.6025 16.2978 12.1191 16.1052 11.7741C14.7688 9.38998 12.1376 4.66958 10.823 2.33541C10.418 1.68481 9.47943 1.73636 9.13092 2.4101C8.73553 3.1175 8.3004 3.89538 7.84081 4.71744C7.69509 4.97831 7.54631 5.24392 7.396 5.51268C5.48122 8.93556 3.24035 12.9423 1.64403 15.7961C1.5625 15.9486 1.41067 16.1974 1.33475 16.362C1.20176 16.6591 1.22775 17.0294 1.39538 17.304C1.58441 17.629 1.93802 17.8073 2.29927 17.7889C2.73389 17.7889 3.65561 17.7884 4.84738 17.7889C5.13016 17.7889 5.42823 17.7889 5.73853 17.7889C9.88246 17.7889 16.2127 17.7915 17.7663 17.7889C18.5209 17.7905 18.9942 16.9363 18.6182 16.2652L18.6172 16.2657ZM9.69699 13.2342L8.93424 11.8704C8.80024 11.6305 8.96787 11.3307 9.23588 11.3307H10.7614C11.0299 11.3307 11.1975 11.6305 11.063 11.8704L10.3003 13.2342C10.1663 13.474 9.83099 13.474 9.69648 13.2342H9.69699ZM8.41912 10.6943C8.35594 10.5281 8.30142 10.3593 8.25658 10.1878L10.7802 10.6943H8.41912ZM9.57165 14.2824C9.46414 14.4223 9.3495 14.5553 9.22823 14.6816L8.39109 12.1723L9.57114 14.2824H9.57165ZM12.0061 11.458C12.1768 11.4843 12.346 11.5206 12.5121 11.5658L10.8256 13.5687L12.0061 11.458ZM8.10117 9.33318C8.07417 9.07967 8.06245 8.82353 8.06347 8.56687L11.3962 10.2452L8.10067 9.33371L8.10117 9.33318ZM7.70579 11.8456L8.58828 15.2459C8.38905 15.3969 8.18015 15.5357 7.96411 15.663L7.70528 11.8456H7.70579ZM13.3069 11.8546C13.5332 11.9571 13.7538 12.075 13.9688 12.2043L10.8944 14.345L13.3069 11.8546ZM8.1399 7.48447C8.20104 7.01847 8.2953 6.55932 8.40943 6.1191L13.4725 10.6623L8.14041 7.48447H8.1399ZM7.01793 16.1369C6.59656 16.3152 6.16449 16.4603 5.73802 16.5781L7.01793 9.78129V16.1369ZM14.8386 12.8134C15.1988 13.1011 15.5371 13.4151 15.8494 13.737L9.50643 15.9912L14.8386 12.8134ZM10.2203 3.56456C11.1537 5.23655 12.509 7.66118 13.8002 9.96905L8.97959 4.99304C9.26288 4.48707 9.5314 4.00688 9.77902 3.56351C9.87736 3.38837 10.1219 3.38837 10.2203 3.56351V3.56456ZM2.69109 16.2358C2.95655 15.7629 3.32137 15.1144 3.40238 14.9651C4.17074 13.5913 5.20557 11.7415 6.27454 9.8302L4.50906 16.6307C3.87674 16.6307 3.33156 16.6307 2.91171 16.6307C2.71555 16.6307 2.59275 16.4114 2.69109 16.2363V16.2358ZM17.0871 16.6318C15.6151 16.6318 12.7572 16.6318 9.91965 16.6318L16.5083 14.8094C16.8537 15.4268 17.1304 15.9212 17.3077 16.2379C17.406 16.413 17.2832 16.6318 17.0876 16.6318H17.0871Z"
fill="black"
/>
</svg>
);
};

View File

@@ -0,0 +1,51 @@
import { Button } from '@affine/admin/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@affine/admin/components/ui/dialog';
import { Input } from '@affine/admin/components/ui/input';
import { CopyIcon } from 'lucide-react';
export const ResetPasswordDialog = ({
link,
open,
onCopy,
onOpenChange,
}: {
link: string;
open: boolean;
onCopy: () => void;
onOpenChange: (open: boolean) => void;
}) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:w-[460px]">
<DialogHeader>
<DialogTitle className="leading-7">Account Recovery Link</DialogTitle>
<DialogDescription className="leading-6">
Please send this recovery link to the user and instruct them to
complete it.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<div className="flex justify-between items-center w-full space-x-4">
<Input
type="text"
value={link}
placeholder="Please type email to confirm"
className="placeholder:opacity-50 text-ellipsis overflow-hidden whitespace-nowrap"
readOnly
/>
<Button type="button" onClick={onCopy} className="space-x-[10px]">
<CopyIcon size={20} /> <span>Copy and Close</span>
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,161 @@
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import {
useMutateQueryResource,
useMutation,
} from '@affine/core/hooks/use-mutation';
import {
createChangePasswordUrlMutation,
createUserMutation,
deleteUserMutation,
listUsersQuery,
updateAccountFeaturesMutation,
updateAccountMutation,
} from '@affine/graphql';
import { useCallback, useMemo, useState } from 'react';
import { toast } from 'sonner';
import type { UserInput } from '../schema';
export const useCreateUser = () => {
const {
trigger: createAccount,
isMutating: creating,
error,
} = useMutation({
mutation: createUserMutation,
});
const { trigger: updateAccountFeatures } = useMutation({
mutation: updateAccountFeaturesMutation,
});
const revalidate = useMutateQueryResource();
const create = useAsyncCallback(
async ({ name, email, features }: UserInput) => {
try {
const account = await createAccount({
input: {
name,
email,
},
});
await updateAccountFeatures({
userId: account.createUser.id,
features,
});
await revalidate(listUsersQuery);
toast('Account updated successfully');
} catch (e) {
toast.error('Failed to update account: ' + (e as Error).message);
}
},
[createAccount, revalidate]
);
return { creating: creating || !!error, create };
};
export const useUpdateUser = () => {
const {
trigger: updateAccount,
isMutating: updating,
error,
} = useMutation({
mutation: updateAccountMutation,
});
const { trigger: updateAccountFeatures } = useMutation({
mutation: updateAccountFeaturesMutation,
});
const revalidate = useMutateQueryResource();
const update = useAsyncCallback(
async ({
userId,
name,
email,
features,
}: UserInput & { userId: string }) => {
try {
await updateAccount({
id: userId,
input: {
name,
email,
},
});
await updateAccountFeatures({
userId,
features,
});
await revalidate(listUsersQuery);
toast('Account updated successfully');
} catch (e) {
toast.error('Failed to update account: ' + (e as Error).message);
}
},
[revalidate, updateAccount]
);
return { updating: updating || !!error, update };
};
export const useResetUserPassword = () => {
const [resetPasswordLink, setResetPasswordLink] = useState('');
const { trigger: resetPassword } = useMutation({
mutation: createChangePasswordUrlMutation,
});
const onResetPassword = useCallback(
async (id: string, callback?: () => void) => {
setResetPasswordLink('');
resetPassword({
userId: id,
callbackUrl: '/auth/changePassword?isClient=false',
})
.then(res => {
setResetPasswordLink(res.createChangePasswordUrl);
callback?.();
})
.catch(e => {
toast.error('Failed to reset password: ' + e.message);
});
},
[resetPassword]
);
return useMemo(() => {
return {
resetPasswordLink,
onResetPassword,
};
}, [onResetPassword, resetPasswordLink]);
};
export const useDeleteUser = () => {
const { trigger: deleteUserById } = useMutation({
mutation: deleteUserMutation,
});
const revalidate = useMutateQueryResource();
const deleteById = useAsyncCallback(
async (id: string, callback?: () => void) => {
await deleteUserById({ id })
.then(async () => {
await revalidate(listUsersQuery);
toast('User deleted successfully');
callback?.();
})
.catch(e => {
toast.error('Failed to delete user: ' + e.message);
});
},
[deleteUserById, revalidate]
);
return deleteById;
};

View File

@@ -0,0 +1,288 @@
import { Button } from '@affine/admin/components/ui/button';
import { Input } from '@affine/admin/components/ui/input';
import { Label } from '@affine/admin/components/ui/label';
import { Separator } from '@affine/admin/components/ui/separator';
import { Switch } from '@affine/admin/components/ui/switch';
import type { FeatureType } from '@affine/graphql';
import { CheckIcon, ChevronRightIcon, XIcon } from 'lucide-react';
import type { ChangeEvent } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useServerConfig } from '../../common';
import type { UserInput, UserType } from '../schema';
import { useCreateUser, useUpdateUser } from './use-user-management';
type UserFormProps = {
title: string;
defaultValue?: Partial<UserInput>;
onClose: () => void;
onConfirm: (user: UserInput) => void;
onValidate: (user: Partial<UserInput>) => boolean;
actions?: React.ReactNode;
};
function UserForm({
title,
defaultValue,
onClose,
onConfirm,
onValidate,
actions,
}: UserFormProps) {
const serverConfig = useServerConfig();
const [changes, setChanges] = useState<Partial<UserInput>>({
features: defaultValue?.features ?? [],
});
const setField = useCallback(
<K extends keyof UserInput>(
field: K,
value: UserInput[K] | ((prev: UserInput[K] | undefined) => UserInput[K])
) => {
setChanges(changes => ({
...changes,
[field]:
typeof value === 'function' ? value(changes[field] as any) : value,
}));
},
[]
);
const canSave = useMemo(() => {
return onValidate(changes);
}, [onValidate, changes]);
const handleConfirm = useCallback(() => {
if (!canSave) {
return;
}
// @ts-expect-error checked
onConfirm(changes);
}, [canSave, changes, onConfirm]);
const onFeatureChanged = useCallback(
(feature: FeatureType, checked: boolean) => {
setField('features', (features = []) => {
if (checked) {
return [...features, feature];
}
return features.filter(f => f !== feature);
});
},
[setField]
);
return (
<div className="flex flex-col h-full gap-1">
<div className=" flex justify-between items-center py-[10px] px-6">
<Button
type="button"
size="icon"
className="w-7 h-7"
variant="ghost"
onClick={onClose}
>
<XIcon size={20} />
</Button>
<span className="text-base font-medium">{title}</span>
<Button
type="submit"
size="icon"
className="w-7 h-7"
variant="ghost"
onClick={handleConfirm}
disabled={!canSave}
>
<CheckIcon size={20} />
</Button>
</div>
<Separator />
<div className="p-4 flex-grow overflow-y-auto space-y-[10px]">
<div className="flex flex-col rounded-md border py-4 gap-4">
<InputItem
label="Name"
field="name"
value={changes.name ?? defaultValue?.name}
onChange={setField}
/>
<Separator />
<InputItem
label="Email"
field="email"
value={changes.email ?? defaultValue?.email}
onChange={setField}
/>
</div>
<div className="border rounded-md">
{serverConfig.availableUserFeatures.map((feature, i) => (
<div key={feature}>
<ToggleItem
name={feature}
checked={(
changes.features ??
defaultValue?.features ??
[]
).includes(feature)}
onChange={onFeatureChanged}
/>
{i < serverConfig.availableUserFeatures.length - 1 && (
<Separator />
)}
</div>
))}
</div>
{actions}
</div>
</div>
);
}
function ToggleItem({
name,
checked,
onChange,
}: {
name: FeatureType;
checked: boolean;
onChange: (name: FeatureType, value: boolean) => void;
}) {
const onToggle = useCallback(
(checked: boolean) => {
onChange(name, checked);
},
[name, onChange]
);
return (
<Label className="flex items-center justify-between px-4 py-3">
<span>{name}</span>
<Switch checked={checked} onCheckedChange={onToggle} />
</Label>
);
}
function InputItem({
label,
field,
value,
onChange,
}: {
label: string;
field: keyof UserInput;
value?: string;
onChange: (field: keyof UserInput, value: string) => void;
}) {
const onValueChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
onChange(field, e.target.value);
},
[field, onChange]
);
return (
<div className="px-5 space-y-3">
<Label className="text-sm font-medium">{label}</Label>
<Input
type="text"
className="py-2 px-3 text-base font-normal"
defaultValue={value}
onChange={onValueChange}
/>
</div>
);
}
const validateCreateUser = (user: Partial<UserInput>) => {
return !!user.name && !!user.email && !!user.features;
};
const validateUpdateUser = (user: Partial<UserInput>) => {
return !!user.name || !!user.email;
};
export function CreateUserForm({ onComplete }: { onComplete: () => void }) {
const { create, creating } = useCreateUser();
useEffect(() => {
if (creating) {
return () => {
onComplete();
};
}
return;
}, [creating, onComplete]);
return (
<UserForm
title="Create User"
onClose={onComplete}
onConfirm={create}
onValidate={validateCreateUser}
/>
);
}
export function UpdateUserForm({
user,
onResetPassword,
onDeleteAccount,
onComplete,
}: {
user: UserType;
onResetPassword: () => void;
onDeleteAccount: () => void;
onComplete: () => void;
}) {
const { update, updating } = useUpdateUser();
const onUpdateUser = useCallback(
(updates: UserInput) => {
update({
...updates,
userId: user.id,
});
},
[user, update]
);
useEffect(() => {
if (updating) {
return () => {
onComplete();
};
}
return;
}, [updating, onComplete]);
return (
<UserForm
title="Update User"
defaultValue={user}
onClose={onComplete}
onConfirm={onUpdateUser}
onValidate={validateUpdateUser}
actions={
<>
<Button
className="w-full flex items-center justify-between text-sm font-medium px-4 py-3"
variant="outline"
onClick={onResetPassword}
>
<span>Reset Password</span>
<ChevronRightIcon size={16} />
</Button>
<Button
className="w-full text-red-500 px-4 py-3 rounded-md flex items-center justify-between text-sm font-medium hover:text-red-500"
variant="outline"
onClick={onDeleteAccount}
>
<span>Delete Account</span>
<ChevronRightIcon size={16} />
</Button>
</>
}
/>
);
}

View File

@@ -0,0 +1,48 @@
import { Separator } from '@affine/admin/components/ui/separator';
import { useQuery } from '@affine/core/hooks/use-query';
import { listUsersQuery } from '@affine/graphql';
import { useState } from 'react';
import { Layout } from '../layout';
import { columns } from './components/columns';
import { DataTable } from './components/data-table';
export function Accounts() {
return <Layout content={<AccountPage />} />;
}
export function AccountPage() {
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: 10,
});
const {
data: { users },
} = useQuery({
query: listUsersQuery,
variables: {
filter: {
first: pagination.pageSize,
skip: pagination.pageIndex * pagination.pageSize,
},
},
});
return (
<div className=" h-screen flex-1 flex-col flex">
<div className="flex items-center justify-between px-6 py-3 my-[2px] max-md:ml-9 max-md:mt-[2px]">
<div className="text-base font-medium">Accounts</div>
</div>
<Separator />
<DataTable
data={users}
// @ts-expect-error do not complains
columns={columns}
pagination={pagination}
onPaginationChange={setPagination}
/>
</div>
);
}
export { Accounts as Component };

View File

@@ -0,0 +1,8 @@
import type { FeatureType, ListUsersQuery } from '@affine/graphql';
export type UserType = ListUsersQuery['users'][0];
export type UserInput = {
name: string;
email: string;
features: FeatureType[];
};

View File

@@ -0,0 +1,44 @@
import { Button } from '@affine/admin/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@affine/admin/components/ui/dialog';
export const DiscardChanges = ({
open,
onClose,
onConfirm,
onOpenChange,
}: {
open: boolean;
onClose: () => void;
onConfirm: () => void;
onOpenChange: (open: boolean) => void;
}) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:w-[460px]">
<DialogHeader>
<DialogTitle className="leading-7">Discard Changes</DialogTitle>
<DialogDescription className="leading-6">
Changes to this prompt will not be saved.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<div className="flex justify-end items-center w-full space-x-4">
<Button type="button" onClick={onClose} variant="outline">
<span>Cancel</span>
</Button>
<Button type="button" onClick={onConfirm} variant="destructive">
<span>Discard</span>
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,146 @@
import { Button } from '@affine/admin/components/ui/button';
import { ScrollArea } from '@affine/admin/components/ui/scroll-area';
import { Separator } from '@affine/admin/components/ui/separator';
import { Textarea } from '@affine/admin/components/ui/textarea';
import { CheckIcon, XIcon } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRightPanel } from '../layout';
import type { Prompt } from './prompts';
import { usePrompt } from './use-prompt';
export function EditPrompt({ item }: { item: Prompt }) {
const { closePanel } = useRightPanel();
const [messages, setMessages] = useState(item.messages);
const { updatePrompt } = usePrompt();
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>, index: number) => {
const newMessages = [...messages];
newMessages[index] = {
...newMessages[index],
content: e.target.value,
};
setMessages(newMessages);
},
[messages]
);
const handleClose = useCallback(() => {
setMessages(item.messages);
closePanel();
}, [closePanel, item.messages]);
const onConfirm = useCallback(() => {
updatePrompt({ name: item.name, messages });
handleClose();
}, [handleClose, item.name, messages, updatePrompt]);
const disableSave = useMemo(
() => JSON.stringify(messages) === JSON.stringify(item.messages),
[item.messages, messages]
);
useEffect(() => {
setMessages(item.messages);
}, [item.messages]);
return (
<div className="flex flex-col h-full gap-1">
<div className="flex justify-between items-center py-[10px] px-6 ">
<Button
type="button"
size="icon"
className="w-7 h-7"
variant="ghost"
onClick={handleClose}
>
<XIcon size={20} />
</Button>
<span className="text-base font-medium">Edit Prompt</span>
<Button
type="submit"
size="icon"
className="w-7 h-7"
variant="ghost"
onClick={onConfirm}
disabled={disableSave}
>
<CheckIcon size={20} />
</Button>
</div>
<Separator />
<ScrollArea>
<div className="px-5 py-4 overflow-y-auto space-y-[10px] flex flex-col gap-5">
<div className="flex flex-col">
<div className="text-sm font-medium">Name</div>
<div className="text-sm font-normal text-zinc-500">{item.name}</div>
</div>
{item.action ? (
<div className="flex flex-col">
<div className="text-sm font-medium">Action</div>
<div className="text-sm font-normal text-zinc-500">
{item.action}
</div>
</div>
) : null}
<div className="flex flex-col">
<div className="text-sm font-medium">Model</div>
<div className="text-sm font-normal text-zinc-500">
{item.model}
</div>
</div>
{item.config ? (
<div className="flex flex-col border rounded p-3">
<div className="text-sm font-medium">Config</div>
{Object.entries(item.config).map(([key, value], index) => (
<div key={key} className="flex flex-col">
{index !== 0 && <Separator />}
<span className="text-sm font-normal">{key}</span>
<span className="text-sm font-normal text-zinc-500">
{value?.toString()}
</span>
</div>
))}
</div>
) : null}
</div>
<div className="px-5 py-4 overflow-y-auto space-y-[10px] flex flex-col">
<div className="text-sm font-medium">Messages</div>
{messages.map((message, index) => (
<div key={index} className="flex flex-col gap-3">
{index !== 0 && <Separator />}
<div>
<div className="text-sm font-normal">Role</div>
<div className="text-sm font-normal text-zinc-500">
{message.role}
</div>
</div>
{message.params ? (
<div>
<div className="text-sm font-medium">Params</div>
{Object.entries(message.params).map(([key, value], index) => (
<div key={key} className="flex flex-col">
{index !== 0 && <Separator />}
<span className="text-sm font-normal">{key}</span>
<span className="text-sm font-normal text-zinc-500">
{value.toString()}
</span>
</div>
))}
</div>
) : null}
<div className="text-sm font-normal">Content</div>
<Textarea
className=" min-h-48"
value={message.content}
onChange={e => handleChange(e, index)}
/>
</div>
))}
</div>
</ScrollArea>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { Separator } from '@affine/admin/components/ui/separator';
import { cn } from '@affine/admin/utils';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { Prompts } from './prompts';
export function Ai() {
return null;
// hide ai config in admin until it's ready
// return <Layout content={<AiPage />} />;
}
export function AiPage() {
return (
<div className=" h-screen flex-1 flex-col flex">
<div className="flex items-center justify-between px-6 py-3 my-[2px] max-md:ml-9 max-md:mt-[2px]">
<div className="text-base font-medium">AI</div>
</div>
<Separator />
<ScrollAreaPrimitive.Root
className={cn('relative overflow-hidden w-full')}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit] [&>div]:!block">
<Prompts />
</ScrollAreaPrimitive.Viewport>
<ScrollAreaPrimitive.ScrollAreaScrollbar
className={cn(
'flex touch-none select-none transition-colors',
'h-full w-2.5 border-l border-l-transparent p-[1px]'
)}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
</div>
);
}
export { Ai as Component };

View File

@@ -0,0 +1,69 @@
import { Button } from '@affine/admin/components/ui/button';
import { Input } from '@affine/admin/components/ui/input';
import { Label } from '@affine/admin/components/ui/label';
import { Separator } from '@affine/admin/components/ui/separator';
import { useState } from 'react';
export function Keys() {
const [openAIKey, setOpenAIKey] = useState('');
const [falAIKey, setFalAIKey] = useState('');
const [unsplashKey, setUnsplashKey] = useState('');
return (
<div className="flex flex-col h-full gap-3 py-5 px-6 w-full">
<div className="flex items-center">
<span className="text-xl font-semibold">Keys</span>
</div>
<div className="flex-grow overflow-y-auto space-y-[10px]">
<div className="flex flex-col rounded-md border py-4 gap-4">
<div className="px-5 space-y-3">
<Label className="text-sm font-medium">OpenAI Key</Label>
<div className="flex items-center gap-2">
<Input
type="text"
className="py-2 px-3 text-base font-normal placeholder:opacity-50"
value={openAIKey}
placeholder="sk-xxxxxxxxxxxxx-xxxxxxxxxxxxxx"
onChange={e => setOpenAIKey(e.target.value)}
/>
<Button disabled>Save</Button>
</div>
</div>
<Separator />
<div className="px-5 space-y-3">
<Label className="text-sm font-medium">Fal.AI Key</Label>
<div className="flex items-center gap-2">
<Input
type="email"
className="py-2 px-3 ext-base font-normal placeholder:opacity-50"
value={falAIKey}
placeholder="00000000-0000-0000-00000000:xxxxxxxxxxxxxxxxx"
onChange={e => setFalAIKey(e.target.value)}
/>
<Button disabled>Save</Button>
</div>
</div>
<Separator />
<div className="px-5 space-y-3">
<Label className="text-sm font-medium">Unsplash Key</Label>
<div className="flex items-center gap-2">
<Input
type="password"
className="py-2 px-3 ext-base font-normal placeholder:opacity-50"
value={unsplashKey}
placeholder="00000000-0000-0000-00000000:xxxxxxxxxxxxxxxxx"
onChange={e => setUnsplashKey(e.target.value)}
/>
<Button disabled>Save</Button>
</div>
</div>
<Separator />
<div className="px-5 space-y-3 text-sm font-normal text-gray-500">
Custom API keys may not perform as expected. AFFiNE does not
guarantee results when using custom API keys.
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,113 @@
import { Button } from '@affine/admin/components/ui/button';
import { Separator } from '@affine/admin/components/ui/separator';
import type { CopilotPromptMessageRole } from '@affine/graphql';
import { useCallback, useState } from 'react';
import { useRightPanel } from '../layout';
import { DiscardChanges } from './discard-changes';
import { EditPrompt } from './edit-prompt';
import { usePrompt } from './use-prompt';
export type Prompt = {
__typename?: 'CopilotPromptType';
name: string;
model: string;
action: string | null;
config: {
__typename?: 'CopilotPromptConfigType';
jsonMode: boolean | null;
frequencyPenalty: number | null;
presencePenalty: number | null;
temperature: number | null;
topP: number | null;
} | null;
messages: Array<{
__typename?: 'CopilotPromptMessageType';
role: CopilotPromptMessageRole;
content: string;
params: Record<string, string> | null;
}>;
};
export function Prompts() {
const { prompts: list } = usePrompt();
return (
<div className="flex flex-col h-full gap-3 py-5 px-6 w-full">
<div className="flex items-center">
<span className="text-xl font-semibold">Prompts</span>
</div>
<div className="flex-grow overflow-y-auto space-y-[10px]">
<div className="flex flex-col rounded-md border w-full">
{list.map((item, index) => (
<PromptRow
key={item.name.concat(index.toString())}
item={item}
index={index}
/>
))}
</div>
</div>
</div>
);
}
export const PromptRow = ({ item, index }: { item: Prompt; index: number }) => {
const { setRightPanelContent, openPanel, isOpen } = useRightPanel();
const [dialogOpen, setDialogOpen] = useState(false);
const handleDiscardChangesCancel = useCallback(() => {
setDialogOpen(false);
}, []);
const handleConfirm = useCallback(
(item: Prompt) => {
setRightPanelContent(<EditPrompt item={item} />);
if (dialogOpen) {
handleDiscardChangesCancel();
}
if (!isOpen) {
openPanel();
}
},
[
dialogOpen,
handleDiscardChangesCancel,
isOpen,
openPanel,
setRightPanelContent,
]
);
const handleEdit = useCallback(
(item: Prompt) => {
if (isOpen) {
setDialogOpen(true);
} else {
handleConfirm(item);
}
},
[handleConfirm, isOpen]
);
return (
<div>
{index !== 0 && <Separator />}
<Button
variant="ghost"
className="flex flex-col gap-1 w-full items-start px-6 py-[14px] h-full "
onClick={() => handleEdit(item)}
>
<div>{item.name}</div>
<div className="text-left w-full opacity-50 overflow-hidden text-ellipsis whitespace-nowrap break-words text-nowrap">
{item.messages.flatMap(message => message.content).join(' ')}
</div>
</Button>
<DiscardChanges
open={dialogOpen}
onOpenChange={setDialogOpen}
onClose={handleDiscardChangesCancel}
onConfirm={() => handleConfirm(item)}
/>
</div>
);
};

View File

@@ -0,0 +1,51 @@
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import {
useMutateQueryResource,
useMutation,
} from '@affine/core/hooks/use-mutation';
import { useQuery } from '@affine/core/hooks/use-query';
import { getPromptsQuery, updatePromptMutation } from '@affine/graphql';
import { toast } from 'sonner';
import type { Prompt } from './prompts';
export const usePrompt = () => {
const { data } = useQuery({
query: getPromptsQuery,
});
const { trigger } = useMutation({
mutation: updatePromptMutation,
});
const revalidate = useMutateQueryResource();
const updatePrompt = useAsyncCallback(
async ({
name,
messages,
}: {
name: string;
messages: Prompt['messages'];
}) => {
await trigger({
name,
messages,
})
.then(async () => {
await revalidate(getPromptsQuery);
toast.success('Prompt updated successfully');
})
.catch(e => {
toast(e.message);
console.error(e);
});
},
[revalidate, trigger]
);
return {
prompts: data.listCopilotPrompts,
updatePrompt,
};
};

View File

@@ -1,14 +1,23 @@
import { Button } from '@affine/admin/components/ui/button';
import { Input } from '@affine/admin/components/ui/input';
import { Label } from '@affine/admin/components/ui/label';
import { FeatureType, getUserFeaturesQuery } from '@affine/graphql';
import { useCallback, useRef } from 'react';
import { useMutateQueryResource } from '@affine/core/hooks/use-mutation';
import {
FeatureType,
getCurrentUserFeaturesQuery,
getUserFeaturesQuery,
} from '@affine/graphql';
import { useCallback, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { useCurrentUser, useServerConfig } from '../common';
import logo from './logo.svg';
export function Auth() {
const currentUser = useCurrentUser();
const serverConfig = useServerConfig();
const revalidate = useMutateQueryResource();
const emailRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
@@ -24,6 +33,14 @@ export function Auth() {
'Content-Type': 'application/json',
},
})
.then(async response => {
if (!response.ok) {
const data = await response.json();
throw new Error(data.message || 'Failed to login');
}
await revalidate(getCurrentUserFeaturesQuery);
return response.json();
})
.then(() =>
fetch('/graphql', {
method: 'POST',
@@ -45,6 +62,7 @@ export function Auth() {
},
}) => {
if (features.includes(FeatureType.Admin)) {
toast.success('Logged in successfully');
navigate('/admin');
} else {
toast.error('You are not an admin');
@@ -54,9 +72,22 @@ export function Auth() {
.catch(err => {
toast.error(`Failed to login: ${err.message}`);
});
}, [navigate]);
}, [navigate, revalidate]);
useEffect(() => {
if (serverConfig.initialized === false) {
navigate('/admin/setup');
return;
} else if (!currentUser) {
return;
} else if (!currentUser?.features.includes?.(FeatureType.Admin)) {
toast.error('You are not an admin, please login the admin account.');
return;
}
}, [currentUser, navigate, serverConfig.initialized]);
return (
<div className="w-full lg:grid lg:min-h-[600px] lg:grid-cols-2 xl:min-h-[800px]">
<div className="w-full lg:grid lg:min-h-[600px] lg:grid-cols-2 xl:min-h-[800px] h-screen">
<div className="flex items-center justify-center py-12">
<div className="mx-auto grid w-[350px] gap-6">
<div className="grid gap-2 text-center">
@@ -88,11 +119,11 @@ export function Auth() {
</div>
</div>
</div>
<div className="hidden bg-muted lg:block">
<div className="hidden bg-muted lg:flex lg:justify-center">
<img
src={logo}
alt="Image"
className="w-1/2 h-1/2 object-cover dark:brightness-[0.2] dark:grayscale relative top-1/4 left-1/4"
className="h-1/2 object-cover dark:brightness-[0.2] dark:grayscale relative top-1/4 "
/>
</div>
</div>

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