mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-02 02:00:49 +08:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5758d5eba5 | |||
| cdff5c3117 | |||
| d44771dfe9 | |||
| 45b05f06b3 | |||
| 04e002eb77 | |||
| a444941b79 | |||
| 39e0ec37fd | |||
| cc1d5b497a | |||
| a4b535a42a | |||
| c797cac87d | |||
| 339ecab00f | |||
| 8e374f5517 | |||
| cd91bea5c1 | |||
| 613597e642 | |||
| a597bdcdf6 | |||
| 316c671c92 | |||
| 95a97b793c | |||
| eb24074871 | |||
| 2a8f18504b | |||
| b85afa7394 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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!
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+6
-42
@@ -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({
|
||||
|
||||
+4
@@ -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
|
||||
|
||||
+78
-37
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -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>`;
|
||||
}
|
||||
|
||||
|
||||
+16
-2
@@ -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) {
|
||||
|
||||
+16
-2
@@ -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}>
|
||||
|
||||
+1
-1
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user