From 31e65d96d46ca24c37efb3b68659147f3bd84aed Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Wed, 18 Jun 2025 20:20:00 +0800 Subject: [PATCH 01/45] fix(server): ci nextest (#12851) ## Summary by CodeRabbit - **Chores** - Updated testing workflow to use a specific version of the testing tool for improved consistency across jobs. --- .github/workflows/build-test.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 932ee85464..3bba0932e4 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -734,7 +734,9 @@ jobs: toolchain: nightly components: miri - name: Install latest nextest release - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@v2 + with: + tool: nextest@0.9.98 - name: Miri Code Check continue-on-error: true @@ -756,7 +758,9 @@ jobs: with: toolchain: stable - name: Install latest nextest release - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@v2 + with: + tool: nextest@0.9.98 - name: Loom Thread Test run: | @@ -855,7 +859,9 @@ jobs: no-build: 'true' - name: Install latest nextest release - uses: taiki-e/install-action@nextest + uses: taiki-e/install-action@v2 + with: + tool: nextest@0.9.98 - name: Run tests run: cargo nextest run --workspace --exclude affine_server_native --features use-as-lib --release --no-fail-fast From e046260debb3050b1d65daea4cd7bed4819508c2 Mon Sep 17 00:00:00 2001 From: Peng Xiao Date: Wed, 18 Jun 2025 20:33:41 +0800 Subject: [PATCH 02/45] feat(core): allow importing affine file within import dialog (#12850) --- .../core/src/desktop/dialogs/import/index.tsx | 126 +++++++++++++++--- packages/frontend/i18n/src/i18n.gen.ts | 8 ++ packages/frontend/i18n/src/resources/en.json | 2 + 3 files changed, 120 insertions(+), 16 deletions(-) diff --git a/packages/frontend/core/src/desktop/dialogs/import/index.tsx b/packages/frontend/core/src/desktop/dialogs/import/index.tsx index 6de66d0fe2..ec46f4c421 100644 --- a/packages/frontend/core/src/desktop/dialogs/import/index.tsx +++ b/packages/frontend/core/src/desktop/dialogs/import/index.tsx @@ -1,13 +1,16 @@ import { Button, IconButton, Modal } from '@affine/component'; import { getStoreManager } from '@affine/core/blocksuite/manager/store'; import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; -import type { - DialogComponentProps, - WORKSPACE_DIALOG_SCHEMA, +import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper'; +import { + type DialogComponentProps, + GlobalDialogService, + type WORKSPACE_DIALOG_SCHEMA, } from '@affine/core/modules/dialogs'; import { UrlService } from '@affine/core/modules/url'; import { getAFFiNEWorkspaceSchema, + type WorkspaceMetadata, WorkspaceService, } from '@affine/core/modules/workspace'; import { DebugLogger } from '@affine/debug'; @@ -27,6 +30,7 @@ import { HelpIcon, NotionIcon, PageIcon, + SaveIcon, ZipIcon, } from '@blocksuite/icons/rc'; import { useService } from '@toeverything/infra'; @@ -36,6 +40,7 @@ import { type ReactElement, type SVGAttributes, useCallback, + useMemo, useState, } from 'react'; @@ -43,8 +48,14 @@ import * as style from './styles.css'; const logger = new DebugLogger('import'); -type ImportType = 'markdown' | 'markdownZip' | 'notion' | 'snapshot' | 'html'; -type AcceptType = 'Markdown' | 'Zip' | 'Html'; +type ImportType = + | 'markdown' + | 'markdownZip' + | 'notion' + | 'snapshot' + | 'html' + | 'dotaffinefile'; +type AcceptType = 'Markdown' | 'Zip' | 'Html' | 'Skip'; // Skip is used for dotaffinefile type Status = 'idle' | 'importing' | 'success' | 'error'; type ImportResult = { docIds: string[]; @@ -56,7 +67,8 @@ type ImportConfig = { fileOptions: { acceptType: AcceptType; multiple: boolean }; importFunction: ( docCollection: Workspace, - files: File[] + files: File[], + handleImportAffineFile: () => Promise ) => Promise; }; @@ -128,7 +140,22 @@ const importOptions = [ testId: 'editor-option-menu-import-snapshot', type: 'snapshot' as ImportType, }, -]; + BUILD_CONFIG.isElectron + ? { + key: 'dotaffinefile', + label: 'com.affine.import.dotaffinefile', + prefixIcon: ( + + ), + suffixIcon: ( + + ), + suffixTooltip: 'com.affine.import.dotaffinefile.tooltip', + testId: 'editor-option-menu-import-dotaffinefile', + type: 'dotaffinefile' as ImportType, + } + : null, +].filter(v => v !== null); const importConfigs: Record = { markdown: { @@ -234,6 +261,17 @@ const importConfigs: Record = { }; }, }, + dotaffinefile: { + fileOptions: { acceptType: 'Skip', multiple: false }, + importFunction: async (_, __, handleImportAffineFile) => { + await handleImportAffineFile(); + return { + docIds: [], + entryId: undefined, + isWorkspaceFile: true, + }; + }, + }, }; const ImportOptionItem = ({ @@ -404,28 +442,84 @@ export const ImportDialog = ({ const workspace = useService(WorkspaceService).workspace; const docCollection = workspace.docCollection; + const globalDialogService = useService(GlobalDialogService); + + const { jumpToPage } = useNavigateHelper(); + const handleCreatedWorkspace = useCallback( + (payload: { metadata: WorkspaceMetadata; defaultDocId?: string }) => { + if (document.startViewTransition) { + document.startViewTransition(() => { + if (payload.defaultDocId) { + jumpToPage(payload.metadata.id, payload.defaultDocId); + } else { + jumpToPage(payload.metadata.id, 'all'); + } + return new Promise(resolve => + setTimeout(resolve, 150) + ); /* start transition after 150ms */ + }); + } else { + if (payload.defaultDocId) { + jumpToPage(payload.metadata.id, payload.defaultDocId); + } else { + jumpToPage(payload.metadata.id, 'all'); + } + } + }, + [jumpToPage] + ); + + const handleImportAffineFile = useMemo(() => { + return async () => { + track.$.navigationPanel.workspaceList.createWorkspace({ + control: 'import', + }); + + return new Promise((resolve, reject) => { + globalDialogService.open('import-workspace', undefined, payload => { + if (payload) { + handleCreatedWorkspace({ metadata: payload.workspace }); + resolve(payload.workspace); + } else { + reject(new Error('No workspace imported')); + } + }); + }); + }; + }, [globalDialogService, handleCreatedWorkspace]); + const handleImport = useAsyncCallback( async (type: ImportType) => { setImportError(null); try { const importConfig = importConfigs[type]; const { acceptType, multiple } = importConfig.fileOptions; - const files = await openFilesWith(acceptType, multiple); - if (!files || files.length === 0) { + const files = + acceptType === 'Skip' + ? [] + : await openFilesWith(acceptType, multiple); + + if (!files || (files.length === 0 && acceptType !== 'Skip')) { throw new Error( t['com.affine.import.status.failed.message.no-file-selected']() ); } - setStatus('importing'); - track.$.importModal.$.import({ - type, - status: 'importing', - }); + if (acceptType !== 'Skip') { + setStatus('importing'); + track.$.importModal.$.import({ + type, + status: 'importing', + }); + } const { docIds, entryId, isWorkspaceFile } = - await importConfig.importFunction(docCollection, files); + await importConfig.importFunction( + docCollection, + files, + handleImportAffineFile + ); setImportResult({ docIds, entryId, isWorkspaceFile }); setStatus('success'); @@ -452,7 +546,7 @@ export const ImportDialog = ({ logger.error('Failed to import', error); } }, - [docCollection, t] + [docCollection, handleImportAffineFile, t] ); const handleComplete = useCallback(() => { diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index 71903f2df5..be37f8415b 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -2411,6 +2411,14 @@ export function useAFFiNEI18N(): { * `Import your AFFiNE workspace and page snapshot file.` */ ["com.affine.import.snapshot.tooltip"](): string; + /** + * `.affine file` + */ + ["com.affine.import.dotaffinefile"](): string; + /** + * `Import your AFFiNE db file (.affine)` + */ + ["com.affine.import.dotaffinefile.tooltip"](): string; /** * `Import failed, please try again.` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 8b558dfe37..c0c1ce833e 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -602,6 +602,8 @@ "com.affine.import.notion.tooltip": "Import your Notion data. Supported import formats: HTML with subpages.", "com.affine.import.snapshot": "Snapshot", "com.affine.import.snapshot.tooltip": "Import your AFFiNE workspace and page snapshot file.", + "com.affine.import.dotaffinefile": ".affine file", + "com.affine.import.dotaffinefile.tooltip": "Import your AFFiNE db file (.affine)", "com.affine.import.status.failed.message": "Import failed, please try again.", "com.affine.import.status.failed.message.no-file-selected": "No file selected", "com.affine.import.status.failed.title": "Import failure", From ce951ec316d3d625fd37f83f84e9124725fc5893 Mon Sep 17 00:00:00 2001 From: Peng Xiao Date: Thu, 19 Jun 2025 09:12:33 +0800 Subject: [PATCH 03/45] feat(editor): by default render code iframe for html preview (#12848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### PR Dependency Tree * **PR #12848** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) ## Summary by CodeRabbit - **New Features** - Introduced a feature flag to enable or disable web container functionality for code block previews. - **Improvements** - Code block HTML previews now support an alternative rendering method based on the new feature flag, enhancing flexibility. - **Chores** - Updated feature flag settings by removing an obsolete flag and adding the new web container flag. - **Tests** - Simplified code block preview tests for faster and more direct validation of HTML preview content. --- .../src/services/feature-flag-service.ts | 2 + .../code-block-preview/html-preview.ts | 40 ++++++++++++++----- .../code-block-preview/iframe-container.ts | 7 ++++ .../code-block-preview/index.ts | 18 --------- .../core/src/modules/feature-flag/constant.ts | 17 ++++---- .../e2e/blocksuite/code/crud.spec.ts | 13 ------ 6 files changed, 46 insertions(+), 51 deletions(-) create mode 100644 packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/iframe-container.ts diff --git a/blocksuite/affine/shared/src/services/feature-flag-service.ts b/blocksuite/affine/shared/src/services/feature-flag-service.ts index bdbf5c55b8..659388d1e3 100644 --- a/blocksuite/affine/shared/src/services/feature-flag-service.ts +++ b/blocksuite/affine/shared/src/services/feature-flag-service.ts @@ -21,6 +21,7 @@ export interface BlockSuiteFlags { enable_table_virtual_scroll: boolean; enable_turbo_renderer: boolean; enable_dom_renderer: boolean; + enable_web_container: boolean; } export class FeatureFlagService extends StoreExtension { @@ -46,6 +47,7 @@ export class FeatureFlagService extends StoreExtension { enable_table_virtual_scroll: false, enable_turbo_renderer: false, enable_dom_renderer: false, + enable_web_container: false, }); setFlag(key: keyof BlockSuiteFlags, value: boolean) { diff --git a/packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/html-preview.ts b/packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/html-preview.ts index 6dd2b27b96..223a44c83d 100644 --- a/packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/html-preview.ts +++ b/packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/html-preview.ts @@ -2,12 +2,14 @@ import track from '@affine/track'; import { CodeBlockPreviewExtension } from '@blocksuite/affine/blocks/code'; import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit'; import type { CodeBlockModel } from '@blocksuite/affine/model'; +import { FeatureFlagService } from '@blocksuite/affine/shared/services'; import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme'; import { css, html, LitElement, type PropertyValues } from 'lit'; import { property, query, state } from 'lit/decorators.js'; import { choose } from 'lit/directives/choose.js'; import { styleMap } from 'lit/directives/style-map.js'; +import { linkIframe } from './iframe-container'; import { linkWebContainer } from './web-container'; export const CodeBlockHtmlPreview = CodeBlockPreviewExtension( @@ -83,20 +85,36 @@ export class HTMLPreview extends SignalWatcher(WithDisposable(LitElement)) { private _link() { this.state = 'loading'; - linkWebContainer(this.iframe, this.model) - .then(() => { - this.state = 'finish'; - }) - .catch(error => { - const errorMessage = `Failed to link WebContainer: ${error}`; - console.error(errorMessage); - track.doc.editor.codeBlock.htmlBlockPreviewFailed({ - type: errorMessage, + const featureFlagService = this.model.store.get(FeatureFlagService); + const isWebContainerEnabled = featureFlagService.getFlag( + 'enable_web_container' + ); + + if (isWebContainerEnabled) { + linkWebContainer(this.iframe, this.model) + .then(() => { + this.state = 'finish'; + }) + .catch(error => { + const errorMessage = `Failed to link WebContainer: ${error}`; + + console.error(errorMessage); + track.doc.editor.codeBlock.htmlBlockPreviewFailed({ + type: errorMessage, + }); + + this.state = 'error'; }); - + } else { + try { + linkIframe(this.iframe, this.model); + this.state = 'finish'; + } catch (error) { + console.error('HTML preview iframe failed:', error); this.state = 'error'; - }); + } + } } override render() { diff --git a/packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/iframe-container.ts b/packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/iframe-container.ts new file mode 100644 index 0000000000..c0043d5c41 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/iframe-container.ts @@ -0,0 +1,7 @@ +import type { CodeBlockModel } from '@blocksuite/affine/model'; + +export function linkIframe(iframe: HTMLIFrameElement, model: CodeBlockModel) { + const html = model.props.text.toString(); + iframe.srcdoc = html; + iframe.sandbox.add('allow-scripts'); +} diff --git a/packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/index.ts b/packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/index.ts index ac2959df29..13f2e6d502 100644 --- a/packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/index.ts +++ b/packages/frontend/core/src/blocksuite/view-extensions/code-block-preview/index.ts @@ -1,5 +1,3 @@ -import { FeatureFlagService } from '@affine/core/modules/feature-flag'; -import track from '@affine/track'; import { type ViewExtensionContext, ViewExtensionProvider, @@ -32,22 +30,6 @@ export class CodeBlockPreviewViewExtension extends ViewExtensionProvider { options?: z.infer ) { super.setup(context, options); - - const framework = options?.framework; - if (!framework) return; - const flag = - framework.get(FeatureFlagService).flags.enable_code_block_html_preview.$ - .value; - if (!flag) return; - - if (!window.crossOriginIsolated) { - track.doc.editor.codeBlock.htmlBlockPreviewFailed({ - type: 'cross-origin-isolated not supported', - }); - - return; - } - context.register(CodeBlockHtmlPreview); } } diff --git a/packages/frontend/core/src/modules/feature-flag/constant.ts b/packages/frontend/core/src/modules/feature-flag/constant.ts index cb557c2dc3..50e2809e2a 100644 --- a/packages/frontend/core/src/modules/feature-flag/constant.ts +++ b/packages/frontend/core/src/modules/feature-flag/constant.ts @@ -273,15 +273,6 @@ export const AFFINE_FLAGS = { configurable: isBetaBuild || isCanaryBuild, defaultState: false, }, - enable_code_block_html_preview: { - category: 'affine', - displayName: - 'com.affine.settings.workspace.experimental-features.enable-code-block-html-preview.name', - description: - 'com.affine.settings.workspace.experimental-features.enable-code-block-html-preview.description', - configurable: isCanaryBuild, - defaultState: isCanaryBuild, - }, enable_adapter_panel: { category: 'affine', displayName: @@ -291,6 +282,14 @@ export const AFFINE_FLAGS = { configurable: isCanaryBuild, defaultState: false, }, + enable_web_container: { + category: 'blocksuite', + bsFlag: 'enable_web_container', + displayName: 'Enable Web Container', + description: 'Enable web container for code block preview', + defaultState: false, + configurable: true, + }, } satisfies { [key in string]: FlagInfo }; // oxlint-disable-next-line no-redeclare diff --git a/tests/affine-local/e2e/blocksuite/code/crud.spec.ts b/tests/affine-local/e2e/blocksuite/code/crud.spec.ts index 7590cfb44b..c85210c090 100644 --- a/tests/affine-local/e2e/blocksuite/code/crud.spec.ts +++ b/tests/affine-local/e2e/blocksuite/code/crud.spec.ts @@ -22,16 +22,12 @@ test.describe('Code Block Autocomplete Operations', () => { test.describe('Code Block Preview', () => { test('enable html preview', async ({ page }) => { const code = page.locator('affine-code'); - const htmlPreview = page.locator('html-preview'); await openHomePage(page); await createNewPage(page); await waitForEditorLoad(page); await gotoContentFromTitle(page); await type(page, '```html aaa'); - await page.waitForTimeout(3000); - // web container can not load as expected at the first time in playwright, not sure why - await page.reload(); await code.hover({ position: { x: 155, @@ -39,15 +35,6 @@ test.describe('Code Block Preview', () => { }, }); await page.getByText('Preview').click(); - - await expect( - page - .locator('iframe[title="HTML Preview"]') - .contentFrame() - .getByText('aaa') - ).toBeHidden(); - await expect(htmlPreview).toHaveText('Rendering the code...'); - await page.waitForTimeout(20000); await expect( page .locator('iframe[title="HTML Preview"]') From 6169cdab3a48c37d52553835b1c52cb935a77873 Mon Sep 17 00:00:00 2001 From: Wu Yue Date: Thu, 19 Jun 2025 09:13:18 +0800 Subject: [PATCH 04/45] feat(core): add stream object api (#12841) Close [AI-193](https://linear.app/affine-design/issue/AI-193) ## Summary by CodeRabbit - **New Features** - Added support for streaming structured AI chat responses as objects, enabling richer and more interactive chat experiences. - Chat messages now include a new field displaying structured stream objects, such as reasoning steps, text deltas, tool calls, and tool results. - GraphQL APIs and queries updated to expose these structured streaming objects in chat histories. - Introduced a new streaming chat endpoint for object-based responses. - **Bug Fixes** - Improved error handling for streaming responses to ensure more robust and informative error reporting. - **Refactor** - Centralized and streamlined session preparation and streaming logic for AI chat providers. - Unified streaming setup across multiple AI model providers. - **Tests** - Extended test coverage for streaming object responses to ensure reliability and correctness. - **Documentation** - Updated type definitions and schemas to reflect new streaming object capabilities in both backend and frontend code. Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com> --- .../migration.sql | 2 + packages/backend/server/schema.prisma | 17 +- .../src/__tests__/copilot-provider.spec.ts | 48 +++++ .../server/src/__tests__/copilot.e2e.ts | 23 +++ .../src/__tests__/mocks/copilot.mock.ts | 43 +++- .../server/src/__tests__/utils/copilot.ts | 8 + .../server/src/plugins/copilot/controller.ts | 189 +++++++++++++----- .../copilot/providers/anthropic/anthropic.ts | 78 ++++++-- .../copilot/providers/anthropic/official.ts | 8 +- .../copilot/providers/anthropic/vertex.ts | 8 +- .../copilot/providers/gemini/gemini.ts | 78 ++++++-- .../copilot/providers/gemini/generative.ts | 18 +- .../copilot/providers/gemini/vertex.ts | 12 +- .../src/plugins/copilot/providers/openai.ts | 126 +++++++++--- .../src/plugins/copilot/providers/provider.ts | 12 ++ .../src/plugins/copilot/providers/types.ts | 27 +++ .../src/plugins/copilot/providers/utils.ts | 73 ++++++- .../server/src/plugins/copilot/resolver.ts | 26 ++- .../server/src/plugins/copilot/session.ts | 2 + packages/backend/server/src/schema.gql | 10 + .../src/graphql/copilot-history-list.gql | 8 + packages/common/graphql/src/graphql/index.ts | 8 + packages/common/graphql/src/schema.ts | 20 ++ .../ai/components/ai-chat-messages/type.ts | 27 +++ 24 files changed, 722 insertions(+), 149 deletions(-) create mode 100644 packages/backend/server/migrations/20250617004240_ai_stream_objects_message/migration.sql diff --git a/packages/backend/server/migrations/20250617004240_ai_stream_objects_message/migration.sql b/packages/backend/server/migrations/20250617004240_ai_stream_objects_message/migration.sql new file mode 100644 index 0000000000..47ffd99bde --- /dev/null +++ b/packages/backend/server/migrations/20250617004240_ai_stream_objects_message/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ai_sessions_messages" ADD COLUMN "streamObjects" JSON; diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index c3a125ad8a..c21e6071bf 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -414,14 +414,15 @@ model AiPrompt { } model AiSessionMessage { - id String @id @default(uuid()) @db.VarChar - sessionId String @map("session_id") @db.VarChar - role AiPromptRole - content String @db.Text - attachments Json? @db.Json - params Json? @db.Json - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) - updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + id String @id @default(uuid()) @db.VarChar + sessionId String @map("session_id") @db.VarChar + role AiPromptRole + content String @db.Text + streamObjects Json? @db.Json + attachments Json? @db.Json + params Json? @db.Json + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) session AiSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) diff --git a/packages/backend/server/src/__tests__/copilot-provider.spec.ts b/packages/backend/server/src/__tests__/copilot-provider.spec.ts index d0cab8fe66..42bd10fd46 100644 --- a/packages/backend/server/src/__tests__/copilot-provider.spec.ts +++ b/packages/backend/server/src/__tests__/copilot-provider.spec.ts @@ -1,5 +1,6 @@ import type { ExecutionContext, TestFn } from 'ava'; import ava from 'ava'; +import { z } from 'zod'; import { ServerFeature, ServerService } from '../core'; import { AuthService } from '../core/auth'; @@ -9,6 +10,8 @@ import { prompts, PromptService } from '../plugins/copilot/prompt'; import { CopilotProviderFactory, CopilotProviderType, + StreamObject, + StreamObjectSchema, } from '../plugins/copilot/providers'; import { TranscriptionResponseSchema } from '../plugins/copilot/transcript/types'; import { @@ -183,6 +186,16 @@ const checkUrl = (url: string) => { } }; +const checkStreamObjects = (result: string) => { + try { + const streamObjects = JSON.parse(result); + z.array(StreamObjectSchema).parse(streamObjects); + return true; + } catch { + return false; + } +}; + const retry = async ( action: string, t: ExecutionContext, @@ -387,6 +400,20 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca }, type: 'text' as const, }, + { + name: 'stream objects', + promptName: ['Chat With AFFiNE AI'], + messages: [ + { + role: 'user' as const, + content: 'what is AFFiNE AI', + }, + ], + verifier: (t: ExecutionContext, result: string) => { + t.truthy(checkStreamObjects(result), 'should be valid stream objects'); + }, + type: 'object' as const, + }, { name: 'Should transcribe short audio', promptName: ['Transcript audio'], @@ -680,6 +707,27 @@ for (const { verifier?.(t, result); break; } + case 'object': { + const streamObjects: StreamObject[] = []; + for await (const chunk of provider.streamObject( + { modelId: prompt.model }, + [ + ...prompt.finish( + messages.reduce( + (acc, m) => Object.assign(acc, (m as any).params || {}), + {} + ) + ), + ...messages, + ], + finalConfig + )) { + streamObjects.push(chunk); + } + t.truthy(streamObjects, 'should return result'); + verifier?.(t, JSON.stringify(streamObjects)); + break; + } case 'image': { const finalMessage = [...messages]; const params = {}; diff --git a/packages/backend/server/src/__tests__/copilot.e2e.ts b/packages/backend/server/src/__tests__/copilot.e2e.ts index bd65c5b0fe..f0f95f9013 100644 --- a/packages/backend/server/src/__tests__/copilot.e2e.ts +++ b/packages/backend/server/src/__tests__/copilot.e2e.ts @@ -39,6 +39,7 @@ import { array2sse, audioTranscription, chatWithImages, + chatWithStreamObject, chatWithText, chatWithTextStream, chatWithWorkflow, @@ -512,6 +513,28 @@ test('should be able to chat with api', async t => { ); } + { + const sessionId = await createCopilotSession( + app, + id, + randomUUID(), + textPromptName + ); + const messageId = await createCopilotMessage(app, sessionId); + + const ret4 = await chatWithStreamObject(app, sessionId, messageId); + + const objects = Array.from('generate text to object stream').map(data => + JSON.stringify({ type: 'text-delta', textDelta: data }) + ); + + t.is( + ret4, + textToEventStream(objects, messageId), + 'should be able to chat with stream object' + ); + } + Sinon.restore(); }); diff --git a/packages/backend/server/src/__tests__/mocks/copilot.mock.ts b/packages/backend/server/src/__tests__/mocks/copilot.mock.ts index c736f85bd0..83d2746caa 100644 --- a/packages/backend/server/src/__tests__/mocks/copilot.mock.ts +++ b/packages/backend/server/src/__tests__/mocks/copilot.mock.ts @@ -9,6 +9,7 @@ import { ModelInputType, ModelOutputType, PromptMessage, + StreamObject, } from '../../plugins/copilot/providers'; import { DEFAULT_DIMENSIONS, @@ -23,7 +24,7 @@ export class MockCopilotProvider extends OpenAIProvider { capabilities: [ { input: [ModelInputType.Text], - output: [ModelOutputType.Text], + output: [ModelOutputType.Text, ModelOutputType.Object], defaultForOutputType: true, }, ], @@ -43,7 +44,7 @@ export class MockCopilotProvider extends OpenAIProvider { capabilities: [ { input: [ModelInputType.Text, ModelInputType.Image], - output: [ModelOutputType.Text], + output: [ModelOutputType.Text, ModelOutputType.Object], }, ], }, @@ -52,7 +53,7 @@ export class MockCopilotProvider extends OpenAIProvider { capabilities: [ { input: [ModelInputType.Text, ModelInputType.Image], - output: [ModelOutputType.Text], + output: [ModelOutputType.Text, ModelOutputType.Object], }, ], }, @@ -61,7 +62,7 @@ export class MockCopilotProvider extends OpenAIProvider { capabilities: [ { input: [ModelInputType.Text, ModelInputType.Image], - output: [ModelOutputType.Text], + output: [ModelOutputType.Text, ModelOutputType.Object], }, ], }, @@ -70,7 +71,7 @@ export class MockCopilotProvider extends OpenAIProvider { capabilities: [ { input: [ModelInputType.Text, ModelInputType.Image], - output: [ModelOutputType.Text], + output: [ModelOutputType.Text, ModelOutputType.Object], }, ], }, @@ -79,7 +80,11 @@ export class MockCopilotProvider extends OpenAIProvider { capabilities: [ { input: [ModelInputType.Text, ModelInputType.Image], - output: [ModelOutputType.Text, ModelOutputType.Structured], + output: [ + ModelOutputType.Text, + ModelOutputType.Object, + ModelOutputType.Structured, + ], }, ], }, @@ -98,7 +103,11 @@ export class MockCopilotProvider extends OpenAIProvider { capabilities: [ { input: [ModelInputType.Text, ModelInputType.Image], - output: [ModelOutputType.Text, ModelOutputType.Structured], + output: [ + ModelOutputType.Text, + ModelOutputType.Object, + ModelOutputType.Structured, + ], }, ], }, @@ -195,4 +204,24 @@ export class MockCopilotProvider extends OpenAIProvider { await sleep(100); return [Array.from(randomBytes(options.dimensions)).map(v => v % 128)]; } + + override async *streamObject( + cond: ModelConditions, + messages: PromptMessage[], + options: CopilotChatOptions = {} + ): AsyncIterable { + const fullCond = { ...cond, outputType: ModelOutputType.Object }; + await this.checkParams({ messages, cond: fullCond, options }); + + // make some time gap for history test case + await sleep(100); + + const result = 'generate text to object stream'; + for (const data of result) { + yield { type: 'text-delta', textDelta: data } as const; + if (options.signal?.aborted) { + break; + } + } + } } diff --git a/packages/backend/server/src/__tests__/utils/copilot.ts b/packages/backend/server/src/__tests__/utils/copilot.ts index 8ae7bb6a67..a907ea671c 100644 --- a/packages/backend/server/src/__tests__/utils/copilot.ts +++ b/packages/backend/server/src/__tests__/utils/copilot.ts @@ -582,6 +582,14 @@ export async function chatWithImages( return chatWithText(app, sessionId, messageId, '/images'); } +export async function chatWithStreamObject( + app: TestingApp, + sessionId: string, + messageId?: string +) { + return chatWithText(app, sessionId, messageId, '/stream-object'); +} + export async function unsplashSearch( app: TestingApp, params: Record = {} diff --git a/packages/backend/server/src/plugins/copilot/controller.ts b/packages/backend/server/src/plugins/copilot/controller.ts index a4e2d0e4fa..1be3c68760 100644 --- a/packages/backend/server/src/plugins/copilot/controller.ts +++ b/packages/backend/server/src/plugins/copilot/controller.ts @@ -51,6 +51,7 @@ import { ModelInputType, ModelOutputType, } from './providers'; +import { StreamObjectParser } from './providers/utils'; import { ChatSession, ChatSessionService } from './session'; import { CopilotStorage } from './storage'; import { ChatMessage, ChatQuerySchema } from './types'; @@ -189,6 +190,45 @@ export class CopilotController implements BeforeApplicationShutdown { return merge(source$.pipe(finalize(() => subject$.next(null))), ping$); } + private async prepareChatSession( + user: CurrentUser, + sessionId: string, + query: Record, + outputType: ModelOutputType + ) { + let { messageId, retry, modelId, params } = ChatQuerySchema.parse(query); + + const { provider, model } = await this.chooseProvider( + outputType, + user.id, + sessionId, + messageId, + modelId + ); + + const [latestMessage, session] = await this.appendSessionMessage( + sessionId, + messageId, + retry + ); + + if (latestMessage) { + params = Object.assign({}, params, latestMessage.params, { + content: latestMessage.content, + attachments: latestMessage.attachments, + }); + } + + const finalMessage = session.finish(params); + + return { + provider, + model, + session, + finalMessage, + }; + } + @Get('/chat/:sessionId') @CallMetric('ai', 'chat', { timer: true }) async chat( @@ -200,36 +240,19 @@ export class CopilotController implements BeforeApplicationShutdown { const info: any = { sessionId, params: query }; try { - let { messageId, retry, reasoning, webSearch, modelId, params } = - ChatQuerySchema.parse(query); - - const { provider, model } = await this.chooseProvider( - ModelOutputType.Text, - user.id, - sessionId, - messageId, - modelId - ); - - const [latestMessage, session] = await this.appendSessionMessage( - sessionId, - messageId, - retry - ); + const { provider, model, session, finalMessage } = + await this.prepareChatSession( + user, + sessionId, + query, + ModelOutputType.Text + ); info.model = model; + info.finalMessage = finalMessage.filter(m => m.role !== 'system'); metrics.ai.counter('chat_calls').add(1, { model }); - if (latestMessage) { - params = Object.assign({}, params, latestMessage.params, { - content: latestMessage.content, - attachments: latestMessage.attachments, - }); - } - - const finalMessage = session.finish(params); - info.finalMessage = finalMessage.filter(m => m.role !== 'system'); - + const { reasoning, webSearch } = ChatQuerySchema.parse(query); const content = await provider.text({ modelId: model }, finalMessage, { ...session.config.promptConfig, signal: this.getSignal(req), @@ -269,37 +292,20 @@ export class CopilotController implements BeforeApplicationShutdown { const info: any = { sessionId, params: query, throwInStream: false }; try { - let { messageId, retry, reasoning, webSearch, modelId, params } = - ChatQuerySchema.parse(query); - - const { provider, model } = await this.chooseProvider( - ModelOutputType.Text, - user.id, - sessionId, - messageId, - modelId - ); - - const [latestMessage, session] = await this.appendSessionMessage( - sessionId, - messageId, - retry - ); + const { provider, model, session, finalMessage } = + await this.prepareChatSession( + user, + sessionId, + query, + ModelOutputType.Text + ); info.model = model; - metrics.ai.counter('chat_stream_calls').add(1, { model }); - - if (latestMessage) { - params = Object.assign({}, params, latestMessage.params, { - content: latestMessage.content, - attachments: latestMessage.attachments, - }); - } - - this.ongoingStreamCount$.next(this.ongoingStreamCount$.value + 1); - const finalMessage = session.finish(params); info.finalMessage = finalMessage.filter(m => m.role !== 'system'); + metrics.ai.counter('chat_stream_calls').add(1, { model }); + this.ongoingStreamCount$.next(this.ongoingStreamCount$.value + 1); + const { messageId, reasoning, webSearch } = ChatQuerySchema.parse(query); const source$ = from( provider.streamText({ modelId: model }, finalMessage, { ...session.config.promptConfig, @@ -348,6 +354,83 @@ export class CopilotController implements BeforeApplicationShutdown { } } + @Sse('/chat/:sessionId/stream-object') + @CallMetric('ai', 'chat_object_stream', { timer: true }) + async chatStreamObject( + @CurrentUser() user: CurrentUser, + @Req() req: Request, + @Param('sessionId') sessionId: string, + @Query() query: Record + ): Promise> { + const info: any = { sessionId, params: query, throwInStream: false }; + + try { + const { provider, model, session, finalMessage } = + await this.prepareChatSession( + user, + sessionId, + query, + ModelOutputType.Object + ); + + info.model = model; + info.finalMessage = finalMessage.filter(m => m.role !== 'system'); + metrics.ai.counter('chat_object_stream_calls').add(1, { model }); + this.ongoingStreamCount$.next(this.ongoingStreamCount$.value + 1); + + const { messageId, reasoning, webSearch } = ChatQuerySchema.parse(query); + const source$ = from( + provider.streamObject({ modelId: model }, finalMessage, { + ...session.config.promptConfig, + signal: this.getSignal(req), + user: user.id, + workspace: session.config.workspaceId, + reasoning, + webSearch, + }) + ).pipe( + connect(shared$ => + merge( + // actual chat event stream + shared$.pipe( + map(data => ({ type: 'message' as const, id: messageId, data })) + ), + // save the generated text to the session + shared$.pipe( + toArray(), + concatMap(values => { + const parser = new StreamObjectParser(); + const streamObjects = parser.mergeTextDelta(values); + const content = parser.mergeContent(streamObjects); + session.push({ + role: 'assistant', + content, + streamObjects, + createdAt: new Date(), + }); + return from(session.save()); + }), + mergeMap(() => EMPTY) + ) + ) + ), + catchError(e => { + metrics.ai.counter('chat_object_stream_errors').add(1); + info.throwInStream = true; + return mapSseError(e, info); + }), + finalize(() => { + this.ongoingStreamCount$.next(this.ongoingStreamCount$.value - 1); + }) + ); + + return this.mergePingStream(messageId || '', source$); + } catch (err) { + metrics.ai.counter('chat_object_stream_errors').add(1, info); + return mapSseError(err, info); + } + } + @Sse('/chat/:sessionId/workflow') @CallMetric('ai', 'chat_workflow', { timer: true }) async chatWorkflow( diff --git a/packages/backend/server/src/plugins/copilot/providers/anthropic/anthropic.ts b/packages/backend/server/src/plugins/copilot/providers/anthropic/anthropic.ts index 25fc1f863b..0832b40203 100644 --- a/packages/backend/server/src/plugins/copilot/providers/anthropic/anthropic.ts +++ b/packages/backend/server/src/plugins/copilot/providers/anthropic/anthropic.ts @@ -13,11 +13,17 @@ import { import { CopilotProvider } from '../provider'; import type { CopilotChatOptions, + CopilotProviderModel, ModelConditions, PromptMessage, + StreamObject, } from '../types'; import { ModelOutputType } from '../types'; -import { chatToGPTMessage, TextStreamParser } from '../utils'; +import { + chatToGPTMessage, + StreamObjectParser, + TextStreamParser, +} from '../utils'; export abstract class AnthropicProvider extends CopilotProvider { private readonly MAX_STEPS = 20; @@ -92,21 +98,7 @@ export abstract class AnthropicProvider extends CopilotProvider { try { metrics.ai.counter('chat_text_stream_calls').add(1, { model: model.id }); - const [system, msgs] = await chatToGPTMessage(messages, true, true); - - const { fullStream } = streamText({ - model: this.instance(model.id), - system, - messages: msgs, - abortSignal: options.signal, - providerOptions: { - anthropic: this.getAnthropicOptions(options, model.id), - }, - tools: await this.getTools(options, model.id), - maxSteps: this.MAX_STEPS, - experimental_continueSteps: true, - }); - + const fullStream = await this.getFullStream(model, messages, options); const parser = new TextStreamParser(); for await (const chunk of fullStream) { const result = parser.parse(chunk); @@ -122,6 +114,60 @@ export abstract class AnthropicProvider extends CopilotProvider { } } + override async *streamObject( + cond: ModelConditions, + messages: PromptMessage[], + options: CopilotChatOptions = {} + ): AsyncIterable { + const fullCond = { ...cond, outputType: ModelOutputType.Object }; + await this.checkParams({ cond: fullCond, messages, options }); + const model = this.selectModel(fullCond); + + try { + metrics.ai + .counter('chat_object_stream_calls') + .add(1, { model: model.id }); + const fullStream = await this.getFullStream(model, messages, options); + const parser = new StreamObjectParser(); + for await (const chunk of fullStream) { + const result = parser.parse(chunk); + if (result) { + yield result; + } + if (options.signal?.aborted) { + await fullStream.cancel(); + break; + } + } + } catch (e: any) { + metrics.ai + .counter('chat_object_stream_errors') + .add(1, { model: model.id }); + throw this.handleError(e); + } + } + + private async getFullStream( + model: CopilotProviderModel, + messages: PromptMessage[], + options: CopilotChatOptions = {} + ) { + const [system, msgs] = await chatToGPTMessage(messages, true, true); + const { fullStream } = streamText({ + model: this.instance(model.id), + system, + messages: msgs, + abortSignal: options.signal, + providerOptions: { + anthropic: this.getAnthropicOptions(options, model.id), + }, + tools: await this.getTools(options, model.id), + maxSteps: this.MAX_STEPS, + experimental_continueSteps: true, + }); + return fullStream; + } + private getAnthropicOptions(options: CopilotChatOptions, model: string) { const result: AnthropicProviderOptions = {}; if (options?.reasoning && this.isReasoningModel(model)) { diff --git a/packages/backend/server/src/plugins/copilot/providers/anthropic/official.ts b/packages/backend/server/src/plugins/copilot/providers/anthropic/official.ts index 317f9235a4..7c348d6918 100644 --- a/packages/backend/server/src/plugins/copilot/providers/anthropic/official.ts +++ b/packages/backend/server/src/plugins/copilot/providers/anthropic/official.ts @@ -20,7 +20,7 @@ export class AnthropicOfficialProvider extends AnthropicProvider extends CopilotProvider { try { metrics.ai.counter('chat_text_stream_calls').add(1, { model: model.id }); - const [system, msgs] = await chatToGPTMessage(messages); - - const { fullStream } = streamText({ - model: this.instance(model.id, { - useSearchGrounding: this.useSearchGrounding(options), - }), - system, - messages: msgs, - abortSignal: options.signal, - maxSteps: this.MAX_STEPS, - providerOptions: { - google: this.getGeminiOptions(options, model.id), - }, - }); - + const fullStream = await this.getFullStream(model, messages, options); const parser = new TextStreamParser(); for await (const chunk of fullStream) { const result = parser.parse(chunk); @@ -180,6 +172,60 @@ export abstract class GeminiProvider extends CopilotProvider { } } + override async *streamObject( + cond: ModelConditions, + messages: PromptMessage[], + options: CopilotChatOptions = {} + ): AsyncIterable { + const fullCond = { ...cond, outputType: ModelOutputType.Object }; + await this.checkParams({ cond: fullCond, messages, options }); + const model = this.selectModel(fullCond); + + try { + metrics.ai + .counter('chat_object_stream_calls') + .add(1, { model: model.id }); + const fullStream = await this.getFullStream(model, messages, options); + const parser = new StreamObjectParser(); + for await (const chunk of fullStream) { + const result = parser.parse(chunk); + if (result) { + yield result; + } + if (options.signal?.aborted) { + await fullStream.cancel(); + break; + } + } + } catch (e: any) { + metrics.ai + .counter('chat_object_stream_errors') + .add(1, { model: model.id }); + throw this.handleError(e); + } + } + + private async getFullStream( + model: CopilotProviderModel, + messages: PromptMessage[], + options: CopilotChatOptions = {} + ) { + const [system, msgs] = await chatToGPTMessage(messages); + const { fullStream } = streamText({ + model: this.instance(model.id, { + useSearchGrounding: this.useSearchGrounding(options), + }), + system, + messages: msgs, + abortSignal: options.signal, + maxSteps: this.MAX_STEPS, + providerOptions: { + google: this.getGeminiOptions(options, model.id), + }, + }); + return fullStream; + } + private getGeminiOptions(options: CopilotChatOptions, model: string) { const result: GoogleGenerativeAIProviderOptions = {}; if (options?.reasoning && this.isReasoningModel(model)) { diff --git a/packages/backend/server/src/plugins/copilot/providers/gemini/generative.ts b/packages/backend/server/src/plugins/copilot/providers/gemini/generative.ts index a053365133..3e4f0d7528 100644 --- a/packages/backend/server/src/plugins/copilot/providers/gemini/generative.ts +++ b/packages/backend/server/src/plugins/copilot/providers/gemini/generative.ts @@ -25,7 +25,11 @@ export class GeminiGenerativeProvider extends GeminiProvider { ModelInputType.Image, ModelInputType.Audio, ], - output: [ModelOutputType.Text, ModelOutputType.Structured], + output: [ + ModelOutputType.Text, + ModelOutputType.Object, + ModelOutputType.Structured, + ], }, ], }, @@ -37,7 +41,11 @@ export class GeminiVertexProvider extends GeminiProvider { ModelInputType.Image, ModelInputType.Audio, ], - output: [ModelOutputType.Text, ModelOutputType.Structured], + output: [ + ModelOutputType.Text, + ModelOutputType.Object, + ModelOutputType.Structured, + ], }, ], }, diff --git a/packages/backend/server/src/plugins/copilot/providers/openai.ts b/packages/backend/server/src/plugins/copilot/providers/openai.ts index a1495663da..a3eaece9ef 100644 --- a/packages/backend/server/src/plugins/copilot/providers/openai.ts +++ b/packages/backend/server/src/plugins/copilot/providers/openai.ts @@ -27,12 +27,19 @@ import type { CopilotChatTools, CopilotEmbeddingOptions, CopilotImageOptions, + CopilotProviderModel, CopilotStructuredOptions, ModelConditions, PromptMessage, + StreamObject, } from './types'; import { CopilotProviderType, ModelInputType, ModelOutputType } from './types'; -import { chatToGPTMessage, CitationParser, TextStreamParser } from './utils'; +import { + chatToGPTMessage, + CitationParser, + StreamObjectParser, + TextStreamParser, +} from './utils'; export const DEFAULT_DIMENSIONS = 256; @@ -65,7 +72,7 @@ export class OpenAIProvider extends CopilotProvider { capabilities: [ { input: [ModelInputType.Text, ModelInputType.Image], - output: [ModelOutputType.Text], + output: [ModelOutputType.Text, ModelOutputType.Object], }, ], }, @@ -75,7 +82,7 @@ export class OpenAIProvider extends CopilotProvider { capabilities: [ { input: [ModelInputType.Text, ModelInputType.Image], - output: [ModelOutputType.Text], + output: [ModelOutputType.Text, ModelOutputType.Object], }, ], }, @@ -84,7 +91,7 @@ export class OpenAIProvider extends CopilotProvider { capabilities: [ { input: [ModelInputType.Text, ModelInputType.Image], - output: [ModelOutputType.Text], + output: [ModelOutputType.Text, ModelOutputType.Object], }, ], }, @@ -94,7 +101,7 @@ export class OpenAIProvider extends CopilotProvider { capabilities: [ { input: [ModelInputType.Text, ModelInputType.Image], - output: [ModelOutputType.Text], + output: [ModelOutputType.Text, ModelOutputType.Object], }, ], }, @@ -103,7 +110,11 @@ export class OpenAIProvider extends CopilotProvider { capabilities: [ { input: [ModelInputType.Text, ModelInputType.Image], - output: [ModelOutputType.Text, ModelOutputType.Structured], + output: [ + ModelOutputType.Text, + ModelOutputType.Object, + ModelOutputType.Structured, + ], defaultForOutputType: true, }, ], @@ -113,7 +124,11 @@ export class OpenAIProvider extends CopilotProvider { capabilities: [ { input: [ModelInputType.Text, ModelInputType.Image], - output: [ModelOutputType.Text, ModelOutputType.Structured], + output: [ + ModelOutputType.Text, + ModelOutputType.Object, + ModelOutputType.Structured, + ], }, ], }, @@ -122,7 +137,11 @@ export class OpenAIProvider extends CopilotProvider { capabilities: [ { input: [ModelInputType.Text, ModelInputType.Image], - output: [ModelOutputType.Text, ModelOutputType.Structured], + output: [ + ModelOutputType.Text, + ModelOutputType.Object, + ModelOutputType.Structured, + ], }, ], }, @@ -131,7 +150,11 @@ export class OpenAIProvider extends CopilotProvider { capabilities: [ { input: [ModelInputType.Text, ModelInputType.Image], - output: [ModelOutputType.Text, ModelOutputType.Structured], + output: [ + ModelOutputType.Text, + ModelOutputType.Object, + ModelOutputType.Structured, + ], }, ], }, @@ -140,7 +163,7 @@ export class OpenAIProvider extends CopilotProvider { capabilities: [ { input: [ModelInputType.Text, ModelInputType.Image], - output: [ModelOutputType.Text], + output: [ModelOutputType.Text, ModelOutputType.Object], }, ], }, @@ -149,7 +172,7 @@ export class OpenAIProvider extends CopilotProvider { capabilities: [ { input: [ModelInputType.Text, ModelInputType.Image], - output: [ModelOutputType.Text], + output: [ModelOutputType.Text, ModelOutputType.Object], }, ], }, @@ -158,7 +181,7 @@ export class OpenAIProvider extends CopilotProvider { capabilities: [ { input: [ModelInputType.Text, ModelInputType.Image], - output: [ModelOutputType.Text], + output: [ModelOutputType.Text, ModelOutputType.Object], }, ], }, @@ -312,26 +335,7 @@ export class OpenAIProvider extends CopilotProvider { try { metrics.ai.counter('chat_text_stream_calls').add(1, { model: model.id }); - const [system, msgs] = await chatToGPTMessage(messages); - - const modelInstance = this.#instance.responses(model.id); - - const { fullStream } = streamText({ - model: modelInstance, - system, - messages: msgs, - frequencyPenalty: options.frequencyPenalty ?? 0, - presencePenalty: options.presencePenalty ?? 0, - temperature: options.temperature ?? 0, - maxTokens: options.maxTokens ?? 4096, - providerOptions: { - openai: this.getOpenAIOptions(options, model.id), - }, - tools: await this.getTools(options, model.id), - maxSteps: this.MAX_STEPS, - abortSignal: options.signal, - }); - + const fullStream = await this.getFullStream(model, messages, options); const citationParser = new CitationParser(); const textParser = new TextStreamParser(); for await (const chunk of fullStream) { @@ -363,6 +367,39 @@ export class OpenAIProvider extends CopilotProvider { } } + override async *streamObject( + cond: ModelConditions, + messages: PromptMessage[], + options: CopilotChatOptions = {} + ): AsyncIterable { + const fullCond = { ...cond, outputType: ModelOutputType.Object }; + await this.checkParams({ cond: fullCond, messages, options }); + const model = this.selectModel(fullCond); + + try { + metrics.ai + .counter('chat_object_stream_calls') + .add(1, { model: model.id }); + const fullStream = await this.getFullStream(model, messages, options); + const parser = new StreamObjectParser(); + for await (const chunk of fullStream) { + const result = parser.parse(chunk); + if (result) { + yield result; + } + if (options.signal?.aborted) { + await fullStream.cancel(); + break; + } + } + } catch (e: any) { + metrics.ai + .counter('chat_object_stream_errors') + .add(1, { model: model.id }); + throw this.handleError(e, model.id, options); + } + } + override async structure( cond: ModelConditions, messages: PromptMessage[], @@ -403,6 +440,31 @@ export class OpenAIProvider extends CopilotProvider { } } + private async getFullStream( + model: CopilotProviderModel, + messages: PromptMessage[], + options: CopilotChatOptions = {} + ) { + const [system, msgs] = await chatToGPTMessage(messages); + const modelInstance = this.#instance.responses(model.id); + const { fullStream } = streamText({ + model: modelInstance, + system, + messages: msgs, + frequencyPenalty: options.frequencyPenalty ?? 0, + presencePenalty: options.presencePenalty ?? 0, + temperature: options.temperature ?? 0, + maxTokens: options.maxTokens ?? 4096, + providerOptions: { + openai: this.getOpenAIOptions(options, model.id), + }, + tools: await this.getTools(options, model.id), + maxSteps: this.MAX_STEPS, + abortSignal: options.signal, + }); + return fullStream; + } + // ====== text to image ====== private async *generateImageWithAttachments( model: string, diff --git a/packages/backend/server/src/plugins/copilot/providers/provider.ts b/packages/backend/server/src/plugins/copilot/providers/provider.ts index fc383fd9b2..ac13aeb1c2 100644 --- a/packages/backend/server/src/plugins/copilot/providers/provider.ts +++ b/packages/backend/server/src/plugins/copilot/providers/provider.ts @@ -33,6 +33,7 @@ import { ModelInputType, type PromptMessage, PromptMessageSchema, + StreamObject, } from './types'; @Injectable() @@ -225,6 +226,17 @@ export abstract class CopilotProvider { options?: CopilotChatOptions ): AsyncIterable; + streamObject( + _model: ModelConditions, + _messages: PromptMessage[], + _options?: CopilotChatOptions + ): AsyncIterable { + throw new CopilotProviderNotSupported({ + provider: this.type, + kind: 'object', + }); + } + structure( _cond: ModelConditions, _messages: PromptMessage[], diff --git a/packages/backend/server/src/plugins/copilot/providers/types.ts b/packages/backend/server/src/plugins/copilot/providers/types.ts index bcec8d1412..57f09c9c5b 100644 --- a/packages/backend/server/src/plugins/copilot/providers/types.ts +++ b/packages/backend/server/src/plugins/copilot/providers/types.ts @@ -118,8 +118,33 @@ export const ChatMessageAttachment = z.union([ }), ]); +export const StreamObjectSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('text-delta'), + textDelta: z.string(), + }), + z.object({ + type: z.literal('reasoning'), + textDelta: z.string(), + }), + z.object({ + type: z.literal('tool-call'), + toolCallId: z.string(), + toolName: z.string(), + args: z.record(z.any()), + }), + z.object({ + type: z.literal('tool-result'), + toolCallId: z.string(), + toolName: z.string(), + args: z.record(z.any()), + result: z.any(), + }), +]); + export const PureMessageSchema = z.object({ content: z.string(), + streamObjects: z.array(StreamObjectSchema).optional().nullable(), attachments: z.array(ChatMessageAttachment).optional().nullable(), params: z.record(z.any()).optional().nullable(), }); @@ -129,6 +154,7 @@ export const PromptMessageSchema = PureMessageSchema.extend({ }).strict(); export type PromptMessage = z.infer; export type PromptParams = NonNullable; +export type StreamObject = z.infer; // ========== options ========== @@ -187,6 +213,7 @@ export enum ModelInputType { export enum ModelOutputType { Text = 'text', + Object = 'object', Embedding = 'embedding', Image = 'image', Structured = 'structured', diff --git a/packages/backend/server/src/plugins/copilot/providers/utils.ts b/packages/backend/server/src/plugins/copilot/providers/utils.ts index 068dab3c03..8a6df94312 100644 --- a/packages/backend/server/src/plugins/copilot/providers/utils.ts +++ b/packages/backend/server/src/plugins/copilot/providers/utils.ts @@ -14,7 +14,7 @@ import { createExaCrawlTool, createExaSearchTool, } from '../tools'; -import { PromptMessage } from './types'; +import { PromptMessage, StreamObject } from './types'; type ChatMessage = CoreUserMessage | CoreAssistantMessage; @@ -387,6 +387,22 @@ export interface CustomAITools extends ToolSet { type ChunkType = TextStreamPart['type']; +export function parseUnknownError(error: unknown) { + if (typeof error === 'string') { + throw new Error(error); + } else if (error instanceof Error) { + throw error; + } else if ( + typeof error === 'object' && + error !== null && + 'message' in error + ) { + throw new Error(String(error.message)); + } else { + throw new Error(JSON.stringify(error)); + } +} + export class TextStreamParser { private readonly CALLOUT_PREFIX = '\n[!]\n'; @@ -446,8 +462,8 @@ export class TextStreamParser { break; } case 'error': { - const error = chunk.error as { type: string; message: string }; - throw new Error(error.message); + parseUnknownError(chunk.error); + break; } } this.lastType = chunk.type; @@ -490,3 +506,54 @@ export class TextStreamParser { return links; } } + +export class StreamObjectParser { + public parse(chunk: TextStreamPart) { + switch (chunk.type) { + case 'reasoning': + case 'text-delta': + case 'tool-call': + case 'tool-result': { + return chunk; + } + case 'error': { + parseUnknownError(chunk.error); + return null; + } + default: { + return null; + } + } + } + + public mergeTextDelta(chunks: StreamObject[]): StreamObject[] { + return chunks.reduce((acc, curr) => { + const prev = acc.at(-1); + switch (curr.type) { + case 'reasoning': + case 'text-delta': { + if (prev && prev.type === curr.type) { + prev.textDelta += curr.textDelta; + } else { + acc.push(curr); + } + break; + } + default: { + acc.push(curr); + break; + } + } + return acc; + }, [] as StreamObject[]); + } + + public mergeContent(chunks: StreamObject[]): string { + return chunks.reduce((acc, curr) => { + if (curr.type === 'text-delta') { + acc += curr.textDelta; + } + return acc; + }, ''); + } +} diff --git a/packages/backend/server/src/plugins/copilot/resolver.ts b/packages/backend/server/src/plugins/copilot/resolver.ts index b70634f8c4..1d74fc6013 100644 --- a/packages/backend/server/src/plugins/copilot/resolver.ts +++ b/packages/backend/server/src/plugins/copilot/resolver.ts @@ -34,7 +34,7 @@ import { Admin } from '../../core/common'; import { AccessController } from '../../core/permission'; import { UserType } from '../../core/user'; import { PromptService } from './prompt'; -import { PromptMessage } from './providers'; +import { PromptMessage, StreamObject } from './providers'; import { ChatSessionService } from './session'; import { CopilotStorage } from './storage'; import { @@ -168,6 +168,27 @@ class QueryChatHistoriesInput implements Partial { // ================== Return Types ================== +@ObjectType('StreamObject') +class StreamObjectType { + @Field(() => String) + type!: string; + + @Field(() => String, { nullable: true }) + textDelta?: string; + + @Field(() => String, { nullable: true }) + toolCallId?: string; + + @Field(() => String, { nullable: true }) + toolName?: string; + + @Field(() => GraphQLJSON, { nullable: true }) + args?: any; + + @Field(() => GraphQLJSON, { nullable: true }) + result?: any; +} + @ObjectType('ChatMessage') class ChatMessageType implements Partial { // id will be null if message is a prompt message @@ -180,6 +201,9 @@ class ChatMessageType implements Partial { @Field(() => String) content!: string; + @Field(() => [StreamObjectType], { nullable: true }) + streamObjects!: StreamObject[]; + @Field(() => [String], { nullable: true }) attachments!: string[]; diff --git a/packages/backend/server/src/plugins/copilot/session.ts b/packages/backend/server/src/plugins/copilot/session.ts index 56f4140a07..d3a0e50ed4 100644 --- a/packages/backend/server/src/plugins/copilot/session.ts +++ b/packages/backend/server/src/plugins/copilot/session.ts @@ -282,6 +282,7 @@ export class ChatSessionService { await tx.aiSessionMessage.createMany({ data: state.messages.map(m => ({ ...m, + streamObjects: m.streamObjects || undefined, attachments: m.attachments || undefined, params: omit(m.params, ['docs']) || undefined, sessionId, @@ -512,6 +513,7 @@ export class ChatSessionService { id: true, role: true, content: true, + streamObjects: true, attachments: true, params: true, createdAt: true, diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index cc26a936a4..83b10e71eb 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -96,6 +96,7 @@ type ChatMessage { id: ID params: JSON role: String! + streamObjects: [StreamObject!] } enum ContextCategories { @@ -1628,6 +1629,15 @@ type SpaceShouldHaveOnlyOneOwnerDataType { spaceId: String! } +type StreamObject { + args: JSON + result: JSON + textDelta: String + toolCallId: String + toolName: String + type: String! +} + type SubscriptionAlreadyExistsDataType { plan: String! } diff --git a/packages/common/graphql/src/graphql/copilot-history-list.gql b/packages/common/graphql/src/graphql/copilot-history-list.gql index 2b3af6c1da..d126c861e8 100644 --- a/packages/common/graphql/src/graphql/copilot-history-list.gql +++ b/packages/common/graphql/src/graphql/copilot-history-list.gql @@ -14,6 +14,14 @@ query getCopilotHistories( id role content + streamObjects { + type + textDelta + toolCallId + toolName + args + result + } attachments createdAt } diff --git a/packages/common/graphql/src/graphql/index.ts b/packages/common/graphql/src/graphql/index.ts index ef9ea3d2b3..318a22aff9 100644 --- a/packages/common/graphql/src/graphql/index.ts +++ b/packages/common/graphql/src/graphql/index.ts @@ -617,6 +617,14 @@ export const getCopilotHistoriesQuery = { id role content + streamObjects { + type + textDelta + toolCallId + toolName + args + result + } attachments createdAt } diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index 0c5e127bb6..2a073a3e59 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -137,6 +137,7 @@ export interface ChatMessage { id: Maybe; params: Maybe; role: Scalars['String']['output']; + streamObjects: Maybe>; } export enum ContextCategories { @@ -2195,6 +2196,16 @@ export interface SpaceShouldHaveOnlyOneOwnerDataType { spaceId: Scalars['String']['output']; } +export interface StreamObject { + __typename?: 'StreamObject'; + args: Maybe; + result: Maybe; + textDelta: Maybe; + toolCallId: Maybe; + toolName: Maybe; + type: Scalars['String']['output']; +} + export interface SubscriptionAlreadyExistsDataType { __typename?: 'SubscriptionAlreadyExistsDataType'; plan: Scalars['String']['output']; @@ -3374,6 +3385,15 @@ export type GetCopilotHistoriesQuery = { content: string; attachments: Array | null; createdAt: string; + streamObjects: Array<{ + __typename?: 'StreamObject'; + type: string; + textDelta: string | null; + toolCallId: string | null; + toolName: string | null; + args: Record | null; + result: Record | null; + }> | null; }>; }>; }; diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-messages/type.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-messages/type.ts index ff8114d008..d15751e4af 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-messages/type.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-messages/type.ts @@ -1,10 +1,37 @@ import { z } from 'zod'; +const StreamObjectSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('text-delta'), + textDelta: z.string(), + }), + z.object({ + type: z.literal('reasoning'), + textDelta: z.string(), + }), + z.object({ + type: z.literal('tool-call'), + toolCallId: z.string(), + toolName: z.string(), + args: z.record(z.any()), + }), + z.object({ + type: z.literal('tool-result'), + toolCallId: z.string(), + toolName: z.string(), + args: z.record(z.any()), + result: z.any(), + }), +]); + +export type StreamObject = z.infer; + const ChatMessageSchema = z.object({ id: z.string(), content: z.string(), role: z.union([z.literal('user'), z.literal('assistant')]), createdAt: z.string(), + streamObjects: z.array(StreamObjectSchema).optional(), attachments: z.array(z.string()).optional(), userId: z.string().optional(), userName: z.string().optional(), From 73402c8447d1597ebd9a2ce3f23fa761a7c2bbd8 Mon Sep 17 00:00:00 2001 From: fengmk2 Date: Thu, 19 Jun 2025 09:14:00 +0800 Subject: [PATCH 05/45] feat(server): parse ydoc to markdown (#12812) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit close AI-190 ## Summary by CodeRabbit - **New Features** - Introduced an endpoint to retrieve a document's markdown content and title. - Added backend support for parsing document snapshots directly into markdown format. - **Tests** - Added comprehensive tests and snapshot files for markdown retrieval, including success and error scenarios. - Improved test coverage for content type validation and markdown parsing utilities. - **Documentation** - Enhanced internal documentation through detailed test cases and snapshot references for new markdown features. #### PR Dependency Tree * **PR #12812** 👈 * **PR #12846** * **PR #12811** This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) --- .../__snapshots__/controller.spec.ts.md | 118 ++++++++++++++++++ .../__snapshots__/controller.spec.ts.snap | Bin 0 -> 1848 bytes .../e2e/doc-service/controller.spec.ts | 42 +++++++ .../doc-service/__tests__/controller.spec.ts | 44 +++++++ .../server/src/core/doc-service/controller.ts | 14 +++ .../reader-from-database.spec.ts.md | 106 ++++++++++++++++ .../reader-from-database.spec.ts.snap | Bin 0 -> 1707 bytes .../__snapshots__/reader-from-rpc.spec.ts.md | 106 ++++++++++++++++ .../reader-from-rpc.spec.ts.snap | Bin 0 -> 1707 bytes .../__tests__/reader-from-database.spec.ts | 25 ++++ .../doc/__tests__/reader-from-rpc.spec.ts | 49 +++++++- .../backend/server/src/core/doc/reader.ts | 49 ++++++++ .../__snapshots__/blocksute.spec.ts.md | 101 +++++++++++++++ .../__snapshots__/blocksute.spec.ts.snap | Bin 12181 -> 12701 bytes .../core/utils/__tests__/blocksute.spec.ts | 11 ++ .../server/src/core/utils/blocksuite.ts | 28 +++++ 16 files changed, 691 insertions(+), 2 deletions(-) create mode 100644 packages/backend/server/src/__tests__/e2e/doc-service/__snapshots__/controller.spec.ts.md create mode 100644 packages/backend/server/src/__tests__/e2e/doc-service/__snapshots__/controller.spec.ts.snap create mode 100644 packages/backend/server/src/__tests__/e2e/doc-service/controller.spec.ts create mode 100644 packages/backend/server/src/core/doc/__tests__/__snapshots__/reader-from-database.spec.ts.md create mode 100644 packages/backend/server/src/core/doc/__tests__/__snapshots__/reader-from-database.spec.ts.snap create mode 100644 packages/backend/server/src/core/doc/__tests__/__snapshots__/reader-from-rpc.spec.ts.md create mode 100644 packages/backend/server/src/core/doc/__tests__/__snapshots__/reader-from-rpc.spec.ts.snap diff --git a/packages/backend/server/src/__tests__/e2e/doc-service/__snapshots__/controller.spec.ts.md b/packages/backend/server/src/__tests__/e2e/doc-service/__snapshots__/controller.spec.ts.md new file mode 100644 index 0000000000..437bb951f2 --- /dev/null +++ b/packages/backend/server/src/__tests__/e2e/doc-service/__snapshots__/controller.spec.ts.md @@ -0,0 +1,118 @@ +# Snapshot report for `src/__tests__/e2e/doc-service/controller.spec.ts` + +The actual snapshot is saved in `controller.spec.ts.snap`. + +Generated by [AVA](https://avajs.dev). + +## should get doc markdown success + +> Snapshot 1 + + { + markdown: `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.␊ + ␊ + ␊ + ␊ + ␊ + ␊ + # 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|AFFiNE|␊ + |For developers or installations guides, please go to AFFiNE Doc|For developers or installations guides, please go to AFFiNE Doc|Developers|␊ + |Quip & Notion with their great concept of "everything is a block"|Quip & Notion with their great concept of "everything is a block"|Reference|␊ + |Trello with their Kanban|Trello with their Kanban|Reference|␊ + |Airtable & Miro with their no-code programable datasheets|Airtable & Miro with their no-code programable datasheets|Reference|␊ + |Miro & Whimiscal with their edgeless visual whiteboard|Miro & Whimiscal with their edgeless visual whiteboard|Reference|␊ + |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.', + } + +## should get doc markdown return null when doc not exists + +> Snapshot 1 + + { + code: 'Not Found', + message: 'Doc not found', + name: 'NOT_FOUND', + status: 404, + type: 'RESOURCE_NOT_FOUND', + } diff --git a/packages/backend/server/src/__tests__/e2e/doc-service/__snapshots__/controller.spec.ts.snap b/packages/backend/server/src/__tests__/e2e/doc-service/__snapshots__/controller.spec.ts.snap new file mode 100644 index 0000000000000000000000000000000000000000..5351944f77cefb69f8ffce6dd38909952e73530a GIT binary patch literal 1848 zcmV-82gmq9RzV5HBjYpP?iTwE=6iu{r`aP++wIx&(q2vM@43m;)4x}v#5;5Y?OU+UK^i^46#20tI zb3OYY(@*ci-t`}3S@zQ`%l>^k%RcGd`nY%NaP*pr*z2u+-nnxp>wT2LJE=$;iRTHU?6a0wA1IN&T)VmrvwPz zwpFSw4Vg6)f?prQLoO*zT){2|GioCT(UB-x(JcNzLwHUwLl^aK@+I6*tTnHMg04j4 zWHq#7!q^59m@TNT{jWH2gg_#B76IT$GBbkrwTFKJ}qyznCv5(1FzGH(RaKGPXSHSlKQ(p_UC!7KYZ0X{9+T zKvr~WoTDNFD+HPmC&hV^v~2~oB@7O9ZWkl3W%F8kYVm*yFacDDcQtWJefcU)=@j_7Sbv2d)A(sG_jkf7Ys6qrgb#7s0ndTGO!) z9jFjB7zr99qGG1mRphASV%qo=pG2)73q9I;P77A1JyG#X4mEeev6626pfhacKY#rD z5O%?GRHxpsBMQ>>$2jpub+YXQIgPkZ|GW=0tcFUb&)`?B@vyzzG!y%~URDall6&fX zi>4IjomM2lOI_SbUQPm8%bUx;OnR9f?wSx2h}&sH-n4Q&C{(o0L5&fmCW?|)rzP9) zyf%%ILR+w2t|tXcBFmXSC(WP15jBpZ#(jt$3lj~==5kI4qQMAt!;ZK-wB&id&!L;v zk|G)_eejl0NC!mO&Z!qKj~L6R=sTjy{hb|Er6G>T#?jCRPS-mH2O2DgcqcHH4f=cC zY#fsv!(*11=Xv^Fv&6+T=Xrj1cB1FPvlFb&^0UEUu=?g_yD1Fp(UdHEmA}hweJ&nd z!gSE(HDI4woo-Fhw)Dl;?&&cNe*DAJ*T327?sqnWf-Q$Hwx$?99W0ketOnk=$-S)y zU7DZoXov@A`I(|tcCnR(NrEJzwF}-?ttn}ZWyK2QLb>+1c<1$RRqOX(*FUQcWy~;=qkFm^|MUaaUCBE3Z3SoKpTrI~V)FN|7!K(&c*eaw$k_M_;o) zlxxeK-?yfi3?yQR_qubzg}>=H(~E-T)pwr<6R8h-0cU;qrA3zx%^2ZO|CB9XEBxe5 zuN4BtHaKvt)sB-(PxWL;r$*u>uN!*b+<4t^>p_-fds&vf&a&)pS(g1X%d&rEU;FCe z=7pMW=x8l3zHz`2_PKTC1*;Dl62WR^_|e`X*YPFBHykz_E)P#$?H@in*t=kLOCrWr m(&de7f9+j#66b#9 { + const owner = await app.signup(); + const workspace = await app.create(Mockers.Workspace, { + owner, + }); + + const docSnapshot = await app.create(Mockers.DocSnapshot, { + workspaceId: workspace.id, + user: owner, + }); + + const res = await app + .GET(`/rpc/workspaces/${workspace.id}/docs/${docSnapshot.id}/markdown`) + .set('x-access-token', crypto.sign(docSnapshot.id)) + .expect(200) + .expect('Content-Type', 'application/json; charset=utf-8'); + + t.snapshot(res.body); +}); + +e2e('should get doc markdown return null when doc not exists', async t => { + const owner = await app.signup(); + const workspace = await app.create(Mockers.Workspace, { + owner, + }); + + const docId = randomUUID(); + const res = await app + .GET(`/rpc/workspaces/${workspace.id}/docs/${docId}/markdown`) + .set('x-access-token', crypto.sign(docId)) + .expect(404) + .expect('Content-Type', 'application/json; charset=utf-8'); + + t.snapshot(res.body); +}); diff --git a/packages/backend/server/src/core/doc-service/__tests__/controller.spec.ts b/packages/backend/server/src/core/doc-service/__tests__/controller.spec.ts index 2187bb7945..d8059dc1f4 100644 --- a/packages/backend/server/src/core/doc-service/__tests__/controller.spec.ts +++ b/packages/backend/server/src/core/doc-service/__tests__/controller.spec.ts @@ -175,6 +175,7 @@ test('should get doc content in json format', async t => { await app .GET(`/rpc/workspaces/${workspace.id}/docs/${docId}/content`) .set('x-access-token', t.context.crypto.sign(docId)) + .expect('Content-Type', 'application/json; charset=utf-8') .expect({ title: 'test title', summary: 'test summary', @@ -184,6 +185,7 @@ test('should get doc content in json format', async t => { await app .GET(`/rpc/workspaces/${workspace.id}/docs/${docId}/content?full=false`) .set('x-access-token', t.context.crypto.sign(docId)) + .expect('Content-Type', 'application/json; charset=utf-8') .expect({ title: 'test title', summary: 'test summary', @@ -205,6 +207,7 @@ test('should get full doc content in json format', async t => { await app .GET(`/rpc/workspaces/${workspace.id}/docs/${docId}/content?full=true`) .set('x-access-token', t.context.crypto.sign(docId)) + .expect('Content-Type', 'application/json; charset=utf-8') .expect({ title: 'test title', summary: 'test summary full', @@ -251,3 +254,44 @@ test('should get workspace content in json format', async t => { }); t.pass(); }); + +test('should get doc markdown in json format', async t => { + const { app } = t.context; + mock.method(t.context.databaseDocReader, 'getDocMarkdown', async () => { + return { + title: 'test title', + markdown: 'test markdown', + }; + }); + + const docId = randomUUID(); + await app + .GET(`/rpc/workspaces/${workspace.id}/docs/${docId}/markdown`) + .set('x-access-token', t.context.crypto.sign(docId)) + .expect('Content-Type', 'application/json; charset=utf-8') + .expect(200) + .expect({ + title: 'test title', + markdown: 'test markdown', + }); + t.pass(); +}); + +test('should 404 when doc markdown not found', async t => { + const { app } = t.context; + + const workspaceId = '123'; + const docId = '123'; + await app + .GET(`/rpc/workspaces/${workspaceId}/docs/${docId}/markdown`) + .set('x-access-token', t.context.crypto.sign(docId)) + .expect({ + status: 404, + code: 'Not Found', + type: 'RESOURCE_NOT_FOUND', + name: 'NOT_FOUND', + message: 'Doc not found', + }) + .expect(404); + t.pass(); +}); diff --git a/packages/backend/server/src/core/doc-service/controller.ts b/packages/backend/server/src/core/doc-service/controller.ts index 513957b1bd..16bdfb0ee2 100644 --- a/packages/backend/server/src/core/doc-service/controller.ts +++ b/packages/backend/server/src/core/doc-service/controller.ts @@ -42,6 +42,20 @@ export class DocRpcController { res.send(doc.bin); } + @SkipThrottle() + @Internal() + @Get('/workspaces/:workspaceId/docs/:docId/markdown') + async getDocMarkdown( + @Param('workspaceId') workspaceId: string, + @Param('docId') docId: string + ) { + const result = await this.docReader.getDocMarkdown(workspaceId, docId); + if (!result) { + throw new NotFound('Doc not found'); + } + return result; + } + @SkipThrottle() @Internal() @Post('/workspaces/:workspaceId/docs/:docId/diff') diff --git a/packages/backend/server/src/core/doc/__tests__/__snapshots__/reader-from-database.spec.ts.md b/packages/backend/server/src/core/doc/__tests__/__snapshots__/reader-from-database.spec.ts.md new file mode 100644 index 0000000000..db9cd1b252 --- /dev/null +++ b/packages/backend/server/src/core/doc/__tests__/__snapshots__/reader-from-database.spec.ts.md @@ -0,0 +1,106 @@ +# Snapshot report for `src/core/doc/__tests__/reader-from-database.spec.ts` + +The actual snapshot is saved in `reader-from-database.spec.ts.snap`. + +Generated by [AVA](https://avajs.dev). + +## should return doc markdown success + +> Snapshot 1 + + { + markdown: `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.␊ + ␊ + ␊ + ␊ + ␊ + ␊ + # 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|AFFiNE|␊ + |For developers or installations guides, please go to AFFiNE Doc|For developers or installations guides, please go to AFFiNE Doc|Developers|␊ + |Quip & Notion with their great concept of "everything is a block"|Quip & Notion with their great concept of "everything is a block"|Reference|␊ + |Trello with their Kanban|Trello with their Kanban|Reference|␊ + |Airtable & Miro with their no-code programable datasheets|Airtable & Miro with their no-code programable datasheets|Reference|␊ + |Miro & Whimiscal with their edgeless visual whiteboard|Miro & Whimiscal with their edgeless visual whiteboard|Reference|␊ + |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.', + } diff --git a/packages/backend/server/src/core/doc/__tests__/__snapshots__/reader-from-database.spec.ts.snap b/packages/backend/server/src/core/doc/__tests__/__snapshots__/reader-from-database.spec.ts.snap new file mode 100644 index 0000000000000000000000000000000000000000..d5593e96448b472fb35ed2badeae40ec4a66906e GIT binary patch literal 1707 zcmV;c22}Y$RzV-WSnmSQ>@_dnVr3m7l?%|xace-97)OD zB^N~)Q#(^T)7qY{PFK(FZP*X-hy09uaQ;{zza&)8?8{vwB2GaI2Rl98)m2YD&(pp4 z!)$7~n9KM7q-dh0%aPRF+7bfAHaIA`fCj_7!eEIG`@V?pD7Qov|j z1+tPz4a_*C*|8>=w#JtFuB#h(1}km@M8XD^W^TqXcf7PzRs;_IE-dN5E-6vH0;uprzY?uOP_o2f?H51)1Hdm7?vj1;1~*SeBj0i ziB7!-zvTjLJ~Kfg9Kb1|&7)}u3bq!f9MWc4mR%57)@^G7Qxbv?>{+OU=?b2>IUK;* z(!`=xxOCDC8*T+KL{nG-oN^mABh5^q*-Ne@@g{b$3(IXefk$nKHHC%)jbYcF6gcII z*5=0LR~iGc#t3sFFT3!%9|ft6e;o+G4#Mk-?s28kq$bm)r(nnxq$uyv1?(Px=)E;X zx4QN0;Nj`Xxpe!!X%}g|=MX2_wLW8wYnQkZkjR0`ZV$VHWo;}$)7ogfC2(AlK%fSV z16jF(N=1LW(}ocD?!j+5A|`;8SF_>u^IfzQ4XhpO5D^1{nV2Lj3Xo@A=P^>ktgo3U_ z<76w^8DVS#2`qc6YyT@w93hZM9%daYC!HQN`)#c~B_hLA)1I}iMAbCfE0uYRamJx} zLk`!CLrdKC=DJlZjI?O4@@Wh;`(9sApaZGV@3&gjGPAtwSlRZR(3uU+`-#@fXzMv@ zKvq;W&QTG86#~tOljA%|-nNC>5(WpV+g|comJeLEMO2}i$|++yxWCE&PVrWUhTwvF{zK=%TWThibmSr!W z>VsN~o*CZDx1ZH9`uj&mE;;%{_z((2Xmmk6E7O^%_@#iFJK;=8w|mh|Y~|m7`^N;1 z!E)4~-moJI()GtU@kV{J?F>0dT-CoC15K)l(&=;feQP`%Y!1!D{;rpmLb2hVMjz3F z!m9I%WO%8IqZH*lkhQ$O{F~&L>E*ErF@boH4&?n=j`KoI>k8BuQEH+nX?0q102j4s zj1<~}-EzGtSQ6Q2{)#n!4yV*Oj=Jwd^jMf^NcNX=x)9}4)B`)>=F*a7*&_~}T1$#( ztc<~1LLn81vYk^eUY;_RPtkWomHS6W>Pi!w&5Wao51gKl3J#QSM0^w&%Le^nr;RhR zGkDArvn)&BYnIrnIm@#3`dqJu>vOEu**eej?O(P&PTRl8sK4m4(TIB%-xF@2l37w8OGu1+sUp6E5C){ae-gvsd*O)wAU@qp z4o448wwdiJerNUkA0oU})sL&k^3C%8|&?UCD2f`kP<8=LBzT zH}A0WJ(qYhPrT`}_gvf!UHiuC*8MHzzr3?Q2eyuM-H=}IM=v*qvv literal 0 HcmV?d00001 diff --git a/packages/backend/server/src/core/doc/__tests__/__snapshots__/reader-from-rpc.spec.ts.md b/packages/backend/server/src/core/doc/__tests__/__snapshots__/reader-from-rpc.spec.ts.md new file mode 100644 index 0000000000..f7342844c8 --- /dev/null +++ b/packages/backend/server/src/core/doc/__tests__/__snapshots__/reader-from-rpc.spec.ts.md @@ -0,0 +1,106 @@ +# Snapshot report for `src/core/doc/__tests__/reader-from-rpc.spec.ts` + +The actual snapshot is saved in `reader-from-rpc.spec.ts.snap`. + +Generated by [AVA](https://avajs.dev). + +## should return doc markdown success + +> Snapshot 1 + + { + markdown: `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.␊ + ␊ + ␊ + ␊ + ␊ + ␊ + # 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|AFFiNE|␊ + |For developers or installations guides, please go to AFFiNE Doc|For developers or installations guides, please go to AFFiNE Doc|Developers|␊ + |Quip & Notion with their great concept of "everything is a block"|Quip & Notion with their great concept of "everything is a block"|Reference|␊ + |Trello with their Kanban|Trello with their Kanban|Reference|␊ + |Airtable & Miro with their no-code programable datasheets|Airtable & Miro with their no-code programable datasheets|Reference|␊ + |Miro & Whimiscal with their edgeless visual whiteboard|Miro & Whimiscal with their edgeless visual whiteboard|Reference|␊ + |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.', + } diff --git a/packages/backend/server/src/core/doc/__tests__/__snapshots__/reader-from-rpc.spec.ts.snap b/packages/backend/server/src/core/doc/__tests__/__snapshots__/reader-from-rpc.spec.ts.snap new file mode 100644 index 0000000000000000000000000000000000000000..d5593e96448b472fb35ed2badeae40ec4a66906e GIT binary patch literal 1707 zcmV;c22}Y$RzV-WSnmSQ>@_dnVr3m7l?%|xace-97)OD zB^N~)Q#(^T)7qY{PFK(FZP*X-hy09uaQ;{zza&)8?8{vwB2GaI2Rl98)m2YD&(pp4 z!)$7~n9KM7q-dh0%aPRF+7bfAHaIA`fCj_7!eEIG`@V?pD7Qov|j z1+tPz4a_*C*|8>=w#JtFuB#h(1}km@M8XD^W^TqXcf7PzRs;_IE-dN5E-6vH0;uprzY?uOP_o2f?H51)1Hdm7?vj1;1~*SeBj0i ziB7!-zvTjLJ~Kfg9Kb1|&7)}u3bq!f9MWc4mR%57)@^G7Qxbv?>{+OU=?b2>IUK;* z(!`=xxOCDC8*T+KL{nG-oN^mABh5^q*-Ne@@g{b$3(IXefk$nKHHC%)jbYcF6gcII z*5=0LR~iGc#t3sFFT3!%9|ft6e;o+G4#Mk-?s28kq$bm)r(nnxq$uyv1?(Px=)E;X zx4QN0;Nj`Xxpe!!X%}g|=MX2_wLW8wYnQkZkjR0`ZV$VHWo;}$)7ogfC2(AlK%fSV z16jF(N=1LW(}ocD?!j+5A|`;8SF_>u^IfzQ4XhpO5D^1{nV2Lj3Xo@A=P^>ktgo3U_ z<76w^8DVS#2`qc6YyT@w93hZM9%daYC!HQN`)#c~B_hLA)1I}iMAbCfE0uYRamJx} zLk`!CLrdKC=DJlZjI?O4@@Wh;`(9sApaZGV@3&gjGPAtwSlRZR(3uU+`-#@fXzMv@ zKvq;W&QTG86#~tOljA%|-nNC>5(WpV+g|comJeLEMO2}i$|++yxWCE&PVrWUhTwvF{zK=%TWThibmSr!W z>VsN~o*CZDx1ZH9`uj&mE;;%{_z((2Xmmk6E7O^%_@#iFJK;=8w|mh|Y~|m7`^N;1 z!E)4~-moJI()GtU@kV{J?F>0dT-CoC15K)l(&=;feQP`%Y!1!D{;rpmLb2hVMjz3F z!m9I%WO%8IqZH*lkhQ$O{F~&L>E*ErF@boH4&?n=j`KoI>k8BuQEH+nX?0q102j4s zj1<~}-EzGtSQ6Q2{)#n!4yV*Oj=Jwd^jMf^NcNX=x)9}4)B`)>=F*a7*&_~}T1$#( ztc<~1LLn81vYk^eUY;_RPtkWomHS6W>Pi!w&5Wao51gKl3J#QSM0^w&%Le^nr;RhR zGkDArvn)&BYnIrnIm@#3`dqJu>vOEu**eej?O(P&PTRl8sK4m4(TIB%-xF@2l37w8OGu1+sUp6E5C){ae-gvsd*O)wAU@qp z4o448wwdiJerNUkA0oU})sL&k^3C%8|&?UCD2f`kP<8=LBzT zH}A0WJ(qYhPrT`}_gvf!UHiuC*8MHzzr3?Q2eyuM-H=}IM=v*qvv literal 0 HcmV?d00001 diff --git a/packages/backend/server/src/core/doc/__tests__/reader-from-database.spec.ts b/packages/backend/server/src/core/doc/__tests__/reader-from-database.spec.ts index eb4de37ef6..e672aedf0e 100644 --- a/packages/backend/server/src/core/doc/__tests__/reader-from-database.spec.ts +++ b/packages/backend/server/src/core/doc/__tests__/reader-from-database.spec.ts @@ -257,3 +257,28 @@ test('should get workspace content with custom avatar', async t => { avatarUrl: `http://localhost:3010/api/workspaces/${workspace.id}/blobs/${avatarKey}`, }); }); + +test('should return doc markdown success', async t => { + const workspace = await module.create(Mockers.Workspace, { + owner: user, + name: '', + }); + + const docSnapshot = await module.create(Mockers.DocSnapshot, { + workspaceId: workspace.id, + user, + }); + + const result = await docReader.getDocMarkdown(workspace.id, docSnapshot.id); + t.snapshot(result); +}); + +test('should read markdown return null when doc not exists', async t => { + const workspace = await module.create(Mockers.Workspace, { + owner: user, + name: '', + }); + + const result = await docReader.getDocMarkdown(workspace.id, randomUUID()); + t.is(result, null); +}); diff --git a/packages/backend/server/src/core/doc/__tests__/reader-from-rpc.spec.ts b/packages/backend/server/src/core/doc/__tests__/reader-from-rpc.spec.ts index 5ae92beeba..005ff18fcc 100644 --- a/packages/backend/server/src/core/doc/__tests__/reader-from-rpc.spec.ts +++ b/packages/backend/server/src/core/doc/__tests__/reader-from-rpc.spec.ts @@ -5,13 +5,24 @@ import { User, Workspace } from '@prisma/client'; import ava, { TestFn } from 'ava'; import { applyUpdate, Doc as YDoc } from 'yjs'; +import { createModule } from '../../../__tests__/create-module'; +import { Mockers } from '../../../__tests__/mocks'; import { createTestingApp, type TestingApp } from '../../../__tests__/utils'; import { UserFriendlyError } from '../../../base'; import { ConfigFactory } from '../../../base/config'; import { Models } from '../../../models'; -import { DatabaseDocReader, DocReader, PgWorkspaceDocStorageAdapter } from '..'; +import { + DatabaseDocReader, + DocReader, + DocStorageModule, + PgWorkspaceDocStorageAdapter, +} from '..'; import { RpcDocReader } from '../reader'; +const module = await createModule({ + imports: [DocStorageModule], +}); + const test = ava as TestFn<{ models: Models; app: TestingApp; @@ -68,6 +79,12 @@ test.afterEach.always(() => { test.after.always(async t => { await t.context.app.close(); await t.context.docApp.close(); + await module.close(); +}); + +test('should be rpc reader', async t => { + const { docReader } = t.context; + t.true(docReader instanceof RpcDocReader); }); test('should return null when doc not found', async t => { @@ -144,7 +161,6 @@ test('should fallback to database doc reader when endpoint network error', async test('should return doc when found', async t => { const { docReader } = t.context; - t.true(docReader instanceof RpcDocReader); const docId = randomUUID(); const timestamp = Date.now(); @@ -359,3 +375,32 @@ test('should return null when workspace bin meta not exists', async t => { const notExists = await docReader.getWorkspaceContent(randomUUID()); t.is(notExists, null); }); + +test('should return doc markdown success', async t => { + const { docReader } = t.context; + + const workspace = await module.create(Mockers.Workspace, { + owner: user, + name: '', + }); + + const docSnapshot = await module.create(Mockers.DocSnapshot, { + workspaceId: workspace.id, + user, + }); + + const result = await docReader.getDocMarkdown(workspace.id, docSnapshot.id); + t.snapshot(result); +}); + +test('should read markdown return null when doc not exists', async t => { + const { docReader } = t.context; + + const workspace = await module.create(Mockers.Workspace, { + owner: user, + name: '', + }); + + const result = await docReader.getDocMarkdown(workspace.id, randomUUID()); + t.is(result, null); +}); diff --git a/packages/backend/server/src/core/doc/reader.ts b/packages/backend/server/src/core/doc/reader.ts index 05124b89bd..2c6da66edf 100644 --- a/packages/backend/server/src/core/doc/reader.ts +++ b/packages/backend/server/src/core/doc/reader.ts @@ -18,6 +18,7 @@ import { Models } from '../../models'; import { WorkspaceBlobStorage } from '../storage'; import { type PageDocContent, + parseDocToMarkdownFromDocSnapshot, parsePageDoc, parseWorkspaceDoc, } from '../utils/blocksuite'; @@ -33,6 +34,11 @@ export interface WorkspaceDocInfo { avatarUrl?: string; } +export interface DocMarkdown { + title: string; + markdown: string; +} + export abstract class DocReader { protected readonly logger = new Logger(DocReader.name); @@ -59,6 +65,11 @@ export abstract class DocReader { docId: string ): Promise; + abstract getDocMarkdown( + workspaceId: string, + docId: string + ): Promise; + abstract getDocDiff( spaceId: string, docId: string, @@ -171,6 +182,17 @@ export class DatabaseDocReader extends DocReader { return await this.workspace.getDoc(workspaceId, docId); } + async getDocMarkdown( + workspaceId: string, + docId: string + ): Promise { + const doc = await this.workspace.getDoc(workspaceId, docId); + if (!doc) { + return null; + } + return parseDocToMarkdownFromDocSnapshot(workspaceId, docId, doc.bin); + } + async getDocDiff( spaceId: string, docId: string, @@ -304,6 +326,33 @@ export class RpcDocReader extends DatabaseDocReader { } } + override async getDocMarkdown( + workspaceId: string, + docId: string + ): Promise { + const url = `${this.config.docService.endpoint}/rpc/workspaces/${workspaceId}/docs/${docId}/markdown`; + const accessToken = this.crypto.sign(docId); + try { + const res = await this.fetch(accessToken, url, 'GET'); + if (!res) { + return null; + } + return (await res.json()) as DocMarkdown; + } catch (e) { + if (e instanceof UserFriendlyError) { + throw e; + } + const err = e as Error; + // other error + this.logger.error( + `Failed to fetch doc markdown ${url}, fallback to database doc reader`, + err + ); + // fallback to database doc reader if the error is not user friendly, like network error + return await super.getDocMarkdown(workspaceId, docId); + } + } + override async getDocDiff( workspaceId: string, docId: string, diff --git a/packages/backend/server/src/core/utils/__tests__/__snapshots__/blocksute.spec.ts.md b/packages/backend/server/src/core/utils/__tests__/__snapshots__/blocksute.spec.ts.md index 0b02895056..258c0c252e 100644 --- a/packages/backend/server/src/core/utils/__tests__/__snapshots__/blocksute.spec.ts.md +++ b/packages/backend/server/src/core/utils/__tests__/__snapshots__/blocksute.spec.ts.md @@ -1366,3 +1366,104 @@ Generated by [AVA](https://avajs.dev). summary: '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. You own your data, with no compromisesLocal-first & Real-time collaborativeWe 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 whiteboardThere 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. ', title: 'Write, Draw, Plan all at Once.', } + +## can parse doc to markdown from doc snapshot + +> Snapshot 1 + + { + markdown: `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.␊ + ␊ + ␊ + ␊ + ␊ + ␊ + # 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|AFFiNE|␊ + |For developers or installations guides, please go to AFFiNE Doc|For developers or installations guides, please go to AFFiNE Doc|Developers|␊ + |Quip & Notion with their great concept of "everything is a block"|Quip & Notion with their great concept of "everything is a block"|Reference|␊ + |Trello with their Kanban|Trello with their Kanban|Reference|␊ + |Airtable & Miro with their no-code programable datasheets|Airtable & Miro with their no-code programable datasheets|Reference|␊ + |Miro & Whimiscal with their edgeless visual whiteboard|Miro & Whimiscal with their edgeless visual whiteboard|Reference|␊ + |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.', + } diff --git a/packages/backend/server/src/core/utils/__tests__/__snapshots__/blocksute.spec.ts.snap b/packages/backend/server/src/core/utils/__tests__/__snapshots__/blocksute.spec.ts.snap index 8c97b9a14d0e49523399ff0b0b763569e8899cf6..7c77db4c2a96a57b327c050c3bcdef90315051af 100644 GIT binary patch literal 12701 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 literal 12181 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 diff --git a/packages/backend/server/src/core/utils/__tests__/blocksute.spec.ts b/packages/backend/server/src/core/utils/__tests__/blocksute.spec.ts index 25c79cece1..90e2a0947f 100644 --- a/packages/backend/server/src/core/utils/__tests__/blocksute.spec.ts +++ b/packages/backend/server/src/core/utils/__tests__/blocksute.spec.ts @@ -5,6 +5,7 @@ import { createModule } from '../../../__tests__/create-module'; import { Mockers } from '../../../__tests__/mocks'; import { Models } from '../../../models'; import { + parseDocToMarkdownFromDocSnapshot, readAllBlocksFromDocSnapshot, readAllDocIdsFromWorkspaceSnapshot, } from '../blocksuite'; @@ -88,3 +89,13 @@ test('can read all blocks from doc snapshot without workspace snapshot', async t blocks: result!.blocks.map(block => omit(block, ['yblock'])), }); }); + +test('can parse doc to markdown from doc snapshot', async t => { + const result = parseDocToMarkdownFromDocSnapshot( + workspace.id, + docSnapshot.id, + docSnapshot.blob + ); + + t.snapshot(result); +}); diff --git a/packages/backend/server/src/core/utils/blocksuite.ts b/packages/backend/server/src/core/utils/blocksuite.ts index 82c627d12d..ff3f64597c 100644 --- a/packages/backend/server/src/core/utils/blocksuite.ts +++ b/packages/backend/server/src/core/utils/blocksuite.ts @@ -8,6 +8,7 @@ // eslint-disable-next-line @typescript-eslint/no-restricted-imports -- import from bundle import { + parsePageDoc as parseDocToMarkdown, readAllBlocksFromDoc, readAllDocIdsFromRootDoc, } from '@affine/reader/dist'; @@ -196,3 +197,30 @@ export async function readAllBlocksFromDocSnapshot( maxSummaryLength, }); } + +export function parseDocToMarkdownFromDocSnapshot( + workspaceId: string, + docId: string, + docSnapshot: Uint8Array +) { + const ydoc = new YDoc({ + guid: docId, + }); + applyUpdate(ydoc, docSnapshot); + + const parsed = parseDocToMarkdown({ + workspaceId, + doc: ydoc, + buildBlobUrl: (blobId: string) => { + return `/${workspaceId}/blobs/${blobId}`; + }, + buildDocUrl: (docId: string) => { + return `/workspace/${workspaceId}/${docId}`; + }, + }); + + return { + title: parsed.title, + markdown: parsed.md, + }; +} From 380f40ebed1c8f7edf0fc6fb51df9c244405bb11 Mon Sep 17 00:00:00 2001 From: Cats Juice Date: Thu, 19 Jun 2025 10:15:42 +0800 Subject: [PATCH 06/45] style(core): adjust app sidebar style (#12852) ## Summary by CodeRabbit - **Style** - Adjusted layout and spacing in sidebar and quick search components for a more compact appearance. - Reduced header and tab heights in the desktop app, increasing main view space. - Updated add-page button size, padding, and font for improved usability. - Refined menu positioning in workspace selector for dense layouts. - Improved macOS window button positioning for better alignment. - Updated and restructured iOS app dependencies. Co-authored-by: fengmk2 --- .../src/main/windows-manager/tab-views.ts | 2 +- .../Packages/Intelligents/Package.resolved | 60 +++++++------------ .../components/root-app-sidebar/index.css.ts | 4 +- .../components/workspace-selector/index.tsx | 2 +- .../components/app-container/styles.css.ts | 4 +- .../pages/workspace/layouts/styles.css.ts | 33 ---------- .../views/add-page-button/index.css.ts | 10 ++-- .../views/quick-search-input/index.css.ts | 3 +- .../app-tabs-header/views/styles.css.ts | 2 +- 9 files changed, 35 insertions(+), 85 deletions(-) delete mode 100644 packages/frontend/core/src/desktop/pages/workspace/layouts/styles.css.ts diff --git a/packages/frontend/apps/electron/src/main/windows-manager/tab-views.ts b/packages/frontend/apps/electron/src/main/windows-manager/tab-views.ts index c099dab9ac..be446fbfc8 100644 --- a/packages/frontend/apps/electron/src/main/windows-manager/tab-views.ts +++ b/packages/frontend/apps/electron/src/main/windows-manager/tab-views.ts @@ -925,7 +925,7 @@ export async function handleWebContentsResize(webContents?: WebContents) { if (isMacOS()) { const window = await getMainWindow(); const factor = webContents?.getZoomFactor() || 1; - window?.setWindowButtonPosition({ x: 16 * factor, y: 24 * factor - 6 }); + window?.setWindowButtonPosition({ x: 14 * factor, y: 14 * factor - 2 }); } } diff --git a/packages/frontend/apps/ios/App/Packages/Intelligents/Package.resolved b/packages/frontend/apps/ios/App/Packages/Intelligents/Package.resolved index 96fcf491f8..a0763ea436 100644 --- a/packages/frontend/apps/ios/App/Packages/Intelligents/Package.resolved +++ b/packages/frontend/apps/ios/App/Packages/Intelligents/Package.resolved @@ -5,44 +5,26 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apollographql/apollo-ios.git", "state" : { - "revision" : "c3f48d45ec1300bc95243bf19f67284f9dc0d14a", - "version" : "1.15.3" + "revision" : "39fea7617346c0731be25f61afd537e7032fb562", + "version" : "1.22.0" } }, { - "identity" : "msdisplaylink", + "identity" : "chidorimenu", "kind" : "remoteSourceControl", - "location" : "https://github.com/Lakr233/MSDisplayLink", + "location" : "https://github.com/Lakr233/ChidoriMenu", "state" : { - "revision" : "6e92b5513e3473e064685e64056c4ac46470e7b0", - "version" : "2.0.3" + "revision" : "3bb4323fe0f7f8f435d15656c3eeffcbb7c9c605", + "version" : "3.0.0" } }, { - "identity" : "networkimage", + "identity" : "splash", "kind" : "remoteSourceControl", - "location" : "https://github.com/gonzalezreal/NetworkImage", + "location" : "https://github.com/JohnSundell/Splash", "state" : { - "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", - "version" : "6.0.1" - } - }, - { - "identity" : "springinterpolation", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Lakr233/SpringInterpolation", - "state" : { - "revision" : "f9d1ee3d2466bdb00fd0ade7f256ed20229c8413", - "version" : "1.3.0" - } - }, - { - "identity" : "sqlite.swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/stephencelis/SQLite.swift.git", - "state" : { - "revision" : "a95fc6df17d108bd99210db5e8a9bac90fe984b8", - "version" : "0.15.3" + "revision" : "7f4df436eb78fe64fe2c32c58006e9949fa28ad8", + "version" : "0.16.0" } }, { @@ -50,8 +32,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-cmark", "state" : { - "revision" : "3ccff77b2dc5b96b77db3da0d68d28068593fa53", - "version" : "0.5.0" + "revision" : "b022b08312decdc46585e0b3440d97f6f22ef703", + "version" : "0.6.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0", + "version" : "1.2.0" } }, { @@ -62,15 +53,6 @@ "revision" : "57051701c58a93603ffa2051f8e9cf0c8cff7814", "version" : "3.3.0" } - }, - { - "identity" : "swift-markdown-ui", - "kind" : "remoteSourceControl", - "location" : "https://github.com/gonzalezreal/swift-markdown-ui", - "state" : { - "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", - "version" : "2.4.1" - } } ], "version" : 2 diff --git a/packages/frontend/core/src/components/root-app-sidebar/index.css.ts b/packages/frontend/core/src/components/root-app-sidebar/index.css.ts index 267737286e..b721bd56ab 100644 --- a/packages/frontend/core/src/components/root-app-sidebar/index.css.ts +++ b/packages/frontend/core/src/components/root-app-sidebar/index.css.ts @@ -6,6 +6,7 @@ export const workspaceAndUserWrapper = style({ justifyContent: 'space-between', gap: 8, width: 'calc(100% + 12px)', + height: 42, paddingRight: 6, alignSelf: 'center', }); @@ -13,8 +14,9 @@ export const quickSearchAndNewPage = style({ display: 'flex', alignItems: 'center', gap: 8, - padding: '8px 0', + padding: '4px 0', marginLeft: -8, + marginRight: -6, }); export const quickSearch = style({ width: 0, diff --git a/packages/frontend/core/src/components/workspace-selector/index.tsx b/packages/frontend/core/src/components/workspace-selector/index.tsx index 28fc558ba0..06d800683d 100644 --- a/packages/frontend/core/src/components/workspace-selector/index.tsx +++ b/packages/frontend/core/src/components/workspace-selector/index.tsx @@ -113,7 +113,7 @@ export const WorkspaceSelector = ({ } contentOptions={{ // hide trigger - sideOffset: -58, + sideOffset: dense ? -32 : -58, onInteractOutside: closeUserWorkspaceList, onEscapeKeyDown: closeUserWorkspaceList, ...menuContentOptions, diff --git a/packages/frontend/core/src/desktop/components/app-container/styles.css.ts b/packages/frontend/core/src/desktop/components/app-container/styles.css.ts index e3b0a61488..6b13276216 100644 --- a/packages/frontend/core/src/desktop/components/app-container/styles.css.ts +++ b/packages/frontend/core/src/desktop/components/app-container/styles.css.ts @@ -59,14 +59,14 @@ export const desktopAppViewMain = style({ display: 'flex', flexFlow: 'row', width: '100%', - height: 'calc(100% - 52px)', + height: 'calc(100% - 40px)', position: 'relative', }); export const desktopTabsHeader = style({ display: 'flex', flexFlow: 'row', - height: '52px', + height: '40px', zIndex: 1, width: '100%', overflow: 'hidden', diff --git a/packages/frontend/core/src/desktop/pages/workspace/layouts/styles.css.ts b/packages/frontend/core/src/desktop/pages/workspace/layouts/styles.css.ts deleted file mode 100644 index 64a6e16cdc..0000000000 --- a/packages/frontend/core/src/desktop/pages/workspace/layouts/styles.css.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { style } from '@vanilla-extract/css'; - -export const browserAppViewContainer = style({ - display: 'flex', - flexFlow: 'row', - height: '100%', - width: '100%', - position: 'relative', -}); - -export const desktopAppViewContainer = style({ - display: 'flex', - flexFlow: 'column', - height: '100%', - width: '100%', -}); - -export const desktopAppViewMain = style({ - display: 'flex', - flexFlow: 'row', - width: '100%', - height: 'calc(100% - 52px)', - position: 'relative', -}); - -export const desktopTabsHeader = style({ - display: 'flex', - flexFlow: 'row', - height: '52px', - zIndex: 1, - width: '100%', - overflow: 'hidden', -}); diff --git a/packages/frontend/core/src/modules/app-sidebar/views/add-page-button/index.css.ts b/packages/frontend/core/src/modules/app-sidebar/views/add-page-button/index.css.ts index 3cfd89df6d..f4efb04526 100644 --- a/packages/frontend/core/src/modules/app-sidebar/views/add-page-button/index.css.ts +++ b/packages/frontend/core/src/modules/app-sidebar/views/add-page-button/index.css.ts @@ -3,9 +3,9 @@ import { cssVarV2 } from '@toeverything/theme/v2'; import { style } from '@vanilla-extract/css'; export const root = style({ - width: 20, - height: 20, - borderRadius: 4, + width: 30, + height: 30, + borderRadius: 8, boxShadow: cssVar('buttonShadow'), borderWidth: 0, background: cssVarV2('button/siderbarPrimary/background'), @@ -15,12 +15,12 @@ export const withAskRoot = style([ root, { width: 'auto', - padding: 4, + padding: 7, }, ]); export const withAskContent = style({ - fontSize: 20, + fontSize: 16, display: 'flex', alignItems: 'center', gap: 4, diff --git a/packages/frontend/core/src/modules/app-sidebar/views/quick-search-input/index.css.ts b/packages/frontend/core/src/modules/app-sidebar/views/quick-search-input/index.css.ts index 24bdc75f93..e0a4760b82 100644 --- a/packages/frontend/core/src/modules/app-sidebar/views/quick-search-input/index.css.ts +++ b/packages/frontend/core/src/modules/app-sidebar/views/quick-search-input/index.css.ts @@ -3,9 +3,8 @@ import { cssVarV2 } from '@toeverything/theme/v2'; import { style } from '@vanilla-extract/css'; export const root = style({ display: 'inline-flex', - background: cssVarV2('button/siderbarPrimary/background'), alignItems: 'center', - borderRadius: '8px', + borderRadius: '4px', fontSize: cssVar('fontSm'), width: '100%', height: '30px', diff --git a/packages/frontend/core/src/modules/app-tabs-header/views/styles.css.ts b/packages/frontend/core/src/modules/app-tabs-header/views/styles.css.ts index 9a2ac8e911..7b672e8200 100644 --- a/packages/frontend/core/src/modules/app-tabs-header/views/styles.css.ts +++ b/packages/frontend/core/src/modules/app-tabs-header/views/styles.css.ts @@ -8,7 +8,7 @@ export const tabMaxWidth = createVar('200px'); export const root = style({ width: '100%', - height: '52px', + height: '40px', display: 'flex', alignItems: 'center', flexDirection: 'row', From d80bfac1d24699f3e02b11fc4f2e1a2e3f10b5da Mon Sep 17 00:00:00 2001 From: fengmk2 Date: Thu, 19 Jun 2025 11:27:04 +0800 Subject: [PATCH 07/45] feat(server): parse ydoc to ai editable markdown format (#12846) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit close AI-213 #### PR Dependency Tree * **PR #12846** 👈 * **PR #12811** This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) ## Summary by CodeRabbit - **New Features** - Introduced support for AI-editable blocks in document parsing, allowing blocks to include metadata for AI-based editing. - Added rendering for todo list items with markdown checkbox syntax. - Unsupported block types are now marked with placeholders in the parsed output. - **Tests** - Added new test cases and snapshots to verify parsing behavior with AI-editable content enabled. --- .../__snapshots__/blocksute.spec.ts.md | 119 +++++ .../__snapshots__/blocksute.spec.ts.snap | Bin 12701 -> 13076 bytes .../core/utils/__tests__/blocksute.spec.ts | 11 + .../server/src/core/utils/blocksuite.ts | 4 +- .../test-doc-with-ai-editable.snapshot.bin | Bin 0 -> 62048 bytes .../__snapshots__/reader.spec.ts.snap | 444 ++++++++++++++++++ .../common/reader/__tests__/reader.spec.ts | 42 ++ .../common/reader/src/doc-parser/parser.ts | 34 +- .../common/reader/src/doc-parser/types.ts | 1 + 9 files changed, 649 insertions(+), 6 deletions(-) create mode 100644 packages/common/reader/__tests__/__fixtures__/test-doc-with-ai-editable.snapshot.bin diff --git a/packages/backend/server/src/core/utils/__tests__/__snapshots__/blocksute.spec.ts.md b/packages/backend/server/src/core/utils/__tests__/__snapshots__/blocksute.spec.ts.md index 258c0c252e..de7fb65f3e 100644 --- a/packages/backend/server/src/core/utils/__tests__/__snapshots__/blocksute.spec.ts.md +++ b/packages/backend/server/src/core/utils/__tests__/__snapshots__/blocksute.spec.ts.md @@ -1467,3 +1467,122 @@ Generated by [AVA](https://avajs.dev). `, title: 'Write, Draw, Plan all at Once.', } + +## can parse doc to markdown from doc snapshot with ai editable + +> Snapshot 1 + + { + markdown: `␊ + 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.␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ␊ + # 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␊ + ␊ + ␊ + ␊ + ␊ + ␊ + ## 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.', + } diff --git a/packages/backend/server/src/core/utils/__tests__/__snapshots__/blocksute.spec.ts.snap b/packages/backend/server/src/core/utils/__tests__/__snapshots__/blocksute.spec.ts.snap index 7c77db4c2a96a57b327c050c3bcdef90315051af..4d486998690864e8033bebc0760fc7c5a222dafc 100644 GIT binary patch literal 13076 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 diff --git a/packages/backend/server/src/core/utils/__tests__/blocksute.spec.ts b/packages/backend/server/src/core/utils/__tests__/blocksute.spec.ts index 90e2a0947f..e337a07ce5 100644 --- a/packages/backend/server/src/core/utils/__tests__/blocksute.spec.ts +++ b/packages/backend/server/src/core/utils/__tests__/blocksute.spec.ts @@ -99,3 +99,14 @@ test('can parse doc to markdown from doc snapshot', async t => { t.snapshot(result); }); + +test('can parse doc to markdown from doc snapshot with ai editable', async t => { + const result = parseDocToMarkdownFromDocSnapshot( + workspace.id, + docSnapshot.id, + docSnapshot.blob, + true + ); + + t.snapshot(result); +}); diff --git a/packages/backend/server/src/core/utils/blocksuite.ts b/packages/backend/server/src/core/utils/blocksuite.ts index ff3f64597c..d522660dac 100644 --- a/packages/backend/server/src/core/utils/blocksuite.ts +++ b/packages/backend/server/src/core/utils/blocksuite.ts @@ -201,7 +201,8 @@ export async function readAllBlocksFromDocSnapshot( export function parseDocToMarkdownFromDocSnapshot( workspaceId: string, docId: string, - docSnapshot: Uint8Array + docSnapshot: Uint8Array, + aiEditable = false ) { const ydoc = new YDoc({ guid: docId, @@ -217,6 +218,7 @@ export function parseDocToMarkdownFromDocSnapshot( buildDocUrl: (docId: string) => { return `/workspace/${workspaceId}/${docId}`; }, + aiEditable, }); return { diff --git a/packages/common/reader/__tests__/__fixtures__/test-doc-with-ai-editable.snapshot.bin b/packages/common/reader/__tests__/__fixtures__/test-doc-with-ai-editable.snapshot.bin new file mode 100644 index 0000000000000000000000000000000000000000..96bedb2424f47975cabf2169330915ad679bd3cc GIT binary patch literal 62048 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 +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. + + + + + + + +# 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 + + + + + +## Affine Development + + + +For developer or installation guides, please go to [AFFiNE Development](https://docs.affine.pro/docs/development/quick-start) + + + + + + +" +`; + +exports[`should parse page full doc work with ai editable 1`] = ` +" +# H1 text + + + +List all flavours in one document. + + + +## H2 ~ H6 + + + +### H3 + + + +#### H4 with emoji 😄 + + + +##### H5 + + + +###### H6 + + + +max is H6 + + + +## List + + + +* item 1 + + + +* item 2 + + + * sub item 1 + + + * sub item 2 + + + * super sub item 1 + + + * sub item 3 + + + +* item 3 + + + + + + + + + + + + + + + +sort list + + + +1. item 1 + + + +1. item 2 + + + +1. item 3 + + + 1. sub item 1 + + + 1. sub item 2 + + + 1. super item 1 + + + 1. super item 2 + + + 1. sub item 3 + + + +1. item 4 + + + + + + + + + + + +Table + + + +|c1|c2|c3|c4| +|---|---|---|---| +|v1|v2|v3|| +||||v4| +||v6||v5| + + + + + + + + + + + +Database + + + + + +Code + + + +\`\`\`javascript +console.log('hello world'); +\`\`\` + + + + + + + +Image + + + + +![-HjsQksaVuEaM0KeTEbBYDgWVOmoVzrAeVK0kn4Jfr0=](blob://-HjsQksaVuEaM0KeTEbBYDgWVOmoVzrAeVK0kn4Jfr0=) + + + + + + + +File + + + + +![IrsiJ9XonFXF8fw9tLQXbSsBbUYFfzLgoFpodeidQOU=](blob://IrsiJ9XonFXF8fw9tLQXbSsBbUYFfzLgoFpodeidQOU=) + + + + + + + +> foo bar quote text + + + + + + + + + + + + +--- + + + + + + + +TeX + + + + + + + + + + + +2025-06-18 13:15 + + + + + + + + + +Mind Map + + + + + + + + + + + +A Link + + + + +[null](doc://FmHFPAPzp51JjFP89aZ-b) + + + +Todo List + + + +- [ ] abc + + + +- [ ] edf + + + - [x] done1 + + + +- [ ] end + + + + + + + +~~delete text~~ + + + + + + + +**Bold text** + + + + + + + +Underline + + + + + + + +Youtube + + + + + + + + + + + + +## end + + + +this is end + + + + + + +" +`; + exports[`should read all doc ids from root doc snapshot work 1`] = ` [ "5nS9BSp3Px", diff --git a/packages/common/reader/__tests__/reader.spec.ts b/packages/common/reader/__tests__/reader.spec.ts index 5323915051..6ca3a81d46 100644 --- a/packages/common/reader/__tests__/reader.spec.ts +++ b/packages/common/reader/__tests__/reader.spec.ts @@ -17,6 +17,12 @@ const rootDocSnapshot = readFileSync( const docSnapshot = readFileSync( path.join(import.meta.dirname, './__fixtures__/test-doc.snapshot.bin') ); +const docSnapshotWithAiEditable = readFileSync( + path.join( + import.meta.dirname, + './__fixtures__/test-doc-with-ai-editable.snapshot.bin' + ) +); test('should read doc blocks work', async () => { const rootDoc = new YDoc({ @@ -118,3 +124,39 @@ test('should parse page doc work', () => { expect(result).toMatchSnapshot(); }); + +test('should parse page doc work with ai editable', () => { + const doc = new YDoc({ + guid: 'test-doc', + }); + applyUpdate(doc, docSnapshot); + + const result = parsePageDoc({ + workspaceId: 'test-space', + doc, + buildBlobUrl: id => `blob://${id}`, + buildDocUrl: id => `doc://${id}`, + renderDocTitle: id => `Doc Title ${id}`, + aiEditable: true, + }); + + expect(result.md).toMatchSnapshot(); +}); + +test('should parse page full doc work with ai editable', () => { + const doc = new YDoc({ + guid: 'test-doc', + }); + applyUpdate(doc, docSnapshotWithAiEditable); + + const result = parsePageDoc({ + workspaceId: 'test-space', + doc, + buildBlobUrl: id => `blob://${id}`, + buildDocUrl: id => `doc://${id}`, + renderDocTitle: id => `Doc Title ${id}`, + aiEditable: true, + }); + + expect(result.md).toMatchSnapshot(); +}); diff --git a/packages/common/reader/src/doc-parser/parser.ts b/packages/common/reader/src/doc-parser/parser.ts index d3e92cd380..3f36f779cb 100644 --- a/packages/common/reader/src/doc-parser/parser.ts +++ b/packages/common/reader/src/doc-parser/parser.ts @@ -34,7 +34,9 @@ export const parseBlockToMd = ( export function parseBlock( context: ParserContext, yBlock: YBlock | undefined, - yBlocks: YBlocks // all blocks + yBlocks: YBlocks, // all blocks + aiEditable = false, + blockLevel = 0 ): ParsedBlock | null { if (!yBlock) { return null; @@ -73,6 +75,8 @@ export function parseBlock( return result; } + let placeholder = false; + try { switch (flavour) { case 'affine:paragraph': { @@ -100,7 +104,12 @@ export function parseBlock( break; } case 'affine:list': { - result.content = (type === 'bulleted' ? '* ' : '1. ') + toMd() + '\n'; + let prefix = type === 'bulleted' ? '* ' : '1. '; + if (type === 'todo') { + const checked = yBlock.get('prop:checked') as boolean; + prefix = checked ? '- [x] ' : '- [ ] '; + } + result.content = prefix + toMd() + '\n'; break; } case 'affine:code': { @@ -218,7 +227,9 @@ export function parseBlock( const child = parseBlock( context, yBlocks.get(cid) as YBlock | undefined, - yBlocks + yBlocks, + aiEditable, + blockLevel + 1 ); if (!child) { return [cid, '']; @@ -375,6 +386,7 @@ export function parseBlock( } default: { // console.warn("Unknown or unsupported flavour", flavour); + placeholder = true; } } @@ -385,7 +397,9 @@ export function parseBlock( parseBlock( context, yBlocks.get(cid) as YBlock | undefined, - yBlocks + yBlocks, + aiEditable, + blockLevel + 1 ) ) .filter( @@ -397,6 +411,16 @@ export function parseBlock( } catch (e) { console.warn('Error converting block to md', e); } + + if (result.content && aiEditable && blockLevel === 2) { + // add a placeholder comment for the block level 2 + if (flavour === 'affine:database' || placeholder) { + result.content = `\n`; + result.children = []; + } else { + result.content = `\n${result.content}`; + } + } return result; } @@ -416,7 +440,7 @@ export const parsePageDoc = (ctx: ParserContext): ParsedDoc => { } else { const yPage = yBlocks.get(maybePageBlock[0]) as YBlock; const title = yPage.get('prop:title') as YText; - const rootBlock = parseBlock(ctx, yPage, yBlocks); + const rootBlock = parseBlock(ctx, yPage, yBlocks, ctx.aiEditable); if (!rootBlock) { return { title: '', diff --git a/packages/common/reader/src/doc-parser/types.ts b/packages/common/reader/src/doc-parser/types.ts index c88ce3e7bb..e95ba6b5fa 100644 --- a/packages/common/reader/src/doc-parser/types.ts +++ b/packages/common/reader/src/doc-parser/types.ts @@ -149,4 +149,5 @@ export interface ParserContext { buildBlobUrl: (blobId: string) => string; buildDocUrl: (docId: string) => string; renderDocTitle?: (docId: string) => string; + aiEditable?: boolean; } From bd04930560801a28e539bd50ea9d21018f3a5af4 Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Thu, 19 Jun 2025 13:17:01 +0800 Subject: [PATCH 08/45] feat(server): allow chat session dangling & pin session support (#12849) fix AI-181 fix AI-179 fix AI-178 fix PD-2682 fix PD-2683 --- .../migration.sql | 21 + packages/backend/server/schema.prisma | 5 +- .../__tests__/__snapshots__/copilot.e2e.ts.md | 28 + .../__snapshots__/copilot.e2e.ts.snap | Bin 961 -> 1110 bytes .../server/src/__tests__/copilot.e2e.ts | 132 ++++- .../server/src/__tests__/copilot.spec.ts | 16 +- .../__snapshots__/copilot-session.spec.ts.md | 162 ++++++ .../copilot-session.spec.ts.snap | Bin 0 -> 1185 bytes .../__tests__/models/copilot-context.spec.ts | 16 +- .../__tests__/models/copilot-session.spec.ts | 341 ++++++++++++ .../server/src/__tests__/utils/copilot.ts | 68 ++- packages/backend/server/src/base/error/def.ts | 4 + .../server/src/base/error/errors.gen.ts | 7 + .../server/src/models/copilot-session.ts | 362 ++++++++++++- packages/backend/server/src/models/index.ts | 2 + .../server/src/plugins/copilot/resolver.ts | 77 ++- .../server/src/plugins/copilot/session.ts | 512 +++++++----------- .../server/src/plugins/copilot/types.ts | 11 +- packages/backend/server/src/schema.gql | 15 +- .../src/graphql/copilot-history-get-ids.gql | 1 + .../src/graphql/copilot-history-list.gql | 1 + .../src/graphql/copilot-session-get.gql | 2 + .../src/graphql/copilot-sessions-get.gql | 2 + packages/common/graphql/src/graphql/index.ts | 6 + packages/common/graphql/src/schema.ts | 19 +- .../blocksuite/ai/provider/setup-provider.tsx | 1 + packages/frontend/i18n/src/i18n.gen.ts | 4 + packages/frontend/i18n/src/resources/en.json | 1 + 28 files changed, 1422 insertions(+), 394 deletions(-) create mode 100644 packages/backend/server/migrations/20250609063353_ai_session_independence/migration.sql create mode 100644 packages/backend/server/src/__tests__/models/__snapshots__/copilot-session.spec.ts.md create mode 100644 packages/backend/server/src/__tests__/models/__snapshots__/copilot-session.spec.ts.snap create mode 100644 packages/backend/server/src/__tests__/models/copilot-session.spec.ts diff --git a/packages/backend/server/migrations/20250609063353_ai_session_independence/migration.sql b/packages/backend/server/migrations/20250609063353_ai_session_independence/migration.sql new file mode 100644 index 0000000000..6def2babdb --- /dev/null +++ b/packages/backend/server/migrations/20250609063353_ai_session_independence/migration.sql @@ -0,0 +1,21 @@ +-- AlterTable +ALTER TABLE "ai_sessions_metadata" ALTER COLUMN "doc_id" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "ai_sessions_metadata" ADD COLUMN "pinned" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +CREATE UNIQUE INDEX idx_ai_session_unique_pinned +ON ai_sessions_metadata (user_id, workspace_id) +WHERE pinned = true AND deleted_at IS NULL; + +-- AlterTable +CREATE UNIQUE INDEX idx_ai_session_unique_doc_root +ON ai_sessions_metadata (user_id, workspace_id, doc_id) +WHERE parent_session_id IS NULL AND doc_id IS NOT NULL AND deleted_at IS NULL; + +-- DropIndex +DROP INDEX "ai_sessions_metadata_user_id_workspace_id_idx"; + +-- CreateIndex +CREATE INDEX "ai_sessions_metadata_user_id_workspace_id_doc_id_idx" ON "ai_sessions_metadata"("user_id", "workspace_id", "doc_id"); diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index c21e6071bf..51e666f944 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -434,8 +434,9 @@ model AiSession { id String @id @default(uuid()) @db.VarChar userId String @map("user_id") @db.VarChar workspaceId String @map("workspace_id") @db.VarChar - docId String @map("doc_id") @db.VarChar + docId String? @map("doc_id") @db.VarChar promptName String @map("prompt_name") @db.VarChar(32) + pinned Boolean @default(false) // the session id of the parent session if this session is a forked session parentSessionId String? @map("parent_session_id") @db.VarChar messageCost Int @default(0) @@ -449,7 +450,7 @@ model AiSession { context AiContext[] @@index([userId]) - @@index([userId, workspaceId]) + @@index([userId, workspaceId, docId]) @@map("ai_sessions_metadata") } diff --git a/packages/backend/server/src/__tests__/__snapshots__/copilot.e2e.ts.md b/packages/backend/server/src/__tests__/__snapshots__/copilot.e2e.ts.md index f5b84bfb9a..3c0ca41331 100644 --- a/packages/backend/server/src/__tests__/__snapshots__/copilot.e2e.ts.md +++ b/packages/backend/server/src/__tests__/__snapshots__/copilot.e2e.ts.md @@ -135,3 +135,31 @@ Generated by [AVA](https://avajs.dev). ], }, ] + +## should create different session types and validate prompt constraints + +> should create session with should create workspace session with text prompt + + [ + { + pinned: false, + }, + ] + +> should create session with should create pinned session with text prompt + + [ + { + docId: 'pinned-doc', + pinned: true, + }, + ] + +> should create session with should create doc session with text prompt + + [ + { + docId: 'normal-doc', + pinned: false, + }, + ] diff --git a/packages/backend/server/src/__tests__/__snapshots__/copilot.e2e.ts.snap b/packages/backend/server/src/__tests__/__snapshots__/copilot.e2e.ts.snap index f69a3815f81fd489f5669dc85db574c7a11fc6ee..22de4d5a0c97bbb16f02a200f7a37c0ae7254477 100644 GIT binary patch literal 1110 zcmV-c1gZN$RzVYJ6%<@3P%ko$iLQGX7G;HnSv)8R z;s+io-Ib&#-BnFhWp<)B9TY`e5WT3Ny9i#y!+7u@-n@7cWD&dy-USb$CpVq$jOoly zGR!Qy%H)!&PQB{)ey`rYs&cj6idE1v+t-ARY%I2z>8gGlvX)@H6$@q+)57WjyBygr z<7pHb$y3u+*1Uc#47ueml5nwr;fJc^tN?fvz-g+SBL9Ncd_YvRmY0`7OCSQ(h|U*A zZU;aD;H|>2@=R+(1eTm}vtF-*PJkTHNutjI+(jv|*SegN=e$vJm#=YAPG=Mk_(z>`#R&fCkm2^SUuC9ROQ=-=HDQfO`kvtr8*;SRq` zMxIu&C_0PWn8;Wzt%!L`#9I$dCn%1LW!=bFr3Z|+t_AFOe8xw`FnGW8Sb1M8@gmh&|diF$m-y|x$0oT zeJ2`os)Q<-#xyg&d-jSiRDmhh(S6hF$Z9T4priC(YiK*iapkZ$oB%*H8wKqj>4NS{ zxf|w#c@(DKN+Qd6Ka3{gP?Bw^R$d3LI&2UX17HB)D*)F4ED~_OEG5%H`DH2L!O%Bz zASo%62x1;3A}mjB`bom|;6PLR*_xPTa+2s}+Lh`P6P&XpzkNep-?=g51DYYrPR8|9 z%DC2uej;SBtK|C*P;-Ds9N<|8*mU++>MHqPI>3$t{OkaKI=~sXWa7_MCs)XE$yFPT zwMJv@?Bi2tQ^9*e7k#==#%L_pwlvLSjnb$|ZpG93x`<=7!o;S~10}`kd~P<^n&tVY z+mpwL6eb!c*ZK!Bik<4}lON6jlTZMeEniuH0(NHUjhFHLZmc)I=Xz5ky5B*eN6!zNZuW+^9pH)s zeC+_&9pLZTD%5a+b1v|b3%uq6S7)eDmHalwy=Mr=BBudNRr)w(Q@S z$)7GO-7_fT9>tvpxN~IJpbrmz4f3uB{&_u;e462U>UhhcdXT`qdg@T z2VT}#^B;={00000000B+l~0HqMHI%rSKU23yF;=Y5<$U0fqIc;ok?O2OHkq|IVlL@ zA3Rid*UZ#Tch}HWo$TmM1EPol@uY$#2wucPJa`aqUOWjB1h0a3!Gq|@OmBC`>Dg>g z*bND?bD659UiJ5W@4c_D_5DZ(BlGrkX_bxSHaA0^MIrYk7k(tU)jX9po$$-b4!IaB zWfCzqLv77l*TYa)@d`-?OPKzsO>7;&DFDZ*b&S08X(}c}1MT8RQHA z!~otY16$AgTQac3s^!|+8t4d!fsPV=0pJ8VAnzEpV`+>SNCR5Mabo53menJfn0)K* zi2D)1Pc?DQ?&5x}#JL2l5b!M3#JRhPn~|^-=)}szmivbWGLfmUl3TfLr*NnLwIDau zkt{dM!Wd<&NUV&6FQe@z=L-}mWBE`StJ4V=eJj(QnjO~X0k6-UXD$|r7)YMW+qS#R zTxY*E^)!GCz$XB{1MpjOZQXhW{Yd*4!-{5KMe<_UtDRa7hFLOtQN1CnQqS^CL|pM) zF(MvEa$_9!>n}4_*vwQ1$EgmaF)A4pc+F*WA*@NW5qIxuA)Jv!eQ*9xsWx+0R#GPs zL_);zq*Dv=8r{i}Ni}lqZg;cW-8}R3 z9NMuIBbklf5Iw?mPP_(W&CuT#FI)pR1c9ZDWcY4f!X#bl!x(#MM-%^P;_8U0`QjQ9GX z&)id=@!lukLjpb};7bB-H2IC2^ZmZ^n?DM_=@31}Q2NnJ)1{NY;avt?WxzKKxXFNj znq8>t0B0TGRR?&}0j@Q;P@B9z9pG;VIOzhlR9(cQB-T_n36=U`Y4w|5xQ-bcLRRvk(9PUE9z7 diff --git a/packages/backend/server/src/__tests__/copilot.e2e.ts b/packages/backend/server/src/__tests__/copilot.e2e.ts index f0f95f9013..3de76d525c 100644 --- a/packages/backend/server/src/__tests__/copilot.e2e.ts +++ b/packages/backend/server/src/__tests__/copilot.e2e.ts @@ -48,7 +48,11 @@ import { createCopilotContext, createCopilotMessage, createCopilotSession, + createDocCopilotSession, + createPinnedCopilotSession, + createWorkspaceCopilotSession, forkCopilotSession, + getCopilotSession, getHistories, listContext, listContextDocAndFiles, @@ -302,12 +306,8 @@ test('should fork session correctly', async t => { // prepare session const { id } = await createWorkspace(app); - const sessionId = await createCopilotSession( - app, - id, - randomUUID(), - textPromptName - ); + const docId = randomUUID(); + const sessionId = await createCopilotSession(app, id, docId, textPromptName); let forkedSessionId: string; // should be able to fork session @@ -316,7 +316,7 @@ test('should fork session correctly', async t => { const messageId = await createCopilotMessage(app, sessionId); await chatWithText(app, sessionId, messageId); } - const histories = await getHistories(app, { workspaceId: id }); + const histories = await getHistories(app, { workspaceId: id, docId }); const latestMessageId = histories[0].messages.findLast( m => m.role === 'assistant' )?.id; @@ -375,7 +375,7 @@ test('should fork session correctly', async t => { }); await app.switchUser(u1); - const histories = await getHistories(app, { workspaceId: id }); + const histories = await getHistories(app, { workspaceId: id, docId }); const latestMessageId = histories .find(h => h.sessionId === forkedSessionId) ?.messages.findLast(m => m.role === 'assistant')?.id; @@ -612,10 +612,11 @@ test('should be able to retry with api', async t => { // normal chat { const { id } = await createWorkspace(app); + const docId = randomUUID(); const sessionId = await createCopilotSession( app, id, - randomUUID(), + docId, textPromptName ); const messageId = await createCopilotMessage(app, sessionId); @@ -623,7 +624,7 @@ test('should be able to retry with api', async t => { await chatWithText(app, sessionId, messageId); await chatWithText(app, sessionId, messageId); - const histories = await getHistories(app, { workspaceId: id }); + const histories = await getHistories(app, { workspaceId: id, docId }); t.deepEqual( histories.map(h => h.messages.map(m => m.content)), [['generate text to text', 'generate text to text']], @@ -634,10 +635,11 @@ test('should be able to retry with api', async t => { // retry chat { const { id } = await createWorkspace(app); + const docId = randomUUID(); const sessionId = await createCopilotSession( app, id, - randomUUID(), + docId, textPromptName ); const messageId = await createCopilotMessage(app, sessionId); @@ -646,7 +648,7 @@ test('should be able to retry with api', async t => { await chatWithText(app, sessionId); // should only have 1 message - const histories = await getHistories(app, { workspaceId: id }); + const histories = await getHistories(app, { workspaceId: id, docId }); t.snapshot( cleanObject(histories), 'should be able to list history after retry' @@ -656,10 +658,11 @@ test('should be able to retry with api', async t => { // retry chat with new message id { const { id } = await createWorkspace(app); + const docId = randomUUID(); const sessionId = await createCopilotSession( app, id, - randomUUID(), + docId, textPromptName ); const messageId = await createCopilotMessage(app, sessionId); @@ -669,7 +672,7 @@ test('should be able to retry with api', async t => { await chatWithText(app, sessionId, newMessageId, '', true); // should only have 1 message - const histories = await getHistories(app, { workspaceId: id }); + const histories = await getHistories(app, { workspaceId: id, docId }); t.snapshot( cleanObject(histories), 'should be able to list history after retry' @@ -746,10 +749,11 @@ test('should be able to list history', async t => { const { app } = t.context; const { id: workspaceId } = await createWorkspace(app); + const docId = randomUUID(); const sessionId = await createCopilotSession( app, workspaceId, - randomUUID(), + docId, textPromptName ); @@ -757,7 +761,7 @@ test('should be able to list history', async t => { await chatWithText(app, sessionId, messageId); { - const histories = await getHistories(app, { workspaceId }); + const histories = await getHistories(app, { workspaceId, docId }); t.deepEqual( histories.map(h => h.messages.map(m => m.content)), [['hello', 'generate text to text']], @@ -768,6 +772,7 @@ test('should be able to list history', async t => { { const histories = await getHistories(app, { workspaceId, + docId, options: { messageOrder: 'desc' }, }); t.deepEqual( @@ -809,17 +814,18 @@ test('should reject request that user have not permission', async t => { } { + const docId = randomUUID(); const sessionId = await createCopilotSession( app, workspaceId, - randomUUID(), + docId, textPromptName ); const messageId = await createCopilotMessage(app, sessionId); await chatWithText(app, sessionId, messageId); - const histories = await getHistories(app, { workspaceId }); + const histories = await getHistories(app, { workspaceId, docId }); t.deepEqual( histories.map(h => h.messages.map(m => m.content)), [['generate text to text']], @@ -1072,3 +1078,93 @@ test('should be able to transcript', async t => { } } }); + +test('should create different session types and validate prompt constraints', async t => { + const { app } = t.context; + const { id: workspaceId } = await createWorkspace(app); + + const validateSession = async ( + description: string, + workspaceId: string, + createPromise: Promise + ) => { + const sessionId = await createPromise; + + t.truthy(sessionId, description); + t.snapshot( + cleanObject( + [await getCopilotSession(app, workspaceId, sessionId)], + ['id', 'workspaceId', 'promptName'] + ), + `should create session with ${description}` + ); + return sessionId; + }; + + await validateSession( + 'should create workspace session with text prompt', + workspaceId, + createWorkspaceCopilotSession(app, workspaceId, textPromptName) + ); + await validateSession( + 'should create pinned session with text prompt', + workspaceId, + createPinnedCopilotSession(app, workspaceId, 'pinned-doc', textPromptName) + ); + await validateSession( + 'should create doc session with text prompt', + workspaceId, + createDocCopilotSession(app, workspaceId, 'normal-doc', textPromptName) + ); +}); + +test('should list histories for different session types correctly', async t => { + const { app } = t.context; + const { id: workspaceId } = await createWorkspace(app); + const pinnedDocId = 'pinned-doc'; + const docId = 'normal-doc'; + + // create sessions and add messages + const [workspaceSessionId, pinnedSessionId, docSessionId] = await Promise.all( + [ + createWorkspaceCopilotSession(app, workspaceId, textPromptName), + createPinnedCopilotSession(app, workspaceId, pinnedDocId, textPromptName), + createDocCopilotSession(app, workspaceId, docId, textPromptName), + ] + ); + + await Promise.all([ + createCopilotMessage(app, workspaceSessionId, 'workspace message'), + createCopilotMessage(app, pinnedSessionId, 'pinned message'), + createCopilotMessage(app, docSessionId, 'doc message'), + ]); + + const testHistoryQuery = async ( + queryDocId: string | undefined, + expectedSessionId: string, + description: string + ) => { + const histories = await getHistories(app, { + workspaceId, + docId: queryDocId, + }); + t.is(histories.length, 1, `should return ${description}`); + t.is( + histories[0].sessionId, + expectedSessionId, + `should return correct ${description}` + ); + }; + + await testHistoryQuery( + undefined, + workspaceSessionId, + 'workspace session history' + ); + await testHistoryQuery( + pinnedDocId, + pinnedSessionId, + 'pinned session history' + ); + await testHistoryQuery(docId, docSessionId, 'doc session history'); +}); diff --git a/packages/backend/server/src/__tests__/copilot.spec.ts b/packages/backend/server/src/__tests__/copilot.spec.ts index 44724e8c9b..38fb5094d0 100644 --- a/packages/backend/server/src/__tests__/copilot.spec.ts +++ b/packages/backend/server/src/__tests__/copilot.spec.ts @@ -275,7 +275,7 @@ test('should be able to manage chat session', async t => { ]); const params = { word: 'world' }; - const commonParams = { docId: 'test', workspaceId: 'test' }; + const commonParams = { docId: 'test', workspaceId: 'test', pinned: false }; const sessionId = await session.create({ userId, @@ -342,11 +342,12 @@ test('should be able to update chat session prompt', async t => { docId: 'test', workspaceId: 'test', userId, + pinned: false, }); t.truthy(sessionId, 'should create session'); // Update the session - const updatedSessionId = await session.updateSessionPrompt({ + const updatedSessionId = await session.updateSession({ sessionId, promptName: 'Search With AFFiNE AI', userId, @@ -371,7 +372,7 @@ test('should be able to fork chat session', async t => { ]); const params = { word: 'world' }; - const commonParams = { docId: 'test', workspaceId: 'test' }; + const commonParams = { docId: 'test', workspaceId: 'test', pinned: false }; // create session const sessionId = await session.create({ userId, @@ -494,6 +495,7 @@ test('should be able to process message id', async t => { workspaceId: 'test', userId, promptName: 'prompt', + pinned: false, }); const s = (await session.get(sessionId))!; @@ -537,6 +539,7 @@ test('should be able to generate with message id', async t => { workspaceId: 'test', userId, promptName: 'prompt', + pinned: false, }); const s = (await session.get(sessionId))!; @@ -559,6 +562,7 @@ test('should be able to generate with message id', async t => { workspaceId: 'test', userId, promptName: 'prompt', + pinned: false, }); const s = (await session.get(sessionId))!; @@ -586,6 +590,7 @@ test('should be able to generate with message id', async t => { workspaceId: 'test', userId, promptName: 'prompt', + pinned: false, }); const s = (await session.get(sessionId))!; @@ -614,6 +619,7 @@ test('should save message correctly', async t => { workspaceId: 'test', userId, promptName: 'prompt', + pinned: false, }); const s = (await session.get(sessionId))!; @@ -643,6 +649,7 @@ test('should revert message correctly', async t => { workspaceId: 'test', userId, promptName: 'prompt', + pinned: false, }); const s = (await session.get(sessionId))!; @@ -742,6 +749,7 @@ test('should handle params correctly in chat session', async t => { workspaceId: 'test', userId, promptName: 'prompt', + pinned: false, }); const s = (await session.get(sessionId))!; @@ -1506,6 +1514,7 @@ test('should be able to manage context', async t => { workspaceId: 'test', userId, promptName: 'prompt', + pinned: false, }); // use mocked embedding client @@ -1729,6 +1738,7 @@ test('should be able to manage workspace embedding', async t => { workspaceId: ws.id, userId, promptName: 'prompt', + pinned: false, }); const contextSession = await context.create(sessionId); diff --git a/packages/backend/server/src/__tests__/models/__snapshots__/copilot-session.spec.ts.md b/packages/backend/server/src/__tests__/models/__snapshots__/copilot-session.spec.ts.md new file mode 100644 index 0000000000..301b6c87c9 --- /dev/null +++ b/packages/backend/server/src/__tests__/models/__snapshots__/copilot-session.spec.ts.md @@ -0,0 +1,162 @@ +# Snapshot report for `src/__tests__/models/copilot-session.spec.ts` + +The actual snapshot is saved in `copilot-session.spec.ts.snap`. + +Generated by [AVA](https://avajs.dev). + +## should list and filter session type + +> workspace sessions should include workspace and pinned sessions + + [ + { + docId: null, + pinned: true, + }, + { + docId: null, + pinned: false, + }, + ] + +> doc sessions should only include sessions with matching docId + + [ + { + docId: 'doc-id-1', + pinned: false, + }, + ] + +> session type identification results + + [ + { + session: { + docId: null, + pinned: false, + }, + type: 'workspace', + }, + { + session: { + docId: undefined, + pinned: false, + }, + type: 'workspace', + }, + { + session: { + docId: null, + pinned: true, + }, + type: 'pinned', + }, + { + session: { + docId: 'doc-id-1', + pinned: false, + }, + type: 'doc', + }, + ] + +## should pin and unpin sessions + +> session states after creating second pinned session + + [ + { + docId: null, + id: 'first-session-id', + pinned: false, + }, + { + docId: null, + id: 'second-session-id', + pinned: true, + }, + ] + +> should return false when no sessions to unpin + + false + +> all sessions should be unpinned after unpin operation + + [ + { + id: 'first-session-id', + pinned: false, + }, + { + id: 'second-session-id', + pinned: false, + }, + { + id: 'third-session-id', + pinned: false, + }, + ] + +## session updates and type conversions + +> session states after pinning - should unpin existing + + [ + { + docId: null, + id: 'session-update-id', + pinned: true, + }, + { + docId: null, + id: 'existing-pinned-session-id', + pinned: false, + }, + ] + +> session state after unpinning + + { + docId: null, + id: 'session-update-id', + pinned: false, + } + +> session type conversion steps + + [ + { + session: { + docId: 'doc-update-id', + pinned: false, + }, + step: 'workspace_to_doc', + type: 'doc', + }, + { + session: { + docId: 'doc-update-id', + pinned: true, + }, + step: 'doc_to_pinned', + type: 'pinned', + }, + { + session: { + docId: null, + pinned: false, + }, + step: 'pinned_to_workspace', + type: 'workspace', + }, + { + session: { + docId: null, + pinned: true, + }, + step: 'workspace_to_pinned', + type: 'pinned', + }, + ] diff --git a/packages/backend/server/src/__tests__/models/__snapshots__/copilot-session.spec.ts.snap b/packages/backend/server/src/__tests__/models/__snapshots__/copilot-session.spec.ts.snap new file mode 100644 index 0000000000000000000000000000000000000000..7c87d7102de6a1b76b7a1f519869369db8a37dcb GIT binary patch literal 1185 zcmV;S1YY|=RzV7bO19TtA z0L>8{jxWv;t?2I*L+7J{4J?qc zofxRrqM92xuei}CHlKSo(-XeWv-C!Zmhzr2E=40GzH zw~QrR?kmv|j;-|1LN5EBlIfz_naD>^_>offeSJ#xV~Z7ab}B3C-E2jf7Xf?+;8y@Q zO4pRRK)^!;>?go3UsbHaR1A^f-Ry`^s&;}yTe#)(zspK3$(_J=$1K%IM!YW_u~fF= z(y}|+T{t1Pl@|IqXKwAq6Fid5&p=9;c0z)XtA6NP9ov)KTJ3V*@`K?nQ9;a(lnFnN zFu_~|a1g*D07uK%z#{!0^TekM4i}xfU^W# zELkpFypogg;||%XaiIK{Q(X~`oh{+Oe%td3recZ5BiAg7DHiKP(BokwC#j4)mh!RR zb7MmKZY)1e;IHuTztrS|d#NTj%hhB%0XqmdK)~`nQ6QVACddZfbIr*nZZ{ar4`$ z`r|K$68Fo{AK%FQvH3Uwy9qc%z?o9TGtU$71pylbTq*U*<}Cu40X$*=NAgy#;FZnL z00std-T*F@`(+k+%xhzKOsRM;lc~(=o>alf$Z(b!(yUg4RjW1c29BB%%#UsP z { await t.context.module.initTestingDB(); - await t.context.copilotSession.createPrompt('prompt-name', 'gpt-4o'); + await t.context.copilotSession.createPrompt('prompt-name', 'gpt-4.1'); user = await t.context.user.create({ email: 'test@affine.pro', }); diff --git a/packages/backend/server/src/__tests__/models/copilot-session.spec.ts b/packages/backend/server/src/__tests__/models/copilot-session.spec.ts new file mode 100644 index 0000000000..36cb4cc040 --- /dev/null +++ b/packages/backend/server/src/__tests__/models/copilot-session.spec.ts @@ -0,0 +1,341 @@ +import { randomUUID } from 'node:crypto'; + +import { PrismaClient, User, Workspace } from '@prisma/client'; +import ava, { ExecutionContext, TestFn } from 'ava'; + +import { CopilotPromptInvalid } from '../../base'; +import { + CopilotSessionModel, + UpdateChatSessionData, + UserModel, + WorkspaceModel, +} from '../../models'; +import { createTestingModule, type TestingModule } from '../utils'; + +interface Context { + module: TestingModule; + db: PrismaClient; + user: UserModel; + workspace: WorkspaceModel; + copilotSession: CopilotSessionModel; +} + +const test = ava as TestFn; + +test.before(async t => { + const module = await createTestingModule(); + t.context.user = module.get(UserModel); + t.context.workspace = module.get(WorkspaceModel); + t.context.copilotSession = module.get(CopilotSessionModel); + t.context.db = module.get(PrismaClient); + t.context.module = module; +}); + +let user: User; +let workspace: Workspace; + +test.beforeEach(async t => { + await t.context.module.initTestingDB(); + user = await t.context.user.create({ + email: 'test@affine.pro', + }); + workspace = await t.context.workspace.create(user.id); +}); + +test.after(async t => { + await t.context.module.close(); +}); + +const createTestPrompts = async ( + copilotSession: CopilotSessionModel, + db: PrismaClient +) => { + await copilotSession.createPrompt('test-prompt', 'gpt-4.1'); + await db.aiPrompt.create({ + data: { name: 'action-prompt', model: 'gpt-4.1', action: 'edit' }, + }); +}; + +const createTestSession = async ( + t: ExecutionContext, + overrides: Partial<{ + sessionId: string; + userId: string; + workspaceId: string; + docId: string | null; + pinned: boolean; + promptName: string; + }> = {} +) => { + const sessionData = { + sessionId: randomUUID(), + userId: user.id, + workspaceId: workspace.id, + docId: null, + pinned: false, + promptName: 'test-prompt', + ...overrides, + }; + + await t.context.copilotSession.create(sessionData); + return sessionData; +}; + +const getSessionState = async (db: PrismaClient, sessionId: string) => { + const session = await db.aiSession.findUnique({ + where: { id: sessionId }, + select: { id: true, pinned: true, docId: true }, + }); + return session; +}; + +test('should list and filter session type', async t => { + const { copilotSession, db } = t.context; + + await createTestPrompts(copilotSession, db); + + const docId = 'doc-id-1'; + await createTestSession(t, { sessionId: randomUUID() }); + await createTestSession(t, { sessionId: randomUUID(), pinned: true }); + await createTestSession(t, { sessionId: randomUUID(), docId }); + + // should list sessions + { + const workspaceSessions = await copilotSession.list(user.id, workspace.id); + + t.snapshot( + workspaceSessions.map(s => ({ docId: s.docId, pinned: s.pinned })), + 'workspace sessions should include workspace and pinned sessions' + ); + } + + { + const docSessions = await copilotSession.list(user.id, workspace.id, docId); + + t.snapshot( + docSessions.map(s => ({ docId: s.docId, pinned: s.pinned })), + 'doc sessions should only include sessions with matching docId' + ); + } + + // should identify session types + { + // check get session type + const testCases = [ + { docId: null, pinned: false }, + { docId: undefined, pinned: false }, + { docId: null, pinned: true }, + { docId, pinned: false }, + ]; + + const sessionTypeResults = testCases.map(session => ({ + session, + type: copilotSession.getSessionType(session), + })); + + t.snapshot(sessionTypeResults, 'session type identification results'); + } +}); + +test('should check session validation for prompts', async t => { + const { copilotSession, db } = t.context; + + await createTestPrompts(copilotSession, db); + + const docId = randomUUID(); + const sessionTypes = [ + { name: 'workspace', session: { docId: null, pinned: false } }, + { name: 'pinned', session: { docId: null, pinned: true } }, + { name: 'doc', session: { docId, pinned: false } }, + ]; + + // non-action prompts should work for all session types + sessionTypes.forEach(({ name, session }) => { + t.notThrows( + () => + copilotSession.checkSessionPrompt(session, 'test-prompt', undefined), + `${name} session should allow non-action prompts` + ); + }); + + // action prompts should only work for doc session type + { + const actionPromptTests = [ + { + name: 'workspace', + session: sessionTypes[0].session, + shouldThrow: true, + }, + { name: 'pinned', session: sessionTypes[1].session, shouldThrow: true }, + { name: 'doc', session: sessionTypes[2].session, shouldThrow: false }, + ]; + + actionPromptTests.forEach(({ name, session, shouldThrow }) => { + if (shouldThrow) { + t.throws( + () => + copilotSession.checkSessionPrompt(session, 'action-prompt', 'edit'), + { instanceOf: CopilotPromptInvalid }, + `${name} session should reject action prompts` + ); + } else { + t.notThrows( + () => + copilotSession.checkSessionPrompt(session, 'action-prompt', 'edit'), + `${name} session should allow action prompts` + ); + } + }); + } +}); + +test('should pin and unpin sessions', async t => { + const { copilotSession, db } = t.context; + + await createTestPrompts(copilotSession, db); + + const firstSessionId = 'first-session-id'; + const secondSessionId = 'second-session-id'; + const thirdSessionId = 'third-session-id'; + + // should unpin existing pinned session when creating a new one + { + await copilotSession.create({ + sessionId: firstSessionId, + userId: user.id, + workspaceId: workspace.id, + docId: null, + promptName: 'test-prompt', + pinned: true, + }); + + const firstSession = await copilotSession.get(firstSessionId); + t.truthy(firstSession, 'first session should be created successfully'); + t.is(firstSession?.pinned, true, 'first session should be pinned'); + + // should unpin the first one when creating second pinned session + await copilotSession.create({ + sessionId: secondSessionId, + userId: user.id, + workspaceId: workspace.id, + docId: null, + promptName: 'test-prompt', + pinned: true, + }); + + const sessionStatesAfterSecondPin = await Promise.all([ + getSessionState(db, firstSessionId), + getSessionState(db, secondSessionId), + ]); + + t.snapshot( + sessionStatesAfterSecondPin, + 'session states after creating second pinned session' + ); + } + + // should can unpin a pinned session + { + await createTestSession(t, { sessionId: thirdSessionId, pinned: true }); + const unpinResult = await copilotSession.unpin(workspace.id, user.id); + t.is( + unpinResult, + true, + 'unpin operation should return true when sessions are unpinned' + ); + + const unpinResultAgain = await copilotSession.unpin(workspace.id, user.id); + t.snapshot( + unpinResultAgain, + 'should return false when no sessions to unpin' + ); + } + + // should unpin all sessions + { + const allSessionsAfterUnpin = await db.aiSession.findMany({ + where: { id: { in: [firstSessionId, secondSessionId, thirdSessionId] } }, + select: { pinned: true, id: true }, + orderBy: { id: 'asc' }, + }); + + t.snapshot( + allSessionsAfterUnpin, + 'all sessions should be unpinned after unpin operation' + ); + } +}); + +test('session updates and type conversions', async t => { + const { copilotSession, db } = t.context; + + await createTestPrompts(copilotSession, db); + + const sessionId = 'session-update-id'; + const docId = 'doc-update-id'; + + await createTestSession(t, { sessionId }); + + // should unpin existing pinned session + { + const existingPinnedId = 'existing-pinned-session-id'; + await createTestSession(t, { sessionId: existingPinnedId, pinned: true }); + + await copilotSession.update(user.id, sessionId, { pinned: true }); + + const sessionStatesAfterPin = await Promise.all([ + getSessionState(db, sessionId), + getSessionState(db, existingPinnedId), + ]); + + t.snapshot( + sessionStatesAfterPin, + 'session states after pinning - should unpin existing' + ); + } + + // should unpin the session + { + await copilotSession.update(user.id, sessionId, { pinned: false }); + const sessionStateAfterUnpin = await getSessionState(db, sessionId); + t.snapshot(sessionStateAfterUnpin, 'session state after unpinning'); + } + + // should convert session types + { + const conversionSteps: any[] = []; + + let session = await db.aiSession.findUnique({ + where: { id: sessionId }, + select: { docId: true, pinned: true }, + }); + + const convertSession = async ( + step: string, + data: UpdateChatSessionData + ) => { + await copilotSession.update(user.id, sessionId, data); + session = await db.aiSession.findUnique({ + where: { id: sessionId }, + select: { docId: true, pinned: true }, + }); + conversionSteps.push({ + step, + session, + type: copilotSession.getSessionType(session!), + }); + }; + + { + await convertSession('workspace_to_doc', { docId }); // Workspace → Doc session + await convertSession('doc_to_pinned', { pinned: true }); // Doc → Pinned session + await convertSession('pinned_to_workspace', { + pinned: false, + docId: null, + }); // Pinned → Workspace session + await convertSession('workspace_to_pinned', { pinned: true }); // Workspace → Pinned session + } + + t.snapshot(conversionSteps, 'session type conversion steps'); + } +}); diff --git a/packages/backend/server/src/__tests__/utils/copilot.ts b/packages/backend/server/src/__tests__/utils/copilot.ts index a907ea671c..30009e8516 100644 --- a/packages/backend/server/src/__tests__/utils/copilot.ts +++ b/packages/backend/server/src/__tests__/utils/copilot.ts @@ -20,8 +20,9 @@ export const cleanObject = ( export async function createCopilotSession( app: TestingApp, workspaceId: string, - docId: string, - promptName: string + docId: string | null, + promptName: string, + pinned: boolean = false ): Promise { const res = await app.gql( ` @@ -29,12 +30,73 @@ export async function createCopilotSession( createCopilotSession(options: $options) } `, - { options: { workspaceId, docId, promptName } } + { options: { workspaceId, docId, promptName, pinned } } ); return res.createCopilotSession; } +export async function createWorkspaceCopilotSession( + app: TestingApp, + workspaceId: string, + promptName: string +): Promise { + return createCopilotSession(app, workspaceId, null, promptName); +} + +export async function createPinnedCopilotSession( + app: TestingApp, + workspaceId: string, + docId: string, + promptName: string +): Promise { + return createCopilotSession(app, workspaceId, docId, promptName, true); +} + +export async function createDocCopilotSession( + app: TestingApp, + workspaceId: string, + docId: string, + promptName: string +): Promise { + return createCopilotSession(app, workspaceId, docId, promptName); +} + +export async function getCopilotSession( + app: TestingApp, + workspaceId: string, + sessionId: string +): Promise<{ + id: string; + docId: string | null; + parentSessionId: string | null; + pinned: boolean; + promptName: string; +}> { + const res = await app.gql( + ` + query getCopilotSession( + $workspaceId: String! + $sessionId: String! + ) { + currentUser { + copilot(workspaceId: $workspaceId) { + session(sessionId: $sessionId) { + id + docId + parentSessionId + pinned + promptName + } + } + } + }`, + { workspaceId, sessionId } + ); + + return res.currentUser?.copilot?.session; +} + export async function updateCopilotSession( app: TestingApp, sessionId: string, diff --git a/packages/backend/server/src/base/error/def.ts b/packages/backend/server/src/base/error/def.ts index ba27d64909..27ea6d77ad 100644 --- a/packages/backend/server/src/base/error/def.ts +++ b/packages/backend/server/src/base/error/def.ts @@ -643,6 +643,10 @@ export const USER_FRIENDLY_ERRORS = { type: 'resource_not_found', message: `Copilot session not found.`, }, + copilot_session_invalid_input: { + type: 'invalid_input', + message: `Copilot session input is invalid.`, + }, copilot_session_deleted: { type: 'action_forbidden', message: `Copilot session has been deleted.`, diff --git a/packages/backend/server/src/base/error/errors.gen.ts b/packages/backend/server/src/base/error/errors.gen.ts index a57749436f..be86944d96 100644 --- a/packages/backend/server/src/base/error/errors.gen.ts +++ b/packages/backend/server/src/base/error/errors.gen.ts @@ -657,6 +657,12 @@ export class CopilotSessionNotFound extends UserFriendlyError { } } +export class CopilotSessionInvalidInput extends UserFriendlyError { + constructor(message?: string) { + super('invalid_input', 'copilot_session_invalid_input', message); + } +} + export class CopilotSessionDeleted extends UserFriendlyError { constructor(message?: string) { super('action_forbidden', 'copilot_session_deleted', message); @@ -1145,6 +1151,7 @@ export enum ErrorNames { WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION, WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION, COPILOT_SESSION_NOT_FOUND, + COPILOT_SESSION_INVALID_INPUT, COPILOT_SESSION_DELETED, NO_COPILOT_PROVIDER_AVAILABLE, COPILOT_FAILED_TO_GENERATE_TEXT, diff --git a/packages/backend/server/src/models/copilot-session.ts b/packages/backend/server/src/models/copilot-session.ts index 106acdf0cd..c335ae364a 100644 --- a/packages/backend/server/src/models/copilot-session.ts +++ b/packages/backend/server/src/models/copilot-session.ts @@ -1,36 +1,366 @@ import { Injectable } from '@nestjs/common'; +import { Transactional } from '@nestjs-cls/transactional'; +import { AiPromptRole, Prisma } from '@prisma/client'; +import { omit } from 'lodash-es'; +import { + CopilotPromptInvalid, + CopilotSessionDeleted, + CopilotSessionInvalidInput, + CopilotSessionNotFound, +} from '../base'; import { BaseModel } from './base'; -interface ChatSessionState { +export enum SessionType { + Workspace = 'workspace', // docId is null and pinned is false + Pinned = 'pinned', // pinned is true + Doc = 'doc', // docId points to specific document +} + +type ChatAttachment = { attachment: string; mimeType: string } | string; + +type ChatStreamObject = { + type: 'text-delta' | 'reasoning' | 'tool-call' | 'tool-result'; + textDelta?: string; + toolCallId?: string; + toolName?: string; + args?: Record; + result?: any; +}; + +type ChatMessage = { + id?: string | undefined; + role: 'system' | 'assistant' | 'user'; + content: string; + attachments?: ChatAttachment[] | null; + params?: Record | null; + streamObjects?: ChatStreamObject[] | null; + createdAt: Date; +}; + +type ChatSession = { sessionId: string; workspaceId: string; - docId: string; + docId?: string | null; + pinned?: boolean; + messages?: ChatMessage[]; // connect ids userId: string; promptName: string; -} + parentSessionId?: string | null; +}; + +export type UpdateChatSessionData = Partial< + Pick +>; +export type UpdateChatSession = Pick & + UpdateChatSessionData; + +export type ListSessionOptions = { + sessionId: string | undefined; + action: boolean | undefined; + fork: boolean | undefined; + limit: number | undefined; + skip: number | undefined; + sessionOrder: 'asc' | 'desc' | undefined; + messageOrder: 'asc' | 'desc' | undefined; +}; -// TODO(@darkskygit): not ready to replace business codes yet, just for test @Injectable() export class CopilotSessionModel extends BaseModel { - async create(state: ChatSessionState) { - const row = await this.db.aiSession.create({ - data: { - id: state.sessionId, - workspaceId: state.workspaceId, - docId: state.docId, - // connect - userId: state.userId, - promptName: state.promptName, - }, - }); - return row; + getSessionType(session: Pick): SessionType { + if (session.pinned) return SessionType.Pinned; + if (!session.docId) return SessionType.Workspace; + return SessionType.Doc; } + checkSessionPrompt( + session: Pick, + promptName: string, + promptAction: string | undefined + ): boolean { + const sessionType = this.getSessionType(session); + + // workspace and pinned sessions cannot use action prompts + if ( + [SessionType.Workspace, SessionType.Pinned].includes(sessionType) && + !!promptAction?.trim() + ) { + throw new CopilotPromptInvalid( + `${promptName} are not allowed for ${sessionType} sessions` + ); + } + + return true; + } + + // NOTE: just for test, remove it after copilot prompt model is ready async createPrompt(name: string, model: string) { await this.db.aiPrompt.create({ data: { name, model }, }); } + + @Transactional() + async create(state: ChatSession) { + if (state.pinned) { + await this.unpin(state.workspaceId, state.userId); + } + + const row = await this.db.aiSession.create({ + data: { + id: state.sessionId, + workspaceId: state.workspaceId, + docId: state.docId, + pinned: state.pinned ?? false, + // connect + userId: state.userId, + promptName: state.promptName, + parentSessionId: state.parentSessionId, + }, + }); + return row; + } + + @Transactional() + async has( + sessionId: string, + userId: string, + params?: Prisma.AiSessionCountArgs['where'] + ) { + return await this.db.aiSession + .count({ where: { id: sessionId, userId, ...params } }) + .then(c => c > 0); + } + + @Transactional() + async getChatSessionId(state: Omit) { + const extraCondition: Record = {}; + if (state.parentSessionId) { + // also check session id if provided session is forked session + extraCondition.id = state.sessionId; + extraCondition.parentSessionId = state.parentSessionId; + } + + const session = await this.db.aiSession.findFirst({ + where: { + userId: state.userId, + workspaceId: state.workspaceId, + docId: state.docId, + parentSessionId: null, + prompt: { action: { equals: null } }, + ...extraCondition, + }, + select: { id: true, deletedAt: true }, + }); + if (session?.deletedAt) throw new CopilotSessionDeleted(); + return session?.id; + } + + @Transactional() + async getExists