mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-02 02:00:49 +08:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5758d5eba5 | |||
| cdff5c3117 | |||
| d44771dfe9 | |||
| 45b05f06b3 | |||
| 04e002eb77 | |||
| a444941b79 | |||
| 39e0ec37fd | |||
| cc1d5b497a | |||
| a4b535a42a | |||
| c797cac87d | |||
| 339ecab00f | |||
| 8e374f5517 | |||
| cd91bea5c1 | |||
| 613597e642 | |||
| a597bdcdf6 | |||
| 316c671c92 | |||
| 95a97b793c | |||
| eb24074871 | |||
| 2a8f18504b | |||
| b85afa7394 | |||
| 8ec4bbb298 | |||
| 812c199b45 | |||
| 36bd8f645a | |||
| 7cff8091e4 | |||
| de8feb98a3 | |||
| fbd6e8fa97 | |||
| bcf6bd1dfc |
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,8 @@ export class MenuSubMenu extends MenuFocusable {
|
||||
.catch(err => console.error(err));
|
||||
});
|
||||
this.menu.openSubMenu(menu);
|
||||
// in case that the menu is not closed, but the component is removed,
|
||||
this.disposables.add(unsub);
|
||||
}
|
||||
|
||||
protected override render(): unknown {
|
||||
|
||||
@@ -116,6 +116,7 @@ export class EdgelessTemplateButton extends EdgelessToolbarToolMixin(
|
||||
`;
|
||||
|
||||
private _cleanup: (() => void) | null = null;
|
||||
private _autoUpdateCleanup: (() => void) | null = null;
|
||||
|
||||
private _prevTool: ToolOptionWithType | null = null;
|
||||
|
||||
@@ -128,6 +129,11 @@ export class EdgelessTemplateButton extends EdgelessToolbarToolMixin(
|
||||
return [TemplateCard1[theme], TemplateCard2[theme], TemplateCard3[theme]];
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.disposables.add(() => this._autoUpdateCleanup?.());
|
||||
}
|
||||
|
||||
private _closePanel() {
|
||||
if (this._openedPanel) {
|
||||
this._openedPanel.remove();
|
||||
@@ -175,8 +181,8 @@ export class EdgelessTemplateButton extends EdgelessToolbarToolMixin(
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const arrowEl = panel.renderRoot.querySelector('.arrow') as HTMLElement;
|
||||
|
||||
autoUpdate(this, panel, () => {
|
||||
this._autoUpdateCleanup?.();
|
||||
this._autoUpdateCleanup = autoUpdate(this, panel, () => {
|
||||
computePosition(this, panel, {
|
||||
placement: 'top',
|
||||
middleware: [offset(20), arrow({ element: arrowEl }), shift()],
|
||||
|
||||
@@ -22,8 +22,11 @@ import { isEqual } from 'lodash-es';
|
||||
})
|
||||
export class InlineComment extends WithDisposable(ShadowlessElement) {
|
||||
static override styles = css`
|
||||
inline-comment {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
inline-comment.unresolved {
|
||||
display: inline-block;
|
||||
background-color: ${unsafeCSSVarV2('block/comment/highlightDefault')};
|
||||
border-bottom: 2px solid
|
||||
${unsafeCSSVarV2('block/comment/highlightUnderline')};
|
||||
|
||||
@@ -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.
@@ -290,6 +290,7 @@ test('should fork session correctly', async t => {
|
||||
|
||||
const assertForkSession = async (
|
||||
workspaceId: string,
|
||||
docId: string,
|
||||
sessionId: string,
|
||||
lastMessageId: string | undefined,
|
||||
error: string,
|
||||
@@ -300,13 +301,7 @@ test('should fork session correctly', async t => {
|
||||
}
|
||||
) =>
|
||||
await asserter(
|
||||
forkCopilotSession(
|
||||
app,
|
||||
workspaceId,
|
||||
randomUUID(),
|
||||
sessionId,
|
||||
lastMessageId
|
||||
)
|
||||
forkCopilotSession(app, workspaceId, docId, sessionId, lastMessageId)
|
||||
);
|
||||
|
||||
// prepare session
|
||||
@@ -330,6 +325,7 @@ test('should fork session correctly', async t => {
|
||||
// should be able to fork session
|
||||
forkedSessionId = await assertForkSession(
|
||||
id,
|
||||
docId,
|
||||
sessionId,
|
||||
latestMessageId!,
|
||||
'should be able to fork session with cloud workspace that user can access'
|
||||
@@ -340,6 +336,7 @@ test('should fork session correctly', async t => {
|
||||
{
|
||||
forkedSessionId = await assertForkSession(
|
||||
id,
|
||||
docId,
|
||||
sessionId,
|
||||
undefined,
|
||||
'should be able to fork session without latestMessageId'
|
||||
@@ -348,18 +345,25 @@ test('should fork session correctly', async t => {
|
||||
|
||||
// should not be able to fork session with wrong latestMessageId
|
||||
{
|
||||
await assertForkSession(id, sessionId, 'wrong-message-id', '', async x => {
|
||||
await t.throwsAsync(
|
||||
x,
|
||||
{ instanceOf: Error },
|
||||
'should not able to fork session with wrong latestMessageId'
|
||||
);
|
||||
});
|
||||
await assertForkSession(
|
||||
id,
|
||||
docId,
|
||||
sessionId,
|
||||
'wrong-message-id',
|
||||
'',
|
||||
async x => {
|
||||
await t.throwsAsync(
|
||||
x,
|
||||
{ instanceOf: Error },
|
||||
'should not able to fork session with wrong latestMessageId'
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const u2 = await app.signupV1();
|
||||
await assertForkSession(id, sessionId, randomUUID(), '', async x => {
|
||||
await assertForkSession(id, docId, sessionId, randomUUID(), '', async x => {
|
||||
await t.throwsAsync(
|
||||
x,
|
||||
{ instanceOf: Error },
|
||||
@@ -371,7 +375,7 @@ test('should fork session correctly', async t => {
|
||||
const inviteId = await inviteUser(app, id, u2.email);
|
||||
await app.switchUser(u2);
|
||||
await acceptInviteById(app, id, inviteId, false);
|
||||
await assertForkSession(id, sessionId, randomUUID(), '', async x => {
|
||||
await assertForkSession(id, docId, sessionId, randomUUID(), '', async x => {
|
||||
await t.throwsAsync(
|
||||
x,
|
||||
{ instanceOf: Error },
|
||||
@@ -389,6 +393,7 @@ test('should fork session correctly', async t => {
|
||||
await app.switchUser(u2);
|
||||
await assertForkSession(
|
||||
id,
|
||||
docId,
|
||||
forkedSessionId,
|
||||
latestMessageId!,
|
||||
'should able to fork a forked session created by other user'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1624,6 +1624,166 @@ const imageActions: Prompt[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const modelActions: Prompt[] = [
|
||||
{
|
||||
name: 'Apply Updates',
|
||||
action: 'Apply Updates',
|
||||
model: 'claude-sonnet-4@20250514',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: `
|
||||
You are a Markdown document update engine.
|
||||
|
||||
You will be given:
|
||||
|
||||
1. content: The original Markdown document
|
||||
- The content is structured into blocks.
|
||||
- Each block starts with a comment like <!-- block_id=... flavour=... --> and contains the block's content.
|
||||
- The content is {{content}}
|
||||
|
||||
2. op: A description of the edit intention
|
||||
- This describes the semantic meaning of the edit, such as "Bold the first paragraph".
|
||||
- The op is {{op}}
|
||||
|
||||
3. updates: A Markdown snippet
|
||||
- The updates is {{updates}}
|
||||
- This represents the block-level changes to apply to the original Markdown.
|
||||
- The update may:
|
||||
- **Replace** an existing block (same block_id, new content)
|
||||
- **Delete** block(s) using <!-- delete block BLOCK_ID -->
|
||||
- **Insert** new block(s) with a new unique block_id
|
||||
- When performing deletions, the update will include **surrounding context blocks** (or use <!-- existing blocks -->) to help you determine where and what to delete.
|
||||
|
||||
Your task:
|
||||
- Apply the update in <updates> to the document in <code>, following the intent described in <op>.
|
||||
- Preserve all block_id and flavour comments.
|
||||
- Maintain the original block order unless the update clearly appends new blocks.
|
||||
- Do not remove or alter unrelated blocks.
|
||||
- Output only the fully updated Markdown content. Do not wrap the content in \`\`\`markdown.
|
||||
|
||||
---
|
||||
|
||||
✍️ Examples
|
||||
|
||||
✅ Replacement (modifying an existing block)
|
||||
|
||||
<code>
|
||||
<!-- block_id=101 flavour=paragraph -->
|
||||
## Introduction
|
||||
|
||||
<!-- block_id=102 flavour=paragraph -->
|
||||
This document provides an overview of the system architecture and its components.
|
||||
</code>
|
||||
|
||||
<op>
|
||||
Make the introduction more formal.
|
||||
</op>
|
||||
|
||||
<updates>
|
||||
<!-- block_id=102 flavour=paragraph -->
|
||||
This document outlines the architectural design and individual components of the system in detail.
|
||||
</updates>
|
||||
|
||||
Expected Output:
|
||||
<!-- block_id=101 flavour=paragraph -->
|
||||
## Introduction
|
||||
|
||||
<!-- block_id=102 flavour=paragraph -->
|
||||
This document outlines the architectural design and individual components of the system in detail.
|
||||
|
||||
---
|
||||
|
||||
➕ Insertion (adding new content)
|
||||
|
||||
<code>
|
||||
<!-- block_id=201 flavour=paragraph -->
|
||||
# Project Summary
|
||||
|
||||
<!-- block_id=202 flavour=paragraph -->
|
||||
This project aims to build a collaborative text editing tool.
|
||||
</code>
|
||||
|
||||
<op>
|
||||
Add a disclaimer section at the end.
|
||||
</op>
|
||||
|
||||
<updates>
|
||||
<!-- block_id=new-301 flavour=paragraph -->
|
||||
## Disclaimer
|
||||
|
||||
<!-- block_id=new-302 flavour=paragraph -->
|
||||
This document is subject to change. Do not distribute externally.
|
||||
</updates>
|
||||
|
||||
Expected Output:
|
||||
<!-- block_id=201 flavour=paragraph -->
|
||||
# Project Summary
|
||||
|
||||
<!-- block_id=202 flavour=paragraph -->
|
||||
This project aims to build a collaborative text editing tool.
|
||||
|
||||
<!-- block_id=new-301 flavour=paragraph -->
|
||||
## Disclaimer
|
||||
|
||||
<!-- block_id=new-302 flavour=paragraph -->
|
||||
This document is subject to change. Do not distribute externally.
|
||||
|
||||
---
|
||||
|
||||
❌ Deletion (removing blocks)
|
||||
|
||||
<code>
|
||||
<!-- block_id=401 flavour=paragraph -->
|
||||
## Author
|
||||
|
||||
<!-- block_id=402 flavour=paragraph -->
|
||||
Written by the AI team at OpenResearch.
|
||||
|
||||
<!-- block_id=403 flavour=paragraph -->
|
||||
## Experimental Section
|
||||
|
||||
<!-- block_id=404 flavour=paragraph -->
|
||||
The following section is still under development and may change without notice.
|
||||
|
||||
<!-- block_id=405 flavour=paragraph -->
|
||||
## License
|
||||
|
||||
<!-- block_id=406 flavour=paragraph -->
|
||||
This document is licensed under CC BY-NC 4.0.
|
||||
</code>
|
||||
|
||||
<op>
|
||||
Remove the experimental section.
|
||||
</op>
|
||||
|
||||
<updates>
|
||||
<!-- delete block_id=403 -->
|
||||
<!-- delete block_id=404 -->
|
||||
</updates>
|
||||
|
||||
Expected Output:
|
||||
<!-- block_id=401 flavour=paragraph -->
|
||||
## Author
|
||||
|
||||
<!-- block_id=402 flavour=paragraph -->
|
||||
Written by the AI team at OpenResearch.
|
||||
|
||||
<!-- block_id=405 flavour=paragraph -->
|
||||
## License
|
||||
|
||||
<!-- block_id=406 flavour=paragraph -->
|
||||
This document is licensed under CC BY-NC 4.0.
|
||||
|
||||
---
|
||||
|
||||
Now apply the \`updates\` to the \`content\`, following the intent in \`op\`, and return the updated Markdown.
|
||||
`,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const CHAT_PROMPT: Omit<Prompt, 'name'> = {
|
||||
model: 'claude-sonnet-4@20250514',
|
||||
optionalModels: [
|
||||
@@ -1861,6 +2021,7 @@ const artifactActions: Prompt[] = [
|
||||
export const prompts: Prompt[] = [
|
||||
...textActions,
|
||||
...imageActions,
|
||||
...modelActions,
|
||||
...chat,
|
||||
...workflows,
|
||||
...artifactActions,
|
||||
|
||||
@@ -37,6 +37,24 @@ export class MorphProvider extends CopilotProvider<MorphConfig> {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'morph-v3-fast',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text],
|
||||
output: [ModelOutputType.Text],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'morph-v3-large',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text],
|
||||
output: [ModelOutputType.Text],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
#instance!: VercelOpenAICompatibleProvider;
|
||||
|
||||
@@ -172,6 +172,7 @@ export abstract class CopilotProvider<C = any> {
|
||||
const getDocContent = buildContentGetter(ac, docReader);
|
||||
tools.doc_edit = createDocEditTool(
|
||||
this.factory,
|
||||
prompt,
|
||||
getDocContent.bind(null, options)
|
||||
);
|
||||
break;
|
||||
|
||||
@@ -472,10 +472,18 @@ export class TextStreamParser {
|
||||
result = this.addPrefix(result);
|
||||
switch (chunk.toolName) {
|
||||
case 'doc_edit': {
|
||||
if (chunk.result && typeof chunk.result === 'object') {
|
||||
result += `\n${chunk.result.result}\n`;
|
||||
if (
|
||||
chunk.result &&
|
||||
typeof chunk.result === 'object' &&
|
||||
Array.isArray(chunk.result.result)
|
||||
) {
|
||||
result += chunk.result.result
|
||||
.map(item => {
|
||||
return `\n${item.changedContent}\n`;
|
||||
})
|
||||
.join('');
|
||||
this.docEditFootnotes[this.docEditFootnotes.length - 1].result =
|
||||
chunk.result.result;
|
||||
result;
|
||||
} else {
|
||||
this.docEditFootnotes.pop();
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
CallMetric,
|
||||
CopilotDocNotFound,
|
||||
CopilotFailedToCreateMessage,
|
||||
CopilotProviderSideError,
|
||||
CopilotSessionNotFound,
|
||||
type FileUpload,
|
||||
paginate,
|
||||
@@ -31,15 +32,18 @@ import {
|
||||
RequestMutex,
|
||||
Throttle,
|
||||
TooManyRequest,
|
||||
UserFriendlyError,
|
||||
} from '../../base';
|
||||
import { CurrentUser } from '../../core/auth';
|
||||
import { Admin } from '../../core/common';
|
||||
import { DocReader } from '../../core/doc';
|
||||
import { AccessController } from '../../core/permission';
|
||||
import { UserType } from '../../core/user';
|
||||
import type { ListSessionOptions, UpdateChatSession } from '../../models';
|
||||
import { CopilotCronJobs } from './cron';
|
||||
import { PromptService } from './prompt';
|
||||
import { PromptMessage, StreamObject } from './providers';
|
||||
import { CopilotProviderFactory } from './providers/factory';
|
||||
import { ChatSessionService } from './session';
|
||||
import { CopilotStorage } from './storage';
|
||||
import { type ChatHistory, type ChatMessage, SubmittedMessage } from './types';
|
||||
@@ -397,7 +401,9 @@ export class CopilotResolver {
|
||||
private readonly ac: AccessController,
|
||||
private readonly mutex: RequestMutex,
|
||||
private readonly chatSession: ChatSessionService,
|
||||
private readonly storage: CopilotStorage
|
||||
private readonly storage: CopilotStorage,
|
||||
private readonly docReader: DocReader,
|
||||
private readonly providerFactory: CopilotProviderFactory
|
||||
) {}
|
||||
|
||||
@ResolveField(() => CopilotQuotaType, {
|
||||
@@ -725,6 +731,65 @@ export class CopilotResolver {
|
||||
}
|
||||
}
|
||||
|
||||
@Query(() => String, {
|
||||
description:
|
||||
'Apply updates to a doc using LLM and return the merged markdown.',
|
||||
})
|
||||
async applyDocUpdates(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args({ name: 'workspaceId', type: () => String })
|
||||
workspaceId: string,
|
||||
@Args({ name: 'docId', type: () => String })
|
||||
docId: string,
|
||||
@Args({ name: 'op', type: () => String })
|
||||
op: string,
|
||||
@Args({ name: 'updates', type: () => String })
|
||||
updates: string
|
||||
): Promise<string> {
|
||||
await this.assertPermission(user, { workspaceId, docId });
|
||||
|
||||
const docContent = await this.docReader.getDocMarkdown(
|
||||
workspaceId,
|
||||
docId,
|
||||
true
|
||||
);
|
||||
if (!docContent || !docContent.markdown) {
|
||||
throw new NotFoundException('Doc not found or empty');
|
||||
}
|
||||
|
||||
const markdown = docContent.markdown.trim();
|
||||
|
||||
// Get LLM provider
|
||||
const provider =
|
||||
await this.providerFactory.getProviderByModel('morph-v3-large');
|
||||
if (!provider) {
|
||||
throw new BadRequestException('No LLM provider available');
|
||||
}
|
||||
|
||||
try {
|
||||
return await provider.text(
|
||||
{ modelId: 'morph-v3-large' },
|
||||
[
|
||||
{
|
||||
role: 'user',
|
||||
content: `<instruction>${op}</instruction>\n<code>${markdown}</code>\n<update>${updates}</update>`,
|
||||
},
|
||||
],
|
||||
{ reasoning: false }
|
||||
);
|
||||
} catch (e: any) {
|
||||
if (e instanceof UserFriendlyError) {
|
||||
throw e;
|
||||
} else {
|
||||
throw new CopilotProviderSideError({
|
||||
provider: provider.type,
|
||||
kind: 'unexpected_response',
|
||||
message: e?.message || 'Unexpected apply response',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private transformToSessionType(
|
||||
session: Omit<ChatHistory, 'messages'>
|
||||
): CopilotSessionType {
|
||||
@@ -779,7 +844,7 @@ export class PromptsManagementResolver {
|
||||
private readonly promptService: PromptService
|
||||
) {}
|
||||
|
||||
@Query(() => Boolean, {
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Trigger generate missing titles cron job',
|
||||
})
|
||||
async triggerGenerateTitleCron() {
|
||||
@@ -787,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',
|
||||
})
|
||||
|
||||
@@ -507,6 +507,8 @@ export class ChatSessionService {
|
||||
return await this.models.copilotSession.fork({
|
||||
...session,
|
||||
userId: options.userId,
|
||||
// docId can be changed in fork
|
||||
docId: options.docId,
|
||||
sessionId: randomUUID(),
|
||||
parentSessionId: options.sessionId,
|
||||
messages,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
||||
|
||||
import { DocReader } from '../../../core/doc';
|
||||
import { AccessController } from '../../../core/permission';
|
||||
import { type PromptService } from '../prompt';
|
||||
import type { CopilotChatOptions, CopilotProviderFactory } from '../providers';
|
||||
|
||||
export const buildContentGetter = (ac: AccessController, doc: DocReader) => {
|
||||
@@ -24,14 +25,20 @@ export const buildContentGetter = (ac: AccessController, doc: DocReader) => {
|
||||
|
||||
export const createDocEditTool = (
|
||||
factory: CopilotProviderFactory,
|
||||
prompt: PromptService,
|
||||
getContent: (targetId?: string) => Promise<string | undefined>
|
||||
) => {
|
||||
return tool({
|
||||
description: `
|
||||
Use this tool to propose an edit to a structured Markdown document with identifiable blocks. Each block begins with a comment like <!-- block_id=... -->, and represents a unit of editable content such as a heading, paragraph, list, or code snippet.
|
||||
Use this tool to propose an edit to a structured Markdown document with identifiable blocks.
|
||||
Each block begins with a comment like <!-- block_id=... -->, and represents a unit of editable content such as a heading, paragraph, list, or code snippet.
|
||||
This will be read by a less intelligent model, which will quickly apply the edit. You should make it clear what the edit is, while also minimizing the unchanged code you write.
|
||||
|
||||
Your task is to return a list of block-level changes needed to fulfill the user's intent. Each change should correspond to a specific user instruction and be represented by one of the following operations:
|
||||
If you receive a markdown without block_id comments, you should call \`doc_read\` tool to get the content.
|
||||
|
||||
Your task is to return a list of block-level changes needed to fulfill the user's intent. **Each change in code_edit must be completely independent: each code_edit entry should only perform a single, isolated change, and must not include the effects of other changes. For example, the updates for a delete operation should only show the context related to the deletion, and must not include any content modified by other operations (such as bolding or insertion). This ensures that each change can be applied independently and in any order.**
|
||||
|
||||
Each change should correspond to a specific user instruction and be represented by one of the following operations:
|
||||
|
||||
replace: Replace the content of a block with updated Markdown.
|
||||
|
||||
@@ -41,83 +48,75 @@ insert: Add a new block, and specify its block_id and content.
|
||||
|
||||
Important Instructions:
|
||||
- Use the existing block structure as-is. Do not reformat or reorder blocks unless explicitly asked.
|
||||
- Always preserve block_id and type in your replacements.
|
||||
- When replacing a block, use the full new block including <!-- block_id=... type=... --> and the updated content.
|
||||
- When inserting, follow the same format as a replacement, but ensure the new block_id does not conflict with existing IDs.
|
||||
- When replacing content, always keep the original block_id unchanged.
|
||||
- When deleting content, only use the format <!-- delete block_id=xxx -->, and only for valid block_id present in the original <code> content.
|
||||
- Each list item should be a block.
|
||||
- Use <!-- existing blocks ... --> for unchanged sections.
|
||||
- If you plan on deleting a section, you must provide surrounding context to indicate the deletion.
|
||||
- Your task is to return a list of block-level changes needed to fulfill the user's intent.
|
||||
- **Each change in code_edit must be completely independent: each code_edit entry should only perform a single, isolated change, and must not include the effects of other changes. For example, the updates for a delete operation should only show the context related to the deletion, and must not include any content modified by other operations (such as bolding or insertion). This ensures that each change can be applied independently and in any order.**
|
||||
|
||||
Example Input Document:
|
||||
\`\`\`md
|
||||
<!-- block_id=block-001 type=paragraph -->
|
||||
# My Holiday Plan
|
||||
Original Content:
|
||||
\`\`\`markdown
|
||||
<!-- block_id=001 flavour=paragraph -->
|
||||
# Andriy Shevchenko
|
||||
|
||||
<!-- block_id=block-002 type=paragraph -->
|
||||
I plan to travel to Paris, France, where I will visit the Eiffel Tower, the Louvre, and the Champs-Élysées.
|
||||
<!-- block_id=002 flavour=paragraph -->
|
||||
## Player Profile
|
||||
|
||||
<!-- block_id=block-003 type=paragraph -->
|
||||
I love Paris.
|
||||
<!-- block_id=003 flavour=paragraph -->
|
||||
Andriy Shevchenko is a legendary Ukrainian striker, best known for his time at AC Milan and Dynamo Kyiv. He won the Ballon d'Or in 2004.
|
||||
|
||||
<!-- block_id=block-004 type=paragraph -->
|
||||
## Reason for the delay
|
||||
<!-- block_id=004 flavour=paragraph -->
|
||||
## Career Overview
|
||||
|
||||
<!-- block_id=block-005 type=paragraph -->
|
||||
This plan has been brewing for a long time, but I always postponed it because I was too busy with work.
|
||||
|
||||
<!-- block_id=block-006 type=paragraph -->
|
||||
## Trip Steps
|
||||
|
||||
<!-- block_id=block-007 type=list -->
|
||||
- Book flight tickets
|
||||
<!-- block_id=block-008 type=list -->
|
||||
- Reserve a hotel
|
||||
<!-- block_id=block-009 type=list -->
|
||||
- Prepare visa documents
|
||||
<!-- block_id=block-010 type=list -->
|
||||
- Plan the itinerary
|
||||
|
||||
<!-- block_id=block-011 type=paragraph -->
|
||||
Additionally, I plan to learn some basic French to make communication easier during the trip.
|
||||
<!-- block_id=005 flavour=list -->
|
||||
- Born in 1976 in Ukraine.
|
||||
<!-- block_id=006 flavour=list -->
|
||||
- Rose to fame at Dynamo Kyiv in the 1990s.
|
||||
<!-- block_id=007 flavour=list -->
|
||||
- Starred at AC Milan (1999–2006), scoring over 170 goals.
|
||||
<!-- block_id=008 flavour=list -->
|
||||
- Played for Chelsea (2006–2009) before returning to Kyiv.
|
||||
<!-- block_id=009 flavour=list -->
|
||||
- Coached Ukraine national team, reaching Euro 2020 quarter-finals.
|
||||
\`\`\`
|
||||
|
||||
Example User Request:
|
||||
|
||||
User Request:
|
||||
\`\`\`
|
||||
Translate the trip steps to Chinese, remove the reason for the delay, and bold the final paragraph.
|
||||
Bold the player’s name in the intro, add a summary section at the end, and remove the career overview.
|
||||
\`\`\`
|
||||
|
||||
Expected Output:
|
||||
|
||||
\`\`\`md
|
||||
<!-- existing blocks ... -->
|
||||
|
||||
<!-- block_id=block-002 type=paragraph -->
|
||||
I plan to travel to Paris, France, where I will visit the Eiffel Tower, the Louvre, and the Champs-Élysées.
|
||||
|
||||
<!-- block_id=block-003 type=paragraph -->
|
||||
I love Paris.
|
||||
|
||||
<!-- delete block-004 -->
|
||||
|
||||
<!-- delete block-005 -->
|
||||
|
||||
<!-- block_id=block-006 type=paragraph -->
|
||||
## Trip Steps
|
||||
|
||||
<!-- block_id=block-007 type=list -->
|
||||
- 订机票
|
||||
<!-- block_id=block-008 type=list -->
|
||||
- 预定酒店
|
||||
<!-- block_id=block-009 type=list -->
|
||||
- 准备签证材料
|
||||
<!-- block_id=block-010 type=list -->
|
||||
- 规划行程
|
||||
|
||||
<!-- existing blocks ... -->
|
||||
|
||||
<!-- block_id=block-011 type=paragraph -->
|
||||
**Additionally, I plan to learn some basic French to make communication easier during the trip.**
|
||||
Example response:
|
||||
\`\`\`json
|
||||
[
|
||||
{
|
||||
"op": "Bold the player's name in the introduction",
|
||||
"updates": "
|
||||
<!-- block_id=003 flavour=paragraph -->
|
||||
**Andriy Shevchenko** is a legendary Ukrainian striker, best known for his time at AC Milan and Dynamo Kyiv. He won the Ballon d'Or in 2004.
|
||||
"
|
||||
},
|
||||
{
|
||||
"op": "Add a summary section at the end",
|
||||
"updates": "
|
||||
<!-- block_id=new-abc123 flavour=paragraph -->
|
||||
## Summary
|
||||
<!-- block_id=new-def456 flavour=paragraph -->
|
||||
Shevchenko is celebrated as one of the greatest Ukrainian footballers of all time. Known for his composure, strength, and goal-scoring instinct, he left a lasting legacy both on and off the pitch.
|
||||
"
|
||||
},
|
||||
{
|
||||
"op": "Delete the career overview section",
|
||||
"updates": "
|
||||
<!-- delete block_id=004 -->
|
||||
<!-- delete block_id=005 -->
|
||||
<!-- delete block_id=006 -->
|
||||
<!-- delete block_id=007 -->
|
||||
<!-- delete block_id=008 -->
|
||||
<!-- delete block_id=009 -->
|
||||
"
|
||||
}
|
||||
]
|
||||
\`\`\`
|
||||
You should specify the following arguments before the others: [doc_id], [origin_content]
|
||||
|
||||
@@ -144,14 +143,32 @@ You should specify the following arguments before the others: [doc_id], [origin_
|
||||
),
|
||||
|
||||
code_edit: z
|
||||
.string()
|
||||
.array(
|
||||
z.object({
|
||||
op: z
|
||||
.string()
|
||||
.describe(
|
||||
'A short description of the change, such as "Bold intro name"'
|
||||
),
|
||||
updates: z
|
||||
.string()
|
||||
.describe(
|
||||
'Markdown block fragments that represent the change, including the block_id and type'
|
||||
),
|
||||
})
|
||||
)
|
||||
.describe(
|
||||
'Specify only the necessary Markdown block-level changes. Return a list of inserted, replaced, or deleted blocks. Each block must start with its <!-- block_id=... type=... --> comment. Use <!-- existing blocks ... --> for unchanged sections.If you plan on deleting a section, you must provide surrounding context to indicate the deletion.'
|
||||
'An array of independent semantic changes to apply to the document.'
|
||||
),
|
||||
}),
|
||||
execute: async ({ doc_id, origin_content, code_edit }) => {
|
||||
try {
|
||||
const provider = await factory.getProviderByModel('morph-v2');
|
||||
const applyPrompt = await prompt.get('Apply Updates');
|
||||
if (!applyPrompt) {
|
||||
return 'Prompt not found';
|
||||
}
|
||||
const model = applyPrompt.model;
|
||||
const provider = await factory.getProviderByModel(model);
|
||||
if (!provider) {
|
||||
return 'Editing docs is not supported';
|
||||
}
|
||||
@@ -160,14 +177,27 @@ You should specify the following arguments before the others: [doc_id], [origin_
|
||||
if (!content) {
|
||||
return 'Doc not found or doc is empty';
|
||||
}
|
||||
const result = await provider.text({ modelId: 'morph-v2' }, [
|
||||
{
|
||||
role: 'user',
|
||||
content: `<code>${content}</code>\n<update>${code_edit}</update>`,
|
||||
},
|
||||
]);
|
||||
|
||||
return { result, content };
|
||||
const changedContents = await Promise.all(
|
||||
code_edit.map(async edit => {
|
||||
return await provider.text({ modelId: model }, [
|
||||
...applyPrompt.finish({
|
||||
content,
|
||||
op: edit.op,
|
||||
updates: edit.updates,
|
||||
}),
|
||||
]);
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
result: changedContents.map((changedContent, index) => ({
|
||||
op: code_edit[index].op,
|
||||
updates: code_edit[index].updates,
|
||||
originalContent: content,
|
||||
changedContent,
|
||||
})),
|
||||
};
|
||||
} catch {
|
||||
return 'Failed to apply edit to the doc';
|
||||
}
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -1518,6 +1524,9 @@ type PublicUserType {
|
||||
type Query {
|
||||
"""get the whole app configuration"""
|
||||
appConfig: JSONObject!
|
||||
|
||||
"""Apply updates to a doc using LLM and return the merged markdown."""
|
||||
applyDocUpdates(docId: String!, op: String!, updates: String!, workspaceId: String!): String!
|
||||
collectAllBlobSizes: WorkspaceBlobSizes! @deprecated(reason: "use `user.quotaUsage` instead")
|
||||
|
||||
"""Get current user"""
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
query applyDocUpdates($workspaceId: String!, $docId: String!, $op: String!, $updates: String!) {
|
||||
applyDocUpdates(workspaceId: $workspaceId, docId: $docId, op: $op, updates: $updates)
|
||||
}
|
||||
@@ -555,6 +555,19 @@ export const uploadCommentAttachmentMutation = {
|
||||
file: true,
|
||||
};
|
||||
|
||||
export const applyDocUpdatesQuery = {
|
||||
id: 'applyDocUpdatesQuery' as const,
|
||||
op: 'applyDocUpdates',
|
||||
query: `query applyDocUpdates($workspaceId: String!, $docId: String!, $op: String!, $updates: String!) {
|
||||
applyDocUpdates(
|
||||
workspaceId: $workspaceId
|
||||
docId: $docId
|
||||
op: $op
|
||||
updates: $updates
|
||||
)
|
||||
}`,
|
||||
};
|
||||
|
||||
export const addContextCategoryMutation = {
|
||||
id: 'addContextCategoryMutation' as const,
|
||||
op: 'addContextCategory',
|
||||
|
||||
@@ -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 */
|
||||
@@ -2073,6 +2077,8 @@ export interface Query {
|
||||
__typename?: 'Query';
|
||||
/** get the whole app configuration */
|
||||
appConfig: Scalars['JSONObject']['output'];
|
||||
/** Apply updates to a doc using LLM and return the merged markdown. */
|
||||
applyDocUpdates: Scalars['String']['output'];
|
||||
/** @deprecated use `user.quotaUsage` instead */
|
||||
collectAllBlobSizes: WorkspaceBlobSizes;
|
||||
/** Get current user */
|
||||
@@ -2120,6 +2126,13 @@ export interface Query {
|
||||
workspaces: Array<WorkspaceType>;
|
||||
}
|
||||
|
||||
export interface QueryApplyDocUpdatesArgs {
|
||||
docId: Scalars['String']['input'];
|
||||
op: Scalars['String']['input'];
|
||||
updates: Scalars['String']['input'];
|
||||
workspaceId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface QueryErrorArgs {
|
||||
name: ErrorNames;
|
||||
}
|
||||
@@ -3509,6 +3522,18 @@ export type UploadCommentAttachmentMutation = {
|
||||
uploadCommentAttachment: string;
|
||||
};
|
||||
|
||||
export type ApplyDocUpdatesQueryVariables = Exact<{
|
||||
workspaceId: Scalars['String']['input'];
|
||||
docId: Scalars['String']['input'];
|
||||
op: Scalars['String']['input'];
|
||||
updates: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
export type ApplyDocUpdatesQuery = {
|
||||
__typename?: 'Query';
|
||||
applyDocUpdates: string;
|
||||
};
|
||||
|
||||
export type AddContextCategoryMutationVariables = Exact<{
|
||||
options: AddContextCategoryInput;
|
||||
}>;
|
||||
@@ -6148,6 +6173,11 @@ export type Queries =
|
||||
variables: ListCommentsQueryVariables;
|
||||
response: ListCommentsQuery;
|
||||
}
|
||||
| {
|
||||
name: 'applyDocUpdatesQuery';
|
||||
variables: ApplyDocUpdatesQueryVariables;
|
||||
response: ApplyDocUpdatesQuery;
|
||||
}
|
||||
| {
|
||||
name: 'listContextObjectQuery';
|
||||
variables: ListContextObjectQueryVariables;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
:root {
|
||||
--noise-background: url(./noise.avif);
|
||||
text-autospace: normal;
|
||||
}
|
||||
|
||||
html,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -348,6 +348,12 @@ declare global {
|
||||
files?: ContextMatchedFileChunk[];
|
||||
docs?: ContextMatchedDocChunk[];
|
||||
}>;
|
||||
applyDocUpdates: (
|
||||
workspaceId: string,
|
||||
docId: string,
|
||||
op: string,
|
||||
updates: string
|
||||
) => Promise<string>;
|
||||
}
|
||||
|
||||
// TODO(@Peng): should be refactored to get rid of implement details (like messages, action, role, etc.)
|
||||
@@ -401,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);
|
||||
|
||||
|
||||
+7
-4
@@ -62,7 +62,7 @@ export class ArtifactPreviewPanel extends WithDisposable(ShadowlessElement) {
|
||||
background-color: ${unsafeCSSVarV2('layer/background/overlayPanel')};
|
||||
box-shadow: ${unsafeCSSVar('overlayPanelShadow')};
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.artifact-panel-header {
|
||||
@@ -71,9 +71,6 @@ export class ArtifactPreviewPanel extends WithDisposable(ShadowlessElement) {
|
||||
justify-content: flex-end;
|
||||
padding: 0 12px;
|
||||
height: 52px;
|
||||
position: sticky;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
background: ${unsafeCSSVarV2('layer/background/overlayPanel')};
|
||||
}
|
||||
|
||||
@@ -104,6 +101,12 @@ export class ArtifactPreviewPanel extends WithDisposable(ShadowlessElement) {
|
||||
color: ${unsafeCSSVarV2('icon/secondary')};
|
||||
}
|
||||
|
||||
.artifact-panel-content {
|
||||
overflow-y: auto;
|
||||
height: calc(100% - 52px);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.artifact-panel-close:hover {
|
||||
background-color: ${unsafeCSSVarV2('layer/background/tertiary')};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -352,6 +353,8 @@ export class CodeArtifactTool extends ArtifactTool<
|
||||
> {
|
||||
static override styles = css`
|
||||
.code-artifact-preview {
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -363,6 +366,11 @@ export class CodeArtifactTool extends ArtifactTool<
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.code-artifact-preview > code-highlighter {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.code-artifact-preview :is(.html-preview-iframe, .html-preview-container) {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -430,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';
|
||||
|
||||
@@ -440,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';
|
||||
@@ -46,7 +46,6 @@ export class DocComposeTool extends ArtifactTool<
|
||||
static override styles = css`
|
||||
.doc-compose-result-preview {
|
||||
padding: 24px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.doc-compose-result-preview-title {
|
||||
@@ -89,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,
|
||||
@@ -99,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;
|
||||
@@ -127,11 +130,7 @@ export class DocComposeTool extends ArtifactTool<
|
||||
theme: this.theme,
|
||||
}}
|
||||
></text-renderer>`
|
||||
: html`<div class="doc-compose-result-preview-loading">
|
||||
${LoadingIcon({
|
||||
size: '32px',
|
||||
})}
|
||||
</div>`}
|
||||
: html``}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import track from '@affine/track';
|
||||
import { WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
|
||||
import { type EditorHost, ShadowlessElement } from '@blocksuite/affine/std';
|
||||
import { AIStarIconWithAnimation } from '@blocksuite/affine-components/icons';
|
||||
import type { NotificationService } from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
CloseIcon,
|
||||
@@ -14,7 +15,9 @@ import {
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { css, html, nothing } from 'lit';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
import { AIProvider } from '../../provider';
|
||||
import { BlockDiffProvider } from '../../services/block-diff';
|
||||
import { diffMarkdown } from '../../utils/apply-model/markdown-diff';
|
||||
import { copyText } from '../../utils/editor-actions';
|
||||
@@ -37,8 +40,12 @@ interface DocEditToolResult {
|
||||
};
|
||||
result:
|
||||
| {
|
||||
result: string;
|
||||
content: string;
|
||||
result: {
|
||||
op: string;
|
||||
updates: string;
|
||||
originalContent: string;
|
||||
changedContent: string;
|
||||
}[];
|
||||
}
|
||||
| ToolError
|
||||
| null;
|
||||
@@ -199,40 +206,108 @@ export class DocEditTool extends WithDisposable(ShadowlessElement) {
|
||||
@state()
|
||||
accessor isCollapsed = false;
|
||||
|
||||
@state()
|
||||
accessor applyingMap: Record<string, boolean> = {};
|
||||
|
||||
@state()
|
||||
accessor acceptingMap: Record<string, boolean> = {};
|
||||
|
||||
get blockDiffService() {
|
||||
return this.host?.std.getOptional(BlockDiffProvider);
|
||||
}
|
||||
|
||||
private async _handleApply(markdown: string) {
|
||||
if (!this.host || this.data.type !== 'tool-result') {
|
||||
return;
|
||||
}
|
||||
track.applyModel.chat.$.apply({
|
||||
instruction: this.data.args.instructions,
|
||||
});
|
||||
await this.blockDiffService?.apply(this.host.store, markdown);
|
||||
get isBusy() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async _handleReject(changedMarkdown: string) {
|
||||
isBusyForOp(op: string) {
|
||||
return this.applyingMap[op] || this.acceptingMap[op];
|
||||
}
|
||||
|
||||
private async _handleApply(op: string, updates: string) {
|
||||
if (
|
||||
!this.host ||
|
||||
this.data.type !== 'tool-result' ||
|
||||
this.isBusyForOp(op)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.applyingMap = { ...this.applyingMap, [op]: true };
|
||||
try {
|
||||
const markdown = await AIProvider.context?.applyDocUpdates(
|
||||
this.host.std.workspace.id,
|
||||
this.data.args.doc_id,
|
||||
op,
|
||||
updates
|
||||
);
|
||||
if (!markdown) {
|
||||
return;
|
||||
}
|
||||
track.applyModel.chat.$.apply({
|
||||
instruction: this.data.args.instructions,
|
||||
operation: op,
|
||||
});
|
||||
await this.blockDiffService?.apply(this.host.store, markdown);
|
||||
} catch (error) {
|
||||
this.notificationService.notify({
|
||||
title: 'Failed to apply updates',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
accent: 'error',
|
||||
onClose: function (): void {},
|
||||
});
|
||||
} finally {
|
||||
this.applyingMap = { ...this.applyingMap, [op]: false };
|
||||
}
|
||||
}
|
||||
|
||||
private async _handleReject(op: string) {
|
||||
if (!this.host || this.data.type !== 'tool-result') {
|
||||
return;
|
||||
}
|
||||
// TODO: set the rejected status
|
||||
track.applyModel.chat.$.reject({
|
||||
instruction: this.data.args.instructions,
|
||||
operation: op,
|
||||
});
|
||||
this.blockDiffService?.setChangedMarkdown(changedMarkdown);
|
||||
this.blockDiffService?.setChangedMarkdown(null);
|
||||
this.blockDiffService?.rejectAll();
|
||||
}
|
||||
|
||||
private async _handleAccept(changedMarkdown: string) {
|
||||
if (!this.host || this.data.type !== 'tool-result') {
|
||||
private async _handleAccept(op: string, updates: string) {
|
||||
if (
|
||||
!this.host ||
|
||||
this.data.type !== 'tool-result' ||
|
||||
this.isBusyForOp(op)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
track.applyModel.chat.$.accept({
|
||||
instruction: this.data.args.instructions,
|
||||
});
|
||||
await this.blockDiffService?.apply(this.host.store, changedMarkdown);
|
||||
await this.blockDiffService?.acceptAll(this.host.store);
|
||||
this.acceptingMap = { ...this.acceptingMap, [op]: true };
|
||||
try {
|
||||
const changedMarkdown = await AIProvider.context?.applyDocUpdates(
|
||||
this.host.std.workspace.id,
|
||||
this.data.args.doc_id,
|
||||
op,
|
||||
updates
|
||||
);
|
||||
if (!changedMarkdown) {
|
||||
return;
|
||||
}
|
||||
track.applyModel.chat.$.accept({
|
||||
instruction: this.data.args.instructions,
|
||||
operation: op,
|
||||
});
|
||||
await this.blockDiffService?.apply(this.host.store, changedMarkdown);
|
||||
await this.blockDiffService?.acceptAll(this.host.store);
|
||||
} catch (error) {
|
||||
this.notificationService.notify({
|
||||
title: 'Failed to apply updates',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
accent: 'error',
|
||||
onClose: function (): void {},
|
||||
});
|
||||
} finally {
|
||||
this.acceptingMap = { ...this.acceptingMap, [op]: false };
|
||||
}
|
||||
}
|
||||
|
||||
private async _toggleCollapse() {
|
||||
@@ -322,69 +397,84 @@ export class DocEditTool extends WithDisposable(ShadowlessElement) {
|
||||
|
||||
const result = this.data.result;
|
||||
|
||||
if (result && 'result' in result && 'content' in result) {
|
||||
const { result: changedMarkdown, content } = result;
|
||||
const { instructions, doc_id: docId } = this.data.args;
|
||||
if (result && 'result' in result && Array.isArray(result.result)) {
|
||||
const { doc_id: docId } = this.data.args;
|
||||
|
||||
const diffs = diffMarkdown(content, changedMarkdown);
|
||||
|
||||
return html`
|
||||
<div class="doc-edit-tool-result-wrapper">
|
||||
<div class="doc-edit-tool-result-title">${instructions}</div>
|
||||
<div
|
||||
class="doc-edit-tool-result-card ${this.isCollapsed
|
||||
? 'collapsed'
|
||||
: ''}"
|
||||
>
|
||||
<div class="doc-edit-tool-result-card-header">
|
||||
<div class="doc-edit-tool-result-card-header-title">
|
||||
${PenIcon({
|
||||
style: `color: ${unsafeCSSVarV2('icon/activated')}`,
|
||||
})}
|
||||
${docId}
|
||||
</div>
|
||||
<div class="doc-edit-tool-result-card-header-operations">
|
||||
<span @click=${() => this._toggleCollapse()}
|
||||
>${this.isCollapsed
|
||||
? ExpandFullIcon()
|
||||
: ExpandCloseIcon()}</span
|
||||
>
|
||||
<span @click=${() => this._handleCopy(changedMarkdown)}>
|
||||
${CopyIcon()}
|
||||
</span>
|
||||
<button @click=${() => this._handleApply(changedMarkdown)}>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="doc-edit-tool-result-card-content">
|
||||
<div class="doc-edit-tool-result-card-content-title">
|
||||
${this.renderBlockDiffs(diffs)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="doc-edit-tool-result-card-footer">
|
||||
return repeat(
|
||||
result.result,
|
||||
change => change.op,
|
||||
({ op, updates, originalContent, changedContent }) => {
|
||||
const diffs = diffMarkdown(originalContent, changedContent);
|
||||
return html`
|
||||
<div class="doc-edit-tool-result-wrapper">
|
||||
<div class="doc-edit-tool-result-title">${op}</div>
|
||||
<div
|
||||
class="doc-edit-tool-result-reject"
|
||||
@click=${() => this._handleReject(changedMarkdown)}
|
||||
class="doc-edit-tool-result-card ${this.isCollapsed
|
||||
? 'collapsed'
|
||||
: ''}"
|
||||
>
|
||||
${CloseIcon({
|
||||
style: `color: ${unsafeCSSVarV2('icon/secondary')}`,
|
||||
})}
|
||||
Reject
|
||||
</div>
|
||||
<div
|
||||
class="doc-edit-tool-result-accept"
|
||||
@click=${() => this._handleAccept(changedMarkdown)}
|
||||
>
|
||||
${DoneIcon({
|
||||
style: `color: ${unsafeCSSVarV2('icon/activated')}`,
|
||||
})}
|
||||
Accept
|
||||
<div class="doc-edit-tool-result-card-header">
|
||||
<div class="doc-edit-tool-result-card-header-title">
|
||||
${PenIcon({
|
||||
style: `color: ${unsafeCSSVarV2('icon/activated')}`,
|
||||
})}
|
||||
${docId}
|
||||
</div>
|
||||
<div class="doc-edit-tool-result-card-header-operations">
|
||||
<span @click=${() => this._toggleCollapse()}
|
||||
>${this.isCollapsed
|
||||
? ExpandFullIcon()
|
||||
: ExpandCloseIcon()}</span
|
||||
>
|
||||
<span @click=${() => this._handleCopy(changedContent)}>
|
||||
${CopyIcon()}
|
||||
</span>
|
||||
<button
|
||||
@click=${() => this._handleApply(op, updates)}
|
||||
?disabled=${this.isBusyForOp(op)}
|
||||
>
|
||||
${this.applyingMap[op]
|
||||
? AIStarIconWithAnimation
|
||||
: html`Apply`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="doc-edit-tool-result-card-content">
|
||||
<div class="doc-edit-tool-result-card-content-title">
|
||||
${this.renderBlockDiffs(diffs)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="doc-edit-tool-result-card-footer">
|
||||
<div
|
||||
class="doc-edit-tool-result-reject"
|
||||
@click=${() => this._handleReject(op)}
|
||||
>
|
||||
${CloseIcon({
|
||||
style: `color: ${unsafeCSSVarV2('icon/secondary')}`,
|
||||
})}
|
||||
Reject
|
||||
</div>
|
||||
<button
|
||||
class="doc-edit-tool-result-accept"
|
||||
@click=${() => this._handleAccept(op, updates)}
|
||||
?disabled=${this.isBusyForOp(op)}
|
||||
style="${this.isBusyForOp(op)
|
||||
? 'pointer-events: none; opacity: 0.6;'
|
||||
: ''}"
|
||||
>
|
||||
${this.acceptingMap[op]
|
||||
? AIStarIconWithAnimation
|
||||
: DoneIcon({
|
||||
style: `color: ${unsafeCSSVarV2('icon/activated')}`,
|
||||
})}
|
||||
${this.acceptingMap[op] ? 'Accepting...' : 'Accept'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
`;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return html`
|
||||
|
||||
+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;
|
||||
}
|
||||
|
||||
@@ -160,6 +160,7 @@ export class ChatCopyMore extends WithDisposable(LitElement) {
|
||||
mainAxis: 0,
|
||||
crossAxis: -100,
|
||||
});
|
||||
this.disposables.add(() => this._morePopper?.dispose());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
addContextCategoryMutation,
|
||||
addContextDocMutation,
|
||||
addContextFileMutation,
|
||||
applyDocUpdatesQuery,
|
||||
cleanupCopilotSessionMutation,
|
||||
createCopilotContextMutation,
|
||||
createCopilotMessageMutation,
|
||||
@@ -185,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);
|
||||
@@ -500,4 +506,21 @@ export class CopilotClient {
|
||||
variables: { workspaceId },
|
||||
}).then(res => res.queryWorkspaceEmbeddingStatus);
|
||||
}
|
||||
|
||||
applyDocUpdates(
|
||||
workspaceId: string,
|
||||
docId: string,
|
||||
op: string,
|
||||
updates: string
|
||||
) {
|
||||
return this.gql({
|
||||
query: applyDocUpdatesQuery,
|
||||
variables: {
|
||||
workspaceId,
|
||||
docId,
|
||||
op,
|
||||
updates,
|
||||
},
|
||||
}).then(res => res.applyDocUpdates);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -733,6 +737,14 @@ Could you make a new website based on these notes and send back just the html fi
|
||||
threshold
|
||||
);
|
||||
},
|
||||
applyDocUpdates: async (
|
||||
workspaceId: string,
|
||||
docId: string,
|
||||
op: string,
|
||||
updates: string
|
||||
) => {
|
||||
return client.applyDocUpdates(workspaceId, docId, op, updates);
|
||||
},
|
||||
});
|
||||
|
||||
AIProvider.provide('histories', {
|
||||
|
||||
@@ -80,13 +80,13 @@ export interface BlockDiffProvider {
|
||||
* Set the original markdown
|
||||
* @param originalMarkdown - The original markdown
|
||||
*/
|
||||
setOriginalMarkdown(originalMarkdown: string): void;
|
||||
setOriginalMarkdown(originalMarkdown: string | null): void;
|
||||
|
||||
/**
|
||||
* Set the changed markdown
|
||||
* @param changedMarkdown - The changed markdown
|
||||
*/
|
||||
setChangedMarkdown(changedMarkdown: string): void;
|
||||
setChangedMarkdown(changedMarkdown: string | null): void;
|
||||
|
||||
/**
|
||||
* Apply the diff to the doc
|
||||
|
||||
@@ -54,6 +54,8 @@ export class EdgelessCopilotWidget extends WidgetComponent<RootBlockModel> {
|
||||
|
||||
private _selectionModelRect!: DOMRect;
|
||||
|
||||
private _autoUpdateCleanup: (() => void) | null = null;
|
||||
|
||||
groups: AIItemGroupConfig[] = [];
|
||||
|
||||
get gfx() {
|
||||
@@ -145,7 +147,8 @@ export class EdgelessCopilotWidget extends WidgetComponent<RootBlockModel> {
|
||||
|
||||
const originMaxHeight = window.getComputedStyle(panel).maxHeight;
|
||||
|
||||
autoUpdate(referenceElement, panel, () => {
|
||||
this._autoUpdateCleanup?.();
|
||||
this._autoUpdateCleanup = autoUpdate(referenceElement, panel, () => {
|
||||
computePosition(referenceElement, panel, {
|
||||
placement: 'bottom-start',
|
||||
middleware: [
|
||||
@@ -267,6 +270,8 @@ export class EdgelessCopilotWidget extends WidgetComponent<RootBlockModel> {
|
||||
this._copilotPanel = null;
|
||||
})
|
||||
);
|
||||
|
||||
this._disposables.add(() => this._autoUpdateCleanup?.());
|
||||
}
|
||||
|
||||
determineInsertionBounds(width = 800, height = 95) {
|
||||
|
||||
@@ -42,6 +42,8 @@ export const empty = style({
|
||||
padding: 32,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
lineHeight: '24px',
|
||||
justifyContent: 'center',
|
||||
color: cssVarV2('text/secondary'),
|
||||
});
|
||||
|
||||
@@ -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.session = session;
|
||||
chatContent.reloadSession();
|
||||
}
|
||||
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,7 +274,7 @@ export const Component = () => {
|
||||
tool.onNewSession = () => {
|
||||
if (!currentSession) return;
|
||||
setCurrentSession(null);
|
||||
chatContent?.reset();
|
||||
reMountChatContent();
|
||||
};
|
||||
|
||||
tool.onTogglePin = async () => {
|
||||
@@ -280,7 +296,6 @@ export const Component = () => {
|
||||
setChatTool(tool);
|
||||
}
|
||||
}, [
|
||||
chatContent,
|
||||
chatTool,
|
||||
currentSession,
|
||||
docDisplayConfig,
|
||||
@@ -291,6 +306,7 @@ export const Component = () => {
|
||||
confirmModal,
|
||||
framework,
|
||||
status,
|
||||
reMountChatContent,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -311,8 +327,6 @@ export const Component = () => {
|
||||
|
||||
// restore pinned session
|
||||
useEffect(() => {
|
||||
if (!chatContent) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
client
|
||||
@@ -328,10 +342,7 @@ export const Component = () => {
|
||||
const session = sessions[0];
|
||||
if (!session) return;
|
||||
setCurrentSession(session);
|
||||
if (chatContent) {
|
||||
chatContent.session = session;
|
||||
chatContent.reloadSession();
|
||||
}
|
||||
reMountChatContent();
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
@@ -339,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