Compare commits

...

20 Commits

Author SHA1 Message Date
Peng Xiao 5758d5eba5 chore: test o4-transcribe 2025-07-16 16:57:56 +08:00
EYHN cdff5c3117 feat(core): add context menu for navigation and explorer (#13216)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

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

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

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

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

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

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

## Summary by CodeRabbit

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

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

#### PR Dependency Tree


* **PR #13224** 👈

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

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

## Summary by CodeRabbit

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

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

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

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

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

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

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

## Summary by CodeRabbit

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

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

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

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

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

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

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

## Summary by CodeRabbit

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

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

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

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

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

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

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

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

## Summary by CodeRabbit

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

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

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

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

## Summary by CodeRabbit

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

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

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


* **PR #13214** 👈

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

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

* **New Features**
* Added a notification icon with a live badge displaying the
notification count in the mobile home header. The badge dynamically
adjusts and caps the count at "99+".
* Introduced a notification menu in the mobile header, allowing users to
view their notifications directly.

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

## Summary by CodeRabbit

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

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

## Summary by CodeRabbit

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

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


* **PR #13213** 👈

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

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

## Summary by CodeRabbit

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

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-15 06:45:05 +00:00
Wu Yue b85afa7394 refactor(core): extract ai-chat-panel-title component (#13209)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Introduced a dedicated AI chat panel title bar with dynamic embedding
progress display and an optional playground button.
* Added a modal playground interface accessible from the chat panel
title when enabled.

* **Refactor**
* Moved the chat panel title and related UI logic into a new, reusable
component for improved modularity.
* Simplified the chat content area by removing the internal chat title
rendering and related methods.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-15 02:56:57 +00:00
76 changed files with 1471 additions and 507 deletions
+1 -1
View File
@@ -465,7 +465,7 @@ jobs:
name: ${{ env.RELEASE_VERSION }}
draft: ${{ inputs.build-type == 'stable' }}
prerelease: ${{ inputs.build-type != 'stable' }}
tag_name: ${{ env.RELEASE_VERSION}}
tag_name: v${{ env.RELEASE_VERSION}}
files: |
./release/*
./release/.env.example
+1
View File
@@ -34,6 +34,7 @@ permissions:
packages: write
security-events: write
attestations: write
issues: write
jobs:
prepare:
@@ -39,6 +39,13 @@ export class CodeBlockHighlighter extends LifeCycleWatcher {
private readonly _loadTheme = async (
highlighter: HighlighterCore
): Promise<void> => {
// It is possible that by the time the highlighter is ready all instances
// have already been unmounted. In that case there is no need to load
// themes or update state.
if (CodeBlockHighlighter._refCount === 0) {
return;
}
const config = this.std.getOptional(CodeBlockConfigExtension.identifier);
const darkTheme = config?.theme?.dark ?? CODE_BLOCK_DEFAULT_DARK_THEME;
const lightTheme = config?.theme?.light ?? CODE_BLOCK_DEFAULT_LIGHT_THEME;
@@ -78,14 +85,27 @@ export class CodeBlockHighlighter extends LifeCycleWatcher {
override unmounted(): void {
CodeBlockHighlighter._refCount--;
// Only dispose the shared highlighter when no instances are using it
if (
CodeBlockHighlighter._refCount === 0 &&
CodeBlockHighlighter._sharedHighlighter
) {
CodeBlockHighlighter._sharedHighlighter.dispose();
// Dispose the shared highlighter **after** any in-flight creation finishes.
if (CodeBlockHighlighter._refCount !== 0) {
return;
}
const doDispose = (highlighter: HighlighterCore | null) => {
if (highlighter) {
highlighter.dispose();
}
CodeBlockHighlighter._sharedHighlighter = null;
CodeBlockHighlighter._highlighterPromise = null;
};
if (CodeBlockHighlighter._sharedHighlighter) {
// Highlighter already created dispose immediately.
doDispose(CodeBlockHighlighter._sharedHighlighter);
} else if (CodeBlockHighlighter._highlighterPromise) {
// Highlighter still being created wait for it, then dispose.
CodeBlockHighlighter._highlighterPromise
.then(doDispose)
.catch(console.error);
}
}
}
@@ -396,6 +396,15 @@ Generated by [AVA](https://avajs.dev).
},
],
},
{
args: [
'copilot.workspace.cleanupTrashedDocEmbeddings',
{},
{
jobId: 'daily-copilot-cleanup-trashed-doc-embeddings',
},
],
},
]
> cleanup empty sessions calls
@@ -164,11 +164,14 @@ test('should insert embedding by doc id', async t => {
);
{
const ret = await t.context.copilotContext.hasWorkspaceEmbedding(
const ret = await t.context.copilotContext.listWorkspaceEmbedding(
workspace.id,
[docId]
);
t.true(ret.has(docId), 'should return doc id when embedding is inserted');
t.true(
ret.includes(docId),
'should return doc id when embedding is inserted'
);
}
{
@@ -317,8 +320,8 @@ test('should merge doc status correctly', async t => {
const hasEmbeddingStub = Sinon.stub(
t.context.copilotContext,
'hasWorkspaceEmbedding'
).resolves(new Set<string>());
'listWorkspaceEmbedding'
).resolves([]);
const stubResult = await t.context.copilotContext.mergeDocStatus(
workspace.id,
@@ -214,6 +214,21 @@ test('should insert and search embedding', async t => {
);
t.false(results.includes(docId), 'docs containing `$` should be excluded');
}
{
const docId = 'empty_doc';
await t.context.doc.upsert({
spaceId: workspace.id,
docId: docId,
blob: Uint8Array.from([0, 0]),
timestamp: Date.now(),
editorId: user.id,
});
const results = await t.context.copilotWorkspace.findDocsToEmbed(
workspace.id
);
t.false(results.includes(docId), 'empty documents should be excluded');
}
});
test('should check need to be embedded', async t => {
@@ -1,3 +1,5 @@
import { setTimeout } from 'node:timers/promises';
import { defer as rxjsDefer, retry } from 'rxjs';
export class RetryablePromise<T> extends Promise<T> {
@@ -48,3 +50,7 @@ export function defer(dispose: () => Promise<void>) {
[Symbol.asyncDispose]: dispose,
};
}
export function sleep(ms: number): Promise<void> {
return setTimeout(ms);
}
+16 -4
View File
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { getStreamAsBuffer } from 'get-stream';
import { JOB_SIGNAL, OnJob } from '../../base';
import { JOB_SIGNAL, OnJob, sleep } from '../../base';
import { type MailName, MailProps, Renderers } from '../../mails';
import { UserProps, WorkspaceProps } from '../../mails/components';
import { Models } from '../../models';
@@ -34,7 +34,7 @@ type SendMailJob<Mail extends MailName = MailName, Props = MailProps<Mail>> = {
declare global {
interface Jobs {
'notification.sendMail': {
'notification.sendMail': { startTime: number } & {
[K in MailName]: SendMailJob<K>;
}[MailName];
}
@@ -50,7 +50,12 @@ export class MailJob {
) {}
@OnJob('notification.sendMail')
async sendMail({ name, to, props }: Jobs['notification.sendMail']) {
async sendMail({
startTime,
name,
to,
props,
}: Jobs['notification.sendMail']) {
let options: Partial<SendOptions> = {};
for (const key in props) {
@@ -100,8 +105,15 @@ export class MailJob {
)),
...options,
});
if (result === false) {
// wait for a while before retrying
const elapsed = Date.now() - startTime;
const retryDelay = Math.min(30 * 1000, Math.round(elapsed / 2000) * 1000);
await sleep(retryDelay);
return JOB_SIGNAL.Retry;
}
return result === false ? JOB_SIGNAL.Retry : undefined;
return undefined;
}
private async fetchWorkspaceProps(workspaceId: string) {
@@ -15,11 +15,14 @@ export class Mailer {
*
* @note never throw
*/
async trySend(command: Jobs['notification.sendMail']) {
async trySend(command: Omit<Jobs['notification.sendMail'], 'startTime'>) {
return this.send(command, true);
}
async send(command: Jobs['notification.sendMail'], suppressError = false) {
async send(
command: Omit<Jobs['notification.sendMail'], 'startTime'>,
suppressError = false
) {
if (!this.sender.configured) {
if (suppressError) {
return false;
@@ -28,7 +31,12 @@ export class Mailer {
}
try {
await this.queue.add('notification.sendMail', command);
await this.queue.add(
'notification.sendMail',
Object.assign({}, command, {
startTime: Date.now(),
}) as Jobs['notification.sendMail']
);
return true;
} catch {
return false;
@@ -84,11 +84,17 @@ export class CopilotContextModel extends BaseModel {
}
async mergeDocStatus(workspaceId: string, docs: ContextDoc[]) {
const docIds = Array.from(new Set(docs.map(doc => doc.id)));
const finishedDoc = await this.hasWorkspaceEmbedding(workspaceId, docIds);
const canEmbedding = await this.checkEmbeddingAvailable();
const finishedDoc = canEmbedding
? await this.listWorkspaceEmbedding(
workspaceId,
Array.from(new Set(docs.map(doc => doc.id)))
)
: [];
const finishedDocSet = new Set(finishedDoc);
for (const doc of docs) {
const status = finishedDoc.has(doc.id)
const status = finishedDocSet.has(doc.id)
? ContextEmbedStatus.finished
: undefined;
// NOTE: when the document has not been synchronized to the server or is in the embedding queue
@@ -120,24 +126,17 @@ export class CopilotContextModel extends BaseModel {
return Number(count) === 2;
}
async hasWorkspaceEmbedding(workspaceId: string, docIds: string[]) {
const canEmbedding = await this.checkEmbeddingAvailable();
if (!canEmbedding) {
return new Set();
}
async listWorkspaceEmbedding(workspaceId: string, docIds?: string[]) {
const existsIds = await this.db.aiWorkspaceEmbedding
.findMany({
.groupBy({
where: {
workspaceId,
docId: { in: docIds },
},
select: {
docId: true,
docId: docIds ? { in: docIds } : undefined,
},
by: ['docId'],
})
.then(r => r.map(r => r.docId));
return new Set(existsIds);
return existsIds;
}
private processEmbeddings(
@@ -58,10 +58,12 @@ export class CopilotWorkspaceConfigModel extends BaseModel {
ON id.workspace_id = s.workspace_id
AND id.doc_id = s.guid
WHERE s.workspace_id = ${workspaceId}
AND s.guid != s.workspace_id
AND s.guid <> s.workspace_id
AND s.guid NOT LIKE '%$%'
AND s.guid NOT LIKE '%:settings:%'
AND e.doc_id IS NULL
AND id.doc_id IS NULL;`;
AND id.doc_id IS NULL
AND s.blob <> E'\\\\x0000';`;
return docIds.map(r => r.id);
}
@@ -160,6 +162,8 @@ export class CopilotWorkspaceConfigModel extends BaseModel {
{ id: { notIn: ignoredDocIds } },
{ id: { not: workspaceId } },
{ id: { not: { contains: '$' } } },
{ id: { not: { contains: ':settings:' } } },
{ blob: { not: new Uint8Array([0, 0]) } },
],
};
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import { type Workspace } from '@prisma/client';
import { Prisma, type Workspace } from '@prisma/client';
import { EventBus } from '../base';
import { BaseModel } from './base';
@@ -93,6 +93,19 @@ export class WorkspaceModel extends BaseModel {
});
}
async list<S extends Prisma.WorkspaceSelect>(
where: Prisma.WorkspaceWhereInput = {},
select?: S
) {
return (await this.db.workspace.findMany({
where,
select,
orderBy: {
sid: 'asc',
},
})) as Prisma.WorkspaceGetPayload<{ select: S }>[];
}
async delete(workspaceId: string) {
const rawResult = await this.db.workspace.deleteMany({
where: {
@@ -8,6 +8,7 @@ declare global {
interface Jobs {
'copilot.session.cleanupEmptySessions': {};
'copilot.session.generateMissingTitles': {};
'copilot.workspace.cleanupTrashedDocEmbeddings': {};
}
}
@@ -20,6 +21,14 @@ export class CopilotCronJobs {
private readonly jobs: JobQueue
) {}
async triggerCleanupTrashedDocEmbeddings() {
await this.jobs.add(
'copilot.workspace.cleanupTrashedDocEmbeddings',
{},
{ jobId: 'daily-copilot-cleanup-trashed-doc-embeddings' }
);
}
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async dailyCleanupJob() {
await this.jobs.add(
@@ -33,6 +42,12 @@ export class CopilotCronJobs {
{},
{ jobId: 'daily-copilot-generate-missing-titles' }
);
await this.jobs.add(
'copilot.workspace.cleanupTrashedDocEmbeddings',
{},
{ jobId: 'daily-copilot-cleanup-trashed-doc-embeddings' }
);
}
async triggerGenerateMissingTitles() {
@@ -68,4 +83,18 @@ export class CopilotCronJobs {
`Scheduled title generation for ${sessions.length} sessions`
);
}
@OnJob('copilot.workspace.cleanupTrashedDocEmbeddings')
async cleanupTrashedDocEmbeddings() {
const workspaces = await this.models.workspace.list(undefined, {
id: true,
});
for (const { id: workspaceId } of workspaces) {
await this.jobs.add(
'copilot.embedding.cleanupTrashedDocEmbeddings',
{ workspaceId },
{ jobId: `cleanup-trashed-doc-embeddings-${workspaceId}` }
);
}
}
}
@@ -12,6 +12,7 @@ import {
OnJob,
} from '../../../base';
import { DocReader } from '../../../core/doc';
import { readAllDocIdsFromWorkspaceSnapshot } from '../../../core/utils/blocksuite';
import { Models } from '../../../models';
import { CopilotStorage } from '../storage';
import { readStream } from '../utils';
@@ -134,10 +135,30 @@ export class CopilotEmbeddingJob {
if (enableDocEmbedding) {
const toBeEmbedDocIds =
await this.models.copilotWorkspace.findDocsToEmbed(workspaceId);
if (!toBeEmbedDocIds.length) {
return;
}
// filter out trashed docs
const rootSnapshot = await this.models.doc.getSnapshot(
workspaceId,
workspaceId
);
if (!rootSnapshot) {
this.logger.warn(
`Root snapshot for workspace ${workspaceId} not found, skipping embedding.`
);
return;
}
const allDocIds = new Set(
readAllDocIdsFromWorkspaceSnapshot(rootSnapshot.blob)
);
this.logger.log(
`Trigger embedding for ${toBeEmbedDocIds.length} docs in workspace ${workspaceId}`
);
for (const docId of toBeEmbedDocIds) {
const finalToBeEmbedDocIds = toBeEmbedDocIds.filter(docId =>
allDocIds.has(docId)
);
for (const docId of finalToBeEmbedDocIds) {
await this.queue.add(
'copilot.embedding.docs',
{
@@ -337,6 +358,10 @@ export class CopilotEmbeddingJob {
const signal = this.getWorkspaceSignal(workspaceId);
try {
const hasNewDoc = await this.models.doc.exists(
workspaceId,
docId.split(':space:')[1] || ''
);
const needEmbedding =
await this.models.copilotWorkspace.checkDocNeedEmbedded(
workspaceId,
@@ -352,8 +377,11 @@ export class CopilotEmbeddingJob {
);
return;
}
const fragment = await this.getDocFragment(workspaceId, docId);
if (fragment) {
// if doc id deprecated, skip embedding and fulfill empty embedding
const fragment = !hasNewDoc
? await this.getDocFragment(workspaceId, docId)
: undefined;
if (!hasNewDoc && fragment) {
// fast fall for empty doc, journal is easily to create a empty doc
if (fragment.summary.trim()) {
const embeddings = await this.embeddingClient.getFileEmbeddings(
@@ -382,7 +410,7 @@ export class CopilotEmbeddingJob {
);
await this.fulfillEmptyEmbedding(workspaceId, docId);
}
} else if (contextId) {
} else {
this.logger.warn(
`Doc ${docId} in workspace ${workspaceId} has no fragment, fulfilling empty embedding.`
);
@@ -415,4 +443,39 @@ export class CopilotEmbeddingJob {
);
}
}
@OnJob('copilot.embedding.cleanupTrashedDocEmbeddings')
async cleanupTrashedDocEmbeddings({
workspaceId,
}: Jobs['copilot.embedding.cleanupTrashedDocEmbeddings']) {
const workspace = await this.models.workspace.get(workspaceId);
if (!workspace) {
this.logger.warn(`workspace ${workspaceId} not found`);
return;
}
const snapshot = await this.models.doc.getSnapshot(
workspaceId,
workspaceId
);
if (!snapshot) {
this.logger.warn(`workspace snapshot ${workspaceId} not found`);
return;
}
const docIdsInWorkspace = readAllDocIdsFromWorkspaceSnapshot(snapshot.blob);
const docIdsInEmbedding =
await this.models.copilotContext.listWorkspaceEmbedding(workspaceId);
const docIdsInWorkspaceSet = new Set(docIdsInWorkspace);
const deletedDocIds = docIdsInEmbedding.filter(
docId => !docIdsInWorkspaceSet.has(docId)
);
for (const docId of deletedDocIds) {
await this.models.copilotContext.deleteWorkspaceEmbedding(
workspaceId,
docId
);
}
}
}
@@ -61,6 +61,10 @@ declare global {
fileId: string;
fileName: string;
};
'copilot.embedding.cleanupTrashedDocEmbeddings': {
workspaceId: string;
};
}
}
@@ -64,8 +64,8 @@ import {
// context
CopilotContextResolver,
CopilotContextService,
// jobs
CopilotEmbeddingJob,
// cron jobs
CopilotCronJobs,
// transcription
CopilotTranscriptionService,
@@ -844,7 +844,7 @@ export class PromptsManagementResolver {
private readonly promptService: PromptService
) {}
@Query(() => Boolean, {
@Mutation(() => Boolean, {
description: 'Trigger generate missing titles cron job',
})
async triggerGenerateTitleCron() {
@@ -852,6 +852,14 @@ export class PromptsManagementResolver {
return true;
}
@Mutation(() => Boolean, {
description: 'Trigger cleanup of trashed doc embeddings',
})
async triggerCleanupTrashedDocEmbeddings() {
await this.cron.triggerCleanupTrashedDocEmbeddings();
return true;
}
@Query(() => [CopilotPromptType], {
description: 'List all copilot prompts',
})
@@ -1,4 +1,5 @@
import { tool } from 'ai';
import { omit } from 'lodash-es';
import { z } from 'zod';
import type { AccessController } from '../../../core/permission';
@@ -8,6 +9,32 @@ import type { ContextSession } from '../context/session';
import type { CopilotChatOptions } from '../providers';
import { toolError } from './error';
const FILTER_PREFIX = [
'Title: ',
'Created at: ',
'Updated at: ',
'Created by: ',
'Updated by: ',
];
function clearEmbeddingChunk(chunk: ChunkSimilarity): ChunkSimilarity {
if (chunk.content) {
const lines = chunk.content.split('\n');
let maxLines = 5;
while (maxLines > 0 && lines.length > 0) {
if (FILTER_PREFIX.some(prefix => lines[0].startsWith(prefix))) {
lines.shift();
maxLines--;
} else {
// only process consecutive metadata rows
break;
}
}
return { ...chunk, content: lines.join('\n') };
}
return chunk;
}
export const buildDocSearchGetter = (
ac: AccessController,
context: CopilotContextService,
@@ -47,18 +74,37 @@ export const buildDocSearchGetter = (
if (!docChunks.length && !fileChunks.length)
return `No results found for "${query}".`;
const docIds = docChunks.map(c => ({
// oxlint-disable-next-line no-non-null-assertion
workspaceId: options.workspace!,
docId: c.docId,
}));
const docAuthors = await models.doc
.findAuthors(docIds)
.then(
docs =>
new Map(
docs
.filter(d => !!d)
.map(doc => [doc.id, omit(doc, ['id', 'workspaceId'])])
)
);
const docMetas = await models.doc
.findAuthors(
docChunks.map(c => ({
// oxlint-disable-next-line no-non-null-assertion
workspaceId: options.workspace!,
docId: c.docId,
}))
)
.then(docs => new Map(docs.filter(d => !!d).map(doc => [doc.id, doc])));
.findMetas(docIds, { select: { title: true } })
.then(
docs =>
new Map(
docs
.filter(d => !!d)
.map(doc => [
doc.docId,
Object.assign({}, doc, docAuthors.get(doc.docId)),
])
)
);
return [
...fileChunks,
...fileChunks.map(clearEmbeddingChunk),
...docChunks.map(c => ({
...c,
...docMetas.get(c.docId),
+6
View File
@@ -1297,6 +1297,12 @@ type Mutation {
setBlob(blob: Upload!, workspaceId: String!): String!
submitAudioTranscription(blob: Upload, blobId: String!, blobs: [Upload!], workspaceId: String!): TranscriptionResultType
"""Trigger cleanup of trashed doc embeddings"""
triggerCleanupTrashedDocEmbeddings: Boolean!
"""Trigger generate missing titles cron job"""
triggerGenerateTitleCron: Boolean!
"""update app configuration"""
updateAppConfig(updates: [UpdateAppConfigInput!]!): JSONObject!
+4
View File
@@ -1440,6 +1440,10 @@ export interface Mutation {
sendVerifyEmail: Scalars['Boolean']['output'];
setBlob: Scalars['String']['output'];
submitAudioTranscription: Maybe<TranscriptionResultType>;
/** Trigger cleanup of trashed doc embeddings */
triggerCleanupTrashedDocEmbeddings: Scalars['Boolean']['output'];
/** Trigger generate missing titles cron job */
triggerGenerateTitleCron: Scalars['Boolean']['output'];
/** update app configuration */
updateAppConfig: Scalars['JSONObject']['output'];
/** Update a comment content */
@@ -10,6 +10,7 @@ import { Suspense } from 'react';
import { RouterProvider } from 'react-router-dom';
import { setupEffects } from './effects';
import { DesktopLanguageSync } from './language-sync';
import { DesktopThemeSync } from './theme-sync';
const { frameworkProvider } = setupEffects();
@@ -46,6 +47,7 @@ export function App() {
<I18nProvider>
<AffineContext store={getCurrentStore()}>
<DesktopThemeSync />
<DesktopLanguageSync />
<RouterProvider
fallbackElement={<AppContainer fallback />}
router={router}
@@ -0,0 +1,18 @@
import { DesktopApiService } from '@affine/core/modules/desktop-api';
import { I18nService } from '@affine/core/modules/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useEffect } from 'react';
export const DesktopLanguageSync = () => {
const i18nService = useService(I18nService);
const currentLanguage = useLiveData(i18nService.i18n.currentLanguageKey$);
const handler = useService(DesktopApiService).api.handler;
useEffect(() => {
handler.i18n.changeLanguage(currentLanguage ?? 'en').catch(err => {
console.error(err);
});
}, [currentLanguage, handler]);
return null;
};
@@ -33,6 +33,7 @@
},
"devDependencies": {
"@affine-tools/utils": "workspace:*",
"@affine/i18n": "workspace:*",
"@affine/native": "workspace:*",
"@affine/nbstore": "workspace:*",
"@electron-forge/cli": "^7.6.0",
@@ -1,3 +1,4 @@
import { I18n } from '@affine/i18n';
import { ipcMain } from 'electron';
import { AFFINE_API_CHANNEL_NAME } from '../shared/type';
@@ -21,6 +22,12 @@ export const debugHandlers = {
},
};
export const i18nHandlers = {
changeLanguage: async (_: Electron.IpcMainInvokeEvent, language: string) => {
return I18n.changeLanguage(language);
},
};
// Note: all of these handlers will be the single-source-of-truth for the apis exposed to the renderer process
export const allHandlers = {
debug: debugHandlers,
@@ -33,6 +40,7 @@ export const allHandlers = {
worker: workerHandlers,
recording: recordingHandlers,
popup: popupHandlers,
i18n: i18nHandlers,
};
export const registerHandlers = () => {
@@ -1,25 +1,17 @@
import type { MainEventRegister } from '../type';
import { globalCacheStorage, globalStateStorage } from './storage';
import { globalCacheUpdates$, globalStateUpdates$ } from './handlers';
export const sharedStorageEvents = {
onGlobalStateChanged: (
fn: (state: Record<string, unknown | undefined>) => void
) => {
const subscription = globalStateStorage.watchAll().subscribe(updates => {
fn(updates);
});
return () => {
subscription.unsubscribe();
};
const subscription = globalStateUpdates$.subscribe(fn);
return () => subscription.unsubscribe();
},
onGlobalCacheChanged: (
fn: (state: Record<string, unknown | undefined>) => void
) => {
const subscription = globalCacheStorage.watchAll().subscribe(updates => {
fn(updates);
});
return () => {
subscription.unsubscribe();
};
const subscription = globalCacheUpdates$.subscribe(fn);
return () => subscription.unsubscribe();
},
} satisfies Record<string, MainEventRegister>;
@@ -1,6 +1,22 @@
import { Subject } from 'rxjs';
import type { NamespaceHandlers } from '../type';
import { globalCacheStorage, globalStateStorage } from './storage';
// Subjects used by shared-storage/events.ts to broadcast updates to all renderer processes
export const globalStateUpdates$ = new Subject<Record<string, any>>();
export const globalCacheUpdates$ = new Subject<Record<string, any>>();
// Revision maps; main generates the next value each time
const globalStateRevisions = new Map<string, number>();
const globalCacheRevisions = new Map<string, number>();
function nextRev(revisions: Map<string, number>, key: string) {
const r = (revisions.get(key) ?? 0) + 1;
revisions.set(key, r);
return r;
}
export const sharedStorageHandlers = {
getAllGlobalState: async () => {
return globalStateStorage.all();
@@ -8,22 +24,36 @@ export const sharedStorageHandlers = {
getAllGlobalCache: async () => {
return globalCacheStorage.all();
},
setGlobalState: async (_, key: string, value: any) => {
return globalStateStorage.set(key, value);
setGlobalState: async (_e, key: string, value: any, sourceId?: string) => {
const rev = nextRev(globalStateRevisions, key);
globalStateStorage.set(key, value);
globalStateUpdates$.next({ [key]: { v: value, r: rev, s: sourceId } });
},
delGlobalState: async (_, key: string) => {
return globalStateStorage.del(key);
delGlobalState: async (_e, key: string, sourceId?: string) => {
const rev = nextRev(globalStateRevisions, key);
globalStateStorage.del(key);
globalStateUpdates$.next({ [key]: { v: undefined, r: rev, s: sourceId } });
},
clearGlobalState: async () => {
return globalStateStorage.clear();
clearGlobalState: async (_e, sourceId?: string) => {
globalStateRevisions.clear();
globalStateStorage.clear();
globalStateUpdates$.next({ '*': { v: undefined, r: 0, s: sourceId } });
},
setGlobalCache: async (_, key: string, value: any) => {
return globalCacheStorage.set(key, value);
setGlobalCache: async (_e, key: string, value: any, sourceId?: string) => {
const rev = nextRev(globalCacheRevisions, key);
globalCacheStorage.set(key, value);
globalCacheUpdates$.next({ [key]: { v: value, r: rev, s: sourceId } });
},
delGlobalCache: async (_, key: string) => {
return globalCacheStorage.del(key);
delGlobalCache: async (_e, key: string, sourceId?: string) => {
const rev = nextRev(globalCacheRevisions, key);
globalCacheStorage.del(key);
globalCacheUpdates$.next({ [key]: { v: undefined, r: rev, s: sourceId } });
},
clearGlobalCache: async () => {
return globalCacheStorage.clear();
clearGlobalCache: async (_e, sourceId?: string) => {
globalCacheRevisions.clear();
globalCacheStorage.clear();
globalCacheUpdates$.next({ '*': { v: undefined, r: 0, s: sourceId } });
},
} satisfies NamespaceHandlers;
@@ -1,5 +1,6 @@
import { join } from 'node:path';
import { I18n } from '@affine/i18n';
import {
app,
BrowserWindow,
@@ -822,42 +823,53 @@ export class WebContentViewsManager {
},
});
if (spellCheckSettings.enabled) {
view.webContents.on('context-menu', (_event, params) => {
const shouldShow =
params.misspelledWord && params.dictionarySuggestions.length > 0;
view.webContents.on('context-menu', (_event, params) => {
const menu = Menu.buildFromTemplate([
{
id: 'cut',
label: I18n['com.affine.context-menu.cut'](),
role: 'cut',
enabled: params.editFlags.canCut,
},
{
id: 'copy',
label: I18n['com.affine.context-menu.copy'](),
role: 'copy',
enabled: params.editFlags.canCopy,
},
{
id: 'paste',
label: I18n['com.affine.context-menu.paste'](),
role: 'paste',
enabled: params.editFlags.canPaste,
},
]);
if (!shouldShow) {
return;
}
const menu = new Menu();
// Add each spelling suggestion
for (const suggestion of params.dictionarySuggestions) {
menu.append(
new MenuItem({
label: suggestion,
click: () => view.webContents.replaceMisspelling(suggestion),
})
);
}
// Add each spelling suggestion
for (const suggestion of params.dictionarySuggestions) {
menu.append(
new MenuItem({
label: suggestion,
click: () => view.webContents.replaceMisspelling(suggestion),
})
);
}
// Allow users to add the misspelled word to the dictionary
if (params.misspelledWord) {
menu.append(
new MenuItem({
label: 'Add to dictionary', // TODO: i18n
click: () =>
view.webContents.session.addWordToSpellCheckerDictionary(
params.misspelledWord
),
})
);
}
// Allow users to add the misspelled word to the dictionary
if (params.misspelledWord) {
menu.append(
new MenuItem({
label: 'Add to dictionary', // TODO: i18n
click: () =>
view.webContents.session.addWordToSpellCheckerDictionary(
params.misspelledWord
),
})
);
}
menu.popup();
});
}
menu.popup();
});
this.webViewsMap$.next(this.tabViewsMap.set(viewId, view));
let unsub = () => {};
@@ -6,6 +6,7 @@ import {
AFFINE_EVENT_CHANNEL_NAME,
} from '../shared/type';
// Load persisted data from main process synchronously at preload time
const initialGlobalState = ipcRenderer.sendSync(
AFFINE_API_CHANNEL_NAME,
'sharedStorage:getAllGlobalState'
@@ -15,6 +16,9 @@ const initialGlobalCache = ipcRenderer.sendSync(
'sharedStorage:getAllGlobalCache'
);
// Unique id for this renderer instance, used to ignore self-originated broadcasts
const CLIENT_ID: string = Math.random().toString(36).slice(2);
function invokeWithCatch(key: string, ...args: any[]) {
ipcRenderer.invoke(AFFINE_API_CHANNEL_NAME, key, ...args).catch(err => {
console.error(`Failed to invoke ${key}`, err);
@@ -34,7 +38,23 @@ function createSharedStorageApi(
memory.setAll(init);
ipcRenderer.on(AFFINE_EVENT_CHANNEL_NAME, (_event, channel, updates) => {
if (channel === `sharedStorage:${event}`) {
for (const [key, value] of Object.entries(updates)) {
for (const [key, raw] of Object.entries(updates)) {
// support both legacy plain value and new { v, r, s } structure
let value: any;
let source: string | undefined;
if (raw && typeof raw === 'object' && 'v' in raw) {
value = (raw as any).v;
source = (raw as any).s;
} else {
value = raw;
}
// Ignore our own broadcasts
if (source && source === CLIENT_ID) {
continue;
}
if (value === undefined) {
memory.del(key);
} else {
@@ -47,11 +67,11 @@ function createSharedStorageApi(
return {
del(key: string) {
memory.del(key);
invokeWithCatch(`sharedStorage:${api.del}`, key);
invokeWithCatch(`sharedStorage:${api.del}`, key, CLIENT_ID);
},
clear() {
memory.clear();
invokeWithCatch(`sharedStorage:${api.clear}`);
invokeWithCatch(`sharedStorage:${api.clear}`, CLIENT_ID);
},
get<T>(key: string): T | undefined {
return memory.get(key);
@@ -61,7 +81,7 @@ function createSharedStorageApi(
},
set(key: string, value: unknown) {
memory.set(key, value);
invokeWithCatch(`sharedStorage:${api.set}`, key, value);
invokeWithCatch(`sharedStorage:${api.set}`, key, value, CLIENT_ID);
},
watch<T>(key: string, cb: (i: T | undefined) => void): () => void {
const subscription = memory.watch(key).subscribe(i => cb(i as T));
@@ -8,6 +8,7 @@
"include": ["./src"],
"references": [
{ "path": "../../../../tools/utils" },
{ "path": "../../i18n" },
{ "path": "../../native" },
{ "path": "../../../common/nbstore" },
{ "path": "../../../common/infra" }
+1
View File
@@ -34,6 +34,7 @@
"@emotion/styled": "^11.14.0",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-collapsible": "^1.1.2",
"@radix-ui/react-context-menu": "^2.2.15",
"@radix-ui/react-dialog": "^1.1.3",
"@radix-ui/react-dropdown-menu": "^2.1.3",
"@radix-ui/react-popover": "^1.1.3",
@@ -0,0 +1,58 @@
import * as RadixContextMenu from '@radix-ui/react-context-menu';
import clsx from 'clsx';
import type { RefAttributes } from 'react';
import * as styles from '../styles.css';
import { DesktopMenuContext } from './context';
import * as desktopStyles from './styles.css';
export type ContextMenuProps = RadixContextMenu.ContextMenuProps &
RadixContextMenu.ContextMenuTriggerProps &
RefAttributes<HTMLSpanElement> & {
items: React.ReactNode;
contentProps?: RadixContextMenu.ContextMenuContentProps;
};
const ContextMenuContextValue = {
type: 'context-menu',
} as const;
export const ContextMenu = ({
children,
onOpenChange,
dir,
modal,
items,
contentProps,
...props
}: ContextMenuProps) => {
return (
<DesktopMenuContext.Provider value={ContextMenuContextValue}>
<RadixContextMenu.Root
onOpenChange={onOpenChange}
dir={dir}
modal={modal}
>
<RadixContextMenu.Trigger {...props}>
{children}
</RadixContextMenu.Trigger>
<RadixContextMenu.Portal>
<RadixContextMenu.Content
className={clsx(
styles.menuContent,
desktopStyles.contentAnimation,
contentProps?.className
)}
style={{
zIndex: 'var(--affine-z-index-popover)',
...contentProps?.style,
}}
{...contentProps}
>
{items}
</RadixContextMenu.Content>
</RadixContextMenu.Portal>
</RadixContextMenu.Root>
</DesktopMenuContext.Provider>
);
};
@@ -0,0 +1,9 @@
import { createContext } from 'react';
interface DesktopMenuContextValue {
type: 'dropdown-menu' | 'context-menu';
}
export const DesktopMenuContext = createContext<DesktopMenuContextValue>({
type: 'dropdown-menu',
});
@@ -1,13 +1,30 @@
import * as ContextMenu from '@radix-ui/react-context-menu';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { useContext } from 'react';
import type { MenuItemProps } from '../menu.types';
import { useMenuItem } from '../use-menu-item';
import { DesktopMenuContext } from './context';
export const DesktopMenuItem = (props: MenuItemProps) => {
const { type } = useContext(DesktopMenuContext);
const { className, children, otherProps } = useMenuItem(props);
return (
<DropdownMenu.Item className={className} {...otherProps}>
{children}
</DropdownMenu.Item>
);
if (type === 'dropdown-menu') {
return (
<DropdownMenu.Item className={className} {...otherProps}>
{children}
</DropdownMenu.Item>
);
}
if (type === 'context-menu') {
return (
<ContextMenu.Item className={className} {...otherProps}>
{children}
</ContextMenu.Item>
);
}
return null;
};
@@ -4,8 +4,13 @@ import React, { useCallback, useImperativeHandle, useState } from 'react';
import type { MenuProps } from '../menu.types';
import * as styles from '../styles.css';
import { DesktopMenuContext } from './context';
import * as desktopStyles from './styles.css';
const MenuContextValue = {
type: 'dropdown-menu',
} as const;
export const DesktopMenu = ({
children,
items,
@@ -53,37 +58,39 @@ export const DesktopMenu = ({
const ContentWrapper = noPortal ? React.Fragment : DropdownMenu.Portal;
return (
<DropdownMenu.Root
modal={modal ?? false}
open={finalOpen}
onOpenChange={handleOpenChange}
{...rootOptions}
>
<DropdownMenu.Trigger
asChild
onClick={e => {
e.stopPropagation();
e.preventDefault();
}}
<DesktopMenuContext.Provider value={MenuContextValue}>
<DropdownMenu.Root
modal={modal ?? false}
open={finalOpen}
onOpenChange={handleOpenChange}
{...rootOptions}
>
{children}
</DropdownMenu.Trigger>
<ContentWrapper {...portalOptions}>
<DropdownMenu.Content
className={clsx(
styles.menuContent,
desktopStyles.contentAnimation,
className
)}
sideOffset={4}
align="start"
style={{ zIndex: 'var(--affine-z-index-popover)', ...contentStyle }}
{...otherContentOptions}
<DropdownMenu.Trigger
asChild
onClick={e => {
e.stopPropagation();
e.preventDefault();
}}
>
{items}
</DropdownMenu.Content>
</ContentWrapper>
</DropdownMenu.Root>
{children}
</DropdownMenu.Trigger>
<ContentWrapper {...portalOptions}>
<DropdownMenu.Content
className={clsx(
styles.menuContent,
desktopStyles.contentAnimation,
className
)}
sideOffset={4}
align="start"
style={{ zIndex: 'var(--affine-z-index-popover)', ...contentStyle }}
{...otherContentOptions}
>
{items}
</DropdownMenu.Content>
</ContentWrapper>
</DropdownMenu.Root>
</DesktopMenuContext.Provider>
);
};
@@ -1,11 +1,13 @@
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
import * as ContextMenu from '@radix-ui/react-context-menu';
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import clsx from 'clsx';
import { useMemo } from 'react';
import { useContext, useMemo } from 'react';
import type { MenuSubProps } from '../menu.types';
import * as styles from '../styles.css';
import { useMenuItem } from '../use-menu-item';
import { DesktopMenuContext } from './context';
export const DesktopMenuSub = ({
children: propsChildren,
@@ -19,12 +21,37 @@ export const DesktopMenuSub = ({
...otherSubContentOptions
} = {},
}: MenuSubProps) => {
const { type } = useContext(DesktopMenuContext);
const { className, children, otherProps } = useMenuItem({
children: propsChildren,
suffixIcon: <ArrowRightSmallIcon />,
...triggerOptions,
});
const contentClassName = useMemo(
() => clsx(styles.menuContent, subContentClassName),
[subContentClassName]
);
if (type === 'context-menu') {
return (
<ContextMenu.Sub defaultOpen={defaultOpen} {...otherSubOptions}>
<ContextMenu.SubTrigger className={className} {...otherProps}>
{children}
</ContextMenu.SubTrigger>
<ContextMenu.Portal {...portalOptions}>
<ContextMenu.SubContent
className={contentClassName}
style={{ zIndex: 'var(--affine-z-index-popover)', ...contentStyle }}
{...otherSubContentOptions}
>
{items}
</ContextMenu.SubContent>
</ContextMenu.Portal>
</ContextMenu.Sub>
);
}
return (
<DropdownMenu.Sub defaultOpen={defaultOpen} {...otherSubOptions}>
<DropdownMenu.SubTrigger className={className} {...otherProps}>
@@ -32,10 +59,7 @@ export const DesktopMenuSub = ({
</DropdownMenu.SubTrigger>
<DropdownMenu.Portal {...portalOptions}>
<DropdownMenu.SubContent
className={useMemo(
() => clsx(styles.menuContent, subContentClassName),
[subContentClassName]
)}
className={contentClassName}
style={{ zIndex: 'var(--affine-z-index-popover)', ...contentStyle }}
{...otherSubContentOptions}
>
@@ -1,4 +1,5 @@
export * from './menu.types';
import { ContextMenu } from './desktop/context-menu';
import { DesktopMenuItem } from './desktop/item';
import { DesktopMenu } from './desktop/root';
import { DesktopMenuSeparator } from './desktop/separator';
@@ -19,6 +20,7 @@ const MenuSub = BUILD_CONFIG.isMobileEdition ? MobileMenuSub : DesktopMenuSub;
const Menu = BUILD_CONFIG.isMobileEdition ? MobileMenu : DesktopMenu;
export {
ContextMenu,
DesktopMenu,
DesktopMenuItem,
DesktopMenuSeparator,
+1
View File
@@ -34,6 +34,7 @@
"@marsidev/react-turnstile": "^1.1.0",
"@preact/signals-core": "^1.8.0",
"@radix-ui/react-collapsible": "^1.1.2",
"@radix-ui/react-context-menu": "^2.1.15",
"@radix-ui/react-dialog": "^1.1.3",
"@radix-ui/react-popover": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.2.2",
@@ -407,7 +407,8 @@ declare global {
) => Promise<CopilotChatHistoryFragment[] | undefined>;
getRecentSessions: (
workspaceId: string,
limit?: number
limit?: number,
offset?: number
) => Promise<AIRecentSession[] | undefined>;
updateSession: (options: UpdateChatSessionInput) => Promise<string>;
}
@@ -0,0 +1,186 @@
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import type { AppThemeService } from '@affine/core/modules/theme';
import type { CopilotChatHistoryFragment } from '@affine/graphql';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import { type NotificationService } from '@blocksuite/affine/shared/services';
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import type { EditorHost } from '@blocksuite/affine/std';
import { ShadowlessElement } from '@blocksuite/affine/std';
import type { ExtensionType, Store } from '@blocksuite/affine/store';
import { CenterPeekIcon } from '@blocksuite/icons/lit';
import { css, html, nothing } from 'lit';
import { property } from 'lit/decorators.js';
import type { SearchMenuConfig } from '../components/ai-chat-add-context';
import type { DocDisplayConfig } from '../components/ai-chat-chips';
import type {
AINetworkSearchConfig,
AIPlaygroundConfig,
AIReasoningConfig,
} from '../components/ai-chat-input';
import type { ChatStatus } from '../components/ai-chat-messages';
import { createPlaygroundModal } from '../components/playground/modal';
import type { AppSidebarConfig } from './chat-config';
export class AIChatPanelTitle extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
.ai-chat-panel-title {
background: var(--affine-background-primary-color);
position: relative;
padding: 8px var(--h-padding, 16px);
width: 100%;
height: 36px;
display: flex;
justify-content: space-between;
align-items: center;
z-index: 1;
svg {
width: 18px;
height: 18px;
color: var(--affine-text-secondary-color);
}
.chat-panel-title-text {
font-size: 14px;
font-weight: 500;
color: var(--affine-text-secondary-color);
}
.chat-panel-playground {
cursor: pointer;
padding: 2px;
margin-left: 8px;
margin-right: auto;
display: flex;
justify-content: center;
align-items: center;
}
.chat-panel-playground:hover svg {
color: ${unsafeCSSVarV2('icon/activated')};
}
}
`;
@property({ attribute: false })
accessor host!: EditorHost;
@property({ attribute: false })
accessor doc!: Store;
@property({ attribute: false })
accessor playgroundConfig!: AIPlaygroundConfig;
@property({ attribute: false })
accessor appSidebarConfig!: AppSidebarConfig;
@property({ attribute: false })
accessor networkSearchConfig!: AINetworkSearchConfig;
@property({ attribute: false })
accessor reasoningConfig!: AIReasoningConfig;
@property({ attribute: false })
accessor searchMenuConfig!: SearchMenuConfig;
@property({ attribute: false })
accessor docDisplayConfig!: DocDisplayConfig;
@property({ attribute: false })
accessor extensions!: ExtensionType[];
@property({ attribute: false })
accessor affineFeatureFlagService!: FeatureFlagService;
@property({ attribute: false })
accessor affineWorkspaceDialogService!: WorkspaceDialogService;
@property({ attribute: false })
accessor affineThemeService!: AppThemeService;
@property({ attribute: false })
accessor notificationService!: NotificationService;
@property({ attribute: false })
accessor session!: CopilotChatHistoryFragment | null | undefined;
@property({ attribute: false })
accessor status!: ChatStatus;
@property({ attribute: false })
accessor embeddingProgress: [number, number] = [0, 0];
@property({ attribute: false })
accessor newSession!: () => void;
@property({ attribute: false })
accessor togglePin!: () => void;
@property({ attribute: false })
accessor openSession!: (sessionId: string) => void;
@property({ attribute: false })
accessor openDoc!: (docId: string, sessionId: string) => void;
private readonly openPlayground = () => {
const playgroundContent = html`
<playground-content
.host=${this.host}
.doc=${this.doc}
.networkSearchConfig=${this.networkSearchConfig}
.reasoningConfig=${this.reasoningConfig}
.playgroundConfig=${this.playgroundConfig}
.appSidebarConfig=${this.appSidebarConfig}
.searchMenuConfig=${this.searchMenuConfig}
.docDisplayConfig=${this.docDisplayConfig}
.extensions=${this.extensions}
.affineFeatureFlagService=${this.affineFeatureFlagService}
.affineThemeService=${this.affineThemeService}
.notificationService=${this.notificationService}
.affineWorkspaceDialogService=${this.affineWorkspaceDialogService}
></playground-content>
`;
createPlaygroundModal(playgroundContent, 'AI Playground');
};
override render() {
const [done, total] = this.embeddingProgress;
const isEmbedding = total > 0 && done < total;
return html`
<div class="ai-chat-panel-title">
<div class="chat-panel-title-text">
${isEmbedding
? html`<span data-testid="chat-panel-embedding-progress"
>Embedding ${done}/${total}</span
>`
: 'AFFiNE AI'}
</div>
${this.playgroundConfig.visible.value
? html`
<div class="chat-panel-playground" @click=${this.openPlayground}>
${CenterPeekIcon()}
</div>
`
: nothing}
<ai-chat-toolbar
.session=${this.session}
.workspaceId=${this.doc.workspace.id}
.docId=${this.doc.id}
.status=${this.status}
.onNewSession=${this.newSession}
.onTogglePin=${this.togglePin}
.onOpenSession=${this.openSession}
.onOpenDoc=${this.openDoc}
.docDisplayConfig=${this.docDisplayConfig}
.notificationService=${this.notificationService}
></ai-chat-toolbar>
</div>
`;
}
}
@@ -9,13 +9,11 @@ import type {
} from '@affine/graphql';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import { type NotificationService } from '@blocksuite/affine/shared/services';
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import type { EditorHost } from '@blocksuite/affine/std';
import { ShadowlessElement } from '@blocksuite/affine/std';
import type { ExtensionType, Store } from '@blocksuite/affine/store';
import { CenterPeekIcon } from '@blocksuite/icons/lit';
import { type Signal, signal } from '@preact/signals-core';
import { css, html, nothing, type PropertyValues } from 'lit';
import { css, html, type PropertyValues } from 'lit';
import { property, state } from 'lit/decorators.js';
import { keyed } from 'lit/directives/keyed.js';
@@ -29,7 +27,6 @@ import type {
AIReasoningConfig,
} from '../components/ai-chat-input';
import type { ChatStatus } from '../components/ai-chat-messages';
import { createPlaygroundModal } from '../components/playground/modal';
import { AIProvider } from '../provider';
import type { AppSidebarConfig } from './chat-config';
@@ -43,12 +40,13 @@ export class ChatPanel extends SignalWatcher(
.chat-panel-container {
height: 100%;
display: flex;
flex-direction: column;
}
.chat-panel-title-text {
font-size: 14px;
font-weight: 500;
color: var(--affine-text-secondary-color);
ai-chat-content {
height: 0;
flex-grow: 1;
}
.chat-loading-container {
@@ -72,20 +70,6 @@ export class ChatPanel extends SignalWatcher(
font-size: var(--affine-font-sm);
color: var(--affine-text-secondary-color);
}
.chat-panel-playground {
cursor: pointer;
padding: 2px;
margin-left: 8px;
margin-right: auto;
display: flex;
justify-content: center;
align-items: center;
}
.chat-panel-playground:hover svg {
color: ${unsafeCSSVarV2('icon/activated')};
}
}
`;
@@ -150,40 +134,6 @@ export class ChatPanel extends SignalWatcher(
return this.session !== undefined;
}
private get chatTitle() {
const [done, total] = this.embeddingProgress;
const isEmbedding = total > 0 && done < total;
return html`
<div class="chat-panel-title-text">
${isEmbedding
? html`<span data-testid="chat-panel-embedding-progress"
>Embedding ${done}/${total}</span
>`
: 'AFFiNE AI'}
</div>
${this.playgroundConfig.visible.value
? html`
<div class="chat-panel-playground" @click=${this.openPlayground}>
${CenterPeekIcon()}
</div>
`
: nothing}
<ai-chat-toolbar
.session=${this.session}
.workspaceId=${this.doc.workspace.id}
.docId=${this.doc.id}
.status=${this.status}
.onNewSession=${this.newSession}
.onTogglePin=${this.togglePin}
.onOpenSession=${this.openSession}
.onOpenDoc=${this.openDoc}
.docDisplayConfig=${this.docDisplayConfig}
.notificationService=${this.notificationService}
></ai-chat-toolbar>
`;
}
private readonly getSessionIdFromUrl = () => {
if (this.affineWorkbenchService) {
const { workbench } = this.affineWorkbenchService;
@@ -368,28 +318,6 @@ export class ChatPanel extends SignalWatcher(
}
};
private readonly openPlayground = () => {
const playgroundContent = html`
<playground-content
.host=${this.host}
.doc=${this.doc}
.networkSearchConfig=${this.networkSearchConfig}
.reasoningConfig=${this.reasoningConfig}
.playgroundConfig=${this.playgroundConfig}
.appSidebarConfig=${this.appSidebarConfig}
.searchMenuConfig=${this.searchMenuConfig}
.docDisplayConfig=${this.docDisplayConfig}
.extensions=${this.extensions}
.affineFeatureFlagService=${this.affineFeatureFlagService}
.affineThemeService=${this.affineThemeService}
.notificationService=${this.notificationService}
.affineWorkspaceDialogService=${this.affineWorkspaceDialogService}
></playground-content>
`;
createPlaygroundModal(playgroundContent, 'AI Playground');
};
protected override updated(changedProperties: PropertyValues) {
if (changedProperties.has('doc')) {
if (this.session?.pinned) {
@@ -441,10 +369,31 @@ export class ChatPanel extends SignalWatcher(
}
return html`<div class="chat-panel-container">
<ai-chat-panel-title
.host=${this.host}
.doc=${this.doc}
.playgroundConfig=${this.playgroundConfig}
.appSidebarConfig=${this.appSidebarConfig}
.networkSearchConfig=${this.networkSearchConfig}
.reasoningConfig=${this.reasoningConfig}
.searchMenuConfig=${this.searchMenuConfig}
.docDisplayConfig=${this.docDisplayConfig}
.extensions=${this.extensions}
.affineFeatureFlagService=${this.affineFeatureFlagService}
.affineWorkspaceDialogService=${this.affineWorkspaceDialogService}
.affineThemeService=${this.affineThemeService}
.notificationService=${this.notificationService}
.session=${this.session}
.status=${this.status}
.embeddingProgress=${this.embeddingProgress}
.newSession=${this.newSession}
.togglePin=${this.togglePin}
.openSession=${this.openSession}
.openDoc=${this.openDoc}
></ai-chat-panel-title>
${keyed(
this.hasPinned ? this.session?.sessionId : this.doc.id,
html`<ai-chat-content
.chatTitle=${this.chatTitle}
.host=${this.host}
.session=${this.session}
.createSession=${this.createSession}
@@ -462,6 +411,7 @@ export class ChatPanel extends SignalWatcher(
.onEmbeddingProgressChange=${this.onEmbeddingProgressChange}
.onContextChange=${this.onContextChange}
.width=${this.sidebarWidth}
.onOpenDoc=${this.openDoc}
></ai-chat-content>`
)}
</div>`;
@@ -86,6 +86,9 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor docDisplayService!: DocDisplayConfig;
@property({ attribute: false })
accessor onOpenDoc!: (docId: string, sessionId?: string) => void;
get state() {
const { isLast, status } = this;
return isLast
@@ -146,6 +149,7 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
.notificationService=${this.notificationService}
.theme=${this.affineThemeService.appTheme.themeSignal}
.docDisplayService=${this.docDisplayService}
.onOpenDoc=${this.onOpenDoc}
></chat-content-stream-objects>`;
}
@@ -0,0 +1,196 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { css, html, LitElement, nothing, type TemplateResult } from 'lit';
import { property } from 'lit/decorators.js';
/**
* ArtifactSkeleton
*
* A lightweight loading skeleton used while an artifact preview is fetching / processing.
* It mimics the layout of a document an optional icon followed by several animated grey lines.
*
* Animation is implemented with pure CSS keyframes (no framer-motion dependency).
* Only a single prop is supported for now:
* - `icon` TemplateResult that will be rendered at the top-left position.
*/
export class ArtifactSkeleton extends LitElement {
/* ----- Styling --------------------------------------------------------------------------- */
static override styles = css`
:host {
/* The host is an inline-block so it can size to its contents. */
display: inline-block;
position: relative;
/* The size roughly follows the design used in the legacy React implementation. */
width: 250px;
height: 200px;
box-sizing: border-box;
}
/* Optional icon wrapper */
.icon {
position: absolute;
top: 10px;
left: 11px;
width: 32px;
height: 32px;
svg {
color: ${unsafeCSSVarV2('icon/activated')};
width: 100%;
height: 100%;
}
}
/* Base line style */
.line {
position: absolute;
left: 11px;
height: 10px;
border-radius: 6px;
background-color: ${unsafeCSSVarV2('layer/background/tertiary')};
}
/* Keyframes for each line width cycles through a handful of values to create movement */
@keyframes line1Anim {
0%,
100% {
width: 98px;
}
25% {
width: 120px;
}
50% {
width: 85px;
}
75% {
width: 110px;
}
}
@keyframes line2Anim {
0%,
100% {
width: 195px;
}
30% {
width: 180px;
}
60% {
width: 210px;
}
80% {
width: 165px;
}
}
@keyframes line3Anim {
0%,
100% {
width: 163px;
}
40% {
width: 140px;
}
70% {
width: 180px;
}
90% {
width: 155px;
}
}
@keyframes line4Anim {
0%,
100% {
width: 107px;
}
20% {
width: 130px;
}
60% {
width: 90px;
}
85% {
width: 115px;
}
}
@keyframes line5Anim {
0%,
100% {
width: 134px;
}
35% {
width: 160px;
}
65% {
width: 120px;
}
80% {
width: 145px;
}
}
@keyframes line6Anim {
0%,
100% {
width: 154px;
}
30% {
width: 135px;
}
55% {
width: 175px;
}
75% {
width: 160px;
}
}
.line1 {
top: 48.5px;
animation: line1Anim 3.2s ease-in-out infinite;
}
.line2 {
top: 73.5px;
animation: line2Anim 4.1s ease-in-out infinite;
}
.line3 {
top: 98.5px;
animation: line3Anim 2.8s ease-in-out infinite;
}
.line4 {
top: 123.5px;
animation: line4Anim 3.7s ease-in-out infinite;
}
.line5 {
top: 148.5px;
animation: line5Anim 3.5s ease-in-out infinite;
}
.line6 {
top: 170.5px;
animation: line6Anim 4.3s ease-in-out infinite;
}
`;
/* ----- Public API ------------------------------------------------------------------------ */
/**
* Optional icon rendered at the top-left corner.
* It should be a lit `TemplateResult`, typically an inline SVG.
*/
@property({ attribute: false })
accessor icon: TemplateResult | null = null;
/* ----- Render --------------------------------------------------------------------------- */
override render() {
return html`
${this.icon ? html`<div class="icon">${this.icon}</div>` : nothing}
<div class="line line1"></div>
<div class="line line2"></div>
<div class="line line3"></div>
<div class="line line4"></div>
<div class="line line5"></div>
<div class="line line6"></div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'artifact-skeleton': ArtifactSkeleton;
}
}
@@ -10,13 +10,7 @@ import { type EditorHost, ShadowlessElement } from '@blocksuite/affine/std';
import type { ExtensionType } from '@blocksuite/affine/store';
import type { NotificationService } from '@blocksuite/affine-shared/services';
import { type Signal } from '@preact/signals-core';
import {
css,
html,
nothing,
type PropertyValues,
type TemplateResult,
} from 'lit';
import { css, html, type PropertyValues, type TemplateResult } from 'lit';
import { property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { createRef, type Ref, ref } from 'lit/directives/ref.js';
@@ -60,24 +54,6 @@ export class AIChatContent extends SignalWatcher(
justify-content: center;
height: 100%;
.ai-chat-title {
background: var(--affine-background-primary-color);
position: relative;
padding: 8px var(--h-padding);
width: 100%;
height: 36px;
display: flex;
justify-content: space-between;
align-items: center;
z-index: 1;
svg {
width: 18px;
height: 18px;
color: var(--affine-text-secondary-color);
}
}
ai-chat-messages {
flex: 1;
overflow-y: auto;
@@ -129,9 +105,6 @@ export class AIChatContent extends SignalWatcher(
@property({ attribute: false })
accessor onboardingOffsetY!: number;
@property({ attribute: false })
accessor chatTitle: TemplateResult<1> | undefined;
@property({ attribute: false })
accessor host: EditorHost | null | undefined;
@@ -184,6 +157,9 @@ export class AIChatContent extends SignalWatcher(
@property({ attribute: false })
accessor onContextChange!: (context: Partial<ChatContextValue>) => void;
@property({ attribute: false })
accessor onOpenDoc!: (docId: string, sessionId?: string) => void;
@property({ attribute: false })
accessor width: Signal<number | undefined> | undefined;
@@ -328,16 +304,6 @@ export class AIChatContent extends SignalWatcher(
}
}
public reset() {
this.updateContext(DEFAULT_CHAT_CONTEXT_VALUE);
this.closePreviewPanel(true);
}
public reloadSession() {
this.reset();
this.initChatContent().catch(console.error);
}
public openPreviewPanel(content?: TemplateResult<1>) {
this.showPreviewPanel = true;
if (content) this.previewPanelContent = content;
@@ -390,10 +356,7 @@ export class AIChatContent extends SignalWatcher(
}
override render() {
const left = html`${this.chatTitle
? html`<div class="ai-chat-title">${this.chatTitle}</div>`
: nothing}
<ai-chat-messages
const left = html` <ai-chat-messages
class=${classMap({
'ai-chat-messages': true,
'independent-mode': !!this.independentMode,
@@ -418,6 +381,7 @@ export class AIChatContent extends SignalWatcher(
.independentMode=${this.independentMode}
.messages=${this.messages}
.docDisplayService=${this.docDisplayConfig}
.onOpenDoc=${this.onOpenDoc}
></ai-chat-messages>
<ai-chat-composer
style=${styleMap({
@@ -206,6 +206,9 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor docDisplayService!: DocDisplayConfig;
@property({ attribute: false })
accessor onOpenDoc!: (docId: string, sessionId?: string) => void;
@query('.chat-panel-messages-container')
accessor messagesContainer: HTMLDivElement | null = null;
@@ -333,6 +336,7 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) {
.width=${this.width}
.independentMode=${this.independentMode}
.docDisplayService=${this.docDisplayService}
.onOpenDoc=${this.onOpenDoc}
></chat-message-assistant>`;
} else if (isChatAction(item) && this.host) {
return html`<chat-message-action
@@ -3,8 +3,8 @@ import { WithDisposable } from '@blocksuite/affine/global/lit';
import { scrollbarStyle } from '@blocksuite/affine/shared/styles';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import { ShadowlessElement } from '@blocksuite/affine/std';
import { css, html, nothing } from 'lit';
import { property, state } from 'lit/decorators.js';
import { css, html, nothing, type PropertyValues } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { AIProvider } from '../../provider';
import type { DocDisplayConfig } from '../ai-chat-chips';
@@ -133,11 +133,21 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor onDocClick!: (docId: string, sessionId: string) => void;
@state()
private accessor sessions: BlockSuitePresets.AIRecentSession[] = [];
@query('.ai-session-history')
accessor scrollContainer!: HTMLElement;
@state()
private accessor loading = true;
private accessor sessions: BlockSuitePresets.AIRecentSession[] | undefined;
@state()
private accessor loadingMore = false;
@state()
private accessor hasMore = true;
private accessor currentOffset = 0;
private readonly pageSize = 10;
private groupSessionsByTime(
sessions: BlockSuitePresets.AIRecentSession[]
@@ -188,23 +198,46 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
}
private async getRecentSessions() {
this.loading = true;
const limit = 50;
const sessions = await AIProvider.session?.getRecentSessions(
this.workspaceId,
limit
);
if (sessions) {
this.sessions = sessions;
}
this.loading = false;
this.loadingMore = true;
const moreSessions =
(await AIProvider.session?.getRecentSessions(
this.workspaceId,
this.pageSize,
this.currentOffset
)) || [];
this.sessions = [...(this.sessions || []), ...moreSessions];
this.currentOffset += moreSessions.length;
this.hasMore = moreSessions.length === this.pageSize;
this.loadingMore = false;
}
private readonly onScroll = () => {
if (!this.hasMore || this.loadingMore) {
return;
}
// load more when within 50px of bottom
const { scrollTop, scrollHeight, clientHeight } = this.scrollContainer;
const threshold = 50;
if (scrollTop + clientHeight >= scrollHeight - threshold) {
this.getRecentSessions().catch(console.error);
}
};
override connectedCallback() {
super.connectedCallback();
this.getRecentSessions().catch(console.error);
}
override firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
this.disposables.add(() => {
this.scrollContainer.removeEventListener('scroll', this.onScroll);
});
this.scrollContainer.addEventListener('scroll', this.onScroll);
}
private renderSessionGroup(
title: string,
sessions: BlockSuitePresets.AIRecentSession[]
@@ -256,35 +289,43 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
</div>`;
}
override render() {
if (this.loading) {
return html`
<div class="ai-session-history">
<div class="loading-container">
<div class="loading-title">Loading history...</div>
</div>
</div>
`;
private renderLoading() {
return html`
<div class="loading-container">
<div class="loading-title">Loading history...</div>
</div>
`;
}
private renderEmpty() {
return html`
<div class="empty-container">
<div class="empty-title">Empty history</div>
</div>
`;
}
private renderHistory() {
if (!this.sessions) {
return this.renderLoading();
}
if (this.sessions.length === 0) {
return html`
<div class="ai-session-history">
<div class="empty-container">
<div class="empty-title">Empty history</div>
</div>
</div>
`;
return this.renderEmpty();
}
const groupedSessions = this.groupSessionsByTime(this.sessions);
return html`
<div class="ai-session-history">
${this.renderSessionGroup('Today', groupedSessions.today)}
${this.renderSessionGroup('Last 7 days', groupedSessions.last7Days)}
${this.renderSessionGroup('Last 30 days', groupedSessions.last30Days)}
${this.renderSessionGroup('Older', groupedSessions.older)}
</div>
${this.renderSessionGroup('Today', groupedSessions.today)}
${this.renderSessionGroup('Last 7 days', groupedSessions.last7Days)}
${this.renderSessionGroup('Last 30 days', groupedSessions.last30Days)}
${this.renderSessionGroup('Older', groupedSessions.older)}
`;
}
override render() {
return html`
<div class="ai-session-history">${this.renderHistory()}</div>
`;
}
}
@@ -58,6 +58,9 @@ export class ChatContentStreamObjects extends WithDisposable(
@property({ attribute: false })
accessor docDisplayService!: DocDisplayConfig;
@property({ attribute: false })
accessor onOpenDoc!: (docId: string, sessionId?: string) => void;
private renderToolCall(streamObject: StreamObject) {
if (streamObject.type !== 'tool-call') {
return nothing;
@@ -183,11 +186,13 @@ export class ChatContentStreamObjects extends WithDisposable(
.data=${streamObject}
.width=${this.width}
.docDisplayService=${this.docDisplayService}
.onOpenDoc=${this.onOpenDoc}
></doc-semantic-search-result>`;
case 'doc_keyword_search':
return html`<doc-keyword-search-result
.data=${streamObject}
.width=${this.width}
.onOpenDoc=${this.onOpenDoc}
></doc-keyword-search-result>`;
case 'doc_read':
return html`<doc-read-result
@@ -2,7 +2,6 @@ import { LoadingIcon } from '@blocksuite/affine/components/icons';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import type { ColorScheme } from '@blocksuite/affine/model';
import { ShadowlessElement } from '@blocksuite/affine/std';
import { type NotificationService } from '@blocksuite/affine-shared/services';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import type { Signal } from '@preact/signals-core';
import {
@@ -42,18 +41,23 @@ export abstract class ArtifactTool<
background-color: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
}
}
.artifact-skeleton-container {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
artifact-skeleton {
margin-top: -24px;
}
}
`;
/** Tool data coming from ChatGPT (tool-call / tool-result). */
@property({ attribute: false })
accessor data!: TData;
@property({ attribute: false })
accessor width: Signal<number | undefined> | undefined;
@property({ attribute: false })
accessor notificationService!: NotificationService;
@property({ attribute: false })
accessor theme!: Signal<ColorScheme>;
@@ -64,14 +68,15 @@ export abstract class ArtifactTool<
*/
protected abstract getCardMeta(): {
title: string;
/** Page / file icon shown when not loading */
icon: TemplateResult | HTMLElement | string | null;
/** Whether the spinner should be displayed */
loading: boolean;
/** Extra css class appended to card root */
className?: string;
};
/**
* Icon shown in the card (when not loading) and in the loading skeleton.
*/
protected abstract getIcon(): TemplateResult | HTMLElement | string | null;
/** Banner shown on the right side of the card (can be undefined). */
protected abstract getBanner(
theme: ColorScheme
@@ -90,11 +95,14 @@ export abstract class ArtifactTool<
/** Open or refresh the preview panel. */
private openOrUpdatePreviewPanel() {
renderPreviewPanel(
this,
this.getPreviewContent(),
this.getPreviewControls()
);
const content = this.isLoading()
? this.renderLoadingSkeleton()
: this.getPreviewContent();
renderPreviewPanel(this, content, this.getPreviewControls());
}
protected isLoading(): boolean {
return this.data.type !== 'tool-result';
}
protected refreshPreviewPanel() {
@@ -108,18 +116,23 @@ export abstract class ArtifactTool<
return null;
}
protected renderLoadingSkeleton() {
const icon = this.getIcon();
return html`<div class="artifact-skeleton-container">
<artifact-skeleton .icon=${icon}></artifact-skeleton>
</div>`;
}
private readonly onCardClick = (_e: Event) => {
this.openOrUpdatePreviewPanel();
};
protected renderCard() {
const { title, icon, loading, className } = this.getCardMeta();
const { title, className } = this.getCardMeta();
const resolvedIcon = loading
? LoadingIcon({
size: '20px',
})
: icon;
const resolvedIcon = this.isLoading()
? LoadingIcon({ size: '20px' })
: this.getIcon();
const banner = this.getBanner(this.theme.value);
@@ -3,6 +3,7 @@ import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import { ColorScheme } from '@blocksuite/affine/model';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import { type BlockStdScope } from '@blocksuite/affine/std';
import type { NotificationService } from '@blocksuite/affine-shared/services';
import {
CodeBlockIcon,
CopyIcon,
@@ -437,6 +438,9 @@ export class CodeArtifactTool extends ArtifactTool<
@property({ attribute: false })
accessor std: BlockStdScope | undefined;
@property({ attribute: false })
accessor notificationService!: NotificationService;
@state()
private accessor mode: 'preview' | 'code' = 'code';
@@ -447,25 +451,19 @@ export class CodeArtifactTool extends ArtifactTool<
}
protected getCardMeta() {
const loading = this.data.type === 'tool-call';
return {
title: this.data.args.title,
icon: CodeBlockIcon({ width: '20', height: '20' }),
loading,
className: 'code-artifact-result',
};
}
protected override getIcon() {
return CodeBlockIcon();
}
protected override getPreviewContent() {
if (this.data.type !== 'tool-result' || !this.data.result) {
// loading state
return html`<div class="code-artifact-preview">
<div
style="display:flex;justify-content:center;align-items:center;height:100%"
>
${CodeBlockIcon({ width: '24', height: '24' })}
</div>
</div>`;
return html``;
}
const result = this.data.result;
@@ -1,11 +1,11 @@
import { getStoreManager } from '@affine/core/blocksuite/manager/store';
import { getAFFiNEWorkspaceSchema } from '@affine/core/modules/workspace';
import { getEmbedLinkedDocIcons } from '@blocksuite/affine/blocks/embed-doc';
import { LoadingIcon } from '@blocksuite/affine/components/icons';
import { RefNodeSlotsProvider } from '@blocksuite/affine/inlines/reference';
import type { ColorScheme } from '@blocksuite/affine/model';
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
import { MarkdownTransformer } from '@blocksuite/affine/widgets/linked-doc';
import type { NotificationService } from '@blocksuite/affine-shared/services';
import { CopyIcon, PageIcon, ToolIcon } from '@blocksuite/icons/lit';
import type { BlockStdScope } from '@blocksuite/std';
import { css, html } from 'lit';
@@ -88,6 +88,9 @@ export class DocComposeTool extends ArtifactTool<
@property({ attribute: false })
accessor std: BlockStdScope | undefined;
@property({ attribute: false })
accessor notificationService!: NotificationService;
protected getBanner(theme: ColorScheme) {
const { LinkedDocEmptyBanner } = getEmbedLinkedDocIcons(
theme,
@@ -98,15 +101,16 @@ export class DocComposeTool extends ArtifactTool<
}
protected getCardMeta() {
const composing = this.data.type === 'tool-call';
return {
title: this.data.args.title,
icon: PageIcon(),
loading: composing,
className: 'doc-compose-result',
};
}
protected override getIcon() {
return PageIcon();
}
protected override getPreviewContent() {
if (!this.std) return html``;
const resultData = this.data;
@@ -126,11 +130,7 @@ export class DocComposeTool extends ArtifactTool<
theme: this.theme,
}}
></text-renderer>`
: html`<div class="doc-compose-result-preview-loading">
${LoadingIcon({
size: '32px',
})}
</div>`}
: html``}
</div>`;
}
@@ -2,7 +2,7 @@ import { WithDisposable } from '@blocksuite/global/lit';
import { PageIcon, SearchIcon } from '@blocksuite/icons/lit';
import { ShadowlessElement } from '@blocksuite/std';
import type { Signal } from '@preact/signals-core';
import { html, nothing } from 'lit';
import { css, html, nothing } from 'lit';
import { property } from 'lit/decorators.js';
import type { ToolResult } from './tool-result-card';
@@ -26,12 +26,21 @@ interface DocKeywordSearchToolResult {
}
export class DocKeywordSearchResult extends WithDisposable(ShadowlessElement) {
static override styles = css`
.doc-keyword-search-result-title {
cursor: pointer;
}
`;
@property({ attribute: false })
accessor data!: DocKeywordSearchToolCall | DocKeywordSearchToolResult;
@property({ attribute: false })
accessor width: Signal<number | undefined> | undefined;
@property({ attribute: false })
accessor onOpenDoc!: (docId: string, sessionId?: string) => void;
renderToolCall() {
return html`<tool-call-card
.name=${`Searching workspace documents for "${this.data.args.query}"`}
@@ -47,7 +56,12 @@ export class DocKeywordSearchResult extends WithDisposable(ShadowlessElement) {
let results: ToolResult[] = [];
try {
results = this.data.result.map(item => ({
title: item.title,
title: html`<span
class="doc-keyword-search-result-title"
@click=${() => this.onOpenDoc(item.docId)}
>
${item.title}
</span>`,
icon: PageIcon(),
}));
} catch (err) {
@@ -2,7 +2,7 @@ import { WithDisposable } from '@blocksuite/global/lit';
import { AiEmbeddingIcon, PageIcon } from '@blocksuite/icons/lit';
import { ShadowlessElement } from '@blocksuite/std';
import type { Signal } from '@preact/signals-core';
import { html, nothing } from 'lit';
import { css, html, nothing } from 'lit';
import { property } from 'lit/decorators.js';
import type { DocDisplayConfig } from '../ai-chat-chips';
@@ -54,6 +54,12 @@ function parseResultContent(content: string) {
}
export class DocSemanticSearchResult extends WithDisposable(ShadowlessElement) {
static override styles = css`
.doc-semantic-search-result-title {
cursor: pointer;
}
`;
@property({ attribute: false })
accessor data!: DocSemanticSearchToolCall | DocSemanticSearchToolResult;
@@ -63,6 +69,9 @@ export class DocSemanticSearchResult extends WithDisposable(ShadowlessElement) {
@property({ attribute: false })
accessor docDisplayService!: DocDisplayConfig;
@property({ attribute: false })
accessor onOpenDoc!: (docId: string, sessionId?: string) => void;
renderToolCall() {
return html`<tool-call-card
.name=${`Finding semantically related pages for "${this.data.args.query}"`}
@@ -82,7 +91,12 @@ export class DocSemanticSearchResult extends WithDisposable(ShadowlessElement) {
.results=${this.data.result
.map(result => ({
...parseResultContent(result.content),
title: this.docDisplayService.getTitle(result.docId),
title: html`<span
class="doc-semantic-search-result-title"
@click=${() => this.onOpenDoc(result.docId)}
>
${this.docDisplayService.getTitle(result.docId)}
</span>`,
}))
.filter(Boolean)}
></tool-result-card>`;
@@ -8,7 +8,7 @@ import { css, html, nothing, type TemplateResult } from 'lit';
import { property, state } from 'lit/decorators.js';
export interface ToolResult {
title: string;
title: string | TemplateResult<1>;
icon?: string | TemplateResult<1>;
content?: string;
}
@@ -22,10 +22,12 @@ import { ActionMindmap } from './chat-panel/actions/mindmap';
import { ActionSlides } from './chat-panel/actions/slides';
import { ActionText } from './chat-panel/actions/text';
import { AILoading } from './chat-panel/ai-loading';
import { AIChatPanelTitle } from './chat-panel/ai-title';
import { ChatMessageAction } from './chat-panel/message/action';
import { ChatMessageAssistant } from './chat-panel/message/assistant';
import { ChatMessageUser } from './chat-panel/message/user';
import { ChatPanelSplitView } from './chat-panel/split-view';
import { ArtifactSkeleton } from './components/ai-artifact-skeleton';
import { AIChatAddContext } from './components/ai-chat-add-context';
import { ChatPanelAddPopover } from './components/ai-chat-chips/add-popover';
import { ChatPanelCandidatesPopover } from './components/ai-chat-chips/candidates-popover';
@@ -141,6 +143,7 @@ export function registerAIEffects() {
customElements.define('ai-session-history', AISessionHistory);
customElements.define('ai-chat-messages', AIChatMessages);
customElements.define('chat-panel', ChatPanel);
customElements.define('ai-chat-panel-title', AIChatPanelTitle);
customElements.define('ai-chat-input', AIChatInput);
customElements.define('ai-chat-add-context', AIChatAddContext);
customElements.define(
@@ -241,4 +244,5 @@ export function registerAIEffects() {
customElements.define('transcription-block', LitTranscriptionBlock);
customElements.define('chat-panel-split-view', ChatPanelSplitView);
customElements.define('artifact-skeleton', ArtifactSkeleton);
}
@@ -186,13 +186,18 @@ export class CopilotClient {
}
}
async getRecentSessions(workspaceId: string, limit?: number) {
async getRecentSessions(
workspaceId: string,
limit?: number,
offset?: number
) {
try {
const res = await this.gql({
query: getCopilotRecentSessionsQuery,
variables: {
workspaceId,
limit,
offset,
},
});
return res.currentUser?.copilot?.chats.edges.map(e => e.node);
@@ -589,8 +589,12 @@ Could you make a new website based on these notes and send back just the html fi
) => {
return client.getSessions(workspaceId, {}, docId, options);
},
getRecentSessions: async (workspaceId: string, limit?: number) => {
return client.getRecentSessions(workspaceId, limit);
getRecentSessions: async (
workspaceId: string,
limit?: number,
offset?: number
) => {
return client.getRecentSessions(workspaceId, limit, offset);
},
updateSession: async (options: UpdateChatSessionInput) => {
return client.updateSession(options);
@@ -1,5 +1,6 @@
import {
Checkbox,
ContextMenu,
DragHandle as DragHandleIcon,
Tooltip,
useDraggable,
@@ -29,7 +30,7 @@ import { PagePreview } from '../../page-list/page-content-preview';
import { DocExplorerContext } from '../context';
import { quickActions } from '../quick-actions.constants';
import * as styles from './doc-list-item.css';
import { MoreMenuButton } from './more-menu';
import { MoreMenuButton, MoreMenuContent } from './more-menu';
import { CardViewProperties, ListViewProperties } from './properties';
export type DocListItemView = 'list' | 'grid' | 'masonry';
@@ -314,38 +315,46 @@ export const ListViewDoc = ({ docId }: DocListItemProps) => {
const t = useI18n();
const docsService = useService(DocsService);
const doc = useLiveData(docsService.list.doc$(docId));
const contextValue = useContext(DocExplorerContext);
const showMoreOperation = useLiveData(contextValue.showMoreOperation$);
if (!doc) {
return null;
}
return (
<li className={styles.listViewRoot}>
<DragHandle id={docId} className={styles.listDragHandle} />
<Select id={docId} className={styles.listSelect} />
<DocIcon id={docId} className={styles.listIcon} />
<div className={styles.listBrief}>
<DocTitle
id={docId}
className={styles.listTitle}
data-testid="doc-list-item-title"
<ContextMenu
asChild
disabled={!showMoreOperation}
items={<MoreMenuContent docId={docId} />}
>
<li className={styles.listViewRoot}>
<DragHandle id={docId} className={styles.listDragHandle} />
<Select id={docId} className={styles.listSelect} />
<DocIcon id={docId} className={styles.listIcon} />
<div className={styles.listBrief}>
<DocTitle
id={docId}
className={styles.listTitle}
data-testid="doc-list-item-title"
/>
<DocPreview id={docId} className={styles.listPreview} />
</div>
<div className={styles.listSpace} />
<ListViewProperties docId={docId} />
{quickActions.map(action => {
return (
<Tooltip key={action.key} content={t.t(action.name)}>
<action.Component doc={doc} />
</Tooltip>
);
})}
<MoreMenuButton
docId={docId}
contentOptions={listMoreMenuContentOptions}
/>
<DocPreview id={docId} className={styles.listPreview} />
</div>
<div className={styles.listSpace} />
<ListViewProperties docId={docId} />
{quickActions.map(action => {
return (
<Tooltip key={action.key} content={t.t(action.name)}>
<action.Component doc={doc} />
</Tooltip>
);
})}
<MoreMenuButton
docId={docId}
contentOptions={listMoreMenuContentOptions}
/>
</li>
</li>
</ContextMenu>
);
};
@@ -7,6 +7,12 @@ export const container = style({
width: '360px',
display: 'flex',
flexDirection: 'column',
selectors: {
'&[data-mobile]': {
width: '100%',
},
},
});
export const header = style({
@@ -90,7 +90,10 @@ export const NotificationList = () => {
}, [notificationListService]);
return (
<div className={styles.container}>
<div
className={styles.container}
data-mobile={BUILD_CONFIG.isMobileEdition ? '' : undefined}
>
<div className={styles.header}>
<span>{t['com.affine.rootAppSidebar.notifications']()}</span>
{notifications.length > 0 && (
@@ -252,7 +252,7 @@ export const NavigationPanelDocNode = ({
extractEmojiAsIcon={enableEmojiIcon}
collapsed={isCollapsed}
setCollapsed={setCollapsed}
collapsible={appSettings.showLinkedDocInSidebar}
collapsible={!!appSettings.showLinkedDocInSidebar}
canDrop={handleCanDrop}
to={`/${docId}`}
onClick={() => {
@@ -1,4 +1,5 @@
import {
ContextMenu,
DropIndicator,
type DropTargetDropEvent,
type DropTargetOptions,
@@ -466,47 +467,54 @@ export const NavigationPanelTreeNode = ({
ref={rootRef}
{...otherProps}
>
<div
className={clsx(styles.contentContainer, styles.draggedOverEffect)}
data-open={!collapsed}
data-self-dragged-over={isSelfDraggedOver}
ref={dropTargetRef}
<ContextMenu
asChild
items={menuOperations.map(({ view, index }) => (
<Fragment key={index}>{view}</Fragment>
))}
>
{to ? (
<LinkComponent
to={to}
className={styles.linkItemRoot}
ref={dragRef}
draggable={false}
>
{content}
</LinkComponent>
) : (
<div ref={dragRef}>{content}</div>
)}
<CustomDragPreview>
<div className={styles.draggingContainer}>{content}</div>
</CustomDragPreview>
{treeInstruction &&
// Do not show drop indicator for self dragged over
!(treeInstruction.type !== 'reparent' && isSelfDraggedOver) &&
treeInstruction.type !== 'instruction-blocked' && (
<DropIndicator instruction={treeInstruction} />
<div
className={clsx(styles.contentContainer, styles.draggedOverEffect)}
data-open={!collapsed}
data-self-dragged-over={isSelfDraggedOver}
ref={dropTargetRef}
>
{to ? (
<LinkComponent
to={to}
className={styles.linkItemRoot}
ref={dragRef}
draggable={false}
>
{content}
</LinkComponent>
) : (
<div ref={dragRef}>{content}</div>
)}
{draggedOver &&
dropEffect &&
draggedOverPosition &&
!isSelfDraggedOver &&
draggedOverDraggable && (
<DropEffect
dropEffect={dropEffect({
source: draggedOverDraggable,
treeInstruction: treeInstruction,
})}
position={draggedOverPosition}
/>
)}
</div>
<CustomDragPreview>
<div className={styles.draggingContainer}>{content}</div>
</CustomDragPreview>
{treeInstruction &&
// Do not show drop indicator for self dragged over
!(treeInstruction.type !== 'reparent' && isSelfDraggedOver) &&
treeInstruction.type !== 'instruction-blocked' && (
<DropIndicator instruction={treeInstruction} />
)}
{draggedOver &&
dropEffect &&
draggedOverPosition &&
!isSelfDraggedOver &&
draggedOverDraggable && (
<DropEffect
dropEffect={dropEffect({
source: draggedOverDraggable,
treeInstruction: treeInstruction,
})}
position={draggedOverPosition}
/>
)}
</div>
</ContextMenu>
<Collapsible.Content style={{ display: dragging ? 'none' : undefined }}>
{/* For lastInGroup check, the placeholder must be placed above all children in the dom */}
<div className={styles.collapseContentPlaceholder}>
@@ -181,7 +181,7 @@ export const AppearanceSettings = () => {
]()}
>
<Switch
checked={appSettings.showLinkedDocInSidebar}
checked={!!appSettings.showLinkedDocInSidebar}
onChange={checked =>
updateSettings('showLinkedDocInSidebar', checked)
}
@@ -94,6 +94,7 @@ export const Component = () => {
const chatToolContainerRef = useRef<HTMLDivElement>(null);
const widthSignalRef = useRef<Signal<number>>(signal(0));
const client = useCopilotClient();
const workbench = useService(WorkbenchService).workbench;
const workspaceId = useService(WorkspaceService).workspace.id;
@@ -147,6 +148,15 @@ export const Component = () => {
}
}, [client, createSession, currentSession, isTogglingPin, workspaceId]);
// remove the old content to trigger re-mount
// to avoid infinitely load and mount, should not make `chatContent` as dependency
const reMountChatContent = useCallback(() => {
setChatContent(prev => {
prev?.remove();
return null;
});
}, []);
const onOpenSession = useCallback(
(sessionId: string) => {
if (isOpeningSession) return;
@@ -155,10 +165,7 @@ export const Component = () => {
.getSession(workspaceId, sessionId)
.then(session => {
setCurrentSession(session);
if (chatContent) {
chatContent.remove();
setChatContent(null);
}
reMountChatContent();
chatTool?.closeHistoryMenu();
})
.catch(console.error)
@@ -166,13 +173,20 @@ export const Component = () => {
setIsOpeningSession(false);
});
},
[chatContent, chatTool, client, isOpeningSession, workspaceId]
[chatTool, client, isOpeningSession, reMountChatContent, workspaceId]
);
const onContextChange = useCallback((context: Partial<ChatContextValue>) => {
setStatus(context.status ?? 'idle');
}, []);
const onOpenDoc = useCallback(
(docId: string) => {
workbench.openDoc(docId, { at: 'active' });
},
[workbench]
);
const confirmModal = useConfirmModal();
const specs = useAISpecs();
const mockStd = useMockStd();
@@ -208,6 +222,7 @@ export const Component = () => {
confirmModal.openConfirmModal
);
content.createSession = createSession;
content.onOpenDoc = onOpenDoc;
if (!chatContent) {
// initial values that won't change
@@ -232,11 +247,12 @@ export const Component = () => {
confirmModal,
onContextChange,
specs,
onOpenDoc,
]);
// init or update header ai-chat-toolbar
useEffect(() => {
if (!isHeaderProvided || !chatToolContainerRef.current || !chatContent) {
if (!isHeaderProvided || !chatToolContainerRef.current) {
return;
}
let tool = chatTool;
@@ -258,8 +274,7 @@ export const Component = () => {
tool.onNewSession = () => {
if (!currentSession) return;
setCurrentSession(null);
chatContent?.remove();
setChatContent(null);
reMountChatContent();
};
tool.onTogglePin = async () => {
@@ -281,7 +296,6 @@ export const Component = () => {
setChatTool(tool);
}
}, [
chatContent,
chatTool,
currentSession,
docDisplayConfig,
@@ -292,6 +306,7 @@ export const Component = () => {
confirmModal,
framework,
status,
reMountChatContent,
]);
useEffect(() => {
@@ -312,8 +327,6 @@ export const Component = () => {
// restore pinned session
useEffect(() => {
if (!chatContent) return;
const controller = new AbortController();
const signal = controller.signal;
client
@@ -329,10 +342,7 @@ export const Component = () => {
const session = sessions[0];
if (!session) return;
setCurrentSession(session);
if (chatContent) {
chatContent.remove();
setChatContent(null);
}
reMountChatContent();
})
.catch(console.error);
@@ -340,7 +350,7 @@ export const Component = () => {
return () => {
controller.abort();
};
}, [chatContent, client, workspaceId]);
}, [client, reMountChatContent, workspaceId]);
const onChatContainerRef = useCallback((node: HTMLDivElement) => {
if (node) {
@@ -109,11 +109,6 @@ export const TagDetail = ({ tagId }: { tagId?: string }) => {
tagId,
]);
if (!currentTag || !tagId) {
return <PageNotFound />;
}
// eslint-disable-next-line react-hooks/rules-of-hooks
const handleDisplayPreferenceChange = useCallback(
(displayPreference: ExplorerDisplayPreference) => {
explorerContextValue.displayPreference$.next(displayPreference);
@@ -121,6 +116,10 @@ export const TagDetail = ({ tagId }: { tagId?: string }) => {
[explorerContextValue]
);
if (!currentTag || !tagId) {
return <PageNotFound />;
}
return (
<DocExplorerContext.Provider value={explorerContextValue}>
<ViewTitle title={tagName ?? 'Untitled'} />
@@ -52,11 +52,23 @@ const Section = () => {
export const AppFallback = () => {
return (
<SafeArea top bottom style={{ height: '100dvh', overflow: 'hidden' }}>
{/* setting */}
<div style={{ padding: 10, display: 'flex', justifyContent: 'end' }}>
{/* notification and setting */}
<div
style={{
padding: 10,
paddingTop: 0,
display: 'flex',
justifyContent: 'end',
gap: 10,
}}
>
<Skeleton
animation="wave"
style={{ width: 23, height: 23, borderRadius: 4 }}
style={{ width: 28, height: 28, borderRadius: 4 }}
/>
<Skeleton
animation="wave"
style={{ width: 28, height: 28, borderRadius: 4 }}
/>
</div>
{/* workspace card */}
@@ -1,13 +1,16 @@
import {
IconButton,
Menu,
SafeArea,
startScopedViewTransition,
} from '@affine/component';
import { NotificationList } from '@affine/core/components/notification/list';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { NotificationCountService } from '@affine/core/modules/notification';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n';
import { SettingsIcon } from '@blocksuite/icons/rc';
import { useService } from '@toeverything/infra';
import { NotificationIcon, SettingsIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import { useCallback, useRef, useState } from 'react';
@@ -29,6 +32,8 @@ export const HomeHeader = () => {
const floatWorkspaceCardRef = useRef<HTMLDivElement>(null);
const t = useI18n();
const workbench = useService(WorkbenchService).workbench;
const notificationCountService = useService(NotificationCountService);
const notificationCount = useLiveData(notificationCountService.count$);
const navSearch = useCallback(() => {
startScopedViewTransition(searchVTScope, () => {
@@ -70,6 +75,21 @@ export const HomeHeader = () => {
className={styles.floatWsSelector}
ref={floatWorkspaceCardRef}
/>
<Menu items={<NotificationList />}>
<div style={{ position: 'relative' }}>
<NotificationIcon width={28} height={28} />
{notificationCount > 0 && (
<div
className={styles.notificationBadge}
style={{
fontSize: notificationCount > 99 ? '8px' : '12px',
}}
>
{notificationCount > 99 ? '99+' : notificationCount}
</div>
)}
</div>
</Menu>
<IconButton
style={{ transition: 'none' }}
onClick={openSetting}
@@ -54,3 +54,17 @@ export const floatWsSelector = style({
},
},
});
export const notificationBadge = style({
position: 'absolute',
top: -2,
right: -2,
backgroundColor: cssVarV2('button/primary'),
color: cssVarV2('text/pureWhite'),
width: '16px',
height: '16px',
fontSize: '12px',
lineHeight: '16px',
borderRadius: '50%',
textAlign: 'center',
});
@@ -123,52 +123,57 @@ const WorkbenchView = ({
[tabsHeaderService, workbench.id]
);
const onContextMenu = useAsyncCallback(async () => {
const action = await tabsHeaderService.showContextMenu?.(
workbench.id,
viewIdx
);
switch (action?.type) {
case 'open-in-split-view': {
track.$.appTabsHeader.$.tabAction({
control: 'contextMenu',
action: 'openInSplitView',
});
break;
}
case 'separate-view': {
track.$.appTabsHeader.$.tabAction({
control: 'contextMenu',
action: 'separateTabs',
});
break;
}
case 'pin-tab': {
if (action.payload.shouldPin) {
const onContextMenu = useAsyncCallback(
async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const action = await tabsHeaderService.showContextMenu?.(
workbench.id,
viewIdx
);
switch (action?.type) {
case 'open-in-split-view': {
track.$.appTabsHeader.$.tabAction({
control: 'contextMenu',
action: 'pin',
});
} else {
track.$.appTabsHeader.$.tabAction({
control: 'contextMenu',
action: 'unpin',
action: 'openInSplitView',
});
break;
}
break;
case 'separate-view': {
track.$.appTabsHeader.$.tabAction({
control: 'contextMenu',
action: 'separateTabs',
});
break;
}
case 'pin-tab': {
if (action.payload.shouldPin) {
track.$.appTabsHeader.$.tabAction({
control: 'contextMenu',
action: 'pin',
});
} else {
track.$.appTabsHeader.$.tabAction({
control: 'contextMenu',
action: 'unpin',
});
}
break;
}
// fixme: when close tab the view may already be gc'ed
case 'close-tab': {
track.$.appTabsHeader.$.tabAction({
control: 'contextMenu',
action: 'close',
});
break;
}
default:
break;
}
// fixme: when close tab the view may already be gc'ed
case 'close-tab': {
track.$.appTabsHeader.$.tabAction({
control: 'contextMenu',
action: 'close',
});
break;
}
default:
break;
}
}, [tabsHeaderService, viewIdx, workbench.id]);
},
[tabsHeaderService, viewIdx, workbench.id]
);
const contentNode = useMemo(() => {
return (
@@ -17,7 +17,7 @@ export const BUILD_IN_SERVERS: (ServerMetadata & { config: ServerConfig })[] =
// since we never build desktop app in selfhosted mode, so it's fine
config: {
serverName: 'Affine Selfhost',
features: [ServerFeature.LocalWorkspace],
features: [],
oauthProviders: [],
type: ServerDeploymentType.Selfhosted,
credentialsRequirement: {
@@ -135,14 +135,11 @@ export class AudioAttachmentBlock extends Entity<AttachmentBlockModel> {
if (!buffer) {
throw new Error('No audio buffer available');
}
const slices = await encodeAudioBlobToOpusSlices(buffer, 64000);
const files = slices.map((slice, index) => {
const blob = new Blob([slice], { type: 'audio/opus' });
return new File([blob], this.props.props.name + `-${index}.opus`, {
type: 'audio/opus',
});
});
return files;
return [
new File([buffer], this.props.props.name + '.mp3', {
type: 'audio/mpeg',
}),
];
},
});
@@ -70,6 +70,9 @@ export const WorkbenchLink = forwardRef<HTMLAnchorElement, WorkbenchLinkProps>(
if (event.defaultPrevented) {
return;
}
if (event.button !== 0 && event.button !== 1) {
return;
}
const at = inferOpenAt(event);
workbench.open(to, { at, replaceHistory, show: false });
event.preventDefault();
+12
View File
@@ -8286,6 +8286,18 @@ export function useAFFiNEI18N(): {
* `Copy link`
*/
["com.affine.comment.copy-link"](): string;
/**
* `Copy`
*/
["com.affine.context-menu.copy"](): string;
/**
* `Paste`
*/
["com.affine.context-menu.paste"](): string;
/**
* `Cut`
*/
["com.affine.context-menu.cut"](): string;
/**
* `An internal error occurred.`
*/
@@ -2079,6 +2079,9 @@
"com.affine.comment.filter.only-current-mode": "Only current mode",
"com.affine.comment.reply": "Reply",
"com.affine.comment.copy-link": "Copy link",
"com.affine.context-menu.copy": "Copy",
"com.affine.context-menu.paste": "Paste",
"com.affine.context-menu.cut": "Cut",
"error.INTERNAL_SERVER_ERROR": "An internal error occurred.",
"error.NETWORK_ERROR": "Network error.",
"error.TOO_MANY_REQUEST": "Too many requests.",
@@ -182,6 +182,9 @@ test.describe('AISettings/Embedding', () => {
uploadThroughput: -1,
});
await utils.settings.disableWorkspaceEmbedding(page);
await utils.settings.enableWorkspaceEmbedding(page);
await utils.settings.waitForFileEmbeddingReadiness(page, 2);
await utils.settings.closeSettingsPanel(page);
+1
View File
@@ -1257,6 +1257,7 @@ export const PackageList = [
name: '@affine/electron',
workspaceDependencies: [
'tools/utils',
'packages/frontend/i18n',
'packages/frontend/native',
'packages/common/nbstore',
'packages/common/infra',
+4 -1
View File
@@ -320,6 +320,7 @@ __metadata:
"@emotion/styled": "npm:^11.14.0"
"@radix-ui/react-avatar": "npm:^1.1.2"
"@radix-ui/react-collapsible": "npm:^1.1.2"
"@radix-ui/react-context-menu": "npm:^2.2.15"
"@radix-ui/react-dialog": "npm:^1.1.3"
"@radix-ui/react-dropdown-menu": "npm:^2.1.3"
"@radix-ui/react-popover": "npm:^1.1.3"
@@ -418,6 +419,7 @@ __metadata:
"@marsidev/react-turnstile": "npm:^1.1.0"
"@preact/signals-core": "npm:^1.8.0"
"@radix-ui/react-collapsible": "npm:^1.1.2"
"@radix-ui/react-context-menu": "npm:^2.1.15"
"@radix-ui/react-dialog": "npm:^1.1.3"
"@radix-ui/react-popover": "npm:^1.1.3"
"@radix-ui/react-scroll-area": "npm:^1.2.2"
@@ -552,6 +554,7 @@ __metadata:
resolution: "@affine/electron@workspace:packages/frontend/apps/electron"
dependencies:
"@affine-tools/utils": "workspace:*"
"@affine/i18n": "workspace:*"
"@affine/native": "workspace:*"
"@affine/nbstore": "workspace:*"
"@electron-forge/cli": "npm:^7.6.0"
@@ -11237,7 +11240,7 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-context-menu@npm:^2.2.3":
"@radix-ui/react-context-menu@npm:^2.1.15, @radix-ui/react-context-menu@npm:^2.2.15, @radix-ui/react-context-menu@npm:^2.2.3":
version: 2.2.15
resolution: "@radix-ui/react-context-menu@npm:2.2.15"
dependencies: