Compare commits

..

1 Commits

Author SHA1 Message Date
Peng Xiao 5758d5eba5 chore: test o4-transcribe 2025-07-16 16:57:56 +08:00
107 changed files with 761 additions and 1610 deletions
-1
View File
@@ -75,7 +75,6 @@ jobs:
with:
secret: ${{ secrets.GITHUB_TOKEN }}
approvers: forehalo,fengmk2
minimum-approvals: 1
fail-on-denial: true
issue-title: Please confirm to release docker image
issue-body: |
@@ -20,7 +20,6 @@
"@blocksuite/affine-block-paragraph": "workspace:*",
"@blocksuite/affine-block-surface": "workspace:*",
"@blocksuite/affine-block-surface-ref": "workspace:*",
"@blocksuite/affine-block-table": "workspace:*",
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-ext-loader": "workspace:*",
"@blocksuite/affine-fragment-doc-title": "workspace:*",
@@ -18,7 +18,6 @@ import {
} from '@blocksuite/affine-block-paragraph';
import { DefaultTool, getSurfaceBlock } from '@blocksuite/affine-block-surface';
import { insertSurfaceRefBlockCommand } from '@blocksuite/affine-block-surface-ref';
import { insertTableBlockCommand } from '@blocksuite/affine-block-table';
import { toggleEmbedCardCreateModal } from '@blocksuite/affine-components/embed-card-modal';
import { toast } from '@blocksuite/affine-components/toast';
import { insertInlineLatex } from '@blocksuite/affine-inline-latex';
@@ -41,20 +40,14 @@ import {
deleteSelectedModelsCommand,
draftSelectedModelsCommand,
duplicateSelectedModelsCommand,
focusBlockEnd,
getBlockSelectionsCommand,
getSelectedModelsCommand,
getTextSelectionCommand,
} from '@blocksuite/affine-shared/commands';
import { REFERENCE_NODE } from '@blocksuite/affine-shared/consts';
import {
FeatureFlagService,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import type { AffineTextStyleAttributes } from '@blocksuite/affine-shared/types';
import {
createDefaultDoc,
isInsideBlockByFlavour,
openSingleFileWith,
type Signal,
} from '@blocksuite/affine-shared/utils';
@@ -94,7 +87,6 @@ import {
RedoIcon,
RightTabIcon,
StrikeThroughIcon,
TableIcon,
TeXIcon,
TextIcon,
TodayIcon,
@@ -266,62 +258,6 @@ const textToolActionItems: KeyboardToolbarActionItem[] = [
.run();
},
},
{
name: 'Table',
icon: TableIcon(),
showWhen: ({ std, rootComponent: { model } }) =>
std.store.schema.flavourSchemaMap.has('affine:table') &&
!isInsideBlockByFlavour(std.store, model, 'affine:edgeless-text'),
action: ({ std }) => {
std.command
.chain()
.pipe(getSelectedModelsCommand)
.pipe(insertTableBlockCommand, {
place: 'after',
removeEmptyLine: true,
})
.pipe(({ insertedTableBlockId }) => {
if (insertedTableBlockId) {
const telemetry = std.getOptional(TelemetryProvider);
telemetry?.track('BlockCreated', {
blockType: 'affine:table',
});
}
})
.run();
},
},
{
name: 'Callout',
icon: FontIcon(),
showWhen: ({ std, rootComponent: { model } }) => {
return (
std.get(FeatureFlagService).getFlag('enable_callout') &&
!isInsideBlockByFlavour(model.store, model, 'affine:edgeless-text')
);
},
action: ({ rootComponent: { model }, std }) => {
const { store } = model;
const parent = store.getParent(model);
if (!parent) return;
const index = parent.children.indexOf(model);
if (index === -1) return;
const calloutId = store.addBlock('affine:callout', {}, parent, index + 1);
if (!calloutId) return;
const paragraphId = store.addBlock('affine:paragraph', {}, calloutId);
if (!paragraphId) return;
std.host.updateComplete
.then(() => {
const paragraph = std.view.getBlock(paragraphId);
if (!paragraph) return;
std.command.exec(focusBlockEnd, {
focusBlock: paragraph,
});
})
.catch(console.error);
},
},
];
const listToolActionItems: KeyboardToolbarActionItem[] = [
@@ -17,7 +17,6 @@
{ "path": "../../blocks/paragraph" },
{ "path": "../../blocks/surface" },
{ "path": "../../blocks/surface-ref" },
{ "path": "../../blocks/table" },
{ "path": "../../components" },
{ "path": "../../ext-loader" },
{ "path": "../../fragments/doc-title" },
@@ -343,18 +343,7 @@ export class LinkedDocPopover extends SignalWatcher(
override willUpdate() {
if (!this.hasUpdated) {
const updatePosition = throttle(() => {
this._position = getPopperPosition(
{
getBoundingClientRect: () => {
return {
...this.getBoundingClientRect(),
// Workaround: the width of the popover is zero when it is not rendered
width: 280,
};
},
},
this.context.startNativeRange
);
this._position = getPopperPosition(this, this.context.startNativeRange);
}, 10);
this.disposables.addFromEvent(window, 'resize', updatePosition);
@@ -461,29 +461,6 @@ test('should create message correctly', async t => {
sessionId,
undefined,
undefined,
new File([new Uint8Array(pngData)], '1.png', { type: 'image/png' })
);
t.truthy(messageId, 'should be able to create message with blob');
}
// with attachments
{
const { id } = await createWorkspace(app);
const sessionId = await createCopilotSession(
app,
id,
randomUUID(),
textPromptName
);
const smallestPng =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII';
const pngData = await fetch(smallestPng).then(res => res.arrayBuffer());
const messageId = await createCopilotMessage(
app,
sessionId,
undefined,
undefined,
undefined,
[new File([new Uint8Array(pngData)], '1.png', { type: 'image/png' })]
);
t.truthy(messageId, 'should be able to create message with blobs');
@@ -13,45 +13,74 @@ Generated by [AVA](https://avajs.dev).
# You own your data, with no compromises␊
## Local-first & Real-time collaborative␊
We love the idea proposed by Ink & Switch in the famous article about you owning your data, despite the cloud. Furthermore, AFFiNE is the first all-in-one workspace that keeps your data ownership with no compromises on real-time collaboration and editing experience.␊
AFFiNE is a local-first application upon CRDTs with real-time collaboration support. Your data is always stored locally while multiple nodes remain synced in real-time.␊
### Blocks that assemble your next docs, tasks kanban or whiteboard␊
There is a large overlap of their atomic "building blocks" between these apps. They are neither open source nor have a plugin system like VS Code for contributors to customize. We want to have something that contains all the features we love and goes one step further.␊
We are building AFFiNE to be a fundamental open source platform that contains all the building blocks for docs, task management and visual collaboration, hoping you can shape your next workflow with us that can make your life better and also connect others, too.␊
If you want to learn more about the product design of AFFiNE, here goes the concepts:␊
To Shape, not to adapt. AFFiNE is built for individuals & teams who care about their data, who refuse vendor lock-in, and who want to have control over their essential tools.␊
## A true canvas for blocks in any form␊
[Many editor apps](http://notion.so) claimed to be a canvas for productivity. Since _the Mother of All Demos,_ Douglas Engelbart, a creative and programable digital workspace has been a pursuit and an ultimate mission for generations of tool makers.␊
"We shape our tools and thereafter our tools shape us”. A lot of pioneers have inspired us a long the way, e.g.:␊
* Quip & Notion with their great concept of "everything is a block"␊
* Trello with their Kanban␊
* Airtable & Miro with their no-code programable datasheets␊
* Miro & Whimiscal with their edgeless visual whiteboard␊
* Remnote & Capacities with their object-based tag system␊
For more details, please refer to our [RoadMap](https://docs.affine.pro/docs/core-concepts/roadmap)␊
## Self Host␊
Self host AFFiNE␊
||Title|Tag|␊
|---|---|---|␊
|Affine Development|Affine Development|<span data-affine-option data-value="AxSe-53xjX" data-option-color="var(--affine-tag-pink)">AFFiNE</span>|␊
@@ -62,12 +91,16 @@ Generated by [AVA](https://avajs.dev).
|Miro & Whimiscal with their edgeless visual whiteboard|Miro & Whimiscal with their edgeless visual whiteboard|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|␊
|Remnote & Capacities with their object-based tag system|Remnote & Capacities with their object-based tag system||␊
## Affine Development␊
For developer or installation guides, please go to [AFFiNE Development](https://docs.affine.pro/docs/development/quick-start)␊
`,
title: 'Write, Draw, Plan all at Once.',
}
@@ -89,19 +89,3 @@ Generated by [AVA](https://avajs.dev).
> should not find docs to embed
0
## should filter outdated doc id style in embedding status
> should include modern doc format
{
embedded: 0,
total: 1,
}
> should count docs after filtering outdated
{
embedded: 1,
total: 1,
}
@@ -306,50 +306,3 @@ test('should check embedding table', async t => {
// t.false(ret, 'should return false when embedding table is not available');
// }
});
test('should filter outdated doc id style in embedding status', async t => {
const docId = randomUUID();
const outdatedDocId = `${workspace.id}:space:${docId}`;
await t.context.doc.upsert({
spaceId: workspace.id,
docId,
blob: Uint8Array.from([1, 2, 3]),
timestamp: Date.now(),
editorId: user.id,
});
await t.context.doc.upsert({
spaceId: workspace.id,
docId: outdatedDocId,
blob: Uint8Array.from([1, 2, 3]),
timestamp: Date.now(),
editorId: user.id,
});
{
const status = await t.context.copilotWorkspace.getEmbeddingStatus(
workspace.id
);
t.snapshot(status, 'should include modern doc format');
}
{
await t.context.copilotContext.insertWorkspaceEmbedding(
workspace.id,
docId,
[
{
index: 0,
content: 'content',
embedding: Array.from({ length: 1024 }, () => 1),
},
]
);
const status = await t.context.copilotWorkspace.getEmbeddingStatus(
workspace.id
);
t.snapshot(status, 'should count docs after filtering outdated');
}
});
@@ -554,73 +554,52 @@ export async function createCopilotMessage(
sessionId: string,
content?: string,
attachments?: string[],
blob?: File,
blobs?: File[],
params?: Record<string, string>
): Promise<string> {
const gql = {
query: `
let resp = app
.POST('/graphql')
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.field(
'operations',
JSON.stringify({
query: `
mutation createCopilotMessage($options: CreateChatMessageInput!) {
createCopilotMessage(options: $options)
}
`,
variables: {
options: {
sessionId,
content,
attachments,
blob: null,
blobs: [],
params,
},
},
};
let resp = app
.POST('/graphql')
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' });
if (blob || blobs) {
resp = resp.field('operations', JSON.stringify(gql));
if (blob) {
resp = resp.field(
'map',
JSON.stringify({ '0': ['variables.options.blob'] })
);
resp = resp.attach('0', Buffer.from(await blob.arrayBuffer()), {
filename: blob.name || 'file',
contentType: blob.type || 'application/octet-stream',
});
} else if (blobs && blobs.length) {
resp = resp.field(
'map',
JSON.stringify(
Array.from<any>({ length: blobs?.length ?? 0 }).reduce(
(acc, _, idx) => {
acc[idx.toString()] = [`variables.options.blobs.${idx}`];
return acc;
},
{}
)
variables: {
options: { sessionId, content, attachments, blobs: [], params },
},
})
)
.field(
'map',
JSON.stringify(
Array.from<any>({ length: blobs?.length ?? 0 }).reduce(
(acc, _, idx) => {
acc[idx.toString()] = [`variables.options.blobs.${idx}`];
return acc;
},
{}
)
)
);
if (blobs && blobs.length) {
for (const [idx, file] of blobs.entries()) {
resp = resp.attach(
idx.toString(),
Buffer.from(await file.arrayBuffer()),
{
filename: file.name || `file${idx}`,
contentType: file.type || 'application/octet-stream',
}
);
for (const [idx, file] of blobs.entries()) {
resp = resp.attach(
idx.toString(),
Buffer.from(await file.arrayBuffer()),
{
filename: file.name || `file${idx}`,
contentType: file.type || 'application/octet-stream',
}
);
}
}
} else {
resp = resp.send(gql);
}
const res = await resp.expect(200);
console.log('createCopilotMessage', res.body);
return res.body.data.createCopilotMessage;
}
@@ -13,45 +13,74 @@ Generated by [AVA](https://avajs.dev).
# You own your data, with no compromises␊
## Local-first & Real-time collaborative␊
We love the idea proposed by Ink & Switch in the famous article about you owning your data, despite the cloud. Furthermore, AFFiNE is the first all-in-one workspace that keeps your data ownership with no compromises on real-time collaboration and editing experience.␊
AFFiNE is a local-first application upon CRDTs with real-time collaboration support. Your data is always stored locally while multiple nodes remain synced in real-time.␊
### Blocks that assemble your next docs, tasks kanban or whiteboard␊
There is a large overlap of their atomic "building blocks" between these apps. They are neither open source nor have a plugin system like VS Code for contributors to customize. We want to have something that contains all the features we love and goes one step further.␊
We are building AFFiNE to be a fundamental open source platform that contains all the building blocks for docs, task management and visual collaboration, hoping you can shape your next workflow with us that can make your life better and also connect others, too.␊
If you want to learn more about the product design of AFFiNE, here goes the concepts:␊
To Shape, not to adapt. AFFiNE is built for individuals & teams who care about their data, who refuse vendor lock-in, and who want to have control over their essential tools.␊
## A true canvas for blocks in any form␊
[Many editor apps](http://notion.so) claimed to be a canvas for productivity. Since _the Mother of All Demos,_ Douglas Engelbart, a creative and programable digital workspace has been a pursuit and an ultimate mission for generations of tool makers.␊
"We shape our tools and thereafter our tools shape us”. A lot of pioneers have inspired us a long the way, e.g.:␊
* Quip & Notion with their great concept of "everything is a block"␊
* Trello with their Kanban␊
* Airtable & Miro with their no-code programable datasheets␊
* Miro & Whimiscal with their edgeless visual whiteboard␊
* Remnote & Capacities with their object-based tag system␊
For more details, please refer to our [RoadMap](https://docs.affine.pro/docs/core-concepts/roadmap)␊
## Self Host␊
Self host AFFiNE␊
||Title|Tag|␊
|---|---|---|␊
|Affine Development|Affine Development|<span data-affine-option data-value="AxSe-53xjX" data-option-color="var(--affine-tag-pink)">AFFiNE</span>|␊
@@ -62,12 +91,16 @@ Generated by [AVA](https://avajs.dev).
|Miro & Whimiscal with their edgeless visual whiteboard|Miro & Whimiscal with their edgeless visual whiteboard|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|␊
|Remnote & Capacities with their object-based tag system|Remnote & Capacities with their object-based tag system||␊
## Affine Development␊
For developer or installation guides, please go to [AFFiNE Development](https://docs.affine.pro/docs/development/quick-start)␊
`,
title: 'Write, Draw, Plan all at Once.',
}
@@ -13,45 +13,74 @@ Generated by [AVA](https://avajs.dev).
# You own your data, with no compromises␊
## Local-first & Real-time collaborative␊
We love the idea proposed by Ink & Switch in the famous article about you owning your data, despite the cloud. Furthermore, AFFiNE is the first all-in-one workspace that keeps your data ownership with no compromises on real-time collaboration and editing experience.␊
AFFiNE is a local-first application upon CRDTs with real-time collaboration support. Your data is always stored locally while multiple nodes remain synced in real-time.␊
### Blocks that assemble your next docs, tasks kanban or whiteboard␊
There is a large overlap of their atomic "building blocks" between these apps. They are neither open source nor have a plugin system like VS Code for contributors to customize. We want to have something that contains all the features we love and goes one step further.␊
We are building AFFiNE to be a fundamental open source platform that contains all the building blocks for docs, task management and visual collaboration, hoping you can shape your next workflow with us that can make your life better and also connect others, too.␊
If you want to learn more about the product design of AFFiNE, here goes the concepts:␊
To Shape, not to adapt. AFFiNE is built for individuals & teams who care about their data, who refuse vendor lock-in, and who want to have control over their essential tools.␊
## A true canvas for blocks in any form␊
[Many editor apps](http://notion.so) claimed to be a canvas for productivity. Since _the Mother of All Demos,_ Douglas Engelbart, a creative and programable digital workspace has been a pursuit and an ultimate mission for generations of tool makers.␊
"We shape our tools and thereafter our tools shape us”. A lot of pioneers have inspired us a long the way, e.g.:␊
* Quip & Notion with their great concept of "everything is a block"␊
* Trello with their Kanban␊
* Airtable & Miro with their no-code programable datasheets␊
* Miro & Whimiscal with their edgeless visual whiteboard␊
* Remnote & Capacities with their object-based tag system␊
For more details, please refer to our [RoadMap](https://docs.affine.pro/docs/core-concepts/roadmap)␊
## Self Host␊
Self host AFFiNE␊
||Title|Tag|␊
|---|---|---|␊
|Affine Development|Affine Development|<span data-affine-option data-value="AxSe-53xjX" data-option-color="var(--affine-tag-pink)">AFFiNE</span>|␊
@@ -62,12 +91,16 @@ Generated by [AVA](https://avajs.dev).
|Miro & Whimiscal with their edgeless visual whiteboard|Miro & Whimiscal with their edgeless visual whiteboard|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|␊
|Remnote & Capacities with their object-based tag system|Remnote & Capacities with their object-based tag system||␊
## Affine Development␊
For developer or installation guides, please go to [AFFiNE Development](https://docs.affine.pro/docs/development/quick-start)␊
`,
title: 'Write, Draw, Plan all at Once.',
}
@@ -1376,45 +1376,74 @@ Generated by [AVA](https://avajs.dev).
# You own your data, with no compromises␊
## Local-first & Real-time collaborative␊
We love the idea proposed by Ink & Switch in the famous article about you owning your data, despite the cloud. Furthermore, AFFiNE is the first all-in-one workspace that keeps your data ownership with no compromises on real-time collaboration and editing experience.␊
AFFiNE is a local-first application upon CRDTs with real-time collaboration support. Your data is always stored locally while multiple nodes remain synced in real-time.␊
### Blocks that assemble your next docs, tasks kanban or whiteboard␊
There is a large overlap of their atomic "building blocks" between these apps. They are neither open source nor have a plugin system like VS Code for contributors to customize. We want to have something that contains all the features we love and goes one step further.␊
We are building AFFiNE to be a fundamental open source platform that contains all the building blocks for docs, task management and visual collaboration, hoping you can shape your next workflow with us that can make your life better and also connect others, too.␊
If you want to learn more about the product design of AFFiNE, here goes the concepts:␊
To Shape, not to adapt. AFFiNE is built for individuals & teams who care about their data, who refuse vendor lock-in, and who want to have control over their essential tools.␊
## A true canvas for blocks in any form␊
[Many editor apps](http://notion.so) claimed to be a canvas for productivity. Since _the Mother of All Demos,_ Douglas Engelbart, a creative and programable digital workspace has been a pursuit and an ultimate mission for generations of tool makers.␊
"We shape our tools and thereafter our tools shape us”. A lot of pioneers have inspired us a long the way, e.g.:␊
* Quip & Notion with their great concept of "everything is a block"␊
* Trello with their Kanban␊
* Airtable & Miro with their no-code programable datasheets␊
* Miro & Whimiscal with their edgeless visual whiteboard␊
* Remnote & Capacities with their object-based tag system␊
For more details, please refer to our [RoadMap](https://docs.affine.pro/docs/core-concepts/roadmap)␊
## Self Host␊
Self host AFFiNE␊
||Title|Tag|␊
|---|---|---|␊
|Affine Development|Affine Development|<span data-affine-option data-value="AxSe-53xjX" data-option-color="var(--affine-tag-pink)">AFFiNE</span>|␊
@@ -1425,12 +1454,16 @@ Generated by [AVA](https://avajs.dev).
|Miro & Whimiscal with their edgeless visual whiteboard|Miro & Whimiscal with their edgeless visual whiteboard|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|␊
|Remnote & Capacities with their object-based tag system|Remnote & Capacities with their object-based tag system||␊
## Affine Development␊
For developer or installation guides, please go to [AFFiNE Development](https://docs.affine.pro/docs/development/quick-start)␊
`,
title: 'Write, Draw, Plan all at Once.',
}
@@ -1443,80 +1476,113 @@ Generated by [AVA](https://avajs.dev).
markdown: `<!-- block_id=FoPQcAyV_m flavour=affine:paragraph -->␊
AFFiNE is an open source all in one workspace, an operating system for all the building blocks of your team wiki, knowledge management and digital assets and a better alternative to Notion and Miro.␊
<!-- block_id=oz48nn_zp8 flavour=affine:paragraph -->␊
<!-- block_id=g8a-D9-jXS flavour=affine:paragraph -->␊
# You own your data, with no compromises␊
<!-- block_id=J8lHN1GR_5 flavour=affine:paragraph -->␊
## Local-first & Real-time collaborative␊
<!-- block_id=xCuWdM0VLz flavour=affine:paragraph -->␊
We love the idea proposed by Ink & Switch in the famous article about you owning your data, despite the cloud. Furthermore, AFFiNE is the first all-in-one workspace that keeps your data ownership with no compromises on real-time collaboration and editing experience.␊
<!-- block_id=zElMi0tViK flavour=affine:paragraph -->␊
AFFiNE is a local-first application upon CRDTs with real-time collaboration support. Your data is always stored locally while multiple nodes remain synced in real-time.␊
<!-- block_id=Z4rK0OF9Wk flavour=affine:paragraph -->␊
<!-- block_id=DQ0Ryb-SpW flavour=affine:paragraph -->␊
### Blocks that assemble your next docs, tasks kanban or whiteboard␊
<!-- block_id=HAZC3URZp_ flavour=affine:paragraph -->␊
There is a large overlap of their atomic "building blocks" between these apps. They are neither open source nor have a plugin system like VS Code for contributors to customize. We want to have something that contains all the features we love and goes one step further.␊
<!-- block_id=0H87ypiuv8 flavour=affine:paragraph -->␊
We are building AFFiNE to be a fundamental open source platform that contains all the building blocks for docs, task management and visual collaboration, hoping you can shape your next workflow with us that can make your life better and also connect others, too.␊
<!-- block_id=Sp4G1KD0Wn flavour=affine:paragraph -->␊
If you want to learn more about the product design of AFFiNE, here goes the concepts:␊
<!-- block_id=RsUhDuEqXa flavour=affine:paragraph -->␊
To Shape, not to adapt. AFFiNE is built for individuals & teams who care about their data, who refuse vendor lock-in, and who want to have control over their essential tools.␊
<!-- block_id=Z2HibKzAr- flavour=affine:paragraph -->␊
## A true canvas for blocks in any form␊
<!-- block_id=UwvWddamzM flavour=affine:paragraph -->␊
[Many editor apps](http://notion.so) claimed to be a canvas for productivity. Since _the Mother of All Demos,_ Douglas Engelbart, a creative and programable digital workspace has been a pursuit and an ultimate mission for generations of tool makers.␊
<!-- block_id=g9xKUjhJj1 flavour=affine:paragraph -->␊
<!-- block_id=wDTn4YJ4pm flavour=affine:paragraph -->␊
"We shape our tools and thereafter our tools shape us”. A lot of pioneers have inspired us a long the way, e.g.:␊
<!-- block_id=xFrrdiP3-V flavour=affine:list -->␊
* Quip & Notion with their great concept of "everything is a block"␊
<!-- block_id=Tp9xyN4Okl flavour=affine:list -->␊
* Trello with their Kanban␊
<!-- block_id=K_4hUzKZFQ flavour=affine:list -->␊
* Airtable & Miro with their no-code programable datasheets␊
<!-- block_id=QwMzON2s7x flavour=affine:list -->␊
* Miro & Whimiscal with their edgeless visual whiteboard␊
<!-- block_id=FFVmit6u1T flavour=affine:list -->␊
* Remnote & Capacities with their object-based tag system␊
<!-- block_id=YqnG5O6AE6 flavour=affine:paragraph -->␊
For more details, please refer to our [RoadMap](https://docs.affine.pro/docs/core-concepts/roadmap)␊
<!-- block_id=sbDTmZMZcq flavour=affine:paragraph -->␊
## Self Host␊
<!-- block_id=QVvitesfbj flavour=affine:paragraph -->␊
Self host AFFiNE␊
<!-- block_id=U_GoHFD9At flavour=affine:database placeholder -->␊
<!-- block_id=NyHXrMX3R1 flavour=affine:paragraph -->␊
## Affine Development␊
<!-- block_id=9-K49otbCv flavour=affine:paragraph -->␊
For developer or installation guides, please go to [AFFiNE Development](https://docs.affine.pro/docs/development/quick-start)␊
<!-- block_id=faFteK9eG- flavour=affine:paragraph -->␊
`,
title: 'Write, Draw, Plan all at Once.',
}
@@ -152,7 +152,7 @@ export class CopilotWorkspaceConfigModel extends BaseModel {
}
@Transactional()
async getEmbeddingStatus(workspaceId: string) {
async getWorkspaceEmbeddingStatus(workspaceId: string) {
const ignoredDocIds = (await this.listIgnoredDocIds(workspaceId)).map(
d => d.docId
);
@@ -168,13 +168,9 @@ export class CopilotWorkspaceConfigModel extends BaseModel {
};
const [docTotal, docEmbedded, fileTotal, fileEmbedded] = await Promise.all([
this.db.snapshot.findMany({
where: snapshotCondition,
select: { id: true },
}),
this.db.snapshot.findMany({
this.db.snapshot.count({ where: snapshotCondition }),
this.db.snapshot.count({
where: { ...snapshotCondition, embedding: { some: {} } },
select: { id: true },
}),
this.db.aiWorkspaceFiles.count({ where: { workspaceId } }),
this.db.aiWorkspaceFiles.count({
@@ -182,23 +178,9 @@ export class CopilotWorkspaceConfigModel extends BaseModel {
}),
]);
const docTotalIds = docTotal.map(d => d.id);
const docTotalSet = new Set(docTotalIds);
const outdatedDocPrefix = `${workspaceId}:space:`;
const duplicateOutdatedDocSet = new Set(
docTotalIds
.filter(id => id.startsWith(outdatedDocPrefix))
.filter(id => docTotalSet.has(id.slice(outdatedDocPrefix.length)))
);
return {
total:
docTotalIds.filter(id => !duplicateOutdatedDocSet.has(id)).length +
fileTotal,
embedded:
docEmbedded
.map(d => d.id)
.filter(id => !duplicateOutdatedDocSet.has(id)).length + fileEmbedded,
total: docTotal + fileTotal,
embedded: docEmbedded + fileEmbedded,
};
}
@@ -356,7 +356,6 @@ export class CopilotContextRootResolver {
return false;
}
@Throttle('strict')
@Query(() => ContextWorkspaceEmbeddingStatus, {
description: 'query workspace embedding status',
})
@@ -373,7 +372,9 @@ export class CopilotContextRootResolver {
if (this.context.canEmbedding) {
const { total, embedded } =
await this.models.copilotWorkspace.getEmbeddingStatus(workspaceId);
await this.models.copilotWorkspace.getWorkspaceEmbeddingStatus(
workspaceId
);
return { total, embedded };
}
@@ -303,7 +303,7 @@ const textActions: Prompt[] = [
{
name: 'Transcript audio',
action: 'Transcript audio',
model: 'gemini-2.5-flash',
model: 'gemini-2.5-pro',
optionalModels: ['gemini-2.5-flash', 'gemini-2.5-pro'],
messages: [
{
@@ -334,7 +334,6 @@ Convert a multi-speaker audio recording into a structured JSON format by transcr
config: {
requireContent: false,
requireAttachment: true,
maxRetries: 1,
},
},
{
@@ -129,16 +129,7 @@ export abstract class GeminiProvider<T> extends CopilotProvider<T> {
system,
messages: msgs,
schema,
providerOptions: {
google: {
thinkingConfig: {
thinkingBudget: -1,
includeThoughts: false,
},
},
},
abortSignal: options.signal,
maxRetries: options.maxRetries || 3,
experimental_repairText: async ({ text, error }) => {
if (error instanceof JSONParseError) {
// strange fixed response, temporarily replace it
@@ -37,7 +37,7 @@ import {
import { CurrentUser } from '../../core/auth';
import { Admin } from '../../core/common';
import { DocReader } from '../../core/doc';
import { AccessController, DocAction } from '../../core/permission';
import { AccessController } from '../../core/permission';
import { UserType } from '../../core/user';
import type { ListSessionOptions, UpdateChatSession } from '../../models';
import { CopilotCronJobs } from './cron';
@@ -143,9 +143,6 @@ class CreateChatMessageInput implements Omit<SubmittedMessage, 'content'> {
@Field(() => [String], { nullable: true, deprecationReason: 'use blobs' })
attachments!: string[] | undefined;
@Field(() => GraphQLUpload, { nullable: true })
blob!: Promise<FileUpload> | undefined;
@Field(() => [GraphQLUpload], { nullable: true })
blobs!: Promise<FileUpload>[] | undefined;
@@ -420,8 +417,7 @@ export class CopilotResolver {
private async assertPermission(
user: CurrentUser,
options: { workspaceId?: string | null; docId?: string | null },
fallbackAction?: DocAction
options: { workspaceId?: string | null; docId?: string | null }
) {
const { workspaceId, docId } = options;
if (!workspaceId) {
@@ -432,7 +428,7 @@ export class CopilotResolver {
.user(user.id)
.doc({ workspaceId, docId })
.allowLocal()
.assert(fallbackAction ?? 'Doc.Update');
.assert('Doc.Update');
} else {
await this.ac
.user(user.id)
@@ -511,7 +507,7 @@ export class CopilotResolver {
if (!workspaceId) {
return [];
} else {
await this.assertPermission(user, { workspaceId, docId }, 'Doc.Read');
await this.assertPermission(user, { workspaceId, docId });
}
const histories = await this.chatSession.list(
@@ -541,7 +537,7 @@ export class CopilotResolver {
if (!workspaceId) {
return paginate([], 'updatedAt', pagination, 0);
} else {
await this.assertPermission(user, { workspaceId, docId }, 'Doc.Read');
await this.assertPermission(user, { workspaceId, docId });
}
const finalOptions = Object.assign(
@@ -707,13 +703,10 @@ export class CopilotResolver {
}
const attachments: PromptMessage['attachments'] = options.attachments || [];
if (options.blob || options.blobs) {
if (options.blobs) {
const { workspaceId } = session.config;
const blobs = await Promise.all(
options.blob ? [options.blob] : options.blobs || []
);
delete options.blob;
const blobs = await Promise.all(options.blobs);
delete options.blobs;
for (const blob of blobs) {
@@ -51,15 +51,7 @@ Important Instructions:
- When inserting, follow the same format as a replacement, but ensure the new block_id does not conflict with existing IDs.
- When replacing content, always keep the original block_id unchanged.
- When deleting content, only use the format <!-- delete block_id=xxx -->, and only for valid block_id present in the original <code> content.
- Each top-level list item should be a block. Like this:
\`\`\`markdown
<!-- block_id=001 flavour=affine:list -->
* Item 1
* SubItem 1
<!-- block_id=002 flavour=affine:list -->
1. Item 1
1. SubItem 1
\`\`\`
- Each list item should be a block.
- Your task is to return a list of block-level changes needed to fulfill the user's intent.
- **Each change in code_edit must be completely independent: each code_edit entry should only perform a single, isolated change, and must not include the effects of other changes. For example, the updates for a delete operation should only show the context related to the deletion, and must not include any content modified by other operations (such as bolding or insertion). This ensures that each change can be applied independently and in any order.**
@@ -150,33 +142,24 @@ You should specify the following arguments before the others: [doc_id], [origin_
'A short, first-person description of the intended edit, clearly summarizing what I will change. For example: "I will translate the steps into English and delete the paragraph explaining the delay." This helps the downstream system understand the purpose of the changes.'
),
code_edit: z.preprocess(
val => {
// BACKGROUND: LLM sometimes returns a JSON string instead of an array.
if (typeof val === 'string') {
return JSON.parse(val);
}
return val;
},
z
.array(
z.object({
op: z
.string()
.describe(
'A short description of the change, such as "Bold intro name"'
),
updates: z
.string()
.describe(
'Markdown block fragments that represent the change, including the block_id and type'
),
})
)
.describe(
'An array of independent semantic changes to apply to the document.'
)
),
code_edit: z
.array(
z.object({
op: z
.string()
.describe(
'A short description of the change, such as "Bold intro name"'
),
updates: z
.string()
.describe(
'Markdown block fragments that represent the change, including the block_id and type'
),
})
)
.describe(
'An array of independent semantic changes to apply to the document.'
),
}),
execute: async ({ doc_id, origin_content, code_edit }) => {
try {
@@ -15,6 +15,7 @@ import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import {
CopilotTranscriptionAudioNotProvided,
CopilotTranscriptionJobNotFound,
type FileUpload,
} from '../../../base';
import { CurrentUser } from '../../../core/auth';
@@ -73,7 +74,7 @@ const FinishedStatus: Set<AiJobStatus> = new Set([
export class CopilotTranscriptionResolver {
constructor(
private readonly ac: AccessController,
private readonly transcript: CopilotTranscriptionService
private readonly service: CopilotTranscriptionService
) {}
private handleJobResult(
@@ -121,7 +122,7 @@ export class CopilotTranscriptionResolver {
throw new CopilotTranscriptionAudioNotProvided();
}
const jobResult = await this.transcript.submitJob(
const jobResult = await this.service.submitTranscriptionJob(
user.id,
workspaceId,
blobId,
@@ -143,11 +144,19 @@ export class CopilotTranscriptionResolver {
.allowLocal()
.assert('Workspace.Copilot');
const jobResult = await this.transcript.retryJob(
const job = await this.service.queryTranscriptionJob(
user.id,
workspaceId,
jobId
);
if (!job || !job.infos) {
throw new CopilotTranscriptionJobNotFound();
}
const jobResult = await this.service.executeTranscriptionJob(
job.id,
job.infos
);
return this.handleJobResult(jobResult);
}
@@ -157,7 +166,7 @@ export class CopilotTranscriptionResolver {
@CurrentUser() user: CurrentUser,
@Args('jobId') jobId: string
): Promise<TranscriptionResultType | null> {
const job = await this.transcript.claimJob(user.id, jobId);
const job = await this.service.claimTranscriptionJob(user.id, jobId);
return this.handleJobResult(job);
}
@@ -181,7 +190,7 @@ export class CopilotTranscriptionResolver {
.allowLocal()
.assert('Workspace.Copilot');
const job = await this.transcript.queryJob(
const job = await this.service.queryTranscriptionJob(
user.id,
copilot.workspaceId,
jobId,
@@ -49,17 +49,7 @@ export class CopilotTranscriptionService {
private readonly providerFactory: CopilotProviderFactory
) {}
private async getModel(userId: string) {
const prompt = await this.prompt.get('Transcript audio');
const hasAccess = await this.models.userFeature.has(
userId,
'unlimited_copilot'
);
// choose the pro model if user has copilot plan
return prompt?.optionalModels[hasAccess ? 1 : 0];
}
async submitJob(
async submitTranscriptionJob(
userId: string,
workspaceId: string,
blobId: string,
@@ -88,26 +78,12 @@ export class CopilotTranscriptionService {
infos.push({ url, mimeType: blob.mimetype });
}
const model = await this.getModel(userId);
return await this.executeJob(jobId, infos, model);
return await this.executeTranscriptionJob(jobId, infos);
}
async retryJob(userId: string, workspaceId: string, jobId: string) {
const job = await this.queryJob(userId, workspaceId, jobId);
if (!job || !job.infos) {
throw new CopilotTranscriptionJobNotFound();
}
const model = await this.getModel(userId);
const jobResult = await this.executeJob(job.id, job.infos, model);
return jobResult;
}
async executeJob(
async executeTranscriptionJob(
jobId: string,
infos: AudioBlobInfos,
modelId?: string
infos: AudioBlobInfos
): Promise<TranscriptionJob> {
const status = AiJobStatus.running;
const success = await this.models.copilotJob.update(jobId, {
@@ -122,13 +98,12 @@ export class CopilotTranscriptionService {
await this.job.add('copilot.transcript.submit', {
jobId,
infos,
modelId,
});
return { id: jobId, status };
}
async claimJob(
async claimTranscriptionJob(
userId: string,
jobId: string
): Promise<TranscriptionJob | null> {
@@ -143,7 +118,7 @@ export class CopilotTranscriptionService {
return null;
}
async queryJob(
async queryTranscriptionJob(
userId: string,
workspaceId: string,
jobId?: string,
@@ -206,20 +181,14 @@ export class CopilotTranscriptionService {
promptName: string,
message: Partial<PromptMessage>,
schema?: ZodType<any>,
prefer?: CopilotProviderType,
modelId?: string
prefer?: CopilotProviderType
): Promise<string> {
const prompt = await this.prompt.get(promptName);
if (!prompt) {
throw new CopilotPromptNotFound({ name: promptName });
}
const cond = {
modelId:
modelId && prompt.optionalModels.includes(modelId)
? modelId
: prompt.model,
};
const cond = { modelId: prompt.model };
const msg = { role: 'user' as const, content: '', ...message };
const config = Object.assign({}, prompt.config);
if (schema) {
@@ -262,19 +231,13 @@ export class CopilotTranscriptionService {
return `${hoursStr}:${minutesStr}:${secondsStr}`;
}
private async callTranscript(
url: string,
mimeType: string,
offset: number,
modelId?: string
) {
private async callTranscript(url: string, mimeType: string, offset: number) {
// NOTE: Vertex provider not support transcription yet, we always use Gemini here
const result = await this.chatWithPrompt(
'Transcript audio',
{ attachments: [url], params: { mimetype: mimeType } },
TranscriptionResponseSchema,
CopilotProviderType.Gemini,
modelId
CopilotProviderType.Gemini
);
const transcription = TranscriptionResponseSchema.parse(
@@ -293,7 +256,6 @@ export class CopilotTranscriptionService {
async transcriptAudio({
jobId,
infos,
modelId,
// @deprecated
url,
mimeType,
@@ -302,7 +264,7 @@ export class CopilotTranscriptionService {
const blobInfos = this.mergeInfos(infos, url, mimeType);
const transcriptions = await Promise.all(
Array.from(blobInfos.entries()).map(([idx, { url, mimeType }]) =>
this.callTranscript(url, mimeType, idx * 10 * 60, modelId)
this.callTranscript(url, mimeType, idx * 10 * 60)
)
);
@@ -56,7 +56,6 @@ declare global {
'copilot.transcript.submit': {
jobId: string;
infos?: AudioBlobInfos;
modelId?: string;
/// @deprecated use `infos` instead
url?: string;
/// @deprecated use `infos` instead
@@ -103,7 +103,6 @@ export class CopilotWorkspaceEmbeddingConfigResolver {
return ignoredDocs;
}
@Mutation(() => Number, {
name: 'updateWorkspaceEmbeddingIgnoredDocs',
complexity: 2,
-1
View File
@@ -457,7 +457,6 @@ type CopilotWorkspaceIgnoredDocTypeEdge {
input CreateChatMessageInput {
attachments: [String!]
blob: Upload
blobs: [Upload!]
content: String
params: JSON
-1
View File
@@ -569,7 +569,6 @@ export interface CopilotWorkspaceIgnoredDocTypeEdge {
export interface CreateChatMessageInput {
attachments?: InputMaybe<Array<Scalars['String']['input']>>;
blob?: InputMaybe<Scalars['Upload']['input']>;
blobs?: InputMaybe<Array<Scalars['Upload']['input']>>;
content?: InputMaybe<Scalars['String']['input']>;
params?: InputMaybe<Scalars['JSON']['input']>;
@@ -58,45 +58,74 @@ exports[`should parse page doc work 1`] = `
# You own your data, with no compromises
## Local-first & Real-time collaborative
We love the idea proposed by Ink & Switch in the famous article about you owning your data, despite the cloud. Furthermore, AFFiNE is the first all-in-one workspace that keeps your data ownership with no compromises on real-time collaboration and editing experience.
AFFiNE is a local-first application upon CRDTs with real-time collaboration support. Your data is always stored locally while multiple nodes remain synced in real-time.
### Blocks that assemble your next docs, tasks kanban or whiteboard
There is a large overlap of their atomic "building blocks" between these apps. They are neither open source nor have a plugin system like VS Code for contributors to customize. We want to have something that contains all the features we love and goes one step further.
We are building AFFiNE to be a fundamental open source platform that contains all the building blocks for docs, task management and visual collaboration, hoping you can shape your next workflow with us that can make your life better and also connect others, too.
If you want to learn more about the product design of AFFiNE, here goes the concepts:
To Shape, not to adapt. AFFiNE is built for individuals & teams who care about their data, who refuse vendor lock-in, and who want to have control over their essential tools.
## A true canvas for blocks in any form
[Many editor apps](http://notion.so) claimed to be a canvas for productivity. Since _the Mother of All Demos,_ Douglas Engelbart, a creative and programable digital workspace has been a pursuit and an ultimate mission for generations of tool makers.
"We shape our tools and thereafter our tools shape us”. A lot of pioneers have inspired us a long the way, e.g.:
* Quip & Notion with their great concept of "everything is a block"
* Trello with their Kanban
* Airtable & Miro with their no-code programable datasheets
* Miro & Whimiscal with their edgeless visual whiteboard
* Remnote & Capacities with their object-based tag system
For more details, please refer to our [RoadMap](https://docs.affine.pro/docs/core-concepts/roadmap)
## Self Host
Self host AFFiNE
||Title|Tag|
|---|---|---|
|Affine Development|Affine Development|<span data-affine-option data-value="AxSe-53xjX" data-option-color="var(--affine-tag-pink)">AFFiNE</span>|
@@ -107,12 +136,16 @@ Self host AFFiNE
|Miro & Whimiscal with their edgeless visual whiteboard|Miro & Whimiscal with their edgeless visual whiteboard|<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>|
|Remnote & Capacities with their object-based tag system|Remnote & Capacities with their object-based tag system||
## Affine Development
For developer or installation guides, please go to [AFFiNE Development](https://docs.affine.pro/docs/development/quick-start)
",
"parsedBlock": {
"children": [
@@ -289,6 +322,7 @@ For developer or installation guides, please go to [AFFiNE Development](https://
{
"children": [],
"content": "* Quip & Notion with their great concept of "everything is a block"
",
"flavour": "affine:list",
"id": "xFrrdiP3-V",
@@ -297,6 +331,7 @@ For developer or installation guides, please go to [AFFiNE Development](https://
{
"children": [],
"content": "* Trello with their Kanban
",
"flavour": "affine:list",
"id": "Tp9xyN4Okl",
@@ -305,6 +340,7 @@ For developer or installation guides, please go to [AFFiNE Development](https://
{
"children": [],
"content": "* Airtable & Miro with their no-code programable datasheets
",
"flavour": "affine:list",
"id": "K_4hUzKZFQ",
@@ -313,6 +349,7 @@ For developer or installation guides, please go to [AFFiNE Development](https://
{
"children": [],
"content": "* Miro & Whimiscal with their edgeless visual whiteboard
",
"flavour": "affine:list",
"id": "QwMzON2s7x",
@@ -321,6 +358,7 @@ For developer or installation guides, please go to [AFFiNE Development](https://
{
"children": [],
"content": "* Remnote & Capacities with their object-based tag system
",
"flavour": "affine:list",
"id": "FFVmit6u1T",
@@ -389,63 +427,77 @@ For developer or installation guides, please go to [AFFiNE Development](https://
"Tag": "<span data-affine-option data-value="AxSe-53xjX" data-option-color="var(--affine-tag-pink)">AFFiNE</span>",
"Title": "Affine Development
",
"undefined": "Affine Development
",
},
{
"Tag": "<span data-affine-option data-value="0jh9gNw4Yl" data-option-color="var(--affine-tag-orange)">Developers</span>",
"Title": "For developers or installations guides, please go to AFFiNE Doc
",
"undefined": "For developers or installations guides, please go to AFFiNE Doc
",
},
{
"Tag": "<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>",
"Title": "Quip & Notion with their great concept of "everything is a block"
",
"undefined": "Quip & Notion with their great concept of "everything is a block"
",
},
{
"Tag": "<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>",
"Title": "Trello with their Kanban
",
"undefined": "Trello with their Kanban
",
},
{
"Tag": "<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>",
"Title": "Airtable & Miro with their no-code programable datasheets
",
"undefined": "Airtable & Miro with their no-code programable datasheets
",
},
{
"Tag": "<span data-affine-option data-value="HgHsKOUINZ" data-option-color="var(--affine-tag-blue)">Reference</span>",
"Title": "Miro & Whimiscal with their edgeless visual whiteboard
",
"undefined": "Miro & Whimiscal with their edgeless visual whiteboard
",
},
{
"Tag": "",
"Title": "Remnote & Capacities with their object-based tag system
",
"undefined": "Remnote & Capacities with their object-based tag system
",
},
],
@@ -507,80 +559,113 @@ exports[`should parse page doc work with ai editable 1`] = `
"<!-- block_id=FoPQcAyV_m flavour=affine:paragraph -->
AFFiNE is an open source all in one workspace, an operating system for all the building blocks of your team wiki, knowledge management and digital assets and a better alternative to Notion and Miro.
<!-- block_id=oz48nn_zp8 flavour=affine:paragraph -->
<!-- block_id=g8a-D9-jXS flavour=affine:paragraph -->
# You own your data, with no compromises
<!-- block_id=J8lHN1GR_5 flavour=affine:paragraph -->
## Local-first & Real-time collaborative
<!-- block_id=xCuWdM0VLz flavour=affine:paragraph -->
We love the idea proposed by Ink & Switch in the famous article about you owning your data, despite the cloud. Furthermore, AFFiNE is the first all-in-one workspace that keeps your data ownership with no compromises on real-time collaboration and editing experience.
<!-- block_id=zElMi0tViK flavour=affine:paragraph -->
AFFiNE is a local-first application upon CRDTs with real-time collaboration support. Your data is always stored locally while multiple nodes remain synced in real-time.
<!-- block_id=Z4rK0OF9Wk flavour=affine:paragraph -->
<!-- block_id=DQ0Ryb-SpW flavour=affine:paragraph -->
### Blocks that assemble your next docs, tasks kanban or whiteboard
<!-- block_id=HAZC3URZp_ flavour=affine:paragraph -->
There is a large overlap of their atomic "building blocks" between these apps. They are neither open source nor have a plugin system like VS Code for contributors to customize. We want to have something that contains all the features we love and goes one step further.
<!-- block_id=0H87ypiuv8 flavour=affine:paragraph -->
We are building AFFiNE to be a fundamental open source platform that contains all the building blocks for docs, task management and visual collaboration, hoping you can shape your next workflow with us that can make your life better and also connect others, too.
<!-- block_id=Sp4G1KD0Wn flavour=affine:paragraph -->
If you want to learn more about the product design of AFFiNE, here goes the concepts:
<!-- block_id=RsUhDuEqXa flavour=affine:paragraph -->
To Shape, not to adapt. AFFiNE is built for individuals & teams who care about their data, who refuse vendor lock-in, and who want to have control over their essential tools.
<!-- block_id=Z2HibKzAr- flavour=affine:paragraph -->
## A true canvas for blocks in any form
<!-- block_id=UwvWddamzM flavour=affine:paragraph -->
[Many editor apps](http://notion.so) claimed to be a canvas for productivity. Since _the Mother of All Demos,_ Douglas Engelbart, a creative and programable digital workspace has been a pursuit and an ultimate mission for generations of tool makers.
<!-- block_id=g9xKUjhJj1 flavour=affine:paragraph -->
<!-- block_id=wDTn4YJ4pm flavour=affine:paragraph -->
"We shape our tools and thereafter our tools shape us”. A lot of pioneers have inspired us a long the way, e.g.:
<!-- block_id=xFrrdiP3-V flavour=affine:list -->
* Quip & Notion with their great concept of "everything is a block"
<!-- block_id=Tp9xyN4Okl flavour=affine:list -->
* Trello with their Kanban
<!-- block_id=K_4hUzKZFQ flavour=affine:list -->
* Airtable & Miro with their no-code programable datasheets
<!-- block_id=QwMzON2s7x flavour=affine:list -->
* Miro & Whimiscal with their edgeless visual whiteboard
<!-- block_id=FFVmit6u1T flavour=affine:list -->
* Remnote & Capacities with their object-based tag system
<!-- block_id=YqnG5O6AE6 flavour=affine:paragraph -->
For more details, please refer to our [RoadMap](https://docs.affine.pro/docs/core-concepts/roadmap)
<!-- block_id=sbDTmZMZcq flavour=affine:paragraph -->
## Self Host
<!-- block_id=QVvitesfbj flavour=affine:paragraph -->
Self host AFFiNE
<!-- block_id=U_GoHFD9At flavour=affine:database placeholder -->
<!-- block_id=NyHXrMX3R1 flavour=affine:paragraph -->
## Affine Development
<!-- block_id=9-K49otbCv flavour=affine:paragraph -->
For developer or installation guides, please go to [AFFiNE Development](https://docs.affine.pro/docs/development/quick-start)
<!-- block_id=faFteK9eG- flavour=affine:paragraph -->
"
`;
@@ -588,74 +673,122 @@ exports[`should parse page full doc work with ai editable 1`] = `
"<!-- block_id=T4qSXc13wz flavour=affine:paragraph -->
# H1 text
<!-- block_id=F5eByK8Fx_ flavour=affine:paragraph -->
List all flavours in one document.
<!-- block_id=6_-Ta2Hpsg flavour=affine:paragraph -->
## H2 ~ H6
<!-- block_id=QLH8pCeJwr flavour=affine:paragraph -->
### H3
<!-- block_id=eRseB5ilzP flavour=affine:paragraph -->
#### H4 with emoji 😄
<!-- block_id=xSEIo9I5jQ flavour=affine:paragraph -->
##### H5
<!-- block_id=h4Fozi-Mvv flavour=affine:paragraph -->
###### H6
<!-- block_id=U-Hd9O6FEZ flavour=affine:paragraph -->
max is H6
<!-- block_id=z2aCxUDpOc flavour=affine:paragraph -->
## List
<!-- block_id=z5Zw7lMlD7 flavour=affine:list -->
* item 1
<!-- block_id=Opmt3x2Ao0 flavour=affine:list -->
* item 2
* sub item 1
* sub item 2
* super sub item 1
* sub item 3
* sub item 1
* sub item 2
* super sub item 1
* sub item 3
<!-- block_id=_EF3g4194w flavour=affine:list -->
* item 3
<!-- block_id=5u-T48lLVF flavour=affine:paragraph -->
<!-- block_id=7urxrvhr-p flavour=affine:paragraph -->
<!-- block_id=U-96XKGGz7 flavour=affine:paragraph -->
<!-- block_id=hOvvRmDGqN flavour=affine:paragraph -->
sort list
<!-- block_id=hcqkMyvKnx flavour=affine:list -->
1. item 1
<!-- block_id=xUsDktnmuD flavour=affine:list -->
1. item 2
<!-- block_id=xa5tsLHHJN flavour=affine:list -->
1. item 3
1. sub item 1
1. sub item 2
1. super item 1
1. super item 2
1. sub item 3
1. sub item 1
1. sub item 2
1. super item 1
1. super item 2
1. sub item 3
<!-- block_id=BX05mQdxJ0 flavour=affine:list -->
1. item 4
<!-- block_id=VYzM3O17th flavour=affine:paragraph -->
<!-- block_id=epKYpKt5vo flavour=affine:paragraph -->
<!-- block_id=5Ghem19uGh flavour=affine:paragraph -->
Table
<!-- block_id=OXvH-s1Jx4 flavour=affine:table -->
|c1|c2|c3|c4|
|---|---|---|---|
@@ -663,129 +796,176 @@ Table
||||v4|
||v6||v5|
<!-- block_id=j2F2hQ3zy9 flavour=affine:paragraph -->
<!-- block_id=jLCRD2G_BC flavour=affine:paragraph -->
<!-- block_id=794ZoPeBJM flavour=affine:paragraph -->
Database
<!-- block_id=xQ7rA57Qxz flavour=affine:database placeholder -->
<!-- block_id=RbMSmluZYK flavour=affine:paragraph -->
Code
<!-- block_id=cJ6CMeUWMg flavour=affine:code -->
\`\`\`javascript
console.log('hello world');
\`\`\`
<!-- block_id=y1xVwkxlDm flavour=affine:paragraph -->
<!-- block_id=BKy3zmm8SE flavour=affine:paragraph -->
Image
<!-- block_id=WFftQ-qXzr flavour=affine:image -->
![-HjsQksaVuEaM0KeTEbBYDgWVOmoVzrAeVK0kn4Jfr0=](blob://-HjsQksaVuEaM0KeTEbBYDgWVOmoVzrAeVK0kn4Jfr0=)
<!-- block_id=F-RKpfxL1z flavour=affine:paragraph -->
<!-- block_id=G3LSqjKv8M flavour=affine:paragraph -->
File
<!-- block_id=pO8JCsiK4z flavour=affine:attachment -->
![IrsiJ9XonFXF8fw9tLQXbSsBbUYFfzLgoFpodeidQOU=](blob://IrsiJ9XonFXF8fw9tLQXbSsBbUYFfzLgoFpodeidQOU=)
<!-- block_id=dTKFqQhJuA flavour=affine:paragraph -->
<!-- block_id=nwld7RMYvp flavour=affine:paragraph -->
> foo bar quote text
<!-- block_id=MwBD3BhRnf flavour=affine:paragraph -->
<!-- block_id=pakOSAm6EU flavour=affine:paragraph -->
<!-- block_id=95-NxAyFuo flavour=affine:divider -->
---
<!-- block_id=r9EllTNiN1 flavour=affine:paragraph -->
<!-- block_id=OpxZ1kYM40 flavour=affine:paragraph -->
TeX
<!-- block_id=gjFqI97IRc flavour=affine:paragraph -->
<!-- block_id=KXBZ1_Pfdw flavour=affine:paragraph -->
<!-- block_id=VHj5gMaGa7 flavour=affine:paragraph -->
2025-06-18 13:15
<!-- block_id=JwaUwzuQEH flavour=affine:paragraph -->
<!-- block_id=_zu2kl56FY flavour=affine:database placeholder -->
<!-- block_id=Kcbp6BLA-y flavour=affine:paragraph -->
Mind Map
<!-- block_id=R_g1tzqzAU flavour=affine:paragraph -->
<!-- block_id=C8G82uLCz1 flavour=affine:paragraph -->
<!-- block_id=J6gfR8YMGy flavour=affine:paragraph -->
A Link
<!-- block_id=yHky0s_H1v flavour=affine:embed-linked-doc -->
[null](doc://FmHFPAPzp51JjFP89aZ-b)
<!-- block_id=P7w3ka4Amo flavour=affine:paragraph -->
Todo List
<!-- block_id=WbeCXu6fcA flavour=affine:list -->
- [ ] abc
<!-- block_id=X_F5fw-MEn flavour=affine:list -->
- [ ] edf
- [x] done1
- [x] done1
<!-- block_id=sdw-couBVA flavour=affine:list -->
- [ ] end
<!-- block_id=COJiWGOVJu flavour=affine:paragraph -->
<!-- block_id=shK7TY-Q3F flavour=affine:paragraph -->
~~delete text~~
<!-- block_id=_NIj4pT_Iy flavour=affine:paragraph -->
<!-- block_id=CaXXPfEt62 flavour=affine:paragraph -->
**Bold text**
<!-- block_id=1WFCwn1708 flavour=affine:paragraph -->
<!-- block_id=25f19QUjQI flavour=affine:paragraph -->
Underline
<!-- block_id=GrS-y17iiw flavour=affine:paragraph -->
<!-- block_id=dJm5C8KsEg flavour=affine:paragraph -->
Youtube
<!-- block_id=epfNja2Txk flavour=affine:embed-youtube -->
<iframe
@@ -799,18 +979,23 @@ Youtube
credentialless>
</iframe>
<!-- block_id=wNb6ZRJKMt flavour=affine:paragraph -->
<!-- block_id=HqKjEGWF_s flavour=affine:paragraph -->
## end
<!-- block_id=FOh_TJmcF1 flavour=affine:paragraph -->
this is end
<!-- block_id=ImCJN2Xint flavour=affine:paragraph -->
"
`;
@@ -22,10 +22,9 @@ export const parseBlockToMd = (
block.content
.split('\n')
.map(line => padding + line)
.slice(0, -1)
.join('\n') +
'\n' +
block.children.map(b => parseBlockToMd(b, padding + ' ')).join('')
block.children.map(b => parseBlockToMd(b, padding + ' ')).join('')
);
} else {
return block.children.map(b => parseBlockToMd(b, padding)).join('');
@@ -110,7 +109,7 @@ export function parseBlock(
const checked = yBlock.get('prop:checked') as boolean;
prefix = checked ? '- [x] ' : '- [ ] ';
}
result.content = prefix + toMd();
result.content = prefix + toMd() + '\n';
break;
}
case 'affine:code': {
@@ -14,7 +14,6 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/AppTheme">
<activity
@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
@@ -90,6 +90,8 @@
/* Begin PBXFileSystemSynchronizedRootGroup section */
C45499AB2D140B5000E21978 /* NBStore */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = NBStore;
sourceTree = "<group>";
};
@@ -337,13 +339,9 @@
);
inputFileListPaths = (
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-AFFiNE/Pods-AFFiNE-frameworks.sh\"\n";
@@ -27,6 +27,15 @@
"version" : "1.1.6"
}
},
{
"identity" : "litext",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Lakr233/Litext",
"state" : {
"revision" : "c37f3ab5826659854311e20d6c3942d4905b00b6",
"version" : "0.5.0"
}
},
{
"identity" : "lrucache",
"kind" : "remoteSourceControl",
@@ -41,8 +50,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/Lakr233/MarkdownView",
"state" : {
"revision" : "446dba45be81c67d0717d19277367dcbe5b2fb12",
"version" : "3.1.9"
"revision" : "29a9da19d6dc21af4e629c423961b0f453ffe192",
"version" : "2.3.8"
}
},
{
@@ -59,8 +68,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/Lakr233/Splash",
"state" : {
"revision" : "de9cde249fdb7a173a6e6b950ab18b11f6c2a557",
"version" : "0.18.0"
"revision" : "4d997712fe07f75695aacdf287aeb3b1f2c6ab88",
"version" : "0.17.0"
}
},
{
@@ -13,5 +13,23 @@ extension AFFiNEViewController: IntelligentsButtonDelegate {
// if it shows up then we are ready to go
let controller = IntelligentsController()
self.present(controller, animated: true)
// IntelligentContext.shared.webView = webView
// button.beginProgress()
// IntelligentContext.shared.preparePresent { result in
// DispatchQueue.main.async {
// button.stopProgress()
// switch result {
// case .success:
// case let .failure(failure):
// let alert = UIAlertController(
// title: "Error",
// message: failure.localizedDescription,
// preferredStyle: .alert
// )
// alert.addAction(UIAlertAction(title: "OK", style: .default))
// self.present(alert, animated: true)
// }
// }
// }
}
}
@@ -64,7 +64,12 @@ class AFFiNEViewController: CAPBridgeViewController {
switch result {
case .failure: break
case .success:
#if DEBUG
// only show the button in debug mode before we get done
self.presentIntelligentsButton()
#else
break
#endif
}
}
}
@@ -69,10 +69,5 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict>
</plist>
@@ -7,7 +7,7 @@ public class GetCopilotRecentSessionsQuery: GraphQLQuery {
public static let operationName: String = "getCopilotRecentSessions"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query getCopilotRecentSessions($workspaceId: String!, $limit: Int = 10) { currentUser { __typename copilot(workspaceId: $workspaceId) { __typename chats( pagination: { first: $limit } options: { fork: false, sessionOrder: desc, withMessages: false } ) { __typename ...PaginatedCopilotChats } } } }"#,
#"query getCopilotRecentSessions($workspaceId: String!, $limit: Int = 10) { currentUser { __typename copilot(workspaceId: $workspaceId) { __typename chats( pagination: { first: $limit } options: { fork: false, sessionOrder: desc, withMessages: true } ) { __typename ...PaginatedCopilotChats } } } }"#,
fragments: [CopilotChatHistory.self, CopilotChatMessage.self, PaginatedCopilotChats.self]
))
@@ -69,7 +69,7 @@ public class GetCopilotRecentSessionsQuery: GraphQLQuery {
"options": [
"fork": false,
"sessionOrder": "desc",
"withMessages": false
"withMessages": true
]
]),
] }
@@ -7,7 +7,7 @@ public class GetWorkspacePageByIdQuery: GraphQLQuery {
public static let operationName: String = "getWorkspacePageById"
public static let operationDocument: ApolloAPI.OperationDocument = .init(
definition: .init(
#"query getWorkspacePageById($workspaceId: String!, $pageId: String!) { workspace(id: $workspaceId) { __typename doc(docId: $pageId) { __typename id mode defaultRole public title summary } } }"#
#"query getWorkspacePageById($workspaceId: String!, $pageId: String!) { workspace(id: $workspaceId) { __typename doc(docId: $pageId) { __typename id mode defaultRole public } } }"#
))
public var workspaceId: String
@@ -68,16 +68,12 @@ public class GetWorkspacePageByIdQuery: GraphQLQuery {
.field("mode", GraphQLEnum<AffineGraphQL.PublicDocMode>.self),
.field("defaultRole", GraphQLEnum<AffineGraphQL.DocRole>.self),
.field("public", Bool.self),
.field("title", String?.self),
.field("summary", String?.self),
] }
public var id: String { __data["id"] }
public var mode: GraphQLEnum<AffineGraphQL.PublicDocMode> { __data["mode"] }
public var defaultRole: GraphQLEnum<AffineGraphQL.DocRole> { __data["defaultRole"] }
public var `public`: Bool { __data["public"] }
public var title: String? { __data["title"] }
public var summary: String? { __data["summary"] }
}
}
}
@@ -21,7 +21,7 @@ let package = Package(
.package(url: "https://github.com/SwifterSwift/SwifterSwift.git", from: "6.0.0"),
.package(url: "https://github.com/Recouse/EventSource", from: "0.1.4"),
.package(url: "https://github.com/Lakr233/ListViewKit", from: "1.1.6"),
.package(url: "https://github.com/Lakr233/MarkdownView", from: "3.1.9"),
.package(url: "https://github.com/Lakr233/MarkdownView", exact: "2.3.8"),
],
targets: [
.target(name: "Intelligents", dependencies: [
@@ -1,33 +0,0 @@
//
// ChatManager+CURD.swift
// Intelligents
//
// Created by on 7/14/25.
//
import AffineGraphQL
import Apollo
import ApolloAPI
import EventSource
import Foundation
import MarkdownParser
import MarkdownView
extension ChatManager {
func clearCurrentSession() {
guard let session = IntelligentContext.shared.currentSession else {
print("[-] no current session to clear")
return
}
let mutation = CleanupCopilotSessionMutation(input: .init(
docId: session.docId ?? "",
sessionIds: [session.id],
workspaceId: session.workspaceId
))
QLService.shared.client.perform(mutation: mutation) { result in
print("[+] cleanup session result: \(result)")
}
}
}
@@ -24,173 +24,53 @@ private extension InputBoxData {
}
}
public extension ChatManager {
func startUserRequest(editorData: InputBoxData, sessionId: String) {
extension ChatManager {
public func startUserRequest(
content: String,
inputBoxData: InputBoxData,
sessionId: String
) {
append(sessionId: sessionId, UserMessageCellViewModel(
id: .init(),
content: editorData.text,
content: inputBoxData.text,
timestamp: .init()
))
append(sessionId: sessionId, UserHintCellViewModel(
id: .init(),
timestamp: .init(),
imageAttachments: editorData.imageAttachments,
fileAttachments: editorData.fileAttachments,
docAttachments: editorData.documentAttachments
imageAttachments: inputBoxData.imageAttachments,
fileAttachments: inputBoxData.fileAttachments,
docAttachments: inputBoxData.documentAttachments
))
let viewModelId = append(sessionId: sessionId, AssistantMessageCellViewModel(
id: .init(),
content: "...",
timestamp: .init()
))
scrollToBottomPublisher.send(sessionId)
guard let workspaceId = IntelligentContext.shared.currentWorkspaceId,
!workspaceId.isEmpty
else {
report(sessionId, ChatError.unknownError)
assertionFailure("Invalid workspace ID")
return
}
DispatchQueue.global().async {
self.prepareContext(
workspaceId: workspaceId,
sessionId: sessionId,
editorData: editorData,
viewModelId: viewModelId
)
}
}
}
private extension ChatManager {
func prepareContext(
workspaceId: String,
sessionId: String,
editorData: InputBoxData,
viewModelId: UUID
) {
assert(!Thread.isMainThread)
let createContext = CreateCopilotContextMutation(
workspaceId: workspaceId,
sessionId: sessionId
)
QLService.shared.client.perform(mutation: createContext) { result in
DispatchQueue.main.async {
switch result {
case let .success(graphQLResult):
guard let contextId = graphQLResult.data?.createCopilotContext else {
self.report(sessionId, ChatError.invalidResponse)
return
}
print("[+] copilot context created: \(contextId)")
DispatchQueue.global().async {
let docAttachGroup = DispatchGroup()
for docAttach in editorData.documentAttachments {
let addDoc = AddContextDocMutation(
options: .init(
contextId: contextId,
docId: docAttach.documentID
)
)
docAttachGroup.enter()
QLService.shared.client.perform(mutation: addDoc) { result in
switch result {
case .success:
print("[+] doc \(docAttach.documentID) added to context")
case let .failure(error):
print("[-] addContextDoc failed: \(error)")
}
docAttachGroup.leave()
}
}
docAttachGroup.notify(queue: .global()) {
var contextSnippet = ""
if !editorData.documentAttachments.isEmpty {
let sem = DispatchSemaphore(value: 0)
let matchQuery = MatchContextQuery(
contextId: .some(contextId),
workspaceId: .some(workspaceId),
content: editorData.text,
limit: .none,
scopedThreshold: .none,
threshold: .none
)
QLService.shared.client.fetch(query: matchQuery) { result in
switch result {
case let .success(queryResult):
let matches = queryResult.data?.currentUser?.copilot.contexts ?? []
let matchDocs = matches.compactMap(\.matchWorkspaceDocs).flatMap(\.self)
for context in matchDocs {
contextSnippet += "<file docId=\"\(context.docId)\" chunk=\"\(context.chunk)\">\(context.content)</file>\n"
}
case let .failure(error):
print("[-] matchContext failed: \(error)")
// self.report(sessionId, error)
}
sem.signal()
}
sem.wait()
}
print("[+] context snippet prepared: \(contextSnippet)")
self.startCopilotResponse(
editorData: editorData,
contextSnippet: contextSnippet,
sessionId: sessionId,
viewModelId: viewModelId
)
}
}
case let .failure(error):
self.report(sessionId, error)
return
}
}
}
}
func startCopilotResponse(
editorData: InputBoxData,
contextSnippet: String,
sessionId: String,
viewModelId: UUID
) {
assert(!Thread.isMainThread)
let messageParameters: [String: AnyHashable] = [
// packages/frontend/core/src/blocksuite/ai/provider/setup-provider.tsx
"docs": editorData.documentAttachments.map(\.documentID), // affine doc
"docs": inputBoxData.documentAttachments.map(\.documentID), // affine doc
"files": [String](), // attachment in context, keep nil for now
"searchMode": editorData.isSearchEnabled ? "MUST" : "AUTO",
"searchMode": inputBoxData.isSearchEnabled ? "MUST" : "AUTO",
]
let attachmentFieldName = "options.blobs"
var uploadableAttachments: [GraphQLFile] = [
editorData.fileAttachments.map { file -> GraphQLFile in
.init(fieldName: attachmentFieldName, originalName: file.name, data: file.data ?? .init())
let uploadableAttachments: [GraphQLFile] = [
inputBoxData.fileAttachments.map { file -> GraphQLFile in
.init(
fieldName: file.name,
originalName: file.name,
data: file.data ?? .init()
)
},
editorData.imageAttachments.map { image -> GraphQLFile in
.init(fieldName: attachmentFieldName, originalName: "image.jpg", data: image.imageData)
inputBoxData.imageAttachments.map { image -> GraphQLFile in
.init(
fieldName: image.hashValue.description,
originalName: "image.jpg",
data: image.imageData
)
},
].flatMap(\.self)
assert(uploadableAttachments.allSatisfy { !($0.data?.isEmpty ?? true) })
// in Apollo, filed name is handled as attached object to field when there is only one attachment
// to use array on our server, we need to append a dummy attachment
// which is ignored if data is empty and name is empty
if uploadableAttachments.count == 1 {
uploadableAttachments.append(.init(fieldName: attachmentFieldName, originalName: "", data: .init()))
}
guard let input = try? CreateChatMessageInput(
attachments: [],
blobs: .some([]), // must have the placeholder
content: .some(contextSnippet.isEmpty ? editorData.text : "\(contextSnippet)\n\(editorData.text)"),
content: .some(content),
params: .some(AffineGraphQL.JSON(_jsonValue: messageParameters)),
sessionId: sessionId
) else {
report(sessionId, ChatError.unknownError)
assertionFailure() // very unlikely to happen
return
}
@@ -203,6 +83,11 @@ private extension ChatManager {
self.report(sessionId, ChatError.invalidResponse)
return
}
let viewModelId = self.append(sessionId: sessionId, AssistantMessageCellViewModel(
id: .init(),
content: .init(),
timestamp: .init()
))
self.startStreamingResponse(
sessionId: sessionId,
messageId: messageIdentifier,
@@ -214,10 +99,8 @@ private extension ChatManager {
}
}
}
}
private extension ChatManager {
func startStreamingResponse(sessionId: String, messageId: String, applyingTo vmId: UUID) {
private func startStreamingResponse(sessionId: String, messageId: String, applyingTo vmId: UUID) {
let base = IntelligentContext.shared.webViewMetadata[.currentServerBaseUrl] as? String
guard let base, let url = URL(string: base) else {
report(sessionId, ChatError.invalidServerConfiguration)
@@ -281,11 +164,24 @@ private extension ChatManager {
vmId: UUID
) {
let result = MarkdownParser().parse(document)
let content = MarkdownTextView.PreprocessContent(parserResult: result, theme: .default)
var renderedContexts: [String: RenderedItem] = [:]
for (key, value) in result.mathContext {
let image = MathRenderer.renderToImage(
latex: value,
fontSize: MarkdownTheme.default.fonts.body.pointSize,
textColor: MarkdownTheme.default.colors.body
)?.withRenderingMode(.alwaysTemplate)
let renderedContext = RenderedItem(
image: image,
text: value
)
renderedContexts["math://\(key)"] = renderedContext
}
with(sessionId: sessionId, vmId: vmId) { (viewModel: inout AssistantMessageCellViewModel) in
viewModel.content = document
viewModel.preprocessedContent = content
viewModel.documentBlocks = result.document
viewModel.documentRenderedContent = renderedContexts
}
}
}
@@ -33,12 +33,6 @@ public class ChatManager: ObservableObject, @unchecked Sendable {
closable.removeAll()
}
public func clearAll() {
assert(Thread.isMainThread)
closeAll()
viewModels.removeAll()
}
public func with(sessionId: String, _ action: (inout OrderedDictionary<MessageID, any ChatCellViewModel>) -> Void) {
if Thread.isMainThread {
if var sessionViewModels = viewModels[sessionId] {
@@ -65,6 +59,8 @@ public class ChatManager: ObservableObject, @unchecked Sendable {
return
}
sessionViewModels[vmId] = vm
} else {
assertionFailure()
}
}
}
@@ -17,7 +17,6 @@ enum BridgedWindowScript: String {
case getCurrentServerBaseUrl = "window.getCurrentServerBaseUrl()"
case getCurrentWorkspaceId = "window.getCurrentWorkspaceId();"
case getCurrentDocId = "window.getCurrentDocId();"
case getAiButtonFeatureFlag = "window.getAiButtonFeatureFlag();"
case getCurrentI18nLocale = "window.getCurrentI18nLocale();"
case createNewDocByMarkdownInCurrentWorkspace = "return await window.createNewDocByMarkdownInCurrentWorkspace(markdown, title);"
@@ -17,7 +17,6 @@ extension IntelligentContext {
(.currentWorkspaceId, .getCurrentWorkspaceId),
(.currentServerBaseUrl, .getCurrentServerBaseUrl),
(.currentI18nLocale, .getCurrentI18nLocale),
(.currentAiButtonFeatureFlag, .getAiButtonFeatureFlag),
]
for (key, script) in keysAndScripts {
DispatchQueue.main.async {
@@ -40,7 +40,6 @@ public class IntelligentContext {
case currentWorkspaceId
case currentServerBaseUrl
case currentI18nLocale
case currentAiButtonFeatureFlag
}
@Published public private(set) var currentSession: ChatSessionObject?
@@ -54,7 +53,6 @@ public class IntelligentContext {
public enum IntelligentError: Error, LocalizedError {
case loginRequired(String)
case sessionCreationFailed(String)
case featureClosed
public var errorDescription: String? {
switch self {
@@ -62,8 +60,6 @@ public class IntelligentContext {
"Login required: \(reason)"
case let .sessionCreationFailed(reason):
"Session creation failed: \(reason)"
case let .featureClosed:
"Intelligent feature closed"
}
}
}
@@ -85,11 +81,6 @@ public class IntelligentContext {
}
webViewGroup.wait()
webViewMetadata = webViewMetadataResult
if webViewMetadataResult[.currentAiButtonFeatureFlag] as? Bool == false {
completion(.failure(IntelligentError.featureClosed))
return
}
// Check required webView metadata
guard let baseUrlString = webViewMetadataResult[.currentServerBaseUrl] as? String,
@@ -85,7 +85,11 @@ extension MainViewController: InputBoxDelegate {
}
ChatManager.shared.closeAll()
ChatManager.shared.startUserRequest(editorData: inputData, sessionId: currentSession.id)
ChatManager.shared.startUserRequest(
content: inputData.text,
inputBoxData: inputData,
sessionId: currentSession.id
)
}
private func showAlert(title: String, message: String) {
@@ -21,6 +21,11 @@ class AssistantMessageCell: ChatBaseCell {
contentView.addSubview(markdownView)
}
override func prepareForReuse() {
super.prepareForReuse()
markdownView.prepareForReuse()
}
override func configure(with viewModel: any ChatCellViewModel) {
super.configure(with: viewModel)
@@ -28,7 +33,10 @@ class AssistantMessageCell: ChatBaseCell {
assertionFailure()
return
}
markdownView.setMarkdown(vm.preprocessedContent)
markdownView.setMarkdown(
vm.documentBlocks,
renderedContent: vm.documentRenderedContent
)
}
override func layoutContentView(bounds: CGRect) {
@@ -45,7 +53,10 @@ class AssistantMessageCell: ChatBaseCell {
markdownViewForSizeCalculation.frame = .init(
x: 0, y: 0, width: width, height: .greatestFiniteMagnitude
)
markdownViewForSizeCalculation.setMarkdownManually(vm.preprocessedContent)
markdownViewForSizeCalculation.setMarkdown(
vm.documentBlocks,
renderedContent: vm.documentRenderedContent
)
let boundingSize = markdownViewForSizeCalculation.boundingSize(for: width)
return ceil(boundingSize.height)
}
@@ -38,7 +38,8 @@ struct AssistantMessageCellViewModel: ChatCellViewModel {
var citations: [CitationViewModel]?
var actions: [MessageActionViewModel]?
var preprocessedContent: MarkdownTextView.PreprocessContent
var documentBlocks: [MarkdownBlockNode]
var documentRenderedContent: RenderContext
init(
id: UUID,
@@ -52,7 +53,7 @@ struct AssistantMessageCellViewModel: ChatCellViewModel {
actions: [MessageActionViewModel]? = nil
) {
// time expensive rendering should not happen here
assert(!Thread.isMainThread || content.count < 10) // allow placeholder content
assert(!Thread.isMainThread || content.isEmpty)
self.id = id
self.content = content
@@ -66,10 +67,21 @@ struct AssistantMessageCellViewModel: ChatCellViewModel {
let parser = MarkdownParser()
let parserResult = parser.parse(content)
preprocessedContent = MarkdownTextView.PreprocessContent(
parserResult: parserResult,
theme: .default,
)
documentBlocks = parserResult.document
var renderedContexts: [String: RenderedItem] = [:]
for (key, value) in parserResult.mathContext {
let image = MathRenderer.renderToImage(
latex: value,
fontSize: MarkdownTheme.default.fonts.body.pointSize,
textColor: MarkdownTheme.default.colors.body
)?.withRenderingMode(.alwaysTemplate)
let renderedContext = RenderedItem(
image: image,
text: value
)
renderedContexts["math://\(key)"] = renderedContext
}
documentRenderedContent = renderedContexts
}
}
@@ -6,8 +6,8 @@ private let unselectedColor: UIColor = .affineIconPrimary
private let selectedColor: UIColor = .affineIconActivated
private let configurableOptions: [ConfigurableOptions] = [
// .networking,
// .reasoning,
.networking,
.reasoning,
]
enum ConfigurableOptions {
case tool
@@ -22,20 +22,11 @@ class MainHeaderView: UIView {
$0.textAlignment = .center
}
private lazy var modelMenu = UIDeferredMenuElement.uncached { completion in
completion([])
}
private lazy var dropdownButton = UIButton(type: .system).then {
$0.imageView?.contentMode = .scaleAspectFit
$0.setImage(UIImage.affineArrowDown, for: .normal)
$0.tintColor = UIColor.affineIconPrimary
$0.addTarget(self, action: #selector(dropdownButtonTapped), for: .touchUpInside)
$0.showsMenuAsPrimaryAction = true
$0.menu = UIMenu(options: [.displayInline], children: [
modelMenu,
])
$0.isHidden = true
}
private lazy var centerStackView = UIStackView().then {
@@ -54,13 +45,6 @@ class MainHeaderView: UIView {
$0.layer.cornerRadius = 8
$0.addTarget(self, action: #selector(menuButtonTapped), for: .touchUpInside)
$0.setContentHuggingPriority(.required, for: .horizontal)
$0.showsMenuAsPrimaryAction = true
$0.menu = .init(options: [.displayInline], children: [
UIAction(title: "Clear History", image: .affineBroom, handler: { _ in
ChatManager.shared.clearCurrentSession()
ChatManager.shared.clearAll()
}),
])
}
private lazy var leftSpacerView = UIView()
+6 -6
View File
@@ -45,13 +45,13 @@ EXTERNAL SOURCES:
:path: "../../../../../node_modules/capacitor-plugin-app-tracking-transparency"
SPEC CHECKSUMS:
Capacitor: 106e7a4205f4618d582b886a975657c61179138d
CapacitorApp: d63334c052278caf5d81585d80b21905c6f93f39
CapacitorBrowser: 081852cf532acf77b9d2953f3a88fe5b9711fb06
Capacitor: 03bc7cbdde6a629a8b910a9d7d78c3cc7ed09ea7
CapacitorApp: febecbb9582cb353aed037e18ec765141f880fe9
CapacitorBrowser: 6299776d496e968505464884d565992faa20444a
CapacitorCordova: 5967b9ba03915ef1d585469d6e31f31dc49be96f
CapacitorHaptics: 70e47470fa1a6bd6338cd102552e3846b7f9a1b3
CapacitorKeyboard: 969647d0ca2e5c737d7300088e2517aa832434e2
CapacitorPluginAppTrackingTransparency: 2a2792623a5a72795f2e8f9ab3f1147573732fd8
CapacitorHaptics: 1f1e17041f435d8ead9ff2a34edd592c6aa6a8d6
CapacitorKeyboard: 09fd91dcde4f8a37313e7f11bde553ad1ed52036
CapacitorPluginAppTrackingTransparency: 92ae9c1cfb5cf477753db9269689332a686f675a
CryptoSwift: 967f37cea5a3294d9cce358f78861652155be483
PODFILE CHECKSUM: 2c1e4be82121f2d9724ecf7e31dd14e165aeb082
+10 -5
View File
@@ -7,6 +7,7 @@ import { NavigationGestureProvider } from '@affine/core/mobile/modules/navigatio
import { VirtualKeyboardProvider } from '@affine/core/mobile/modules/virtual-keyboard';
import { router } from '@affine/core/mobile/router';
import { configureCommonModules } from '@affine/core/modules';
import { AIButtonProvider } from '@affine/core/modules/ai-button';
import {
AuthProvider,
AuthService,
@@ -17,7 +18,6 @@ import {
ValidatorProvider,
} from '@affine/core/modules/cloud';
import { DocsService } from '@affine/core/modules/doc';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { GlobalContextService } from '@affine/core/modules/global-context';
import { I18nProvider } from '@affine/core/modules/i18n';
import { LifecycleService } from '@affine/core/modules/lifecycle';
@@ -62,6 +62,7 @@ import { BlocksuiteMenuConfigProvider } from './bs-menu-config';
import { ModalConfigProvider } from './modal-config';
import { Auth } from './plugins/auth';
import { Hashcash } from './plugins/hashcash';
import { Intelligents } from './plugins/intelligents';
import { NbStoreNativeDBApis } from './plugins/nbstore';
import { writeEndpointToken } from './proxy';
import { enableNavigationGesture$ } from './web-navigation-control';
@@ -161,6 +162,14 @@ framework.impl(HapticProvider, {
selectionChanged: () => Haptics.selectionChanged(),
selectionEnd: () => Haptics.selectionEnd(),
});
framework.impl(AIButtonProvider, {
presentAIButton: () => {
return Intelligents.presentIntelligentsButton();
},
dismissAIButton: () => {
return Intelligents.dismissIntelligentsButton();
},
});
framework.scope(ServerScope).override(AuthProvider, resolver => {
const serverService = resolver.get(ServerService);
const endpoint = serverService.server.baseUrl;
@@ -215,10 +224,6 @@ const frameworkProvider = framework.provider();
(window as any).getCurrentI18nLocale = () => {
return I18n.language;
};
(window as any).getAiButtonFeatureFlag = () => {
const featureFlagService = frameworkProvider.get(FeatureFlagService);
return featureFlagService.flags.enable_mobile_ai_button.value;
};
(window as any).getCurrentWorkspaceId = () => {
const globalContextService = frameworkProvider.get(GlobalContextService);
return globalContextService.globalContext.workspaceId.get();
@@ -0,0 +1,4 @@
export interface IntelligentsPlugin {
presentIntelligentsButton(): Promise<void>;
dismissIntelligentsButton(): Promise<void>;
}
@@ -0,0 +1,8 @@
import { registerPlugin } from '@capacitor/core';
import type { IntelligentsPlugin } from './definitions';
const Intelligents = registerPlugin<IntelligentsPlugin>('Intelligents');
export * from './definitions';
export { Intelligents };
@@ -310,10 +310,7 @@ math {
/* AI Block Diff */
.ai-block-diff-deleted {
background-color: var(
--affine-v2-aI-applyDeleteHighlight,
#ffeaea
) !important;
background-color: var(--aI-applyDeleteHighlight, #ffeaea) !important;
border-radius: 4px !important;
padding: 8px 0px !important;
margin-bottom: 10px !important;
@@ -1,4 +1,4 @@
import { createVar, fallbackVar, style } from '@vanilla-extract/css';
import { createVar, style } from '@vanilla-extract/css';
export const topOffsetVar = createVar();
export const bottomOffsetVar = createVar();
@@ -9,13 +9,14 @@ export const safeArea = style({
paddingTop: `calc(${topOffsetVar} + 12px)`,
},
'&[data-bottom]': {
paddingBottom: `calc(${fallbackVar(bottomOffsetVar, '0px')} + 0px)`,
paddingBottom: `calc(${bottomOffsetVar} + 0px)`,
},
'&[data-standalone][data-top]': {
paddingTop: `calc(env(safe-area-inset-top, 12px) + ${topOffsetVar})`,
},
'&[data-standalone][data-bottom]': {
paddingBottom: `calc(env(safe-area-inset-bottom, 0px) + ${fallbackVar(bottomOffsetVar, '0px')})`,
// paddingBottom: 'env(safe-area-inset-bottom, 12px)',
paddingBottom: `calc(env(safe-area-inset-bottom, 0px) + ${bottomOffsetVar})`,
},
},
});
-3
View File
@@ -16,7 +16,6 @@
"@affine/graphql": "workspace:*",
"@affine/i18n": "workspace:*",
"@affine/nbstore": "workspace:*",
"@affine/reader": "workspace:*",
"@affine/templates": "workspace:*",
"@affine/track": "workspace:*",
"@blocksuite/affine": "workspace:*",
@@ -84,7 +83,6 @@
"react-transition-state": "^2.2.0",
"react-virtuoso": "^4.12.3",
"rxjs": "^7.8.1",
"semver": "^7.7.2",
"ses": "^1.10.0",
"shiki": "^3.7.0",
"socket.io-client": "^4.8.1",
@@ -102,7 +100,6 @@
"@types/bytes": "^3.1.5",
"@types/image-blob-reduce": "^4.1.4",
"@types/lodash-es": "^4.17.12",
"@types/semver": "^7",
"@vanilla-extract/css": "^1.17.0",
"fake-indexeddb": "^6.0.0",
"lodash-es": "^4.17.21",
@@ -39,8 +39,7 @@ describe('applyPatchToDoc', () => {
});
});
// FIXME: markdown parse error in test mode
it.skip('should replace a block', async () => {
it('should replace a block', async () => {
const host = affine`
<affine-page id="page">
<affine-note id="note">
@@ -74,8 +73,7 @@ describe('applyPatchToDoc', () => {
});
});
// FIXME: markdown parse error in test mode
it.skip('should insert a block at index', async () => {
it('should insert a block at index', async () => {
const host = affine`
<affine-page id="page">
<affine-note id="note">
@@ -1,4 +1,3 @@
import type { AIDraftService } from '@affine/core/modules/ai-button';
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import type { AppThemeService } from '@affine/core/modules/theme';
@@ -116,9 +115,6 @@ export class ChatPanel extends SignalWatcher(
@property({ attribute: false })
accessor notificationService!: NotificationService;
@property({ attribute: false })
accessor aiDraftService!: AIDraftService;
@state()
accessor session: CopilotChatHistoryFragment | null | undefined;
@@ -412,7 +408,6 @@ export class ChatPanel extends SignalWatcher(
.affineWorkspaceDialogService=${this.affineWorkspaceDialogService}
.affineThemeService=${this.affineThemeService}
.notificationService=${this.notificationService}
.aiDraftService=${this.aiDraftService}
.onEmbeddingProgressChange=${this.onEmbeddingProgressChange}
.onContextChange=${this.onContextChange}
.width=${this.sidebarWidth}
@@ -1,6 +1,5 @@
import './ai-chat-composer-tip';
import type { AIDraftService } from '@affine/core/modules/ai-button';
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import type {
ContextEmbedStatus,
@@ -117,9 +116,6 @@ export class AIChatComposer extends SignalWatcher(
@property({ attribute: false })
accessor notificationService!: NotificationService;
@property({ attribute: false })
accessor aiDraftService!: AIDraftService;
@state()
accessor chips: ChatChip[] = [];
@@ -165,7 +161,6 @@ export class AIChatComposer extends SignalWatcher(
.reasoningConfig=${this.reasoningConfig}
.docDisplayConfig=${this.docDisplayConfig}
.searchMenuConfig=${this.searchMenuConfig}
.aiDraftService=${this.aiDraftService}
.portalContainer=${this.portalContainer}
.onChatSuccess=${this.onChatSuccess}
.trackOptions=${this.trackOptions}
@@ -1,5 +1,3 @@
import type { AIDraftService } from '@affine/core/modules/ai-button';
import type { AIDraftState } from '@affine/core/modules/ai-button/services/ai-draft';
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import type { AppThemeService } from '@affine/core/modules/theme';
@@ -17,7 +15,6 @@ import { property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { createRef, type Ref, ref } from 'lit/directives/ref.js';
import { styleMap } from 'lit/directives/style-map.js';
import { pick } from 'lodash-es';
import { HISTORY_IMAGE_ACTIONS } from '../../chat-panel/const';
import { type AIChatParams, AIProvider } from '../../provider/ai-provider';
@@ -152,9 +149,6 @@ export class AIChatContent extends SignalWatcher(
@property({ attribute: false })
accessor notificationService!: NotificationService;
@property({ attribute: false })
accessor aiDraftService!: AIDraftService;
@property({ attribute: false })
accessor onEmbeddingProgressChange:
| ((count: Record<ContextEmbedStatus, number>) => void)
@@ -269,19 +263,6 @@ export class AIChatContent extends SignalWatcher(
private readonly updateContext = (context: Partial<ChatContextValue>) => {
this.chatContextValue = { ...this.chatContextValue, ...context };
this.onContextChange?.(context);
this.updateDraft(context).catch(console.error);
};
private readonly updateDraft = async (context: Partial<ChatContextValue>) => {
const draft: Partial<AIDraftState> = pick(context, [
'quote',
'images',
'markdown',
]);
if (!Object.keys(draft).length) {
return;
}
await this.aiDraftService.setDraft(draft);
};
private readonly initChatContent = async () => {
@@ -341,19 +322,8 @@ export class AIChatContent extends SignalWatcher(
override connectedCallback() {
super.connectedCallback();
this.initChatContent().catch(console.error);
this.aiDraftService
.getDraft()
.then(draft => {
this.chatContextValue = {
...this.chatContextValue,
...draft,
};
})
.catch(console.error);
this._disposables.add(
AIProvider.slots.actions.subscribe(({ event }) => {
const { status } = this.chatContextValue;
@@ -433,7 +403,6 @@ export class AIChatContent extends SignalWatcher(
.searchMenuConfig=${this.searchMenuConfig}
.affineWorkspaceDialogService=${this.affineWorkspaceDialogService}
.notificationService=${this.notificationService}
.aiDraftService=${this.aiDraftService}
.trackOptions=${{
where: 'chat-panel',
control: 'chat-send',
@@ -1,11 +1,10 @@
import type { AIDraftService } from '@affine/core/modules/ai-button';
import type { CopilotChatHistoryFragment } from '@affine/graphql';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import type { EditorHost } from '@blocksuite/affine/std';
import { ShadowlessElement } from '@blocksuite/affine/std';
import { ArrowUpBigIcon, CloseIcon } from '@blocksuite/icons/lit';
import { css, html, nothing, type PropertyValues } from 'lit';
import { css, html, nothing } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
@@ -352,9 +351,6 @@ export class AIChatInput extends SignalWatcher(
@property({ attribute: false })
accessor searchMenuConfig!: SearchMenuConfig;
@property({ attribute: false })
accessor aiDraftService!: AIDraftService;
@property({ attribute: false })
accessor isRootSession: boolean = true;
@@ -383,7 +379,6 @@ export class AIChatInput extends SignalWatcher(
override connectedCallback() {
super.connectedCallback();
this._disposables.add(
AIProvider.slots.requestSendWithChat.subscribe(
(params: AISendParams | null) => {
@@ -404,17 +399,6 @@ export class AIChatInput extends SignalWatcher(
);
}
protected override firstUpdated(changedProperties: PropertyValues): void {
super.firstUpdated(changedProperties);
this.aiDraftService
.getDraft()
.then(draft => {
this.textarea.value = draft.input;
this.isInputEmpty = !this.textarea.value.trim();
})
.catch(console.error);
}
protected override render() {
const { images, status } = this.chatContextValue;
const hasImages = images.length > 0;
@@ -522,11 +506,9 @@ export class AIChatInput extends SignalWatcher(
}
};
private readonly _handleInput = async () => {
private readonly _handleInput = () => {
const { textarea } = this;
const value = textarea.value.trim();
this.isInputEmpty = !value;
this.isInputEmpty = !textarea.value.trim();
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 'px';
let imagesHeight = this.imagePreviewGrid?.scrollHeight ?? 0;
@@ -535,10 +517,6 @@ export class AIChatInput extends SignalWatcher(
textarea.style.height = '148px';
textarea.style.overflowY = 'scroll';
}
await this.aiDraftService.setDraft({
input: value,
});
};
private readonly _handleKeyDown = async (evt: KeyboardEvent) => {
@@ -594,9 +572,6 @@ export class AIChatInput extends SignalWatcher(
this.textarea.style.height = 'unset';
await this.send(value);
await this.aiDraftService.setDraft({
input: '',
});
};
private readonly _handleModelChange = (modelId: string) => {
@@ -1,4 +1,4 @@
import type { CopilotChatHistoryFragment } from '@affine/graphql';
import type { CopilotSessionType } from '@affine/graphql';
import { WithDisposable } from '@blocksuite/affine/global/lit';
import { scrollbarStyle } from '@blocksuite/affine/shared/styles';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
@@ -73,10 +73,6 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
background: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
}
.ai-session-item[aria-selected='true'] .ai-session-title {
color: ${unsafeCSSVarV2('text/emphasis')};
}
.ai-session-doc:hover {
background: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
}
@@ -123,7 +119,7 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
`;
@property({ attribute: false })
accessor session!: CopilotChatHistoryFragment | null | undefined;
accessor session!: CopilotSessionType | null | undefined;
@property({ attribute: false })
accessor workspaceId!: string;
@@ -260,8 +256,6 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
e.stopPropagation();
this.onSessionClick(session.sessionId);
}}
aria-selected=${this.session?.sessionId === session.sessionId}
data-session-id=${session.sessionId}
>
<div class="ai-session-title">
${session.title || 'New chat'}
@@ -2,7 +2,7 @@ import track from '@affine/track';
import { WithDisposable } from '@blocksuite/affine/global/lit';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import { type EditorHost, ShadowlessElement } from '@blocksuite/affine/std';
import { LoadingIcon } from '@blocksuite/affine-components/icons';
import { AIStarIconWithAnimation } from '@blocksuite/affine-components/icons';
import type { NotificationService } from '@blocksuite/affine-shared/services';
import {
CloseIcon,
@@ -83,7 +83,7 @@ export class DocEditTool extends WithDisposable(ShadowlessElement) {
display: flex;
flex-direction: column;
align-items: flex-start;
background: ${unsafeCSSVar('--affine-overlay-panel-shadow')};
background: ${unsafeCSSVarV2('layer/background/overlayPanel')};
box-shadow: ${unsafeCSSVar('shadow1')};
border-radius: 8px;
width: 100%;
@@ -96,6 +96,7 @@ export class DocEditTool extends WithDisposable(ShadowlessElement) {
width: 100%;
justify-content: space-between;
border-bottom: 1px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
.doc-edit-tool-result-card-header-title {
display: flex;
@@ -117,17 +118,9 @@ export class DocEditTool extends WithDisposable(ShadowlessElement) {
padding-right: 8px;
color: ${unsafeCSSVarV2('text/secondary')};
button {
cursor: pointer;
padding: 2px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
button:hover {
background: ${unsafeCSSVar('hoverColor')};
span {
width: 20px;
height: 20px;
}
}
}
@@ -135,27 +128,23 @@ export class DocEditTool extends WithDisposable(ShadowlessElement) {
.doc-edit-tool-result-card-content {
padding: 8px;
width: 100%;
border-top: 1px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
}
.doc-edit-tool-result-card-footer {
display: flex;
justify-content: flex-end;
align-items: center;
padding: 8px;
gap: 4px;
width: 100%;
cursor: pointer;
button {
.doc-edit-tool-result-reject,
.doc-edit-tool-result-accept {
display: flex;
align-items: center;
gap: 4px;
padding: 4px;
border-radius: 4px;
}
button:hover {
background: ${unsafeCSSVar('hoverColor')};
padding: 8px;
}
}
@@ -432,55 +421,55 @@ export class DocEditTool extends WithDisposable(ShadowlessElement) {
${docId}
</div>
<div class="doc-edit-tool-result-card-header-operations">
<button @click=${() => this._toggleCollapse()}>
${this.isCollapsed ? ExpandFullIcon() : ExpandCloseIcon()}
<affine-tooltip>
${this.isCollapsed ? 'Expand' : 'Collapse'}
</affine-tooltip>
</button>
<button @click=${() => this._handleCopy(changedContent)}>
<span @click=${() => this._toggleCollapse()}
>${this.isCollapsed
? ExpandFullIcon()
: ExpandCloseIcon()}</span
>
<span @click=${() => this._handleCopy(changedContent)}>
${CopyIcon()}
<affine-tooltip>Copy</affine-tooltip>
</button>
</span>
<button
@click=${() => this._handleApply(op, updates)}
?disabled=${this.isBusyForOp(op)}
>
${this.applyingMap[op]
? html`${LoadingIcon()} Applying`
: 'Apply'}
? AIStarIconWithAnimation
: html`Apply`}
</button>
</div>
</div>
<div class="doc-edit-tool-result-card-content">
${this.renderBlockDiffs(diffs)}
<div class="doc-edit-tool-result-card-footer">
<button
class="doc-edit-tool-result-reject"
@click=${() => this._handleReject(op)}
>
${CloseIcon({
style: `color: ${unsafeCSSVarV2('icon/secondary')}`,
})}
Reject
</button>
<button
class="doc-edit-tool-result-accept"
@click=${() => this._handleAccept(op, updates)}
?disabled=${this.isBusyForOp(op)}
style="${this.isBusyForOp(op)
? 'pointer-events: none; opacity: 0.6;'
: ''}"
>
${this.acceptingMap[op]
? html`${LoadingIcon()}`
: DoneIcon({
style: `color: ${unsafeCSSVarV2('icon/activated')}`,
})}
${this.acceptingMap[op] ? 'Accepting...' : 'Accept'}
</button>
<div class="doc-edit-tool-result-card-content-title">
${this.renderBlockDiffs(diffs)}
</div>
</div>
<div class="doc-edit-tool-result-card-footer">
<div
class="doc-edit-tool-result-reject"
@click=${() => this._handleReject(op)}
>
${CloseIcon({
style: `color: ${unsafeCSSVarV2('icon/secondary')}`,
})}
Reject
</div>
<button
class="doc-edit-tool-result-accept"
@click=${() => this._handleAccept(op, updates)}
?disabled=${this.isBusyForOp(op)}
style="${this.isBusyForOp(op)
? 'pointer-events: none; opacity: 0.6;'
: ''}"
>
${this.acceptingMap[op]
? AIStarIconWithAnimation
: DoneIcon({
style: `color: ${unsafeCSSVarV2('icon/activated')}`,
})}
${this.acceptingMap[op] ? 'Accepting...' : 'Accept'}
</button>
</div>
</div>
</div>
`;
@@ -1,10 +1,14 @@
import { parsePageDoc } from '@affine/reader';
import { LifeCycleWatcher } from '@blocksuite/affine/std';
import { Extension, type Store } from '@blocksuite/affine/store';
import {
BlockMarkdownAdapterMatcherIdentifier,
MarkdownAdapter,
} from '@blocksuite/affine-shared/adapters';
import { type Container, createIdentifier } from '@blocksuite/global/di';
import { LiveData } from '@toeverything/infra';
import type { Subscription } from 'rxjs';
import { blockTagMarkdownAdapterMatcher } from '../adapters/block-tag';
import { applyPatchToDoc } from '../utils/apply-model/apply-patch-to-doc';
import {
generateRenderDiff,
@@ -377,25 +381,24 @@ export class BlockDiffService extends Extension implements BlockDiffProvider {
}
getMarkdownFromDoc = async (doc: Store) => {
const cloned = doc.provider.container.clone();
cloned.addImpl(
BlockMarkdownAdapterMatcherIdentifier,
blockTagMarkdownAdapterMatcher
);
const job = doc.getTransformer();
const snapshot = job.docToSnapshot(doc);
const spaceDoc = doc.doc.spaceDoc;
const adapter = new MarkdownAdapter(job, cloned.provider());
if (!snapshot) {
throw new Error('Failed to get snapshot');
return 'Failed to get markdown from doc';
}
const parsed = parsePageDoc({
doc: spaceDoc,
workspaceId: doc.workspace.id,
buildBlobUrl: (blobId: string) => {
return `/${doc.workspace.id}/blobs/${blobId}`;
},
buildDocUrl: (docId: string) => {
return `/workspace/${doc.workspace.id}/${docId}`;
},
aiEditable: true,
// FIXME: reverse the block matchers to make the block tag adapter the first one
adapter.blockMatchers.reverse();
const markdown = await adapter.fromDocSnapshot({
snapshot,
assets: job.assetsManager,
});
return parsed.md;
return markdown.file;
};
}
@@ -2,7 +2,6 @@ import { WidgetComponent, WidgetViewExtension } from '@blocksuite/affine/std';
import { ThemeProvider } from '@blocksuite/affine-shared/services';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { css, html, nothing, type TemplateResult } from 'lit';
import { repeat } from 'lit/directives/repeat.js';
import { literal, unsafeStatic } from 'lit/static-html.js';
import { BlockDiffProvider } from '../../services/block-diff';
@@ -92,13 +91,11 @@ export class AffineBlockDiffWidgetForBlock extends WidgetComponent {
}
private _renderInsert(from: string, blocks: Block[]) {
return html`${repeat(
blocks,
block => block.id,
(block, offset) => {
return blocks
.map((block, offset) => {
const diffId = `insert-${block.id}-${offset}`;
return this.diffService.isRejected('insert', `${from}:${offset}`)
? nothing
? null
: html`<div class="ai-block-diff insert" data-diff-id=${diffId}>
<chat-content-rich-text
.text=${block.content}
@@ -123,8 +120,8 @@ export class AffineBlockDiffWidgetForBlock extends WidgetComponent {
})}
></ai-block-diff-options>
</div>`;
}
)}`;
})
.filter(Boolean) as TemplateResult[];
}
private _renderUpdate(blockId: string, content: string) {
@@ -192,12 +189,11 @@ export class AffineBlockDiffWidgetForBlock extends WidgetComponent {
return nothing;
}
const diffMap = service.getDiff();
const { deletes, inserts, updates } = diffMap;
const { deletes, inserts, updates } = service.getDiff();
let deleteDiff: TemplateResult | symbol = nothing;
let updateDiff: TemplateResult | symbol = nothing;
let insertDiff: TemplateResult | symbol = nothing;
let insertDiff: TemplateResult[] | symbol = nothing;
if (deletes.includes(attached)) {
deleteDiff = this._renderDelete(attached);
@@ -9,40 +9,20 @@ import * as styles from './styles.css';
export const BlocksuiteEditorJournalDocTitle = ({ page }: { page: Store }) => {
const journalService = useService(JournalService);
const journalDateStr = useLiveData(journalService.journalDate$(page.id));
return <BlocksuiteEditorJournalDocTitleUI date={journalDateStr} />;
};
export const BlocksuiteEditorJournalDocTitleUI = ({
date: dateStr,
overrideClassName,
}: {
date?: string;
/**
* The `doc-title-container` class style is defined in editor,
* which means if we use this component outside editor, the style will not work,
* so we provide a className to override
*/
overrideClassName?: string;
}) => {
const localizedJournalDate = i18nTime(dateStr, {
const journalDate = journalDateStr ? dayjs(journalDateStr) : null;
const isTodayJournal = useLiveData(journalService.journalToday$(page.id));
const localizedJournalDate = i18nTime(journalDateStr, {
absolute: { accuracy: 'day' },
});
const t = useI18n();
// TODO(catsjuice): i18n
const today = dayjs();
const date = dayjs(dateStr);
const day = dayjs(date).format('dddd') ?? null;
const isToday = date.isSame(today, 'day');
const day = journalDate?.format('dddd') ?? null;
return (
<div
className={overrideClassName ?? 'doc-title-container'}
data-testid="journal-title"
>
<div className="doc-title-container" data-testid="journal-title">
<span data-testid="date">{localizedJournalDate}</span>
{isToday ? (
{isTodayJournal ? (
<span className={styles.titleTodayTag} data-testid="date-today-label">
{t['com.affine.today']()}
</span>
@@ -1,14 +1,11 @@
import type { WeekDatePickerHandle } from '@affine/component';
import { WeekDatePicker } from '@affine/component';
import {
JOURNAL_DATE_FORMAT,
JournalService,
} from '@affine/core/modules/journal';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { useJournalRouteHelper } from '@affine/core/components/hooks/use-journal';
import { JournalService } from '@affine/core/modules/journal';
import type { Store } from '@blocksuite/affine/store';
import { useLiveData, useService } from '@toeverything/infra';
import dayjs from 'dayjs';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
export interface JournalWeekDatePickerProps {
page: Store;
@@ -20,29 +17,17 @@ export const JournalWeekDatePicker = ({ page }: JournalWeekDatePickerProps) => {
const journalService = useService(JournalService);
const journalDateStr = useLiveData(journalService.journalDate$(page.id));
const journalDate = journalDateStr ? dayjs(journalDateStr) : null;
const { openJournal } = useJournalRouteHelper();
const [date, setDate] = useState(
(journalDate ?? dayjs()).format(JOURNAL_DATE_FORMAT)
(journalDate ?? dayjs()).format('YYYY-MM-DD')
);
const workbench = useService(WorkbenchService).workbench;
useEffect(() => {
if (!journalDate) return;
setDate(journalDate.format(JOURNAL_DATE_FORMAT));
setDate(journalDate.format('YYYY-MM-DD'));
handleRef.current?.setCursor?.(journalDate);
}, [journalDate]);
const openJournal = useCallback(
(date: string) => {
const docs = journalService.journalsByDate$(date).value;
if (docs.length > 0) {
workbench.openDoc(docs[0].id, { at: 'active' });
} else {
workbench.open(`/journals?date=${date}`, { at: 'active' });
}
},
[journalService, workbench]
);
return (
<WeekDatePicker
data-testid="journal-week-picker"
@@ -4,6 +4,7 @@ import {
defaultImageProxyMiddleware,
embedSyncedDocMiddleware,
MarkdownAdapter,
MixTextAdapter,
pasteMiddleware,
PlainTextAdapter,
titleMiddleware,
@@ -145,7 +146,7 @@ export const markdownToSnapshot = async (
? [defaultImageProxyMiddleware, pasteMiddleware(host.std)]
: [defaultImageProxyMiddleware];
const transformer = store.getTransformer(middlewares);
const markdownAdapter = new MarkdownAdapter(transformer, store.provider);
const markdownAdapter = new MixTextAdapter(transformer, store.provider);
const payload = {
file: markdown,
assets: transformer.assetsManager,
@@ -153,31 +154,10 @@ export const markdownToSnapshot = async (
pageId: store.id,
};
const page = await markdownAdapter.toDoc(payload);
if (page) {
const pageSnapshot = transformer.docToSnapshot(page);
if (pageSnapshot) {
const snapshot: SliceSnapshot = {
type: 'slice',
content: [
pageSnapshot.blocks.children.find(
b => b.flavour === 'affine:note'
) as BlockSnapshot,
],
workspaceId: payload.workspaceId,
pageId: payload.pageId,
};
return {
snapshot,
transformer,
};
}
}
const snapshot = await markdownAdapter.toSliceSnapshot(payload);
return {
snapshot: null,
snapshot,
transformer,
};
};
@@ -514,7 +514,7 @@ export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
return;
}, [doc, onChange, snapshotHelper]);
// Add keydown handler to commit on Enter key
// Add keydown handler to commit on CMD/CTRL + Enter key
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (readonly) return;
@@ -523,8 +523,8 @@ export const CommentEditor = forwardRef<CommentEditorRef, CommentEditorProps>(
const activeElement = document.activeElement;
if (!editorRef.current?.contains(activeElement)) return;
// If Enter is pressed without CMD/CTRL key, commit the comment
if (e.key === 'Enter' && !(e.metaKey || e.ctrlKey)) {
// If Enter is pressed with CMD/CTRL key, commit the comment
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
e.stopPropagation();
handleCommit();
@@ -157,7 +157,6 @@ const ActionMenu = ({
}
>
<IconButton
className={styles.actionButton}
variant="solid"
icon={<MoreHorizontalIcon />}
disabled={disabled}
@@ -492,7 +491,6 @@ const CommentItem = ({
>
{canResolveComment && (
<IconButton
className={styles.actionButton}
variant="solid"
onClick={handleResolve}
icon={<DoneIcon />}
@@ -163,10 +163,6 @@ export const commentActions = style({
},
});
export const actionButton = style({
backgroundColor: cssVarV2('button/buttonOverHover'),
});
export const readonlyCommentContainer = style({
display: 'flex',
flexDirection: 'column',
@@ -1,57 +0,0 @@
import type { Server } from '@affine/core/modules/cloud';
import { useLiveData } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2';
import semver from 'semver';
const rules = [
{
min: '0.23.0',
tip: (receivedVersion: string, requiredVersion: string) => (
<div>
<p
style={{
color: cssVarV2('status/error'),
fontSize: 14,
lineHeight: '22px',
}}
>
Your server version{' '}
<b style={{ fontWeight: 600 }}>{receivedVersion}</b> is not compatible
with current client. Please upgrade your server to{' '}
<b style={{ fontWeight: 600 }}>{requiredVersion}</b> or higher to use
this client.
</p>
<div style={{ marginTop: '12px', color: cssVarV2.text.primary }}>
<span style={{ fontWeight: 500 }}>Instructions:</span>
<br />
<a
style={{
whiteSpace: 'break-spaces',
wordBreak: 'break-all',
fontSize: 12,
lineHeight: '16px',
}}
>
https://docs.affine.pro/self-host-affine/install/upgrade
</a>
</div>
</div>
),
},
];
/**
* Return the error tip if the server version is not meet the requirement
*/
export const useSelfhostLoginVersionGuard = (server: Server) => {
const serverVersion =
useLiveData(server.config$.selector(c => c.version)) ?? '0.0.0';
for (const rule of rules) {
if (semver.lt(serverVersion, rule.min)) {
return rule.tip(serverVersion, rule.min);
}
}
return null;
};
@@ -10,9 +10,8 @@ import {
SidebarScrollableContainer,
} from '@affine/core/modules/app-sidebar/views';
import { ExternalMenuLinkItem } from '@affine/core/modules/app-sidebar/views/menu-item/external-menu-link-item';
import { AuthService, ServerService } from '@affine/core/modules/cloud';
import { AuthService } from '@affine/core/modules/cloud';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { CMDKQuickSearchService } from '@affine/core/modules/quicksearch/services/cmdk';
import type { Workspace } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n';
@@ -89,11 +88,6 @@ const AllDocsButton = () => {
};
const AIChatButton = () => {
const featureFlagService = useService(FeatureFlagService);
const serverService = useService(ServerService);
const serverFeatures = useLiveData(serverService.server.features$);
const enableAI = useLiveData(featureFlagService.flags.enable_ai.$);
const { workbenchService } = useServices({
WorkbenchService,
});
@@ -102,10 +96,6 @@ const AIChatButton = () => {
workbench.location$.selector(location => location.pathname === '/chat')
);
if (!enableAI || !serverFeatures?.copilot) {
return null;
}
return (
<MenuLinkItem icon={<AiOutlineIcon />} active={aiChatActive} to={'/chat'}>
<span data-testid="ai-chat">Intelligence</span>
@@ -21,7 +21,7 @@ export const AppSidebarJournalButton = () => {
return (
<MenuLinkItem
data-testid="slider-bar-journals-button"
active={isJournal || location.pathname.startsWith('/journals')}
active={isJournal}
to={'/journals'}
icon={<Icon />}
>
@@ -27,7 +27,6 @@ import {
useState,
} from 'react';
import { useSelfhostLoginVersionGuard } from '../hooks/affine/use-selfhost-login-version-guard';
import type { SignInState } from '.';
import { Back } from './back';
import * as style from './style.css';
@@ -55,7 +54,6 @@ export const SignInStep = ({
const serverName = useLiveData(
serverService.server.config$.selector(c => c.serverName)
);
const versionError = useSelfhostLoginVersionGuard(serverService.server);
const isSelfhosted = useLiveData(
serverService.server.config$.selector(
c => c.type === ServerDeploymentType.Selfhosted
@@ -127,20 +125,6 @@ export const SignInStep = ({
}));
}, [changeState]);
if (versionError && isSelfhosted) {
return (
<AuthContainer>
<AuthHeader
title={t['com.affine.auth.sign.in']()}
subTitle={serverName}
/>
<AuthContent>
<div>{versionError}</div>
</AuthContent>
</AuthContainer>
);
}
return (
<AuthContainer>
<AuthHeader
@@ -1,4 +1,3 @@
import { Scrollable } from '@affine/component';
import { Avatar } from '@affine/component/ui/avatar';
import { UserPlanButton } from '@affine/core/components/affine/auth/user-plan-button';
import { useCatchEventCallback } from '@affine/core/components/hooks/use-catch-event-hook';
@@ -226,18 +225,13 @@ export const SettingSidebar = ({
</Suspense>
) : null}
<Scrollable.Root>
<Scrollable.Viewport>
{groups.map(group => (
<SettingSidebarGroup
key={group.key}
title={group.title}
items={group.items}
/>
))}
<Scrollable.Scrollbar />
</Scrollable.Viewport>
</Scrollable.Root>
{groups.map(group => (
<SettingSidebarGroup
key={group.key}
title={group.title}
items={group.items}
/>
))}
</div>
);
};
@@ -5,7 +5,7 @@ export const settingSlideBar = style({
width: '25%',
maxWidth: '242px',
background: cssVar('backgroundSecondaryColor'),
padding: '20px 0px 0px 12px',
padding: '20px 12px',
height: '100%',
flexShrink: 0,
display: 'flex',
@@ -123,7 +123,6 @@ export const sidebarGroup = style({
display: 'flex',
flexDirection: 'column',
gap: '4px',
paddingRight: '12px',
});
export const accountButton = style({
@@ -0,0 +1,11 @@
import { useJournalRouteHelper } from '@affine/core/components/hooks/use-journal';
import { useEffect } from 'react';
// this route page acts as a redirector to today's journal
export const Component = () => {
const { openToday } = useJournalRouteHelper();
useEffect(() => {
openToday({ replaceHistory: true });
}, [openToday]);
return null;
};
@@ -11,7 +11,6 @@ import { getViewManager } from '@affine/core/blocksuite/manager/view';
import { NotificationServiceImpl } from '@affine/core/blocksuite/view-extensions/editor-view/notification-service';
import { useAIChatConfig } from '@affine/core/components/hooks/affine/use-ai-chat-config';
import { useAISpecs } from '@affine/core/components/hooks/affine/use-ai-specs';
import { AIDraftService } from '@affine/core/modules/ai-button';
import {
EventSourceService,
FetchService,
@@ -222,7 +221,6 @@ export const Component = () => {
confirmModal.closeConfirmModal,
confirmModal.openConfirmModal
);
content.aiDraftService = framework.get(AIDraftService);
content.createSession = createSession;
content.onOpenDoc = onOpenDoc;
@@ -4,7 +4,6 @@ import type { AffineEditorContainer } from '@affine/core/blocksuite/block-suite-
import { NotificationServiceImpl } from '@affine/core/blocksuite/view-extensions/editor-view/notification-service';
import { useAIChatConfig } from '@affine/core/components/hooks/affine/use-ai-chat-config';
import { useAISpecs } from '@affine/core/components/hooks/affine/use-ai-specs';
import { AIDraftService } from '@affine/core/modules/ai-button';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { AppThemeService } from '@affine/core/modules/theme';
@@ -96,7 +95,6 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel(
confirmModal.closeConfirmModal,
confirmModal.openConfirmModal
);
chatPanelRef.current.aiDraftService = framework.get(AIDraftService);
containerRef.current?.append(chatPanelRef.current);
} else {
@@ -9,6 +9,7 @@ import {
useConfirmModal,
} from '@affine/component';
import { Guard } from '@affine/core/components/guard';
import { useJournalRouteHelper } from '@affine/core/components/hooks/use-journal';
import { MoveToTrash } from '@affine/core/components/page-list';
import {
type DocRecord,
@@ -17,10 +18,7 @@ import {
} from '@affine/core/modules/doc';
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
import { JournalService } from '@affine/core/modules/journal';
import {
WorkbenchLink,
WorkbenchService,
} from '@affine/core/modules/workbench';
import { WorkbenchLink } from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n';
import { CalendarXmarkIcon, EditIcon } from '@blocksuite/icons/rc';
import {
@@ -105,25 +103,13 @@ const mobile = environment.isMobile;
export const EditorJournalPanel = () => {
const t = useI18n();
const doc = useServiceOptional(DocService)?.doc;
const workbench = useService(WorkbenchService).workbench;
const journalService = useService(JournalService);
const journalDateStr = useLiveData(
doc ? journalService.journalDate$(doc.id) : null
);
const journalDate = journalDateStr ? dayjs(journalDateStr) : null;
const isJournal = !!journalDate;
const openJournal = useCallback(
(date: string) => {
const docs = journalService.journalsByDate$(date).value;
if (docs.length > 0) {
workbench.openDoc(docs[0].id, { at: 'active' });
} else {
workbench.open(`/journals?date=${date}`, { at: 'active' });
}
},
[journalService, workbench]
);
const { openJournal } = useJournalRouteHelper();
const onDateSelect = useCallback(
(date: string) => {
@@ -1,78 +0,0 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const header = style({
display: 'flex',
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
padding: '0 36px',
});
export const todayButton = style({
position: 'absolute',
right: 0,
});
export const body = style({
width: '100%',
height: '100%',
borderTop: `0.5px solid ${cssVarV2.layer.insideBorder.border}`,
selectors: {
'&[data-mobile]': {
borderTop: 'none',
},
},
});
export const content = style({
maxWidth: 944,
padding: '0px 50px',
margin: '0 auto',
selectors: {
'[data-mobile] &': {
padding: '0 24px',
},
},
});
export const docTitleContainer = style({
color: cssVarV2.text.primary,
fontSize: 40,
lineHeight: '50px',
fontWeight: 700,
padding: '38px 0',
});
export const placeholder = style({
height: 200,
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
border: `1px dashed ${cssVarV2.layer.insideBorder.border}`,
borderRadius: 8,
});
export const placeholderIcon = style({
width: 36,
height: 36,
borderRadius: 36,
backgroundColor: cssVarV2.button.emptyIconBackground,
color: cssVarV2.icon.primary,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 20,
marginBottom: 4,
});
export const placeholderText = style({
fontSize: 14,
lineHeight: '22px',
marginBottom: 16,
color: cssVarV2.text.tertiary,
});
@@ -1,155 +0,0 @@
import {
Button,
WeekDatePicker,
type WeekDatePickerHandle,
} from '@affine/component';
import { BlocksuiteEditorJournalDocTitleUI } from '@affine/core/blocksuite/block-suite-editor/journal-doc-title';
import {
JOURNAL_DATE_FORMAT,
JournalService,
} from '@affine/core/modules/journal';
import {
ViewBody,
ViewHeader,
ViewIcon,
ViewService,
ViewTitle,
WorkbenchService,
} from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n';
import { TodayIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import dayjs from 'dayjs';
import type { Location } from 'history';
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { AllDocSidebarTabs } from '../layouts/all-doc-sidebar-tabs';
import * as styles from './index.css';
export function getDateFromUrl(location: Location) {
const searchParams = new URLSearchParams(location.search);
const date = searchParams.get('date')
? dayjs(searchParams.get('date'))
: dayjs();
return date.format(JOURNAL_DATE_FORMAT);
}
export const JournalPlaceholder = ({ dateString }: { dateString: string }) => {
const t = useI18n();
const [redirecting, setRedirecting] = useState(false);
const workbench = useService(WorkbenchService).workbench;
const journalService = useService(JournalService);
const createJournal = useCallback(() => {
if (redirecting) return;
setRedirecting(true);
const doc = journalService.ensureJournalByDate(dateString);
workbench.openDoc(doc.id, {
replaceHistory: true,
at: 'active',
});
}, [dateString, journalService, redirecting, workbench]);
return (
<div className={styles.body} data-mobile={BUILD_CONFIG.isMobileEdition}>
<div className={styles.content}>
<BlocksuiteEditorJournalDocTitleUI
date={dateString}
overrideClassName={styles.docTitleContainer}
/>
<div className={styles.placeholder}>
<div className={styles.placeholderIcon}>
<TodayIcon />
</div>
<div className={styles.placeholderText}>
{t['com.affine.journal.placeholder.title']()}
</div>
<Button
variant="primary"
onClick={createJournal}
data-testid="confirm-create-journal-button"
>
{t['com.affine.journal.placeholder.create']()}
</Button>
</div>
</div>
</div>
);
};
const weekStyle = { maxWidth: 800, width: '100%' };
// this route page acts as a redirector to today's journal
export const JournalsPageWithConfirmation = () => {
const handleRef = useRef<WeekDatePickerHandle>(null);
const t = useI18n();
const journalService = useService(JournalService);
const workbench = useService(WorkbenchService).workbench;
const view = useService(ViewService).view;
const location = useLiveData(view.location$);
const dateString = getDateFromUrl(location);
const todayString = dayjs().format(JOURNAL_DATE_FORMAT);
const isToday = dateString === todayString;
const [ready, setReady] = useState(false);
const openJournal = useCallback(
(date: string) => {
workbench.open(`/journals?date=${date}`, { at: 'active' });
},
[workbench]
);
useLayoutEffect(() => {
// only handle current route
if (!location.pathname.startsWith('/journals')) return;
// check if the journal is created
const docs = journalService.journalsByDate$(dateString).value;
if (docs.length === 0) {
setReady(true);
return;
}
// if created, redirect to the journal
const journal = docs[0];
workbench.openDoc(journal.id, { replaceHistory: true, at: 'active' });
}, [dateString, journalService, location.pathname, view, workbench]);
if (!ready) return null;
return (
<>
<ViewTitle title="" />
<ViewIcon icon="journal" />
<ViewHeader>
<div className={styles.header}>
<WeekDatePicker
data-testid="journal-week-picker"
handleRef={handleRef}
style={weekStyle}
value={dateString}
onChange={openJournal}
/>
{!isToday ? (
<Button
className={styles.todayButton}
onClick={() => openJournal(todayString)}
>
{t['com.affine.today']()}
</Button>
) : null}
</div>
</ViewHeader>
<ViewBody>
<JournalPlaceholder dateString={dateString} />
</ViewBody>
<AllDocSidebarTabs />
</>
);
};
export const Component = () => {
return <JournalsPageWithConfirmation />;
};
@@ -14,11 +14,7 @@ import {
EditorsService,
} from '@affine/core/modules/editor';
import { PeekViewManagerModal } from '@affine/core/modules/peek-view';
import {
ViewIcon,
ViewTitle,
WorkbenchService,
} from '@affine/core/modules/workbench';
import { ViewIcon, ViewTitle } from '@affine/core/modules/workbench';
import {
type Workspace,
WorkspacesService,
@@ -38,17 +34,6 @@ import { ShareFooter } from './share-footer';
import { ShareHeader } from './share-header';
import * as styles from './share-page.css';
const useUpdateBasename = (workspace: Workspace | null) => {
const location = useLocation();
const basename = location.pathname.match(/\/workspace\/[^/]+/g)?.[0] ?? '/';
useEffect(() => {
if (workspace) {
const workbench = workspace.scope.get(WorkbenchService).workbench;
workbench.updateBasename(basename);
}
}, [basename, workspace]);
};
export const SharePage = ({
workspaceId,
docId,
@@ -202,7 +187,6 @@ const SharePageInner = ({
const t = useI18n();
const pageTitle = useLiveData(page?.title$);
const { jumpToPageBlock, openPage } = useNavigateHelper();
useUpdateBasename(workspace);
const onEditorLoad = useCallback(
(editorContainer: AffineEditorContainer) => {
@@ -39,7 +39,7 @@ export const workbenchRoutes = [
},
{
path: '/journals',
lazy: () => import('./pages/workspace/journals'),
lazy: () => import('./pages/journals'),
},
{
path: '/settings',
@@ -19,8 +19,9 @@ export const AppTabJournal = ({ tab }: AppTabCustomFCProps) => {
const JournalIcon = useLiveData(docDisplayMetaService.icon$(maybeDocId));
const handleOpenToday = useCallback(() => {
workbench.open('/journals', { at: 'active' });
}, [workbench]);
const docId = journalService.ensureJournalByDate(new Date()).id;
workbench.openDoc({ docId, fromTab: 'true' }, { at: 'active' });
}, [journalService, workbench]);
const Icon = journalDate ? JournalIcon : TodayIcon;
@@ -252,15 +252,11 @@ const MobileDetailPage = ({
const handleDateChange = useCallback(
(date: string) => {
const docs = journalService.journalsByDate$(date).value;
if (docs.length > 0) {
workbench.openDoc(
{ docId: docs[0].id, fromTab: fromTab ? 'true' : undefined },
{ replaceHistory: true }
);
} else {
workbench.open(`/journals?date=${date}`);
}
const docId = journalService.ensureJournalByDate(date).id;
workbench.openDoc(
{ docId, fromTab: fromTab ? 'true' : undefined },
{ replaceHistory: true }
);
},
[fromTab, journalService, workbench]
);
@@ -1,26 +0,0 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const container = style({
display: 'flex',
flexDirection: 'column',
height: '100dvh',
overflow: 'hidden',
backgroundColor: cssVarV2('layer/background/primary'),
});
export const header = style({
backgroundColor: cssVarV2('layer/background/primary'),
});
export const headerTitle = style({
color: cssVarV2('text/primary'),
fontSize: 17,
lineHeight: '22px',
fontWeight: 600,
letterSpacing: -0.43,
});
export const journalDatePicker = style({
backgroundColor: cssVarV2('layer/background/primary'),
});
@@ -1,78 +0,0 @@
import {
getDateFromUrl,
JournalPlaceholder,
} from '@affine/core/desktop/pages/workspace/journals';
import { JournalService } from '@affine/core/modules/journal';
import { ViewService, WorkbenchService } from '@affine/core/modules/workbench';
import { i18nTime } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2';
import dayjs from 'dayjs';
import { useCallback, useLayoutEffect, useState } from 'react';
import { AppTabs, PageHeader } from '../../components';
import { JournalDatePicker } from './detail/journal-date-picker';
import * as styles from './journals.css';
export const JournalsPageWithConfirmation = () => {
const journalService = useService(JournalService);
const workbench = useService(WorkbenchService).workbench;
const view = useService(ViewService).view;
const location = useLiveData(view.location$);
const dateString = getDateFromUrl(location);
const [ready, setReady] = useState(false);
const allJournalDates = useLiveData(journalService.allJournalDates$);
const handleDateChange = useCallback(
(date: string) => {
workbench.open(`/journals?date=${date}`, { at: 'active' });
},
[workbench]
);
useLayoutEffect(() => {
// only handle current route
if (!location.pathname.startsWith('/journals')) return;
// check if the journal is created
const docs = journalService.journalsByDate$(dateString).value;
if (docs.length === 0) {
setReady(true);
return;
}
// if created, redirect to the journal
const journal = docs[0];
workbench.openDoc(journal.id, { replaceHistory: true, at: 'active' });
}, [dateString, journalService, location.pathname, view, workbench]);
if (!ready) return null;
return (
<>
<div className={styles.container}>
<PageHeader
className={styles.header}
bottom={
<JournalDatePicker
date={dateString}
onChange={handleDateChange}
withDotDates={allJournalDates}
className={styles.journalDatePicker}
/>
}
contentClassName={styles.headerTitle}
bottomSpacer={94}
>
{i18nTime(dayjs(dateString), { absolute: { accuracy: 'month' } })}
</PageHeader>
<JournalPlaceholder dateString={dateString} />
</div>
<AppTabs background={cssVarV2('layer/background/primary')} />
</>
);
};
export const Component = () => {
return <JournalsPageWithConfirmation />;
};
@@ -11,7 +11,6 @@ import { WorkbenchService } from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n';
import { NotificationIcon, SettingsIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2';
import clsx from 'clsx';
import { useCallback, useRef, useState } from 'react';
@@ -77,13 +76,7 @@ export const HomeHeader = () => {
ref={floatWorkspaceCardRef}
/>
<Menu items={<NotificationList />}>
<div
style={{
position: 'relative',
lineHeight: 0,
color: cssVarV2.icon.primary,
}}
>
<div style={{ position: 'relative' }}>
<NotificationIcon width={28} height={28} />
{notificationCount > 0 && (
<div
@@ -4,7 +4,6 @@ import { Component as All } from './pages/workspace/all';
import { Component as Collection } from './pages/workspace/collection';
import { Component as CollectionDetail } from './pages/workspace/collection/detail';
import { Component as Home } from './pages/workspace/home';
import { Component as Journals } from './pages/workspace/journals';
import { Component as Search } from './pages/workspace/search';
import { Component as Tag } from './pages/workspace/tag';
import { Component as TagDetail } from './pages/workspace/tag/detail';
@@ -42,11 +41,6 @@ export const workbenchRoutes = [
// lazy: () => import('./pages/workspace/tag/detail'),
Component: TagDetail,
},
{
path: '/journals',
// lazy: () => import('./pages/workspace/journals'),
Component: Journals,
},
{
path: '/trash',
lazy: () => import('./pages/workspace/trash'),
@@ -1,15 +1,12 @@
export { AIButtonProvider } from './provider/ai-button';
export { AIButtonService } from './services/ai-button';
export { AIDraftService } from './services/ai-draft';
import type { Framework } from '@toeverything/infra';
import { FeatureFlagService } from '../feature-flag';
import { CacheStorage, GlobalStateService } from '../storage';
import { WorkspaceScope } from '../workspace';
import { GlobalStateService } from '../storage';
import { AIButtonProvider } from './provider/ai-button';
import { AIButtonService } from './services/ai-button';
import { AIDraftService } from './services/ai-draft';
import { AINetworkSearchService } from './services/network-search';
import { AIPlaygroundService } from './services/playground';
import { AIReasoningService } from './services/reasoning';
@@ -34,9 +31,3 @@ export function configureAIReasoningModule(framework: Framework) {
export function configureAIPlaygroundModule(framework: Framework) {
framework.service(AIPlaygroundService, [FeatureFlagService]);
}
export function configureAIDraftModule(framework: Framework) {
framework
.scope(WorkspaceScope)
.service(AIDraftService, [GlobalStateService, CacheStorage]);
}
@@ -1,159 +0,0 @@
import { Service } from '@toeverything/infra';
import type { CacheStorage, GlobalStateService } from '../../storage';
const AI_DRAFTS_KEY = 'AIDrafts';
const AI_DRAFT_FILES_PREFIX = 'AIDraftFile:';
export interface CacheFile {
name: string;
size: number;
type: string;
cacheKey: string;
}
export interface AIDraftState {
input: string;
quote: string;
markdown: string;
images: File[];
}
export interface AIDraftGlobal {
input: string;
quote: string;
markdown: string;
images: CacheFile[];
}
const DEFAULT_VALUE = {
input: '',
quote: '',
markdown: '',
images: [],
};
export class AIDraftService extends Service {
private state: AIDraftState | null = null;
constructor(
private readonly globalStateService: GlobalStateService,
private readonly cacheStorage: CacheStorage
) {
super();
}
setDraft = async (data: Partial<AIDraftState>) => {
const state = await this.getState();
const newState = {
...state,
...data,
};
this.state = newState;
await this.saveDraft(newState);
};
getDraft = async () => {
const state = await this.getState();
return state;
};
private readonly saveDraft = async (state: AIDraftState) => {
const draft =
this.globalStateService.globalState.get<AIDraftGlobal>(AI_DRAFTS_KEY) ||
DEFAULT_VALUE;
const addedImages = state.images.filter(image => {
return !draft.images.some(cacheImage => {
return cacheImage.cacheKey === this.getCacheKey(image);
});
});
const removedImages = draft.images.filter(cacheImage => {
return !state.images.some(image => {
return cacheImage.cacheKey === this.getCacheKey(image);
});
});
const cacheKeys = removedImages.map(image => image.cacheKey);
await this.removeFilesFromCache(cacheKeys);
await this.addFilesToCache(addedImages);
this.globalStateService.globalState.set<AIDraftGlobal>(AI_DRAFTS_KEY, {
input: state.input,
quote: state.quote,
markdown: state.markdown,
images: state.images.map(image => {
return {
name: image.name,
size: image.size,
type: image.type,
cacheKey: this.getCacheKey(image),
};
}),
});
};
private readonly initState = async () => {
if (this.state) {
return;
}
const draft =
this.globalStateService.globalState.get<AIDraftGlobal>(AI_DRAFTS_KEY);
if (draft) {
const images = await this.restoreFilesFromData(draft.images);
this.state = {
input: draft.input,
quote: draft.quote,
markdown: draft.markdown,
images,
};
} else {
this.state = DEFAULT_VALUE;
}
};
private readonly getState = async () => {
await this.initState();
return this.state as AIDraftState;
};
private readonly getCacheKey = (file: File) => {
return AI_DRAFT_FILES_PREFIX + file.name + file.size;
};
private readonly addFilesToCache = async (files: File[]) => {
for (const file of files) {
const arrayBuffer = await file.arrayBuffer();
const cacheKey = this.getCacheKey(file);
await this.cacheStorage.set(cacheKey, arrayBuffer);
}
};
private readonly removeFilesFromCache = async (cacheKeys: string[]) => {
for (const cacheKey of cacheKeys) {
await this.cacheStorage.del(cacheKey);
}
};
private readonly restoreFilesFromData = async (
cacheFiles: CacheFile[]
): Promise<File[]> => {
const files: File[] = [];
for (const cacheFile of cacheFiles) {
try {
const arrayBuffer = await this.cacheStorage.get<ArrayBuffer>(
cacheFile.cacheKey
);
if (arrayBuffer) {
const file = new File([arrayBuffer], cacheFile.name, {
type: cacheFile.type,
});
files.push(file);
}
} catch (error) {
console.warn(`Failed to restore file ${cacheFile.name}:`, error);
}
}
return files;
};
}
@@ -3,7 +3,6 @@ import { type Framework } from '@toeverything/infra';
import {
configureAIButtonModule,
configureAIDraftModule,
configureAINetworkSearchModule,
configureAIPlaygroundModule,
configureAIReasoningModule,
@@ -111,7 +110,6 @@ export function configureCommonModules(framework: Framework) {
configureAIReasoningModule(framework);
configureAIPlaygroundModule(framework);
configureAIButtonModule(framework);
configureAIDraftModule(framework);
configureTemplateDocModule(framework);
configureBlobManagementModule(framework);
configureMediaModule(framework);
@@ -135,14 +135,11 @@ export class AudioAttachmentBlock extends Entity<AttachmentBlockModel> {
if (!buffer) {
throw new Error('No audio buffer available');
}
const slices = await encodeAudioBlobToOpusSlices(buffer, 64000);
const files = slices.map((slice, index) => {
const blob = new Blob([slice], { type: 'audio/opus' });
return new File([blob], this.props.props.name + `-${index}.opus`, {
type: 'audio/opus',
});
});
return files;
return [
new File([buffer], this.props.props.name + '.mp3', {
type: 'audio/mpeg',
}),
];
},
});
-1
View File
@@ -15,7 +15,6 @@
{ "path": "../../common/graphql" },
{ "path": "../i18n" },
{ "path": "../../common/nbstore" },
{ "path": "../../common/reader" },
{ "path": "../track" },
{ "path": "../../../blocksuite/affine/all" },
{ "path": "../../../blocksuite/affine/components" },
-8
View File
@@ -2493,14 +2493,6 @@ export function useAFFiNEI18N(): {
* `Updated`
*/
["com.affine.journal.updated-today"](): string;
/**
* `No Journal`
*/
["com.affine.journal.placeholder.title"](): string;
/**
* `Create Daily Journal`
*/
["com.affine.journal.placeholder.create"](): string;
/**
* `Just now`
*/

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