Compare commits

...

17 Commits

Author SHA1 Message Date
yoyoyohamapi e186ac71c3 feat(core): apply model prompts opt 2025-07-16 14:56:28 +08: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
53 changed files with 1089 additions and 319 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',
})
@@ -51,7 +51,15 @@ Important Instructions:
- When inserting, follow the same format as a replacement, but ensure the new block_id does not conflict with existing IDs.
- When replacing content, always keep the original block_id unchanged.
- When deleting content, only use the format <!-- delete block_id=xxx -->, and only for valid block_id present in the original <code> content.
- Each list item should be a block.
- Each top-level list item should be a block. Like this:
\`\`\`markdown
<!-- block_id=001 flavour=affine:list -->
* Item 1
* SubItem 1
<!-- block_id=002 flavour=affine:list -->
1. Item 1
1. SubItem 1
\`\`\`
- Your task is to return a list of block-level changes needed to fulfill the user's intent.
- **Each change in code_edit must be completely independent: each code_edit entry should only perform a single, isolated change, and must not include the effects of other changes. For example, the updates for a delete operation should only show the context related to the deletion, and must not include any content modified by other operations (such as bolding or insertion). This ensures that each change can be applied independently and in any order.**
@@ -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 */
@@ -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;
@@ -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));
@@ -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);
@@ -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={() => {
@@ -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',
});
@@ -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);