mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-08 18:43:46 +00:00
Compare commits
86 Commits
0.23.0-bet
...
v0.23.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edd97ae73b | ||
|
|
0770b109cb | ||
|
|
4018b3aeca | ||
|
|
c90d511251 | ||
|
|
bdf1389258 | ||
|
|
dc68c2385d | ||
|
|
07f2f7b5a8 | ||
|
|
38107910f9 | ||
|
|
ea21de8311 | ||
|
|
21360591a9 | ||
|
|
5300eff8f1 | ||
|
|
46a2ad750f | ||
|
|
3949714618 | ||
|
|
7b9e0a215d | ||
|
|
b93d5d5e86 | ||
|
|
c8dc51ccae | ||
|
|
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 | ||
|
|
8627560fd5 | ||
|
|
9a3e44c6d6 | ||
|
|
7b53641a94 | ||
|
|
3948b8eada | ||
|
|
d05bb9992c | ||
|
|
b2c09825ac | ||
|
|
65453c31c6 | ||
|
|
d9e8ce802f | ||
|
|
d5f63b9e43 | ||
|
|
ebefbeefc8 | ||
|
|
4d7d8f215f | ||
|
|
b6187718ea | ||
|
|
3ee82bd9ce | ||
|
|
3dbdb99435 | ||
|
|
0d414d914a | ||
|
|
41f338bce0 | ||
|
|
6f87c1ca50 | ||
|
|
33f6496d79 | ||
|
|
847ef00a75 | ||
|
|
93f13e9e01 | ||
|
|
a2b86bc6d2 | ||
|
|
aee7a8839e | ||
|
|
0e8ffce126 | ||
|
|
9cda655c9e | ||
|
|
15726bd522 | ||
|
|
d65a7494a4 | ||
|
|
0f74e1fa0f | ||
|
|
fef4a9eeb6 | ||
|
|
58dc53581f | ||
|
|
b23f380539 | ||
|
|
d29a97f86c | ||
|
|
0f287f9661 | ||
|
|
18f13626cc | ||
|
|
0eeea5e173 | ||
|
|
2052a34d19 | ||
|
|
b79439b01d | ||
|
|
2dacba9011 | ||
|
|
af9c455ee0 | ||
|
|
3d45c7623f | ||
|
|
e0f88451e1 | ||
|
|
aba0a3d485 | ||
|
|
8b579e3a92 | ||
|
|
d98b45ca3d | ||
|
|
fc1104cd68 |
2
.github/workflows/release-desktop.yml
vendored
2
.github/workflows/release-desktop.yml
vendored
@@ -465,7 +465,7 @@ jobs:
|
||||
name: ${{ env.RELEASE_VERSION }}
|
||||
draft: ${{ inputs.build-type == 'stable' }}
|
||||
prerelease: ${{ inputs.build-type != 'stable' }}
|
||||
tag_name: ${{ env.RELEASE_VERSION}}
|
||||
tag_name: v${{ env.RELEASE_VERSION}}
|
||||
files: |
|
||||
./release/*
|
||||
./release/.env.example
|
||||
|
||||
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
@@ -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 {
|
||||
|
||||
@@ -18,6 +18,7 @@ export const LoadingIcon = ({
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
style="fill: none;"
|
||||
>
|
||||
<style>
|
||||
.spinner {
|
||||
|
||||
@@ -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')};
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"@blocksuite/affine-block-paragraph": "workspace:*",
|
||||
"@blocksuite/affine-block-surface": "workspace:*",
|
||||
"@blocksuite/affine-block-surface-ref": "workspace:*",
|
||||
"@blocksuite/affine-block-table": "workspace:*",
|
||||
"@blocksuite/affine-components": "workspace:*",
|
||||
"@blocksuite/affine-ext-loader": "workspace:*",
|
||||
"@blocksuite/affine-fragment-doc-title": "workspace:*",
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '@blocksuite/affine-block-paragraph';
|
||||
import { DefaultTool, getSurfaceBlock } from '@blocksuite/affine-block-surface';
|
||||
import { insertSurfaceRefBlockCommand } from '@blocksuite/affine-block-surface-ref';
|
||||
import { insertTableBlockCommand } from '@blocksuite/affine-block-table';
|
||||
import { toggleEmbedCardCreateModal } from '@blocksuite/affine-components/embed-card-modal';
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import { insertInlineLatex } from '@blocksuite/affine-inline-latex';
|
||||
@@ -40,14 +41,20 @@ import {
|
||||
deleteSelectedModelsCommand,
|
||||
draftSelectedModelsCommand,
|
||||
duplicateSelectedModelsCommand,
|
||||
focusBlockEnd,
|
||||
getBlockSelectionsCommand,
|
||||
getSelectedModelsCommand,
|
||||
getTextSelectionCommand,
|
||||
} from '@blocksuite/affine-shared/commands';
|
||||
import { REFERENCE_NODE } from '@blocksuite/affine-shared/consts';
|
||||
import {
|
||||
FeatureFlagService,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import type { AffineTextStyleAttributes } from '@blocksuite/affine-shared/types';
|
||||
import {
|
||||
createDefaultDoc,
|
||||
isInsideBlockByFlavour,
|
||||
openSingleFileWith,
|
||||
type Signal,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
@@ -87,6 +94,7 @@ import {
|
||||
RedoIcon,
|
||||
RightTabIcon,
|
||||
StrikeThroughIcon,
|
||||
TableIcon,
|
||||
TeXIcon,
|
||||
TextIcon,
|
||||
TodayIcon,
|
||||
@@ -258,6 +266,62 @@ const textToolActionItems: KeyboardToolbarActionItem[] = [
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Table',
|
||||
icon: TableIcon(),
|
||||
showWhen: ({ std, rootComponent: { model } }) =>
|
||||
std.store.schema.flavourSchemaMap.has('affine:table') &&
|
||||
!isInsideBlockByFlavour(std.store, model, 'affine:edgeless-text'),
|
||||
action: ({ std }) => {
|
||||
std.command
|
||||
.chain()
|
||||
.pipe(getSelectedModelsCommand)
|
||||
.pipe(insertTableBlockCommand, {
|
||||
place: 'after',
|
||||
removeEmptyLine: true,
|
||||
})
|
||||
.pipe(({ insertedTableBlockId }) => {
|
||||
if (insertedTableBlockId) {
|
||||
const telemetry = std.getOptional(TelemetryProvider);
|
||||
telemetry?.track('BlockCreated', {
|
||||
blockType: 'affine:table',
|
||||
});
|
||||
}
|
||||
})
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Callout',
|
||||
icon: FontIcon(),
|
||||
showWhen: ({ std, rootComponent: { model } }) => {
|
||||
return (
|
||||
std.get(FeatureFlagService).getFlag('enable_callout') &&
|
||||
!isInsideBlockByFlavour(model.store, model, 'affine:edgeless-text')
|
||||
);
|
||||
},
|
||||
action: ({ rootComponent: { model }, std }) => {
|
||||
const { store } = model;
|
||||
const parent = store.getParent(model);
|
||||
if (!parent) return;
|
||||
|
||||
const index = parent.children.indexOf(model);
|
||||
if (index === -1) return;
|
||||
const calloutId = store.addBlock('affine:callout', {}, parent, index + 1);
|
||||
if (!calloutId) return;
|
||||
const paragraphId = store.addBlock('affine:paragraph', {}, calloutId);
|
||||
if (!paragraphId) return;
|
||||
std.host.updateComplete
|
||||
.then(() => {
|
||||
const paragraph = std.view.getBlock(paragraphId);
|
||||
if (!paragraph) return;
|
||||
std.command.exec(focusBlockEnd, {
|
||||
focusBlock: paragraph,
|
||||
});
|
||||
})
|
||||
.catch(console.error);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const listToolActionItems: KeyboardToolbarActionItem[] = [
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
{ "path": "../../blocks/paragraph" },
|
||||
{ "path": "../../blocks/surface" },
|
||||
{ "path": "../../blocks/surface-ref" },
|
||||
{ "path": "../../blocks/table" },
|
||||
{ "path": "../../components" },
|
||||
{ "path": "../../ext-loader" },
|
||||
{ "path": "../../fragments/doc-title" },
|
||||
|
||||
@@ -343,7 +343,18 @@ export class LinkedDocPopover extends SignalWatcher(
|
||||
override willUpdate() {
|
||||
if (!this.hasUpdated) {
|
||||
const updatePosition = throttle(() => {
|
||||
this._position = getPopperPosition(this, this.context.startNativeRange);
|
||||
this._position = getPopperPosition(
|
||||
{
|
||||
getBoundingClientRect: () => {
|
||||
return {
|
||||
...this.getBoundingClientRect(),
|
||||
// Workaround: the width of the popover is zero when it is not rendered
|
||||
width: 280,
|
||||
};
|
||||
},
|
||||
},
|
||||
this.context.startNativeRange
|
||||
);
|
||||
}, 10);
|
||||
|
||||
this.disposables.addFromEvent(window, 'resize', updatePosition);
|
||||
|
||||
@@ -372,3 +372,75 @@ Generated by [AVA](https://avajs.dev).
|
||||
[assistant]: Quantum computing uses quantum mechanics principles.`,
|
||||
promptName: 'Summary as title',
|
||||
}
|
||||
|
||||
## should handle copilot cron jobs correctly
|
||||
|
||||
> daily job scheduling calls
|
||||
|
||||
[
|
||||
{
|
||||
args: [
|
||||
'copilot.session.cleanupEmptySessions',
|
||||
{},
|
||||
{
|
||||
jobId: 'daily-copilot-cleanup-empty-sessions',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
args: [
|
||||
'copilot.session.generateMissingTitles',
|
||||
{},
|
||||
{
|
||||
jobId: 'daily-copilot-generate-missing-titles',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
args: [
|
||||
'copilot.workspace.cleanupTrashedDocEmbeddings',
|
||||
{},
|
||||
{
|
||||
jobId: 'daily-copilot-cleanup-trashed-doc-embeddings',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
> cleanup empty sessions calls
|
||||
|
||||
[
|
||||
{
|
||||
args: [
|
||||
'Date',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
> title generation calls
|
||||
|
||||
{
|
||||
jobCalls: [
|
||||
{
|
||||
args: [
|
||||
'copilot.session.generateTitle',
|
||||
{
|
||||
sessionId: 'session1',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
args: [
|
||||
'copilot.session.generateTitle',
|
||||
{
|
||||
sessionId: 'session2',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
modelCalls: [
|
||||
{
|
||||
args: [],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -351,10 +351,10 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
|
||||
params: {
|
||||
files: [
|
||||
{
|
||||
blobId: 'euclidean_distance',
|
||||
fileName: 'euclidean_distance.rs',
|
||||
fileType: 'text/rust',
|
||||
fileContent: TestAssets.Code,
|
||||
blobId: 'todo_md',
|
||||
fileName: 'todo.md',
|
||||
fileType: 'text/markdown',
|
||||
fileContent: TestAssets.TODO,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -476,6 +476,7 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
|
||||
},
|
||||
},
|
||||
],
|
||||
config: { model: 'gemini-2.5-pro' },
|
||||
verifier: (t: ExecutionContext<Tester>, result: string) => {
|
||||
t.notThrows(() => {
|
||||
TranscriptionResponseSchema.parse(JSON.parse(result));
|
||||
@@ -697,11 +698,12 @@ for (const {
|
||||
t.truthy(provider, 'should have provider');
|
||||
await retry(`action: ${promptName}`, t, async t => {
|
||||
const finalConfig = Object.assign({}, prompt.config, config);
|
||||
const modelId = finalConfig.model || prompt.model;
|
||||
|
||||
switch (type) {
|
||||
case 'text': {
|
||||
const result = await provider.text(
|
||||
{ modelId: prompt.model },
|
||||
{ modelId },
|
||||
[
|
||||
...prompt.finish(
|
||||
messages.reduce(
|
||||
@@ -720,7 +722,7 @@ for (const {
|
||||
}
|
||||
case 'structured': {
|
||||
const result = await provider.structure(
|
||||
{ modelId: prompt.model },
|
||||
{ modelId },
|
||||
[
|
||||
...prompt.finish(
|
||||
messages.reduce(
|
||||
@@ -739,7 +741,7 @@ for (const {
|
||||
case 'object': {
|
||||
const streamObjects: StreamObject[] = [];
|
||||
for await (const chunk of provider.streamObject(
|
||||
{ modelId: prompt.model },
|
||||
{ modelId },
|
||||
[
|
||||
...prompt.finish(
|
||||
messages.reduce(
|
||||
@@ -771,7 +773,7 @@ for (const {
|
||||
});
|
||||
}
|
||||
const stream = provider.streamImages(
|
||||
{ modelId: prompt.model },
|
||||
{ modelId },
|
||||
[
|
||||
...prompt.finish(
|
||||
finalMessage.reduce(
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '../models';
|
||||
import { CopilotModule } from '../plugins/copilot';
|
||||
import { CopilotContextService } from '../plugins/copilot/context';
|
||||
import { CopilotCronJobs } from '../plugins/copilot/cron';
|
||||
import {
|
||||
CopilotEmbeddingJob,
|
||||
MockEmbeddingClient,
|
||||
@@ -77,6 +78,7 @@ type Context = {
|
||||
jobs: CopilotEmbeddingJob;
|
||||
storage: CopilotStorage;
|
||||
workflow: CopilotWorkflowService;
|
||||
cronJobs: CopilotCronJobs;
|
||||
executors: {
|
||||
image: CopilotChatImageExecutor;
|
||||
text: CopilotChatTextExecutor;
|
||||
@@ -137,6 +139,7 @@ test.before(async t => {
|
||||
const jobs = module.get(CopilotEmbeddingJob);
|
||||
const transcript = module.get(CopilotTranscriptionService);
|
||||
const workspaceEmbedding = module.get(CopilotWorkspaceService);
|
||||
const cronJobs = module.get(CopilotCronJobs);
|
||||
|
||||
t.context.module = module;
|
||||
t.context.auth = auth;
|
||||
@@ -153,6 +156,7 @@ test.before(async t => {
|
||||
t.context.jobs = jobs;
|
||||
t.context.transcript = transcript;
|
||||
t.context.workspaceEmbedding = workspaceEmbedding;
|
||||
t.context.cronJobs = cronJobs;
|
||||
|
||||
t.context.executors = {
|
||||
image: module.get(CopilotChatImageExecutor),
|
||||
@@ -1931,3 +1935,71 @@ test('should handle generateSessionTitle correctly under various conditions', as
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle copilot cron jobs correctly', async t => {
|
||||
const { cronJobs, copilotSession } = t.context;
|
||||
|
||||
// mock calls
|
||||
const mockCleanupResult = { removed: 2, cleaned: 3 };
|
||||
const mockSessions = [
|
||||
{ id: 'session1', _count: { messages: 1 } },
|
||||
{ id: 'session2', _count: { messages: 2 } },
|
||||
];
|
||||
const cleanupStub = Sinon.stub(
|
||||
copilotSession,
|
||||
'cleanupEmptySessions'
|
||||
).resolves(mockCleanupResult);
|
||||
const toBeGenerateStub = Sinon.stub(
|
||||
copilotSession,
|
||||
'toBeGenerateTitle'
|
||||
).resolves(mockSessions);
|
||||
const jobAddStub = Sinon.stub(cronJobs['jobs'], 'add').resolves();
|
||||
|
||||
// daily cleanup job scheduling
|
||||
{
|
||||
await cronJobs.dailyCleanupJob();
|
||||
t.snapshot(
|
||||
jobAddStub.getCalls().map(call => ({
|
||||
args: call.args,
|
||||
})),
|
||||
'daily job scheduling calls'
|
||||
);
|
||||
|
||||
jobAddStub.reset();
|
||||
cleanupStub.reset();
|
||||
toBeGenerateStub.reset();
|
||||
}
|
||||
|
||||
// cleanup empty sessions
|
||||
{
|
||||
// mock
|
||||
cleanupStub.resolves(mockCleanupResult);
|
||||
toBeGenerateStub.resolves(mockSessions);
|
||||
|
||||
await cronJobs.cleanupEmptySessions();
|
||||
t.snapshot(
|
||||
cleanupStub.getCalls().map(call => ({
|
||||
args: call.args.map(arg => (arg instanceof Date ? 'Date' : arg)), // Replace Date with string for stable snapshot
|
||||
})),
|
||||
'cleanup empty sessions calls'
|
||||
);
|
||||
}
|
||||
|
||||
// generate missing titles
|
||||
await cronJobs.generateMissingTitles();
|
||||
t.snapshot(
|
||||
{
|
||||
modelCalls: toBeGenerateStub.getCalls().map(call => ({
|
||||
args: call.args,
|
||||
})),
|
||||
jobCalls: jobAddStub.getCalls().map(call => ({
|
||||
args: call.args,
|
||||
})),
|
||||
},
|
||||
'title generation calls'
|
||||
);
|
||||
|
||||
cleanupStub.restore();
|
||||
toBeGenerateStub.restore();
|
||||
jobAddStub.restore();
|
||||
});
|
||||
|
||||
@@ -111,6 +111,19 @@ export class MockCopilotProvider extends OpenAIProvider {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'gemini-2.5-pro',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text, ModelInputType.Image],
|
||||
output: [
|
||||
ModelOutputType.Text,
|
||||
ModelOutputType.Object,
|
||||
ModelOutputType.Structured,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
override async text(
|
||||
|
||||
@@ -565,3 +565,65 @@ Generated by [AVA](https://avajs.dev).
|
||||
workspaceSessionExists: true,
|
||||
},
|
||||
}
|
||||
|
||||
## should cleanup empty sessions correctly
|
||||
|
||||
> cleanup empty sessions results
|
||||
|
||||
{
|
||||
cleanupResult: {
|
||||
cleaned: 0,
|
||||
removed: 0,
|
||||
},
|
||||
remainingSessions: [
|
||||
{
|
||||
deleted: false,
|
||||
pinned: false,
|
||||
type: 'zeroCost',
|
||||
},
|
||||
{
|
||||
deleted: false,
|
||||
pinned: false,
|
||||
type: 'zeroCost',
|
||||
},
|
||||
{
|
||||
deleted: false,
|
||||
pinned: false,
|
||||
type: 'noMessages',
|
||||
},
|
||||
{
|
||||
deleted: false,
|
||||
pinned: false,
|
||||
type: 'noMessages',
|
||||
},
|
||||
{
|
||||
deleted: false,
|
||||
pinned: false,
|
||||
type: 'recent',
|
||||
},
|
||||
{
|
||||
deleted: false,
|
||||
pinned: false,
|
||||
type: 'withMessages',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
## should get sessions for title generation correctly
|
||||
|
||||
> sessions for title generation results
|
||||
|
||||
{
|
||||
onlyValidSessionsReturned: true,
|
||||
sessions: [
|
||||
{
|
||||
assistantMessageCount: 1,
|
||||
isValid: true,
|
||||
},
|
||||
{
|
||||
assistantMessageCount: 2,
|
||||
isValid: true,
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -164,11 +164,14 @@ test('should insert embedding by doc id', async t => {
|
||||
);
|
||||
|
||||
{
|
||||
const ret = await t.context.copilotContext.hasWorkspaceEmbedding(
|
||||
const ret = await t.context.copilotContext.listWorkspaceEmbedding(
|
||||
workspace.id,
|
||||
[docId]
|
||||
);
|
||||
t.true(ret.has(docId), 'should return doc id when embedding is inserted');
|
||||
t.true(
|
||||
ret.includes(docId),
|
||||
'should return doc id when embedding is inserted'
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
@@ -317,8 +320,8 @@ test('should merge doc status correctly', async t => {
|
||||
|
||||
const hasEmbeddingStub = Sinon.stub(
|
||||
t.context.copilotContext,
|
||||
'hasWorkspaceEmbedding'
|
||||
).resolves(new Set<string>());
|
||||
'listWorkspaceEmbedding'
|
||||
).resolves([]);
|
||||
|
||||
const stubResult = await t.context.copilotContext.mergeDocStatus(
|
||||
workspace.id,
|
||||
|
||||
@@ -917,3 +917,178 @@ test('should handle fork and session attachment operations', async t => {
|
||||
'attach and detach operation results'
|
||||
);
|
||||
});
|
||||
|
||||
test('should cleanup empty sessions correctly', async t => {
|
||||
const { copilotSession, db } = t.context;
|
||||
await createTestPrompts(copilotSession, db);
|
||||
|
||||
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
||||
|
||||
// should be deleted
|
||||
const neverUsedSessionIds: string[] = [randomUUID(), randomUUID()];
|
||||
await Promise.all(
|
||||
neverUsedSessionIds.map(async id => {
|
||||
await createTestSession(t, { sessionId: id });
|
||||
await db.aiSession.update({
|
||||
where: { id },
|
||||
data: { messageCost: 0, updatedAt: oneDayAgo },
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// should be marked as deleted
|
||||
const emptySessionIds: string[] = [randomUUID(), randomUUID()];
|
||||
await Promise.all(
|
||||
emptySessionIds.map(async id => {
|
||||
await createTestSession(t, { sessionId: id });
|
||||
await db.aiSession.update({
|
||||
where: { id },
|
||||
data: { messageCost: 100, updatedAt: oneDayAgo },
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// should not be affected
|
||||
const recentSessionId = randomUUID();
|
||||
await createTestSession(t, { sessionId: recentSessionId });
|
||||
await db.aiSession.update({
|
||||
where: { id: recentSessionId },
|
||||
data: { messageCost: 0, updatedAt: twoHoursAgo },
|
||||
});
|
||||
|
||||
// Create session with messages (should not be affected)
|
||||
const sessionWithMsgId = randomUUID();
|
||||
await createSessionWithMessages(
|
||||
t,
|
||||
{ sessionId: sessionWithMsgId },
|
||||
'test message'
|
||||
);
|
||||
|
||||
const result = await copilotSession.cleanupEmptySessions(oneDayAgo);
|
||||
|
||||
const remainingSessions = await db.aiSession.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: [
|
||||
...neverUsedSessionIds,
|
||||
...emptySessionIds,
|
||||
recentSessionId,
|
||||
sessionWithMsgId,
|
||||
],
|
||||
},
|
||||
},
|
||||
select: { id: true, deletedAt: true, pinned: true },
|
||||
});
|
||||
|
||||
t.snapshot(
|
||||
{
|
||||
cleanupResult: result,
|
||||
remainingSessions: remainingSessions.map(s => ({
|
||||
deleted: !!s.deletedAt,
|
||||
pinned: s.pinned,
|
||||
type: neverUsedSessionIds.includes(s.id)
|
||||
? 'zeroCost'
|
||||
: emptySessionIds.includes(s.id)
|
||||
? 'noMessages'
|
||||
: s.id === recentSessionId
|
||||
? 'recent'
|
||||
: 'withMessages',
|
||||
})),
|
||||
},
|
||||
'cleanup empty sessions results'
|
||||
);
|
||||
});
|
||||
|
||||
test('should get sessions for title generation correctly', async t => {
|
||||
const { copilotSession, db } = t.context;
|
||||
await createTestPrompts(copilotSession, db);
|
||||
|
||||
// create valid sessions with messages
|
||||
const sessionIds: string[] = [randomUUID(), randomUUID()];
|
||||
await Promise.all(
|
||||
sessionIds.map(async (id, index) => {
|
||||
await createTestSession(t, { sessionId: id });
|
||||
await db.aiSession.update({
|
||||
where: { id },
|
||||
data: {
|
||||
updatedAt: new Date(Date.now() - index * 1000),
|
||||
messages: {
|
||||
create: Array.from({ length: index + 1 }, (_, i) => ({
|
||||
role: 'assistant',
|
||||
content: `assistant message ${i}`,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// create excluded sessions
|
||||
const excludedSessions = [
|
||||
{
|
||||
reason: 'hasTitle',
|
||||
setupFn: async (id: string) => {
|
||||
await createTestSession(t, { sessionId: id });
|
||||
await db.aiSession.update({
|
||||
where: { id },
|
||||
data: { title: 'Existing Title' },
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
reason: 'isDeleted',
|
||||
setupFn: async (id: string) => {
|
||||
await createTestSession(t, { sessionId: id });
|
||||
await db.aiSession.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
reason: 'noMessages',
|
||||
setupFn: async (id: string) => {
|
||||
await createTestSession(t, { sessionId: id });
|
||||
},
|
||||
},
|
||||
{
|
||||
reason: 'isAction',
|
||||
setupFn: async (id: string) => {
|
||||
await createTestSession(t, {
|
||||
sessionId: id,
|
||||
promptName: TEST_PROMPTS.ACTION,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
reason: 'noAssistantMessages',
|
||||
setupFn: async (id: string) => {
|
||||
await createTestSession(t, { sessionId: id });
|
||||
await db.aiSessionMessage.create({
|
||||
data: { sessionId: id, role: 'user', content: 'User message only' },
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await Promise.all(
|
||||
excludedSessions.map(async session => {
|
||||
await session.setupFn(randomUUID());
|
||||
})
|
||||
);
|
||||
|
||||
const result = await copilotSession.toBeGenerateTitle();
|
||||
|
||||
t.snapshot(
|
||||
{
|
||||
total: result.length,
|
||||
sessions: result.map(s => ({
|
||||
assistantMessageCount: s._count.messages,
|
||||
isValid: sessionIds.includes(s.id),
|
||||
})),
|
||||
onlyValidSessionsReturned: result.every(s => sessionIds.includes(s.id)),
|
||||
},
|
||||
'sessions for title generation results'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -433,7 +433,7 @@ export async function submitAudioTranscription(
|
||||
for (const [idx, buffer] of content.entries()) {
|
||||
resp = resp.attach(idx.toString(), buffer, {
|
||||
filename: fileName,
|
||||
contentType: 'application/octet-stream',
|
||||
contentType: 'audio/opus',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import { DocRendererModule } from './core/doc-renderer';
|
||||
import { DocServiceModule } from './core/doc-service';
|
||||
import { FeatureModule } from './core/features';
|
||||
import { MailModule } from './core/mail';
|
||||
import { MonitorModule } from './core/monitor';
|
||||
import { NotificationModule } from './core/notification';
|
||||
import { PermissionModule } from './core/permission';
|
||||
import { QuotaModule } from './core/quota';
|
||||
@@ -112,6 +113,8 @@ export const FunctionalityModules = [
|
||||
WebSocketModule,
|
||||
JobModule.forRoot(),
|
||||
ModelsModule,
|
||||
ScheduleModule.forRoot(),
|
||||
MonitorModule,
|
||||
];
|
||||
|
||||
export class AppModuleBuilder {
|
||||
@@ -151,12 +154,8 @@ export function buildAppModule(env: Env) {
|
||||
// basic
|
||||
.use(...FunctionalityModules)
|
||||
|
||||
// enable schedule module on graphql server and doc service
|
||||
.useIf(
|
||||
() => env.flavors.graphql || env.flavors.doc,
|
||||
ScheduleModule.forRoot(),
|
||||
IndexerModule
|
||||
)
|
||||
// enable indexer module on graphql server and doc service
|
||||
.useIf(() => env.flavors.graphql || env.flavors.doc, IndexerModule)
|
||||
|
||||
// auth
|
||||
.use(UserModule, AuthModule, PermissionModule)
|
||||
|
||||
@@ -653,12 +653,19 @@ export const USER_FRIENDLY_ERRORS = {
|
||||
},
|
||||
no_copilot_provider_available: {
|
||||
type: 'internal_server_error',
|
||||
message: `No copilot provider available.`,
|
||||
args: { modelId: 'string' },
|
||||
message: ({ modelId }) => `No copilot provider available: ${modelId}`,
|
||||
},
|
||||
copilot_failed_to_generate_text: {
|
||||
type: 'internal_server_error',
|
||||
message: `Failed to generate text.`,
|
||||
},
|
||||
copilot_failed_to_generate_embedding: {
|
||||
type: 'internal_server_error',
|
||||
args: { provider: 'string', message: 'string' },
|
||||
message: ({ provider, message }) =>
|
||||
`Failed to generate embedding with ${provider}: ${message}`,
|
||||
},
|
||||
copilot_failed_to_create_message: {
|
||||
type: 'internal_server_error',
|
||||
message: `Failed to create chat message.`,
|
||||
|
||||
@@ -668,10 +668,14 @@ export class CopilotSessionDeleted extends UserFriendlyError {
|
||||
super('action_forbidden', 'copilot_session_deleted', message);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class NoCopilotProviderAvailableDataType {
|
||||
@Field() modelId!: string
|
||||
}
|
||||
|
||||
export class NoCopilotProviderAvailable extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
super('internal_server_error', 'no_copilot_provider_available', message);
|
||||
constructor(args: NoCopilotProviderAvailableDataType, message?: string | ((args: NoCopilotProviderAvailableDataType) => string)) {
|
||||
super('internal_server_error', 'no_copilot_provider_available', message, args);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -680,6 +684,17 @@ export class CopilotFailedToGenerateText extends UserFriendlyError {
|
||||
super('internal_server_error', 'copilot_failed_to_generate_text', message);
|
||||
}
|
||||
}
|
||||
@ObjectType()
|
||||
class CopilotFailedToGenerateEmbeddingDataType {
|
||||
@Field() provider!: string
|
||||
@Field() message!: string
|
||||
}
|
||||
|
||||
export class CopilotFailedToGenerateEmbedding extends UserFriendlyError {
|
||||
constructor(args: CopilotFailedToGenerateEmbeddingDataType, message?: string | ((args: CopilotFailedToGenerateEmbeddingDataType) => string)) {
|
||||
super('internal_server_error', 'copilot_failed_to_generate_embedding', message, args);
|
||||
}
|
||||
}
|
||||
|
||||
export class CopilotFailedToCreateMessage extends UserFriendlyError {
|
||||
constructor(message?: string) {
|
||||
@@ -1179,6 +1194,7 @@ export enum ErrorNames {
|
||||
COPILOT_SESSION_DELETED,
|
||||
NO_COPILOT_PROVIDER_AVAILABLE,
|
||||
COPILOT_FAILED_TO_GENERATE_TEXT,
|
||||
COPILOT_FAILED_TO_GENERATE_EMBEDDING,
|
||||
COPILOT_FAILED_TO_CREATE_MESSAGE,
|
||||
UNSPLASH_IS_NOT_CONFIGURED,
|
||||
COPILOT_ACTION_TAKEN,
|
||||
@@ -1239,5 +1255,5 @@ registerEnumType(ErrorNames, {
|
||||
export const ErrorDataUnionType = createUnionType({
|
||||
name: 'ErrorDataUnion',
|
||||
types: () =>
|
||||
[GraphqlBadRequestDataType, HttpRequestErrorDataType, QueryTooLongDataType, ValidationErrorDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, InvalidOauthCallbackCodeDataType, MissingOauthQueryParameterDataType, InvalidOauthResponseDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, DocUpdateBlockedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, NoMoreSeatDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderNotSupportedDataType, CopilotProviderSideErrorDataType, CopilotInvalidContextDataType, CopilotContextFileNotSupportedDataType, CopilotFailedToModifyContextDataType, CopilotFailedToMatchContextDataType, CopilotFailedToMatchGlobalContextDataType, CopilotFailedToAddWorkspaceFileEmbeddingDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseToActivateDataType, InvalidLicenseUpdateParamsDataType, UnsupportedClientVersionDataType, MentionUserDocAccessDeniedDataType, InvalidAppConfigDataType, InvalidAppConfigInputDataType, InvalidSearchProviderRequestDataType, InvalidIndexerInputDataType] as const,
|
||||
[GraphqlBadRequestDataType, HttpRequestErrorDataType, QueryTooLongDataType, ValidationErrorDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, InvalidOauthCallbackCodeDataType, MissingOauthQueryParameterDataType, InvalidOauthResponseDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, DocUpdateBlockedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, NoMoreSeatDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, NoCopilotProviderAvailableDataType, CopilotFailedToGenerateEmbeddingDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderNotSupportedDataType, CopilotProviderSideErrorDataType, CopilotInvalidContextDataType, CopilotContextFileNotSupportedDataType, CopilotFailedToModifyContextDataType, CopilotFailedToMatchContextDataType, CopilotFailedToMatchGlobalContextDataType, CopilotFailedToAddWorkspaceFileEmbeddingDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseToActivateDataType, InvalidLicenseUpdateParamsDataType, UnsupportedClientVersionDataType, MentionUserDocAccessDeniedDataType, InvalidAppConfigDataType, InvalidAppConfigInputDataType, InvalidSearchProviderRequestDataType, InvalidIndexerInputDataType] as const,
|
||||
});
|
||||
|
||||
@@ -59,7 +59,9 @@ export type KnownMetricScopes =
|
||||
| 'mail'
|
||||
| 'ai'
|
||||
| 'event'
|
||||
| 'queue';
|
||||
| 'queue'
|
||||
| 'storage'
|
||||
| 'process';
|
||||
|
||||
const metricCreators: MetricCreators = {
|
||||
counter(meter: Meter, name: string, opts?: MetricOptions) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
9
packages/backend/server/src/core/monitor/index.ts
Normal file
9
packages/backend/server/src/core/monitor/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { MonitorService } from './service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [MonitorService],
|
||||
})
|
||||
export class MonitorModule {}
|
||||
28
packages/backend/server/src/core/monitor/service.ts
Normal file
28
packages/backend/server/src/core/monitor/service.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
|
||||
import { metrics } from '../../base';
|
||||
|
||||
@Injectable()
|
||||
export class MonitorService {
|
||||
protected logger = new Logger(MonitorService.name);
|
||||
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async monitor() {
|
||||
const memoryUsage = process.memoryUsage();
|
||||
this.logger.log(
|
||||
`memory usage: rss: ${memoryUsage.rss}, heapTotal: ${memoryUsage.heapTotal}, heapUsed: ${memoryUsage.heapUsed}, external: ${memoryUsage.external}, arrayBuffers: ${memoryUsage.arrayBuffers}`
|
||||
);
|
||||
metrics.process.gauge('node_process_rss').record(memoryUsage.rss);
|
||||
metrics.process
|
||||
.gauge('node_process_heap_total')
|
||||
.record(memoryUsage.heapTotal);
|
||||
metrics.process
|
||||
.gauge('node_process_heap_used')
|
||||
.record(memoryUsage.heapUsed);
|
||||
metrics.process.gauge('node_process_external').record(memoryUsage.external);
|
||||
metrics.process
|
||||
.gauge('node_process_array_buffers')
|
||||
.record(memoryUsage.arrayBuffers);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
autoMetadata,
|
||||
Config,
|
||||
EventBus,
|
||||
metrics,
|
||||
OnEvent,
|
||||
type StorageProvider,
|
||||
StorageProviderFactory,
|
||||
@@ -69,15 +70,23 @@ export class CommentAttachmentStorage {
|
||||
blob,
|
||||
meta
|
||||
);
|
||||
const mime = meta.contentType ?? 'application/octet-stream';
|
||||
const size = blob.length;
|
||||
await this.models.commentAttachment.upsert({
|
||||
workspaceId,
|
||||
docId,
|
||||
key,
|
||||
name,
|
||||
mime: meta.contentType ?? 'application/octet-stream',
|
||||
size: blob.length,
|
||||
mime,
|
||||
size,
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
metrics.storage.histogram('comment_attachment_size').record(size, { mime });
|
||||
metrics.storage.counter('comment_attachment_total').add(1, { mime });
|
||||
this.logger.log(
|
||||
`uploaded comment attachment ${workspaceId}/${docId}/${key} with size ${size}, mime: ${mime}, name: ${name}, user: ${userId}`
|
||||
);
|
||||
}
|
||||
|
||||
async get(
|
||||
|
||||
@@ -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(
|
||||
@@ -165,6 +164,13 @@ export class CopilotContextModel extends BaseModel {
|
||||
fileId: string,
|
||||
embeddings: Embedding[]
|
||||
) {
|
||||
if (embeddings.length === 0) {
|
||||
this.logger.warn(
|
||||
`No embeddings provided for contextId: ${contextId}, fileId: ${fileId}. Skipping insertion.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const values = this.processEmbeddings(contextId, fileId, embeddings);
|
||||
|
||||
await this.db.$executeRaw`
|
||||
@@ -204,6 +210,13 @@ export class CopilotContextModel extends BaseModel {
|
||||
docId: string,
|
||||
embeddings: Embedding[]
|
||||
) {
|
||||
if (embeddings.length === 0) {
|
||||
this.logger.warn(
|
||||
`No embeddings provided for workspaceId: ${workspaceId}, docId: ${docId}. Skipping insertion.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const values = this.processEmbeddings(
|
||||
workspaceId,
|
||||
docId,
|
||||
|
||||
@@ -582,4 +582,56 @@ export class CopilotSessionModel extends BaseModel {
|
||||
.map(({ messageCost, prompt: { action } }) => (action ? 1 : messageCost))
|
||||
.reduce((prev, cost) => prev + cost, 0);
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async cleanupEmptySessions(earlyThen: Date) {
|
||||
// delete never used sessions
|
||||
const { count: removed } = await this.db.aiSession.deleteMany({
|
||||
where: {
|
||||
messageCost: 0,
|
||||
deletedAt: null,
|
||||
// filter session updated more than 24 hours ago
|
||||
updatedAt: { lt: earlyThen },
|
||||
},
|
||||
});
|
||||
|
||||
// mark empty sessions as deleted
|
||||
const { count: cleaned } = await this.db.aiSession.updateMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
messages: { none: {} },
|
||||
// filter session updated more than 24 hours ago
|
||||
updatedAt: { lt: earlyThen },
|
||||
},
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
pinned: false,
|
||||
},
|
||||
});
|
||||
|
||||
return { removed, cleaned };
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async toBeGenerateTitle() {
|
||||
const sessions = await this.db.aiSession
|
||||
.findMany({
|
||||
where: {
|
||||
title: null,
|
||||
deletedAt: null,
|
||||
messages: { some: {} },
|
||||
// only generate titles for non-actions sessions
|
||||
prompt: { action: null },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
// count assistant messages
|
||||
_count: { select: { messages: { where: { role: 'assistant' } } } },
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
})
|
||||
.then(s => s.filter(s => s._count.messages > 0));
|
||||
|
||||
return sessions;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]) } },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -283,6 +287,13 @@ export class CopilotWorkspaceConfigModel extends BaseModel {
|
||||
fileId: string,
|
||||
embeddings: Embedding[]
|
||||
) {
|
||||
if (embeddings.length === 0) {
|
||||
this.logger.warn(
|
||||
`No embeddings provided for workspaceId: ${workspaceId}, fileId: ${fileId}. Skipping insertion.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const values = this.processEmbeddings(workspaceId, fileId, embeddings);
|
||||
await this.db.$executeRaw`
|
||||
INSERT INTO "ai_workspace_file_embeddings"
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -125,7 +125,10 @@ export class CopilotContextService implements OnApplicationBootstrap {
|
||||
|
||||
async get(id: string): Promise<ContextSession> {
|
||||
if (!this.embeddingClient) {
|
||||
throw new NoCopilotProviderAvailable('embedding client not configured');
|
||||
throw new NoCopilotProviderAvailable(
|
||||
{ modelId: 'embedding' },
|
||||
'embedding client not configured'
|
||||
);
|
||||
}
|
||||
|
||||
const context = await this.getCachedSession(id);
|
||||
|
||||
@@ -124,7 +124,7 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
modelId: model,
|
||||
});
|
||||
if (!provider) {
|
||||
throw new NoCopilotProviderAvailable();
|
||||
throw new NoCopilotProviderAvailable({ modelId: model });
|
||||
}
|
||||
|
||||
return { provider, model, hasAttachment };
|
||||
@@ -299,6 +299,13 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
this.ongoingStreamCount$.next(this.ongoingStreamCount$.value + 1);
|
||||
|
||||
const { signal, onConnectionClosed } = getSignal(req);
|
||||
let endBeforePromiseResolve = false;
|
||||
onConnectionClosed(isAborted => {
|
||||
if (isAborted) {
|
||||
endBeforePromiseResolve = true;
|
||||
}
|
||||
});
|
||||
|
||||
const { messageId, reasoning, webSearch } = ChatQuerySchema.parse(query);
|
||||
|
||||
const source$ = from(
|
||||
@@ -322,21 +329,21 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
shared$.pipe(
|
||||
reduce((acc, chunk) => acc + chunk, ''),
|
||||
tap(buffer => {
|
||||
onConnectionClosed(isAborted => {
|
||||
session.push({
|
||||
role: 'assistant',
|
||||
content: isAborted ? '> Request aborted' : buffer,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
void session
|
||||
.save()
|
||||
.catch(err =>
|
||||
this.logger.error(
|
||||
'Failed to save session in sse stream',
|
||||
err
|
||||
)
|
||||
);
|
||||
session.push({
|
||||
role: 'assistant',
|
||||
content: endBeforePromiseResolve
|
||||
? '> Request aborted'
|
||||
: buffer,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
void session
|
||||
.save()
|
||||
.catch(err =>
|
||||
this.logger.error(
|
||||
'Failed to save session in sse stream',
|
||||
err
|
||||
)
|
||||
);
|
||||
}),
|
||||
ignoreElements()
|
||||
)
|
||||
@@ -384,6 +391,13 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
this.ongoingStreamCount$.next(this.ongoingStreamCount$.value + 1);
|
||||
|
||||
const { signal, onConnectionClosed } = getSignal(req);
|
||||
let endBeforePromiseResolve = false;
|
||||
onConnectionClosed(isAborted => {
|
||||
if (isAborted) {
|
||||
endBeforePromiseResolve = true;
|
||||
}
|
||||
});
|
||||
|
||||
const { messageId, reasoning, webSearch } = ChatQuerySchema.parse(query);
|
||||
|
||||
const source$ = from(
|
||||
@@ -407,25 +421,25 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
shared$.pipe(
|
||||
reduce((acc, chunk) => acc.concat([chunk]), [] as StreamObject[]),
|
||||
tap(result => {
|
||||
onConnectionClosed(isAborted => {
|
||||
const parser = new StreamObjectParser();
|
||||
const streamObjects = parser.mergeTextDelta(result);
|
||||
const content = parser.mergeContent(streamObjects);
|
||||
session.push({
|
||||
role: 'assistant',
|
||||
content: isAborted ? '> Request aborted' : content,
|
||||
streamObjects: isAborted ? null : streamObjects,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
void session
|
||||
.save()
|
||||
.catch(err =>
|
||||
this.logger.error(
|
||||
'Failed to save session in sse stream',
|
||||
err
|
||||
)
|
||||
);
|
||||
const parser = new StreamObjectParser();
|
||||
const streamObjects = parser.mergeTextDelta(result);
|
||||
const content = parser.mergeContent(streamObjects);
|
||||
session.push({
|
||||
role: 'assistant',
|
||||
content: endBeforePromiseResolve
|
||||
? '> Request aborted'
|
||||
: content,
|
||||
streamObjects: endBeforePromiseResolve ? null : streamObjects,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
void session
|
||||
.save()
|
||||
.catch(err =>
|
||||
this.logger.error(
|
||||
'Failed to save session in sse stream',
|
||||
err
|
||||
)
|
||||
);
|
||||
}),
|
||||
ignoreElements()
|
||||
)
|
||||
@@ -477,6 +491,13 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
this.ongoingStreamCount$.next(this.ongoingStreamCount$.value + 1);
|
||||
|
||||
const { signal, onConnectionClosed } = getSignal(req);
|
||||
let endBeforePromiseResolve = false;
|
||||
onConnectionClosed(isAborted => {
|
||||
if (isAborted) {
|
||||
endBeforePromiseResolve = true;
|
||||
}
|
||||
});
|
||||
|
||||
const source$ = from(
|
||||
this.workflow.runGraph(params, session.model, {
|
||||
...session.config.promptConfig,
|
||||
@@ -526,21 +547,21 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
return acc;
|
||||
}, ''),
|
||||
tap(content => {
|
||||
onConnectionClosed(isAborted => {
|
||||
session.push({
|
||||
role: 'assistant',
|
||||
content: isAborted ? '> Request aborted' : content,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
void session
|
||||
.save()
|
||||
.catch(err =>
|
||||
this.logger.error(
|
||||
'Failed to save session in sse stream',
|
||||
err
|
||||
)
|
||||
);
|
||||
session.push({
|
||||
role: 'assistant',
|
||||
content: endBeforePromiseResolve
|
||||
? '> Request aborted'
|
||||
: content,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
void session
|
||||
.save()
|
||||
.catch(err =>
|
||||
this.logger.error(
|
||||
'Failed to save session in sse stream',
|
||||
err
|
||||
)
|
||||
);
|
||||
}),
|
||||
ignoreElements()
|
||||
)
|
||||
@@ -604,6 +625,13 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
this.ongoingStreamCount$.next(this.ongoingStreamCount$.value + 1);
|
||||
|
||||
const { signal, onConnectionClosed } = getSignal(req);
|
||||
let endBeforePromiseResolve = false;
|
||||
onConnectionClosed(isAborted => {
|
||||
if (isAborted) {
|
||||
endBeforePromiseResolve = true;
|
||||
}
|
||||
});
|
||||
|
||||
const source$ = from(
|
||||
provider.streamImages(
|
||||
{
|
||||
@@ -639,22 +667,20 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
shared$.pipe(
|
||||
reduce((acc, chunk) => acc.concat([chunk]), [] as string[]),
|
||||
tap(attachments => {
|
||||
onConnectionClosed(isAborted => {
|
||||
session.push({
|
||||
role: 'assistant',
|
||||
content: isAborted ? '> Request aborted' : '',
|
||||
attachments: isAborted ? [] : attachments,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
void session
|
||||
.save()
|
||||
.catch(err =>
|
||||
this.logger.error(
|
||||
'Failed to save session in sse stream',
|
||||
err
|
||||
)
|
||||
);
|
||||
session.push({
|
||||
role: 'assistant',
|
||||
content: endBeforePromiseResolve ? '> Request aborted' : '',
|
||||
attachments: endBeforePromiseResolve ? [] : attachments,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
void session
|
||||
.save()
|
||||
.catch(err =>
|
||||
this.logger.error(
|
||||
'Failed to save session in sse stream',
|
||||
err
|
||||
)
|
||||
);
|
||||
}),
|
||||
ignoreElements()
|
||||
)
|
||||
|
||||
100
packages/backend/server/src/plugins/copilot/cron.ts
Normal file
100
packages/backend/server/src/plugins/copilot/cron.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
|
||||
import { JobQueue, OneDay, OnJob } from '../../base';
|
||||
import { Models } from '../../models';
|
||||
|
||||
declare global {
|
||||
interface Jobs {
|
||||
'copilot.session.cleanupEmptySessions': {};
|
||||
'copilot.session.generateMissingTitles': {};
|
||||
'copilot.workspace.cleanupTrashedDocEmbeddings': {};
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CopilotCronJobs {
|
||||
private readonly logger = new Logger(CopilotCronJobs.name);
|
||||
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
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(
|
||||
'copilot.session.cleanupEmptySessions',
|
||||
{},
|
||||
{ jobId: 'daily-copilot-cleanup-empty-sessions' }
|
||||
);
|
||||
|
||||
await this.jobs.add(
|
||||
'copilot.session.generateMissingTitles',
|
||||
{},
|
||||
{ jobId: 'daily-copilot-generate-missing-titles' }
|
||||
);
|
||||
|
||||
await this.jobs.add(
|
||||
'copilot.workspace.cleanupTrashedDocEmbeddings',
|
||||
{},
|
||||
{ jobId: 'daily-copilot-cleanup-trashed-doc-embeddings' }
|
||||
);
|
||||
}
|
||||
|
||||
async triggerGenerateMissingTitles() {
|
||||
await this.jobs.add(
|
||||
'copilot.session.generateMissingTitles',
|
||||
{},
|
||||
{ jobId: 'trigger-copilot-generate-missing-titles' }
|
||||
);
|
||||
}
|
||||
|
||||
@OnJob('copilot.session.cleanupEmptySessions')
|
||||
async cleanupEmptySessions() {
|
||||
const { removed, cleaned } =
|
||||
await this.models.copilotSession.cleanupEmptySessions(
|
||||
new Date(Date.now() - OneDay)
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Cleanup completed: ${removed} sessions deleted, ${cleaned} sessions marked as deleted`
|
||||
);
|
||||
}
|
||||
|
||||
@OnJob('copilot.session.generateMissingTitles')
|
||||
async generateMissingTitles() {
|
||||
const sessions = await this.models.copilotSession.toBeGenerateTitle();
|
||||
|
||||
for (const session of sessions) {
|
||||
await this.jobs.add('copilot.session.generateTitle', {
|
||||
sessionId: session.id,
|
||||
});
|
||||
}
|
||||
this.logger.log(
|
||||
`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}` }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
CopilotPromptNotFound,
|
||||
CopilotProviderNotSupported,
|
||||
} from '../../../base';
|
||||
import { CopilotFailedToGenerateEmbedding } from '../../../base/error/errors.gen';
|
||||
import { ChunkSimilarity, Embedding } from '../../../models';
|
||||
import { PromptService } from '../prompt';
|
||||
import {
|
||||
@@ -74,6 +75,12 @@ class ProductionEmbeddingClient extends EmbeddingClient {
|
||||
input,
|
||||
{ dimensions: EMBEDDING_DIMENSIONS }
|
||||
);
|
||||
if (embeddings.length !== input.length) {
|
||||
throw new CopilotFailedToGenerateEmbedding({
|
||||
provider: provider.type,
|
||||
message: `Expected ${input.length} embeddings, got ${embeddings.length}`,
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(embeddings.entries()).map(([index, embedding]) => ({
|
||||
index,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
CopilotContextService,
|
||||
} from './context';
|
||||
import { CopilotController } from './controller';
|
||||
import { CopilotCronJobs } from './cron';
|
||||
import { CopilotEmbeddingJob } from './embedding';
|
||||
import { ChatMessageCache } from './message';
|
||||
import { PromptService } from './prompt';
|
||||
@@ -63,7 +64,9 @@ import {
|
||||
// context
|
||||
CopilotContextResolver,
|
||||
CopilotContextService,
|
||||
// jobs
|
||||
CopilotEmbeddingJob,
|
||||
CopilotCronJobs,
|
||||
// transcription
|
||||
CopilotTranscriptionService,
|
||||
CopilotTranscriptionResolver,
|
||||
|
||||
@@ -304,6 +304,7 @@ const textActions: Prompt[] = [
|
||||
name: 'Transcript audio',
|
||||
action: 'Transcript audio',
|
||||
model: 'gemini-2.5-flash',
|
||||
optionalModels: ['gemini-2.5-flash', 'gemini-2.5-pro'],
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
@@ -333,6 +334,7 @@ Convert a multi-speaker audio recording into a structured JSON format by transcr
|
||||
config: {
|
||||
requireContent: false,
|
||||
requireAttachment: true,
|
||||
maxRetries: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1623,6 +1625,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: [
|
||||
@@ -1795,11 +1957,75 @@ const chat: Prompt[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const artifactActions: Prompt[] = [
|
||||
{
|
||||
name: 'Code Artifact',
|
||||
model: 'claude-sonnet-4@20250514',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `
|
||||
When sent new notes, respond ONLY with the contents of the html file.
|
||||
DO NOT INCLUDE ANY OTHER TEXT, EXPLANATIONS, APOLOGIES, OR INTRODUCTORY/CLOSING PHRASES.
|
||||
IF USER DOES NOT SPECIFY A STYLE, FOLLOW THE DEFAULT STYLE.
|
||||
<generate_guide>
|
||||
- The results should be a single HTML file.
|
||||
- Use tailwindcss to style the website
|
||||
- Put any additional CSS styles in a style tag and any JavaScript in a script tag.
|
||||
- Use unpkg or skypack to import any required dependencies.
|
||||
- Use Google fonts to pull in any open source fonts you require.
|
||||
- Use lucide icons for any icons.
|
||||
- If you have any images, load them from Unsplash or use solid colored rectangles.
|
||||
</generate_guide>
|
||||
|
||||
<DO_NOT_USE_COLORS>
|
||||
- DO NOT USE ANY COLORS
|
||||
</DO_NOT_USE_COLORS>
|
||||
<DO_NOT_USE_GRADIENTS>
|
||||
- DO NOT USE ANY GRADIENTS
|
||||
</DO_NOT_USE_GRADIENTS>
|
||||
|
||||
<COLOR_THEME>
|
||||
- --affine-blue-300: #93e2fd
|
||||
- --affine-blue-400: #60cffa
|
||||
- --affine-blue-500: #3ab5f7
|
||||
- --affine-blue-600: #1e96eb
|
||||
- --affine-blue-700: #1e67af
|
||||
- --affine-text-primary-color: #121212
|
||||
- --affine-text-secondary-color: #8e8d91
|
||||
- --affine-text-disable-color: #a9a9ad
|
||||
- --affine-background-overlay-panel-color: #fbfbfc
|
||||
- --affine-background-secondary-color: #f4f4f5
|
||||
- --affine-background-primary-color: #fff
|
||||
</COLOR_THEME>
|
||||
<default_style_guide>
|
||||
- MUST USE White and Blue(#1e96eb) as the primary color
|
||||
- KEEP THE DEFAULT STYLE SIMPLE AND CLEAN
|
||||
- DO NOT USE ANY COMPLEX STYLES
|
||||
- DO NOT USE ANY GRADIENTS
|
||||
- USE LESS SHADOWS
|
||||
- USE RADIUS 4px or 8px for rounded corners
|
||||
- USE 12px or 16px for padding
|
||||
- Use the tailwind color gray, zinc, slate, neutral much more.
|
||||
- Use 0.5px border should be better
|
||||
</default_style_guide>
|
||||
`,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: '{{content}}',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const prompts: Prompt[] = [
|
||||
...textActions,
|
||||
...imageActions,
|
||||
...modelActions,
|
||||
...chat,
|
||||
...workflows,
|
||||
...artifactActions,
|
||||
];
|
||||
|
||||
export async function refreshPrompts(db: PrismaClient) {
|
||||
|
||||
@@ -129,7 +129,16 @@ export abstract class GeminiProvider<T> extends CopilotProvider<T> {
|
||||
system,
|
||||
messages: msgs,
|
||||
schema,
|
||||
providerOptions: {
|
||||
google: {
|
||||
thinkingConfig: {
|
||||
thinkingBudget: -1,
|
||||
includeThoughts: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
abortSignal: options.signal,
|
||||
maxRetries: options.maxRetries || 3,
|
||||
experimental_repairText: async ({ text, error }) => {
|
||||
if (error instanceof JSONParseError) {
|
||||
// strange fixed response, temporarily replace it
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -141,6 +141,7 @@ export abstract class CopilotProvider<C = any> {
|
||||
this.logger.debug(`getTools: ${JSON.stringify(options.tools)}`);
|
||||
const ac = this.moduleRef.get(AccessController, { strict: false });
|
||||
const docReader = this.moduleRef.get(DocReader, { strict: false });
|
||||
const models = this.moduleRef.get(Models, { strict: false });
|
||||
const prompt = this.moduleRef.get(PromptService, {
|
||||
strict: false,
|
||||
});
|
||||
@@ -171,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;
|
||||
@@ -182,7 +184,12 @@ export abstract class CopilotProvider<C = any> {
|
||||
const docContext = options.session
|
||||
? await context.getBySessionId(options.session)
|
||||
: null;
|
||||
const searchDocs = buildDocSearchGetter(ac, context, docContext);
|
||||
const searchDocs = buildDocSearchGetter(
|
||||
ac,
|
||||
context,
|
||||
docContext,
|
||||
models
|
||||
);
|
||||
tools.doc_semantic_search = createDocSemanticSearchTool(
|
||||
searchDocs.bind(null, options)
|
||||
);
|
||||
@@ -204,7 +211,6 @@ export abstract class CopilotProvider<C = any> {
|
||||
break;
|
||||
}
|
||||
case 'docRead': {
|
||||
const models = this.moduleRef.get(Models, { strict: false });
|
||||
const getDoc = buildDocContentGetter(ac, docReader, models);
|
||||
tools.doc_read = createDocReadTool(getDoc.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,14 +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';
|
||||
@@ -396,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, {
|
||||
@@ -724,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 {
|
||||
@@ -773,7 +839,26 @@ class CreateCopilotPromptInput {
|
||||
@Admin()
|
||||
@Resolver(() => String)
|
||||
export class PromptsManagementResolver {
|
||||
constructor(private readonly promptService: PromptService) {}
|
||||
constructor(
|
||||
private readonly cron: CopilotCronJobs,
|
||||
private readonly promptService: PromptService
|
||||
) {}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Trigger generate missing titles cron job',
|
||||
})
|
||||
async triggerGenerateTitleCron() {
|
||||
await this.cron.triggerGenerateMissingTitles();
|
||||
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,
|
||||
@@ -569,7 +571,7 @@ export class ChatSessionService {
|
||||
});
|
||||
|
||||
if (!provider) {
|
||||
throw new NoCopilotProviderAvailable();
|
||||
throw new NoCopilotProviderAvailable({ modelId: prompt.model });
|
||||
}
|
||||
|
||||
return provider.text(cond, [...prompt.finish({}), msg], config);
|
||||
|
||||
@@ -5,9 +5,7 @@ import { z } from 'zod';
|
||||
import type { PromptService } from '../prompt';
|
||||
import type { CopilotProviderFactory } from '../providers';
|
||||
import { toolError } from './error';
|
||||
|
||||
const logger = new Logger('CodeArtifactTool');
|
||||
|
||||
/**
|
||||
* A copilot tool that produces a completely self-contained HTML artifact.
|
||||
* The returned HTML must include <style> and <script> tags directly so that
|
||||
@@ -37,23 +35,20 @@ export const createCodeArtifactTool = (
|
||||
}),
|
||||
execute: async ({ title, userPrompt }) => {
|
||||
try {
|
||||
const prompt = await promptService.get('Make it real with text');
|
||||
const prompt = await promptService.get('Code Artifact');
|
||||
if (!prompt) {
|
||||
throw new Error('Prompt not found');
|
||||
}
|
||||
|
||||
const provider = await factory.getProviderByModel(prompt.model);
|
||||
if (!provider) {
|
||||
throw new Error('Provider not found');
|
||||
}
|
||||
|
||||
const content = await provider.text(
|
||||
{
|
||||
modelId: prompt.model,
|
||||
},
|
||||
[...prompt.finish({}), { role: 'user', content: userPrompt }]
|
||||
prompt.finish({ content: userPrompt })
|
||||
);
|
||||
|
||||
// Remove surrounding ``` or ```html fences if present
|
||||
let stripped = content.trim();
|
||||
if (stripped.startsWith('```')) {
|
||||
@@ -65,7 +60,6 @@ export const createCodeArtifactTool = (
|
||||
stripped = stripped.slice(0, -3);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
html: stripped,
|
||||
|
||||
@@ -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,17 +1,45 @@
|
||||
import { tool } from 'ai';
|
||||
import { omit } from 'lodash-es';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { AccessController } from '../../../core/permission';
|
||||
import type { ChunkSimilarity } from '../../../models';
|
||||
import type { ChunkSimilarity, Models } from '../../../models';
|
||||
import type { CopilotContextService } from '../context';
|
||||
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,
|
||||
docContext: ContextSession | null
|
||||
docContext: ContextSession | null,
|
||||
models: Models
|
||||
) => {
|
||||
const searchDocs = async (
|
||||
options: CopilotChatOptions,
|
||||
@@ -45,7 +73,43 @@ export const buildDocSearchGetter = (
|
||||
}
|
||||
if (!docChunks.length && !fileChunks.length)
|
||||
return `No results found for "${query}".`;
|
||||
return [...fileChunks, ...docChunks];
|
||||
|
||||
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
|
||||
.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.map(clearEmbeddingChunk),
|
||||
...docChunks.map(c => ({
|
||||
...c,
|
||||
...docMetas.get(c.docId),
|
||||
})),
|
||||
] as ChunkSimilarity[];
|
||||
};
|
||||
return searchDocs;
|
||||
};
|
||||
|
||||
@@ -15,7 +15,6 @@ import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||
|
||||
import {
|
||||
CopilotTranscriptionAudioNotProvided,
|
||||
CopilotTranscriptionJobNotFound,
|
||||
type FileUpload,
|
||||
} from '../../../base';
|
||||
import { CurrentUser } from '../../../core/auth';
|
||||
@@ -74,7 +73,7 @@ const FinishedStatus: Set<AiJobStatus> = new Set([
|
||||
export class CopilotTranscriptionResolver {
|
||||
constructor(
|
||||
private readonly ac: AccessController,
|
||||
private readonly service: CopilotTranscriptionService
|
||||
private readonly transcript: CopilotTranscriptionService
|
||||
) {}
|
||||
|
||||
private handleJobResult(
|
||||
@@ -122,7 +121,7 @@ export class CopilotTranscriptionResolver {
|
||||
throw new CopilotTranscriptionAudioNotProvided();
|
||||
}
|
||||
|
||||
const jobResult = await this.service.submitTranscriptionJob(
|
||||
const jobResult = await this.transcript.submitJob(
|
||||
user.id,
|
||||
workspaceId,
|
||||
blobId,
|
||||
@@ -144,19 +143,11 @@ export class CopilotTranscriptionResolver {
|
||||
.allowLocal()
|
||||
.assert('Workspace.Copilot');
|
||||
|
||||
const job = await this.service.queryTranscriptionJob(
|
||||
const jobResult = await this.transcript.retryJob(
|
||||
user.id,
|
||||
workspaceId,
|
||||
jobId
|
||||
);
|
||||
if (!job || !job.infos) {
|
||||
throw new CopilotTranscriptionJobNotFound();
|
||||
}
|
||||
|
||||
const jobResult = await this.service.executeTranscriptionJob(
|
||||
job.id,
|
||||
job.infos
|
||||
);
|
||||
|
||||
return this.handleJobResult(jobResult);
|
||||
}
|
||||
@@ -166,7 +157,7 @@ export class CopilotTranscriptionResolver {
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('jobId') jobId: string
|
||||
): Promise<TranscriptionResultType | null> {
|
||||
const job = await this.service.claimTranscriptionJob(user.id, jobId);
|
||||
const job = await this.transcript.claimJob(user.id, jobId);
|
||||
return this.handleJobResult(job);
|
||||
}
|
||||
|
||||
@@ -190,7 +181,7 @@ export class CopilotTranscriptionResolver {
|
||||
.allowLocal()
|
||||
.assert('Workspace.Copilot');
|
||||
|
||||
const job = await this.service.queryTranscriptionJob(
|
||||
const job = await this.transcript.queryJob(
|
||||
user.id,
|
||||
copilot.workspaceId,
|
||||
jobId,
|
||||
|
||||
@@ -49,7 +49,17 @@ export class CopilotTranscriptionService {
|
||||
private readonly providerFactory: CopilotProviderFactory
|
||||
) {}
|
||||
|
||||
async submitTranscriptionJob(
|
||||
private async getModel(userId: string) {
|
||||
const prompt = await this.prompt.get('Transcript audio');
|
||||
const hasAccess = await this.models.userFeature.has(
|
||||
userId,
|
||||
'unlimited_copilot'
|
||||
);
|
||||
// choose the pro model if user has copilot plan
|
||||
return prompt?.optionalModels[hasAccess ? 1 : 0];
|
||||
}
|
||||
|
||||
async submitJob(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
blobId: string,
|
||||
@@ -78,12 +88,26 @@ export class CopilotTranscriptionService {
|
||||
infos.push({ url, mimeType: blob.mimetype });
|
||||
}
|
||||
|
||||
return await this.executeTranscriptionJob(jobId, infos);
|
||||
const model = await this.getModel(userId);
|
||||
return await this.executeJob(jobId, infos, model);
|
||||
}
|
||||
|
||||
async executeTranscriptionJob(
|
||||
async retryJob(userId: string, workspaceId: string, jobId: string) {
|
||||
const job = await this.queryJob(userId, workspaceId, jobId);
|
||||
if (!job || !job.infos) {
|
||||
throw new CopilotTranscriptionJobNotFound();
|
||||
}
|
||||
|
||||
const model = await this.getModel(userId);
|
||||
const jobResult = await this.executeJob(job.id, job.infos, model);
|
||||
|
||||
return jobResult;
|
||||
}
|
||||
|
||||
async executeJob(
|
||||
jobId: string,
|
||||
infos: AudioBlobInfos
|
||||
infos: AudioBlobInfos,
|
||||
modelId?: string
|
||||
): Promise<TranscriptionJob> {
|
||||
const status = AiJobStatus.running;
|
||||
const success = await this.models.copilotJob.update(jobId, {
|
||||
@@ -98,12 +122,13 @@ export class CopilotTranscriptionService {
|
||||
await this.job.add('copilot.transcript.submit', {
|
||||
jobId,
|
||||
infos,
|
||||
modelId,
|
||||
});
|
||||
|
||||
return { id: jobId, status };
|
||||
}
|
||||
|
||||
async claimTranscriptionJob(
|
||||
async claimJob(
|
||||
userId: string,
|
||||
jobId: string
|
||||
): Promise<TranscriptionJob | null> {
|
||||
@@ -118,7 +143,7 @@ export class CopilotTranscriptionService {
|
||||
return null;
|
||||
}
|
||||
|
||||
async queryTranscriptionJob(
|
||||
async queryJob(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
jobId?: string,
|
||||
@@ -171,7 +196,7 @@ export class CopilotTranscriptionService {
|
||||
);
|
||||
|
||||
if (!provider) {
|
||||
throw new NoCopilotProviderAvailable();
|
||||
throw new NoCopilotProviderAvailable({ modelId });
|
||||
}
|
||||
|
||||
return provider;
|
||||
@@ -181,14 +206,20 @@ export class CopilotTranscriptionService {
|
||||
promptName: string,
|
||||
message: Partial<PromptMessage>,
|
||||
schema?: ZodType<any>,
|
||||
prefer?: CopilotProviderType
|
||||
prefer?: CopilotProviderType,
|
||||
modelId?: string
|
||||
): Promise<string> {
|
||||
const prompt = await this.prompt.get(promptName);
|
||||
if (!prompt) {
|
||||
throw new CopilotPromptNotFound({ name: promptName });
|
||||
}
|
||||
|
||||
const cond = { modelId: prompt.model };
|
||||
const cond = {
|
||||
modelId:
|
||||
modelId && prompt.optionalModels.includes(modelId)
|
||||
? modelId
|
||||
: prompt.model,
|
||||
};
|
||||
const msg = { role: 'user' as const, content: '', ...message };
|
||||
const config = Object.assign({}, prompt.config);
|
||||
if (schema) {
|
||||
@@ -231,13 +262,19 @@ export class CopilotTranscriptionService {
|
||||
return `${hoursStr}:${minutesStr}:${secondsStr}`;
|
||||
}
|
||||
|
||||
private async callTranscript(url: string, mimeType: string, offset: number) {
|
||||
private async callTranscript(
|
||||
url: string,
|
||||
mimeType: string,
|
||||
offset: number,
|
||||
modelId?: string
|
||||
) {
|
||||
// NOTE: Vertex provider not support transcription yet, we always use Gemini here
|
||||
const result = await this.chatWithPrompt(
|
||||
'Transcript audio',
|
||||
{ attachments: [url], params: { mimetype: mimeType } },
|
||||
TranscriptionResponseSchema,
|
||||
CopilotProviderType.Gemini
|
||||
CopilotProviderType.Gemini,
|
||||
modelId
|
||||
);
|
||||
|
||||
const transcription = TranscriptionResponseSchema.parse(
|
||||
@@ -256,6 +293,7 @@ export class CopilotTranscriptionService {
|
||||
async transcriptAudio({
|
||||
jobId,
|
||||
infos,
|
||||
modelId,
|
||||
// @deprecated
|
||||
url,
|
||||
mimeType,
|
||||
@@ -264,7 +302,7 @@ export class CopilotTranscriptionService {
|
||||
const blobInfos = this.mergeInfos(infos, url, mimeType);
|
||||
const transcriptions = await Promise.all(
|
||||
Array.from(blobInfos.entries()).map(([idx, { url, mimeType }]) =>
|
||||
this.callTranscript(url, mimeType, idx * 10 * 60)
|
||||
this.callTranscript(url, mimeType, idx * 10 * 60, modelId)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ declare global {
|
||||
'copilot.transcript.submit': {
|
||||
jobId: string;
|
||||
infos?: AudioBlobInfos;
|
||||
modelId?: string;
|
||||
/// @deprecated use `infos` instead
|
||||
url?: string;
|
||||
/// @deprecated use `infos` instead
|
||||
|
||||
@@ -140,10 +140,13 @@ export class ElasticsearchProvider extends SearchProvider {
|
||||
const result = await this.request(
|
||||
'POST',
|
||||
url.toString(),
|
||||
JSON.stringify({ query })
|
||||
JSON.stringify({ query }),
|
||||
'application/json',
|
||||
// ignore 409 error: version_conflict_engine_exception, version conflict, required seqNo [255898790], primary term [3]. current document has seqNo [256133002] and primary term [3]
|
||||
[409]
|
||||
);
|
||||
this.logger.debug(
|
||||
`deleted by query ${table} ${JSON.stringify(query)} in ${Date.now() - start}ms, result: ${JSON.stringify(result)}`
|
||||
`deleted by query ${table} ${JSON.stringify(query)} in ${Date.now() - start}ms, result: ${JSON.stringify(result).substring(0, 500)}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -264,7 +267,8 @@ export class ElasticsearchProvider extends SearchProvider {
|
||||
method: 'POST' | 'PUT',
|
||||
url: string,
|
||||
body: string,
|
||||
contentType = 'application/json'
|
||||
contentType = 'application/json',
|
||||
ignoreErrorStatus?: number[]
|
||||
) {
|
||||
const headers = {
|
||||
'Content-Type': contentType,
|
||||
@@ -280,6 +284,10 @@ export class ElasticsearchProvider extends SearchProvider {
|
||||
headers,
|
||||
});
|
||||
const data = await response.json();
|
||||
if (ignoreErrorStatus?.includes(response.status)) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// handle error, status >= 400
|
||||
// {
|
||||
// "error": {
|
||||
|
||||
@@ -291,6 +291,11 @@ type CopilotFailedToAddWorkspaceFileEmbeddingDataType {
|
||||
message: String!
|
||||
}
|
||||
|
||||
type CopilotFailedToGenerateEmbeddingDataType {
|
||||
message: String!
|
||||
provider: String!
|
||||
}
|
||||
|
||||
type CopilotFailedToMatchContextDataType {
|
||||
content: String!
|
||||
contextId: String!
|
||||
@@ -616,7 +621,7 @@ type EditorType {
|
||||
name: String!
|
||||
}
|
||||
|
||||
union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotContextFileNotSupportedDataType | CopilotDocNotFoundDataType | CopilotFailedToAddWorkspaceFileEmbeddingDataType | CopilotFailedToMatchContextDataType | CopilotFailedToMatchGlobalContextDataType | CopilotFailedToModifyContextDataType | CopilotInvalidContextDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderNotSupportedDataType | CopilotProviderSideErrorDataType | DocActionDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | DocUpdateBlockedDataType | ExpectToGrantDocUserRolesDataType | ExpectToRevokeDocUserRolesDataType | ExpectToUpdateDocUserRoleDataType | GraphqlBadRequestDataType | HttpRequestErrorDataType | InvalidAppConfigDataType | InvalidAppConfigInputDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidIndexerInputDataType | InvalidLicenseToActivateDataType | InvalidLicenseUpdateParamsDataType | InvalidOauthCallbackCodeDataType | InvalidOauthResponseDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | InvalidSearchProviderRequestDataType | MemberNotFoundInSpaceDataType | MentionUserDocAccessDeniedDataType | MissingOauthQueryParameterDataType | NoMoreSeatDataType | NotInSpaceDataType | QueryTooLongDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SpaceShouldHaveOnlyOneOwnerDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedClientVersionDataType | UnsupportedSubscriptionPlanDataType | ValidationErrorDataType | VersionRejectedDataType | WorkspacePermissionNotFoundDataType | WrongSignInCredentialsDataType
|
||||
union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotContextFileNotSupportedDataType | CopilotDocNotFoundDataType | CopilotFailedToAddWorkspaceFileEmbeddingDataType | CopilotFailedToGenerateEmbeddingDataType | CopilotFailedToMatchContextDataType | CopilotFailedToMatchGlobalContextDataType | CopilotFailedToModifyContextDataType | CopilotInvalidContextDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderNotSupportedDataType | CopilotProviderSideErrorDataType | DocActionDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | DocUpdateBlockedDataType | ExpectToGrantDocUserRolesDataType | ExpectToRevokeDocUserRolesDataType | ExpectToUpdateDocUserRoleDataType | GraphqlBadRequestDataType | HttpRequestErrorDataType | InvalidAppConfigDataType | InvalidAppConfigInputDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidIndexerInputDataType | InvalidLicenseToActivateDataType | InvalidLicenseUpdateParamsDataType | InvalidOauthCallbackCodeDataType | InvalidOauthResponseDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | InvalidSearchProviderRequestDataType | MemberNotFoundInSpaceDataType | MentionUserDocAccessDeniedDataType | MissingOauthQueryParameterDataType | NoCopilotProviderAvailableDataType | NoMoreSeatDataType | NotInSpaceDataType | QueryTooLongDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SpaceShouldHaveOnlyOneOwnerDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedClientVersionDataType | UnsupportedSubscriptionPlanDataType | ValidationErrorDataType | VersionRejectedDataType | WorkspacePermissionNotFoundDataType | WrongSignInCredentialsDataType
|
||||
|
||||
enum ErrorNames {
|
||||
ACCESS_DENIED
|
||||
@@ -645,6 +650,7 @@ enum ErrorNames {
|
||||
COPILOT_EMBEDDING_UNAVAILABLE
|
||||
COPILOT_FAILED_TO_ADD_WORKSPACE_FILE_EMBEDDING
|
||||
COPILOT_FAILED_TO_CREATE_MESSAGE
|
||||
COPILOT_FAILED_TO_GENERATE_EMBEDDING
|
||||
COPILOT_FAILED_TO_GENERATE_TEXT
|
||||
COPILOT_FAILED_TO_MATCH_CONTEXT
|
||||
COPILOT_FAILED_TO_MATCH_GLOBAL_CONTEXT
|
||||
@@ -1291,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!
|
||||
|
||||
@@ -1336,6 +1348,10 @@ type Mutation {
|
||||
verifyEmail(token: String!): Boolean!
|
||||
}
|
||||
|
||||
type NoCopilotProviderAvailableDataType {
|
||||
modelId: String!
|
||||
}
|
||||
|
||||
type NoMoreSeatDataType {
|
||||
spaceId: String!
|
||||
}
|
||||
@@ -1508,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"""
|
||||
|
||||
@@ -82,6 +82,10 @@ export type RequestOptions<Q extends GraphQLQuery> = QueryVariablesOption<Q> & {
|
||||
* @default 15000
|
||||
*/
|
||||
timeout?: number;
|
||||
/**
|
||||
* Abort signal
|
||||
*/
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export type QueryOptions<Q extends GraphQLQuery> = RequestOptions<Q> & {
|
||||
@@ -207,6 +211,7 @@ export const gqlFetcherFactory = (
|
||||
headers,
|
||||
body: isFormData ? body : JSON.stringify(body),
|
||||
timeout: options.timeout,
|
||||
signal: options.signal,
|
||||
})
|
||||
).then(async res => {
|
||||
if (res.headers.get('content-type')?.startsWith('application/json')) {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
query applyDocUpdates($workspaceId: String!, $docId: String!, $op: String!, $updates: String!) {
|
||||
applyDocUpdates(workspaceId: $workspaceId, docId: $docId, op: $op, updates: $updates)
|
||||
}
|
||||
@@ -3,15 +3,17 @@
|
||||
query getCopilotRecentSessions(
|
||||
$workspaceId: String!
|
||||
$limit: Int = 10
|
||||
$offset: Int = 0
|
||||
) {
|
||||
currentUser {
|
||||
copilot(workspaceId: $workspaceId) {
|
||||
chats(
|
||||
pagination: { first: $limit }
|
||||
pagination: { first: $limit, offset: $offset }
|
||||
options: {
|
||||
action: false
|
||||
fork: false
|
||||
sessionOrder: desc
|
||||
withMessages: true
|
||||
withMessages: false
|
||||
}
|
||||
) {
|
||||
...PaginatedCopilotChats
|
||||
|
||||
@@ -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',
|
||||
@@ -1068,12 +1081,12 @@ ${paginatedCopilotChatsFragment}`,
|
||||
export const getCopilotRecentSessionsQuery = {
|
||||
id: 'getCopilotRecentSessionsQuery' as const,
|
||||
op: 'getCopilotRecentSessions',
|
||||
query: `query getCopilotRecentSessions($workspaceId: String!, $limit: Int = 10) {
|
||||
query: `query getCopilotRecentSessions($workspaceId: String!, $limit: Int = 10, $offset: Int = 0) {
|
||||
currentUser {
|
||||
copilot(workspaceId: $workspaceId) {
|
||||
chats(
|
||||
pagination: {first: $limit}
|
||||
options: {fork: false, sessionOrder: desc, withMessages: true}
|
||||
pagination: {first: $limit, offset: $offset}
|
||||
options: {action: false, fork: false, sessionOrder: desc, withMessages: false}
|
||||
) {
|
||||
...PaginatedCopilotChats
|
||||
}
|
||||
|
||||
@@ -375,6 +375,12 @@ export interface CopilotFailedToAddWorkspaceFileEmbeddingDataType {
|
||||
message: Scalars['String']['output'];
|
||||
}
|
||||
|
||||
export interface CopilotFailedToGenerateEmbeddingDataType {
|
||||
__typename?: 'CopilotFailedToGenerateEmbeddingDataType';
|
||||
message: Scalars['String']['output'];
|
||||
provider: Scalars['String']['output'];
|
||||
}
|
||||
|
||||
export interface CopilotFailedToMatchContextDataType {
|
||||
__typename?: 'CopilotFailedToMatchContextDataType';
|
||||
content: Scalars['String']['output'];
|
||||
@@ -737,6 +743,7 @@ export type ErrorDataUnion =
|
||||
| CopilotContextFileNotSupportedDataType
|
||||
| CopilotDocNotFoundDataType
|
||||
| CopilotFailedToAddWorkspaceFileEmbeddingDataType
|
||||
| CopilotFailedToGenerateEmbeddingDataType
|
||||
| CopilotFailedToMatchContextDataType
|
||||
| CopilotFailedToMatchGlobalContextDataType
|
||||
| CopilotFailedToModifyContextDataType
|
||||
@@ -769,6 +776,7 @@ export type ErrorDataUnion =
|
||||
| MemberNotFoundInSpaceDataType
|
||||
| MentionUserDocAccessDeniedDataType
|
||||
| MissingOauthQueryParameterDataType
|
||||
| NoCopilotProviderAvailableDataType
|
||||
| NoMoreSeatDataType
|
||||
| NotInSpaceDataType
|
||||
| QueryTooLongDataType
|
||||
@@ -816,6 +824,7 @@ export enum ErrorNames {
|
||||
COPILOT_EMBEDDING_UNAVAILABLE = 'COPILOT_EMBEDDING_UNAVAILABLE',
|
||||
COPILOT_FAILED_TO_ADD_WORKSPACE_FILE_EMBEDDING = 'COPILOT_FAILED_TO_ADD_WORKSPACE_FILE_EMBEDDING',
|
||||
COPILOT_FAILED_TO_CREATE_MESSAGE = 'COPILOT_FAILED_TO_CREATE_MESSAGE',
|
||||
COPILOT_FAILED_TO_GENERATE_EMBEDDING = 'COPILOT_FAILED_TO_GENERATE_EMBEDDING',
|
||||
COPILOT_FAILED_TO_GENERATE_TEXT = 'COPILOT_FAILED_TO_GENERATE_TEXT',
|
||||
COPILOT_FAILED_TO_MATCH_CONTEXT = 'COPILOT_FAILED_TO_MATCH_CONTEXT',
|
||||
COPILOT_FAILED_TO_MATCH_GLOBAL_CONTEXT = 'COPILOT_FAILED_TO_MATCH_GLOBAL_CONTEXT',
|
||||
@@ -1431,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 */
|
||||
@@ -1881,6 +1894,11 @@ export interface MutationVerifyEmailArgs {
|
||||
token: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface NoCopilotProviderAvailableDataType {
|
||||
__typename?: 'NoCopilotProviderAvailableDataType';
|
||||
modelId: Scalars['String']['output'];
|
||||
}
|
||||
|
||||
export interface NoMoreSeatDataType {
|
||||
__typename?: 'NoMoreSeatDataType';
|
||||
spaceId: Scalars['String']['output'];
|
||||
@@ -2059,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 */
|
||||
@@ -2106,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;
|
||||
}
|
||||
@@ -3495,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;
|
||||
}>;
|
||||
@@ -4351,6 +4390,7 @@ export type GetCopilotSessionQuery = {
|
||||
export type GetCopilotRecentSessionsQueryVariables = Exact<{
|
||||
workspaceId: Scalars['String']['input'];
|
||||
limit?: InputMaybe<Scalars['Int']['input']>;
|
||||
offset?: InputMaybe<Scalars['Int']['input']>;
|
||||
}>;
|
||||
|
||||
export type GetCopilotRecentSessionsQuery = {
|
||||
@@ -6133,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,
|
||||
@@ -309,7 +310,10 @@ math {
|
||||
|
||||
/* AI Block Diff */
|
||||
.ai-block-diff-deleted {
|
||||
background-color: var(--aI-applyDeleteHighlight, #ffeaea) !important;
|
||||
background-color: var(
|
||||
--affine-v2-aI-applyDeleteHighlight,
|
||||
#ffeaea
|
||||
) !important;
|
||||
border-radius: 4px !important;
|
||||
padding: 8px 0px !important;
|
||||
margin-bottom: 10px !important;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import * as RadixContextMenu from '@radix-ui/react-context-menu';
|
||||
import clsx from 'clsx';
|
||||
import type { RefAttributes } from 'react';
|
||||
|
||||
import * as styles from '../styles.css';
|
||||
import { DesktopMenuContext } from './context';
|
||||
import * as desktopStyles from './styles.css';
|
||||
|
||||
export type ContextMenuProps = RadixContextMenu.ContextMenuProps &
|
||||
RadixContextMenu.ContextMenuTriggerProps &
|
||||
RefAttributes<HTMLSpanElement> & {
|
||||
items: React.ReactNode;
|
||||
contentProps?: RadixContextMenu.ContextMenuContentProps;
|
||||
};
|
||||
|
||||
const ContextMenuContextValue = {
|
||||
type: 'context-menu',
|
||||
} as const;
|
||||
|
||||
export const ContextMenu = ({
|
||||
children,
|
||||
onOpenChange,
|
||||
dir,
|
||||
modal,
|
||||
items,
|
||||
contentProps,
|
||||
...props
|
||||
}: ContextMenuProps) => {
|
||||
return (
|
||||
<DesktopMenuContext.Provider value={ContextMenuContextValue}>
|
||||
<RadixContextMenu.Root
|
||||
onOpenChange={onOpenChange}
|
||||
dir={dir}
|
||||
modal={modal}
|
||||
>
|
||||
<RadixContextMenu.Trigger {...props}>
|
||||
{children}
|
||||
</RadixContextMenu.Trigger>
|
||||
<RadixContextMenu.Portal>
|
||||
<RadixContextMenu.Content
|
||||
className={clsx(
|
||||
styles.menuContent,
|
||||
desktopStyles.contentAnimation,
|
||||
contentProps?.className
|
||||
)}
|
||||
style={{
|
||||
zIndex: 'var(--affine-z-index-popover)',
|
||||
...contentProps?.style,
|
||||
}}
|
||||
{...contentProps}
|
||||
>
|
||||
{items}
|
||||
</RadixContextMenu.Content>
|
||||
</RadixContextMenu.Portal>
|
||||
</RadixContextMenu.Root>
|
||||
</DesktopMenuContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
interface DesktopMenuContextValue {
|
||||
type: 'dropdown-menu' | 'context-menu';
|
||||
}
|
||||
|
||||
export const DesktopMenuContext = createContext<DesktopMenuContextValue>({
|
||||
type: 'dropdown-menu',
|
||||
});
|
||||
@@ -1,13 +1,30 @@
|
||||
import * as ContextMenu from '@radix-ui/react-context-menu';
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import { useContext } from 'react';
|
||||
|
||||
import type { MenuItemProps } from '../menu.types';
|
||||
import { useMenuItem } from '../use-menu-item';
|
||||
import { DesktopMenuContext } from './context';
|
||||
|
||||
export const DesktopMenuItem = (props: MenuItemProps) => {
|
||||
const { type } = useContext(DesktopMenuContext);
|
||||
const { className, children, otherProps } = useMenuItem(props);
|
||||
return (
|
||||
<DropdownMenu.Item className={className} {...otherProps}>
|
||||
{children}
|
||||
</DropdownMenu.Item>
|
||||
);
|
||||
|
||||
if (type === 'dropdown-menu') {
|
||||
return (
|
||||
<DropdownMenu.Item className={className} {...otherProps}>
|
||||
{children}
|
||||
</DropdownMenu.Item>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'context-menu') {
|
||||
return (
|
||||
<ContextMenu.Item className={className} {...otherProps}>
|
||||
{children}
|
||||
</ContextMenu.Item>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -4,8 +4,13 @@ import React, { useCallback, useImperativeHandle, useState } from 'react';
|
||||
|
||||
import type { MenuProps } from '../menu.types';
|
||||
import * as styles from '../styles.css';
|
||||
import { DesktopMenuContext } from './context';
|
||||
import * as desktopStyles from './styles.css';
|
||||
|
||||
const MenuContextValue = {
|
||||
type: 'dropdown-menu',
|
||||
} as const;
|
||||
|
||||
export const DesktopMenu = ({
|
||||
children,
|
||||
items,
|
||||
@@ -53,37 +58,39 @@ export const DesktopMenu = ({
|
||||
|
||||
const ContentWrapper = noPortal ? React.Fragment : DropdownMenu.Portal;
|
||||
return (
|
||||
<DropdownMenu.Root
|
||||
modal={modal ?? false}
|
||||
open={finalOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
{...rootOptions}
|
||||
>
|
||||
<DropdownMenu.Trigger
|
||||
asChild
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
<DesktopMenuContext.Provider value={MenuContextValue}>
|
||||
<DropdownMenu.Root
|
||||
modal={modal ?? false}
|
||||
open={finalOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
{...rootOptions}
|
||||
>
|
||||
{children}
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<ContentWrapper {...portalOptions}>
|
||||
<DropdownMenu.Content
|
||||
className={clsx(
|
||||
styles.menuContent,
|
||||
desktopStyles.contentAnimation,
|
||||
className
|
||||
)}
|
||||
sideOffset={4}
|
||||
align="start"
|
||||
style={{ zIndex: 'var(--affine-z-index-popover)', ...contentStyle }}
|
||||
{...otherContentOptions}
|
||||
<DropdownMenu.Trigger
|
||||
asChild
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{items}
|
||||
</DropdownMenu.Content>
|
||||
</ContentWrapper>
|
||||
</DropdownMenu.Root>
|
||||
{children}
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<ContentWrapper {...portalOptions}>
|
||||
<DropdownMenu.Content
|
||||
className={clsx(
|
||||
styles.menuContent,
|
||||
desktopStyles.contentAnimation,
|
||||
className
|
||||
)}
|
||||
sideOffset={4}
|
||||
align="start"
|
||||
style={{ zIndex: 'var(--affine-z-index-popover)', ...contentStyle }}
|
||||
{...otherContentOptions}
|
||||
>
|
||||
{items}
|
||||
</DropdownMenu.Content>
|
||||
</ContentWrapper>
|
||||
</DropdownMenu.Root>
|
||||
</DesktopMenuContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
|
||||
import * as ContextMenu from '@radix-ui/react-context-menu';
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import clsx from 'clsx';
|
||||
import { useMemo } from 'react';
|
||||
import { useContext, useMemo } from 'react';
|
||||
|
||||
import type { MenuSubProps } from '../menu.types';
|
||||
import * as styles from '../styles.css';
|
||||
import { useMenuItem } from '../use-menu-item';
|
||||
import { DesktopMenuContext } from './context';
|
||||
|
||||
export const DesktopMenuSub = ({
|
||||
children: propsChildren,
|
||||
@@ -19,12 +21,37 @@ export const DesktopMenuSub = ({
|
||||
...otherSubContentOptions
|
||||
} = {},
|
||||
}: MenuSubProps) => {
|
||||
const { type } = useContext(DesktopMenuContext);
|
||||
const { className, children, otherProps } = useMenuItem({
|
||||
children: propsChildren,
|
||||
suffixIcon: <ArrowRightSmallIcon />,
|
||||
...triggerOptions,
|
||||
});
|
||||
|
||||
const contentClassName = useMemo(
|
||||
() => clsx(styles.menuContent, subContentClassName),
|
||||
[subContentClassName]
|
||||
);
|
||||
|
||||
if (type === 'context-menu') {
|
||||
return (
|
||||
<ContextMenu.Sub defaultOpen={defaultOpen} {...otherSubOptions}>
|
||||
<ContextMenu.SubTrigger className={className} {...otherProps}>
|
||||
{children}
|
||||
</ContextMenu.SubTrigger>
|
||||
<ContextMenu.Portal {...portalOptions}>
|
||||
<ContextMenu.SubContent
|
||||
className={contentClassName}
|
||||
style={{ zIndex: 'var(--affine-z-index-popover)', ...contentStyle }}
|
||||
{...otherSubContentOptions}
|
||||
>
|
||||
{items}
|
||||
</ContextMenu.SubContent>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu.Sub>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu.Sub defaultOpen={defaultOpen} {...otherSubOptions}>
|
||||
<DropdownMenu.SubTrigger className={className} {...otherProps}>
|
||||
@@ -32,10 +59,7 @@ export const DesktopMenuSub = ({
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.Portal {...portalOptions}>
|
||||
<DropdownMenu.SubContent
|
||||
className={useMemo(
|
||||
() => clsx(styles.menuContent, subContentClassName),
|
||||
[subContentClassName]
|
||||
)}
|
||||
className={contentClassName}
|
||||
style={{ zIndex: 'var(--affine-z-index-popover)', ...contentStyle }}
|
||||
{...otherSubContentOptions}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './menu.types';
|
||||
import { ContextMenu } from './desktop/context-menu';
|
||||
import { DesktopMenuItem } from './desktop/item';
|
||||
import { DesktopMenu } from './desktop/root';
|
||||
import { DesktopMenuSeparator } from './desktop/separator';
|
||||
@@ -19,6 +20,7 @@ const MenuSub = BUILD_CONFIG.isMobileEdition ? MobileMenuSub : DesktopMenuSub;
|
||||
const Menu = BUILD_CONFIG.isMobileEdition ? MobileMenu : DesktopMenu;
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
DesktopMenu,
|
||||
DesktopMenuItem,
|
||||
DesktopMenuSeparator,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createVar, style } from '@vanilla-extract/css';
|
||||
import { createVar, fallbackVar, style } from '@vanilla-extract/css';
|
||||
|
||||
export const topOffsetVar = createVar();
|
||||
export const bottomOffsetVar = createVar();
|
||||
@@ -9,14 +9,13 @@ export const safeArea = style({
|
||||
paddingTop: `calc(${topOffsetVar} + 12px)`,
|
||||
},
|
||||
'&[data-bottom]': {
|
||||
paddingBottom: `calc(${bottomOffsetVar} + 0px)`,
|
||||
paddingBottom: `calc(${fallbackVar(bottomOffsetVar, '0px')} + 0px)`,
|
||||
},
|
||||
'&[data-standalone][data-top]': {
|
||||
paddingTop: `calc(env(safe-area-inset-top, 12px) + ${topOffsetVar})`,
|
||||
},
|
||||
'&[data-standalone][data-bottom]': {
|
||||
// paddingBottom: 'env(safe-area-inset-bottom, 12px)',
|
||||
paddingBottom: `calc(env(safe-area-inset-bottom, 0px) + ${bottomOffsetVar})`,
|
||||
paddingBottom: `calc(env(safe-area-inset-bottom, 0px) + ${fallbackVar(bottomOffsetVar, '0px')})`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
@@ -82,6 +83,7 @@
|
||||
"react-transition-state": "^2.2.0",
|
||||
"react-virtuoso": "^4.12.3",
|
||||
"rxjs": "^7.8.1",
|
||||
"semver": "^7.7.2",
|
||||
"ses": "^1.10.0",
|
||||
"shiki": "^3.7.0",
|
||||
"socket.io-client": "^4.8.1",
|
||||
@@ -99,6 +101,7 @@
|
||||
"@types/bytes": "^3.1.5",
|
||||
"@types/image-blob-reduce": "^4.1.4",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/semver": "^7",
|
||||
"@vanilla-extract/css": "^1.17.0",
|
||||
"fake-indexeddb": "^6.0.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
186
packages/frontend/core/src/blocksuite/ai/chat-panel/ai-title.ts
Normal file
186
packages/frontend/core/src/blocksuite/ai/chat-panel/ai-title.ts
Normal file
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AIDraftService } from '@affine/core/modules/ai-button';
|
||||
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';
|
||||
@@ -9,21 +10,17 @@ 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';
|
||||
|
||||
import { AffineIcon } from '../_common/icons';
|
||||
import type {
|
||||
DocDisplayConfig,
|
||||
SearchMenuConfig,
|
||||
} from '../components/ai-chat-chips';
|
||||
import type { SearchMenuConfig } from '../components/ai-chat-add-context';
|
||||
import type { DocDisplayConfig } from '../components/ai-chat-chips';
|
||||
import type { ChatContextValue } from '../components/ai-chat-content';
|
||||
import type {
|
||||
AINetworkSearchConfig,
|
||||
@@ -31,7 +28,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';
|
||||
|
||||
@@ -45,12 +41,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 {
|
||||
@@ -74,20 +71,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')};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -133,6 +116,9 @@ export class ChatPanel extends SignalWatcher(
|
||||
@property({ attribute: false })
|
||||
accessor notificationService!: NotificationService;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor aiDraftService!: AIDraftService;
|
||||
|
||||
@state()
|
||||
accessor session: CopilotChatHistoryFragment | null | undefined;
|
||||
|
||||
@@ -152,40 +138,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;
|
||||
@@ -370,27 +322,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}
|
||||
></playground-content>
|
||||
`;
|
||||
|
||||
createPlaygroundModal(playgroundContent, 'AI Playground');
|
||||
};
|
||||
|
||||
protected override updated(changedProperties: PropertyValues) {
|
||||
if (changedProperties.has('doc')) {
|
||||
if (this.session?.pinned) {
|
||||
@@ -442,10 +373,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}
|
||||
@@ -460,9 +412,11 @@ export class ChatPanel extends SignalWatcher(
|
||||
.affineWorkspaceDialogService=${this.affineWorkspaceDialogService}
|
||||
.affineThemeService=${this.affineThemeService}
|
||||
.notificationService=${this.notificationService}
|
||||
.aiDraftService=${this.aiDraftService}
|
||||
.onEmbeddingProgressChange=${this.onEmbeddingProgressChange}
|
||||
.onContextChange=${this.onContextChange}
|
||||
.width=${this.sidebarWidth}
|
||||
.onOpenDoc=${this.openDoc}
|
||||
></ai-chat-content>`
|
||||
)}
|
||||
</div>`;
|
||||
|
||||
@@ -3,8 +3,11 @@ import type { AppThemeService } from '@affine/core/modules/theme';
|
||||
import type { CopilotChatHistoryFragment } from '@affine/graphql';
|
||||
import { WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import { isInsidePageEditor } from '@blocksuite/affine/shared/utils';
|
||||
import type { EditorHost } from '@blocksuite/affine/std';
|
||||
import { ShadowlessElement } from '@blocksuite/affine/std';
|
||||
import {
|
||||
type BlockStdScope,
|
||||
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';
|
||||
@@ -15,6 +18,7 @@ import {
|
||||
EdgelessEditorActions,
|
||||
PageEditorActions,
|
||||
} from '../../_common/chat-actions-handle';
|
||||
import type { DocDisplayConfig } from '../../components/ai-chat-chips';
|
||||
import {
|
||||
type ChatMessage,
|
||||
type ChatStatus,
|
||||
@@ -37,6 +41,9 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
|
||||
@property({ attribute: false })
|
||||
accessor host: EditorHost | null | undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor std: BlockStdScope | null | undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor item!: ChatMessage;
|
||||
|
||||
@@ -73,6 +80,15 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
|
||||
@property({ attribute: false })
|
||||
accessor notificationService!: NotificationService;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor independentMode: boolean | undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor docDisplayService!: DocDisplayConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onOpenDoc!: (docId: string, sessionId?: string) => void;
|
||||
|
||||
get state() {
|
||||
const { isLast, status } = this;
|
||||
return isLast
|
||||
@@ -124,6 +140,7 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
|
||||
private renderStreamObjects(answer: StreamObject[]) {
|
||||
return html`<chat-content-stream-objects
|
||||
.host=${this.host}
|
||||
.std=${this.std}
|
||||
.answer=${answer}
|
||||
.state=${this.state}
|
||||
.width=${this.width}
|
||||
@@ -131,6 +148,8 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
|
||||
.affineFeatureFlagService=${this.affineFeatureFlagService}
|
||||
.notificationService=${this.notificationService}
|
||||
.theme=${this.affineThemeService.appTheme.themeSignal}
|
||||
.docDisplayService=${this.docDisplayService}
|
||||
.onOpenDoc=${this.onOpenDoc}
|
||||
></chat-content-stream-objects>`;
|
||||
}
|
||||
|
||||
@@ -168,7 +187,7 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
|
||||
: EdgelessEditorActions
|
||||
: null;
|
||||
|
||||
const showActions = host && !!markdown;
|
||||
const showActions = host && !!markdown && !this.independentMode;
|
||||
|
||||
return html`
|
||||
<chat-copy-more
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { createLitPortal } from '@blocksuite/affine/components/portal';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/affine/std';
|
||||
import { PlusIcon } from '@blocksuite/icons/lit';
|
||||
import { flip, offset } from '@floating-ui/dom';
|
||||
import { css, html } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
|
||||
import type { ChatChip, DocDisplayConfig } from '../ai-chat-chips';
|
||||
import type { SearchMenuConfig } from './type';
|
||||
|
||||
export class AIChatAddContext extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
.ai-chat-add-context {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
`;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor docId: string | undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor independentMode: boolean | undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor addChip!: (chip: ChatChip) => Promise<void>;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor addImages!: (images: File[]) => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor docDisplayConfig!: DocDisplayConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor searchMenuConfig!: SearchMenuConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor portalContainer: HTMLElement | null = null;
|
||||
|
||||
@query('.ai-chat-add-context')
|
||||
accessor addButton!: HTMLDivElement;
|
||||
|
||||
private abortController: AbortController | null = null;
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div
|
||||
class="ai-chat-add-context"
|
||||
data-testid="chat-panel-with-button"
|
||||
@click=${this.toggleAddDocMenu}
|
||||
>
|
||||
${PlusIcon()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private readonly toggleAddDocMenu = () => {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
return;
|
||||
}
|
||||
|
||||
this.abortController = new AbortController();
|
||||
this.abortController.signal.addEventListener('abort', () => {
|
||||
this.abortController = null;
|
||||
});
|
||||
|
||||
createLitPortal({
|
||||
template: html`
|
||||
<chat-panel-add-popover
|
||||
.docId=${this.docId}
|
||||
.independentMode=${this.independentMode}
|
||||
.addChip=${this.addChip}
|
||||
.addImages=${this.addImages}
|
||||
.searchMenuConfig=${this.searchMenuConfig}
|
||||
.docDisplayConfig=${this.docDisplayConfig}
|
||||
.abortController=${this.abortController}
|
||||
></chat-panel-add-popover>
|
||||
`,
|
||||
portalStyles: {
|
||||
zIndex: 'var(--affine-z-index-popover)',
|
||||
},
|
||||
container: this.portalContainer ?? document.body,
|
||||
computePosition: {
|
||||
referenceElement: this.addButton,
|
||||
placement: 'top-start',
|
||||
middleware: [offset({ crossAxis: -30, mainAxis: 8 }), flip()],
|
||||
autoUpdate: { animationFrame: true },
|
||||
},
|
||||
abortController: this.abortController,
|
||||
closeOnClickAway: true,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './ai-chat-add-context';
|
||||
export * from './type';
|
||||
@@ -0,0 +1,24 @@
|
||||
import type {
|
||||
SearchCollectionMenuAction,
|
||||
SearchDocMenuAction,
|
||||
SearchTagMenuAction,
|
||||
} from '@affine/core/modules/search-menu/services';
|
||||
import type { LinkedMenuGroup } from '@blocksuite/affine/widgets/linked-doc';
|
||||
|
||||
export interface SearchMenuConfig {
|
||||
getDocMenuGroup: (
|
||||
query: string,
|
||||
action: SearchDocMenuAction,
|
||||
abortSignal: AbortSignal
|
||||
) => LinkedMenuGroup;
|
||||
getTagMenuGroup: (
|
||||
query: string,
|
||||
action: SearchTagMenuAction,
|
||||
abortSignal: AbortSignal
|
||||
) => LinkedMenuGroup;
|
||||
getCollectionMenuGroup: (
|
||||
query: string,
|
||||
action: SearchCollectionMenuAction,
|
||||
abortSignal: AbortSignal
|
||||
) => LinkedMenuGroup;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { toast } from '@affine/component';
|
||||
import type { TagMeta } from '@affine/core/components/page-list';
|
||||
import type { CollectionMeta } from '@affine/core/modules/collection';
|
||||
import track from '@affine/track';
|
||||
import track, { type EventArgs } from '@affine/track';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import { scrollbarStyle } from '@blocksuite/affine/shared/styles';
|
||||
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
|
||||
@@ -21,8 +21,8 @@ import { css, html, type TemplateResult } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
import { MAX_IMAGE_COUNT } from '../ai-chat-input';
|
||||
import type { ChatChip, DocDisplayConfig, SearchMenuConfig } from './type';
|
||||
import type { SearchMenuConfig } from '../ai-chat-add-context';
|
||||
import type { ChatChip, DocDisplayConfig } from './type';
|
||||
|
||||
enum AddPopoverMode {
|
||||
Default = 'default',
|
||||
@@ -120,6 +120,12 @@ export class ChatPanelAddPopover extends SignalWatcher(
|
||||
|
||||
private accessor _query = '';
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor independentMode: boolean | undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor docId: string | undefined;
|
||||
|
||||
@state()
|
||||
private accessor _searchGroups: MenuGroup[] = [];
|
||||
|
||||
@@ -165,35 +171,31 @@ export class ChatPanelAddPopover extends SignalWatcher(
|
||||
const files = await openFilesWith();
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
this.abortController.abort();
|
||||
const images = files.filter(file => file.type.startsWith('image/'));
|
||||
if (images.length > 0) {
|
||||
this.addImages(images);
|
||||
}
|
||||
|
||||
const others = files.filter(file => !file.type.startsWith('image/'));
|
||||
for (const file of others) {
|
||||
const addChipPromises = others.map(async file => {
|
||||
if (file.size > 50 * 1024 * 1024) {
|
||||
toast(`${file.name} is too large, please upload a file less than 50MB`);
|
||||
} else {
|
||||
await this.addChip({
|
||||
file,
|
||||
state: 'processing',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
await this.addChip({
|
||||
file,
|
||||
state: 'processing',
|
||||
});
|
||||
});
|
||||
await Promise.all(addChipPromises);
|
||||
this._track('file');
|
||||
this.abortController.abort();
|
||||
};
|
||||
|
||||
private readonly _addImageChip = async () => {
|
||||
if (this.isImageUploadDisabled) return;
|
||||
|
||||
const images = await openFilesWith('Images');
|
||||
if (!images) return;
|
||||
if (this.uploadImageCount + images.length > MAX_IMAGE_COUNT) {
|
||||
toast(`You can only upload up to ${MAX_IMAGE_COUNT} images`);
|
||||
return;
|
||||
}
|
||||
this.abortController.abort();
|
||||
this.addImages(images);
|
||||
};
|
||||
|
||||
@@ -289,9 +291,6 @@ export class ChatPanelAddPopover extends SignalWatcher(
|
||||
@property({ attribute: 'data-testid', reflect: true })
|
||||
accessor testId: string = 'ai-search-input';
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor isImageUploadDisabled!: boolean;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor uploadImageCount!: number;
|
||||
|
||||
@@ -498,31 +497,32 @@ export class ChatPanelAddPopover extends SignalWatcher(
|
||||
}
|
||||
|
||||
private readonly _addDocChip = async (meta: DocMeta) => {
|
||||
this.abortController.abort();
|
||||
await this.addChip({
|
||||
docId: meta.id,
|
||||
state: 'processing',
|
||||
});
|
||||
const mode = this.docDisplayConfig.getDocPrimaryMode(meta.id);
|
||||
this._track('doc', mode);
|
||||
this.abortController.abort();
|
||||
const method = meta.id === this.docId ? 'cur-doc' : 'doc';
|
||||
this._track(method, mode);
|
||||
};
|
||||
|
||||
private readonly _addTagChip = async (tag: TagMeta) => {
|
||||
this.abortController.abort();
|
||||
await this.addChip({
|
||||
tagId: tag.id,
|
||||
state: 'processing',
|
||||
});
|
||||
this._track('tags');
|
||||
this.abortController.abort();
|
||||
};
|
||||
|
||||
private readonly _addCollectionChip = async (collection: CollectionMeta) => {
|
||||
this.abortController.abort();
|
||||
await this.addChip({
|
||||
collectionId: collection.id,
|
||||
state: 'processing',
|
||||
});
|
||||
this._track('collections');
|
||||
this.abortController.abort();
|
||||
};
|
||||
|
||||
private readonly _handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -568,10 +568,13 @@ export class ChatPanelAddPopover extends SignalWatcher(
|
||||
}
|
||||
|
||||
private _track(
|
||||
method: 'doc' | 'file' | 'tags' | 'collections',
|
||||
method: EventArgs['addEmbeddingDoc']['method'],
|
||||
type?: 'page' | 'edgeless'
|
||||
) {
|
||||
track.$.chatPanel.chatPanelInput.addEmbeddingDoc({
|
||||
const page = this.independentMode
|
||||
? track.$.intelligence
|
||||
: track.$.chatPanel;
|
||||
page.chatPanelInput.addEmbeddingDoc({
|
||||
control: 'addButton',
|
||||
method,
|
||||
type,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createLitPortal } from '@blocksuite/affine/components/portal';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
|
||||
import { ShadowlessElement } from '@blocksuite/affine/std';
|
||||
import { MoreVerticalIcon, PlusIcon } from '@blocksuite/icons/lit';
|
||||
import { MoreVerticalIcon } from '@blocksuite/icons/lit';
|
||||
import { flip, offset } from '@floating-ui/dom';
|
||||
import { computed, type Signal, signal } from '@preact/signals-core';
|
||||
import { css, html, nothing, type PropertyValues } from 'lit';
|
||||
@@ -11,16 +11,7 @@ import { property, query, state } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { isEqual } from 'lodash-es';
|
||||
|
||||
import { AIProvider } from '../../provider';
|
||||
import type {
|
||||
ChatChip,
|
||||
CollectionChip,
|
||||
DocChip,
|
||||
DocDisplayConfig,
|
||||
FileChip,
|
||||
SearchMenuConfig,
|
||||
TagChip,
|
||||
} from './type';
|
||||
import type { ChatChip, DocChip, DocDisplayConfig, FileChip } from './type';
|
||||
import {
|
||||
estimateTokenCount,
|
||||
getChipKey,
|
||||
@@ -39,44 +30,46 @@ export class ChatPanelChips extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
.chips-wrapper {
|
||||
.ai-chat-panel-chips {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
.add-button,
|
||||
.collapse-button,
|
||||
.more-candidate-button {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: ${unsafeCSSVarV2('icon/primary')};
|
||||
}
|
||||
.add-button:hover,
|
||||
.collapse-button:hover,
|
||||
.more-candidate-button:hover {
|
||||
background-color: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
|
||||
}
|
||||
.more-candidate-button {
|
||||
border-width: 1px;
|
||||
border-style: dashed;
|
||||
border-color: ${unsafeCSSVarV2('icon/tertiary')};
|
||||
background: ${unsafeCSSVarV2('layer/background/secondary')};
|
||||
color: ${unsafeCSSVarV2('icon/secondary')};
|
||||
}
|
||||
.more-candidate-button svg {
|
||||
color: ${unsafeCSSVarV2('icon/secondary')};
|
||||
|
||||
.collapse-button,
|
||||
.more-candidate-button {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: ${unsafeCSSVarV2('icon/primary')};
|
||||
}
|
||||
|
||||
.collapse-button:hover,
|
||||
.more-candidate-button:hover {
|
||||
background-color: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
|
||||
}
|
||||
|
||||
.more-candidate-button {
|
||||
border-width: 1px;
|
||||
border-style: dashed;
|
||||
border-color: ${unsafeCSSVarV2('icon/tertiary')};
|
||||
background: ${unsafeCSSVarV2('layer/background/secondary')};
|
||||
color: ${unsafeCSSVarV2('icon/secondary')};
|
||||
}
|
||||
|
||||
.more-candidate-button svg {
|
||||
color: ${unsafeCSSVarV2('icon/secondary')};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -86,38 +79,38 @@ export class ChatPanelChips extends SignalWatcher(
|
||||
accessor chips!: ChatChip[];
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor createContextId!: () => Promise<string | undefined>;
|
||||
accessor isCollapsed!: boolean;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor updateChips!: (chips: ChatChip[]) => void;
|
||||
accessor independentMode: boolean | undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor addImages!: (images: File[]) => void;
|
||||
accessor addChip!: (chip: ChatChip) => Promise<void>;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor pollContextDocsAndFiles!: () => void;
|
||||
accessor updateChip!: (
|
||||
chip: ChatChip,
|
||||
options: Partial<DocChip | FileChip>
|
||||
) => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor removeChip!: (chip: ChatChip) => Promise<void>;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor toggleCollapse!: () => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor docDisplayConfig!: DocDisplayConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor searchMenuConfig!: SearchMenuConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor portalContainer: HTMLElement | null = null;
|
||||
|
||||
@property({ attribute: 'data-testid', reflect: true })
|
||||
accessor testId = 'chat-panel-chips';
|
||||
|
||||
@query('.add-button')
|
||||
accessor addButton!: HTMLDivElement;
|
||||
|
||||
@query('.more-candidate-button')
|
||||
accessor moreCandidateButton!: HTMLDivElement;
|
||||
|
||||
@state()
|
||||
accessor isCollapsed = false;
|
||||
|
||||
@state()
|
||||
accessor referenceDocs: Signal<
|
||||
Array<{
|
||||
@@ -144,14 +137,7 @@ export class ChatPanelChips extends SignalWatcher(
|
||||
const isCollapsed = this.isCollapsed && allChips.length > 1;
|
||||
const chips = isCollapsed ? allChips.slice(0, 1) : allChips;
|
||||
|
||||
return html`<div class="chips-wrapper">
|
||||
<div
|
||||
class="add-button"
|
||||
data-testid="chat-panel-with-button"
|
||||
@click=${this._toggleAddDocMenu}
|
||||
>
|
||||
${PlusIcon()}
|
||||
</div>
|
||||
return html`<div class="ai-chat-panel-chips">
|
||||
${repeat(
|
||||
chips,
|
||||
chip => getChipKey(chip),
|
||||
@@ -159,9 +145,10 @@ export class ChatPanelChips extends SignalWatcher(
|
||||
if (isDocChip(chip)) {
|
||||
return html`<chat-panel-doc-chip
|
||||
.chip=${chip}
|
||||
.addChip=${this._addChip}
|
||||
.updateChip=${this._updateChip}
|
||||
.removeChip=${this._removeChip}
|
||||
.independentMode=${this.independentMode}
|
||||
.addChip=${this.addChip}
|
||||
.updateChip=${this.updateChip}
|
||||
.removeChip=${this.removeChip}
|
||||
.checkTokenLimit=${this._checkTokenLimit}
|
||||
.docDisplayConfig=${this.docDisplayConfig}
|
||||
></chat-panel-doc-chip>`;
|
||||
@@ -169,7 +156,7 @@ export class ChatPanelChips extends SignalWatcher(
|
||||
if (isFileChip(chip)) {
|
||||
return html`<chat-panel-file-chip
|
||||
.chip=${chip}
|
||||
.removeChip=${this._removeChip}
|
||||
.removeChip=${this.removeChip}
|
||||
></chat-panel-file-chip>`;
|
||||
}
|
||||
if (isTagChip(chip)) {
|
||||
@@ -180,7 +167,7 @@ export class ChatPanelChips extends SignalWatcher(
|
||||
return html`<chat-panel-tag-chip
|
||||
.chip=${chip}
|
||||
.tag=${tag}
|
||||
.removeChip=${this._removeChip}
|
||||
.removeChip=${this.removeChip}
|
||||
></chat-panel-tag-chip>`;
|
||||
}
|
||||
if (isCollectionChip(chip)) {
|
||||
@@ -193,7 +180,7 @@ export class ChatPanelChips extends SignalWatcher(
|
||||
return html`<chat-panel-collection-chip
|
||||
.chip=${chip}
|
||||
.collection=${collection}
|
||||
.removeChip=${this._removeChip}
|
||||
.removeChip=${this.removeChip}
|
||||
></chat-panel-collection-chip>`;
|
||||
}
|
||||
return null;
|
||||
@@ -208,7 +195,7 @@ export class ChatPanelChips extends SignalWatcher(
|
||||
</div>`
|
||||
: nothing}
|
||||
${isCollapsed
|
||||
? html`<div class="collapse-button" @click=${this._toggleCollapse}>
|
||||
? html`<div class="collapse-button" @click=${this.toggleCollapse}>
|
||||
+${allChips.length - 1}
|
||||
</div>`
|
||||
: nothing}
|
||||
@@ -227,14 +214,6 @@ export class ChatPanelChips extends SignalWatcher(
|
||||
}
|
||||
|
||||
protected override updated(_changedProperties: PropertyValues): void {
|
||||
if (
|
||||
_changedProperties.has('chatContextValue') &&
|
||||
_changedProperties.get('chatContextValue')?.status === 'loading' &&
|
||||
this.isCollapsed === false
|
||||
) {
|
||||
this.isCollapsed = true;
|
||||
}
|
||||
|
||||
if (_changedProperties.has('chips')) {
|
||||
this._updateReferenceDocs();
|
||||
}
|
||||
@@ -245,46 +224,6 @@ export class ChatPanelChips extends SignalWatcher(
|
||||
this._cleanup?.();
|
||||
}
|
||||
|
||||
private readonly _toggleCollapse = () => {
|
||||
this.isCollapsed = !this.isCollapsed;
|
||||
};
|
||||
|
||||
private readonly _toggleAddDocMenu = () => {
|
||||
if (this._abortController) {
|
||||
this._abortController.abort();
|
||||
return;
|
||||
}
|
||||
|
||||
this._abortController = new AbortController();
|
||||
this._abortController.signal.addEventListener('abort', () => {
|
||||
this._abortController = null;
|
||||
});
|
||||
|
||||
createLitPortal({
|
||||
template: html`
|
||||
<chat-panel-add-popover
|
||||
.addChip=${this._addChip}
|
||||
.addImages=${this.addImages}
|
||||
.searchMenuConfig=${this.searchMenuConfig}
|
||||
.docDisplayConfig=${this.docDisplayConfig}
|
||||
.abortController=${this._abortController}
|
||||
></chat-panel-add-popover>
|
||||
`,
|
||||
portalStyles: {
|
||||
zIndex: 'var(--affine-z-index-popover)',
|
||||
},
|
||||
container: this.portalContainer ?? document.body,
|
||||
computePosition: {
|
||||
referenceElement: this.addButton,
|
||||
placement: 'top-start',
|
||||
middleware: [offset({ crossAxis: -30, mainAxis: 8 }), flip()],
|
||||
autoUpdate: { animationFrame: true },
|
||||
},
|
||||
abortController: this._abortController,
|
||||
closeOnClickAway: true,
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _toggleMoreCandidatesMenu = () => {
|
||||
if (this._abortController) {
|
||||
this._abortController.abort();
|
||||
@@ -303,7 +242,7 @@ export class ChatPanelChips extends SignalWatcher(
|
||||
createLitPortal({
|
||||
template: html`
|
||||
<chat-panel-candidates-popover
|
||||
.addChip=${this._addChip}
|
||||
.addChip=${this.addChip}
|
||||
.referenceDocs=${referenceDocs}
|
||||
.docDisplayConfig=${this.docDisplayConfig}
|
||||
.abortController=${this._abortController}
|
||||
@@ -324,190 +263,6 @@ export class ChatPanelChips extends SignalWatcher(
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _addChip = async (chip: ChatChip) => {
|
||||
this.isCollapsed = false;
|
||||
// remove the chip if it already exists
|
||||
const chips = this._omitChip(this.chips, chip);
|
||||
this.updateChips([...chips, chip]);
|
||||
if (chips.length < this.chips.length) {
|
||||
await this._removeFromContext(chip);
|
||||
}
|
||||
await this._addToContext(chip);
|
||||
this.pollContextDocsAndFiles();
|
||||
};
|
||||
|
||||
private readonly _updateChip = (
|
||||
chip: ChatChip,
|
||||
options: Partial<DocChip | FileChip>
|
||||
) => {
|
||||
const index = this._findChipIndex(this.chips, chip);
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
const nextChip: ChatChip = {
|
||||
...chip,
|
||||
...options,
|
||||
};
|
||||
this.updateChips([
|
||||
...this.chips.slice(0, index),
|
||||
nextChip,
|
||||
...this.chips.slice(index + 1),
|
||||
]);
|
||||
};
|
||||
|
||||
private readonly _removeChip = async (chip: ChatChip) => {
|
||||
const chips = this._omitChip(this.chips, chip);
|
||||
this.updateChips(chips);
|
||||
if (chips.length < this.chips.length) {
|
||||
await this._removeFromContext(chip);
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _addToContext = async (chip: ChatChip) => {
|
||||
if (isDocChip(chip)) {
|
||||
return await this._addDocToContext(chip);
|
||||
}
|
||||
if (isFileChip(chip)) {
|
||||
return await this._addFileToContext(chip);
|
||||
}
|
||||
if (isTagChip(chip)) {
|
||||
return await this._addTagToContext(chip);
|
||||
}
|
||||
if (isCollectionChip(chip)) {
|
||||
return await this._addCollectionToContext(chip);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
private readonly _addDocToContext = async (chip: DocChip) => {
|
||||
try {
|
||||
const contextId = await this.createContextId();
|
||||
if (!contextId || !AIProvider.context) {
|
||||
throw new Error('Context not found');
|
||||
}
|
||||
await AIProvider.context.addContextDoc({
|
||||
contextId,
|
||||
docId: chip.docId,
|
||||
});
|
||||
} catch (e) {
|
||||
this._updateChip(chip, {
|
||||
state: 'failed',
|
||||
tooltip: e instanceof Error ? e.message : 'Add context doc error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _addFileToContext = async (chip: FileChip) => {
|
||||
try {
|
||||
const contextId = await this.createContextId();
|
||||
if (!contextId || !AIProvider.context) {
|
||||
throw new Error('Context not found');
|
||||
}
|
||||
const contextFile = await AIProvider.context.addContextFile(chip.file, {
|
||||
contextId,
|
||||
});
|
||||
this._updateChip(chip, {
|
||||
state: contextFile.status,
|
||||
blobId: contextFile.blobId,
|
||||
fileId: contextFile.id,
|
||||
});
|
||||
} catch (e) {
|
||||
this._updateChip(chip, {
|
||||
state: 'failed',
|
||||
tooltip: e instanceof Error ? e.message : 'Add context file error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _addTagToContext = async (chip: TagChip) => {
|
||||
try {
|
||||
const contextId = await this.createContextId();
|
||||
if (!contextId || !AIProvider.context) {
|
||||
throw new Error('Context not found');
|
||||
}
|
||||
// TODO: server side docIds calculation
|
||||
const docIds = this.docDisplayConfig.getTagPageIds(chip.tagId);
|
||||
await AIProvider.context.addContextTag({
|
||||
contextId,
|
||||
tagId: chip.tagId,
|
||||
docIds,
|
||||
});
|
||||
this._updateChip(chip, {
|
||||
state: 'finished',
|
||||
});
|
||||
} catch (e) {
|
||||
this._updateChip(chip, {
|
||||
state: 'failed',
|
||||
tooltip: e instanceof Error ? e.message : 'Add context tag error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _addCollectionToContext = async (chip: CollectionChip) => {
|
||||
try {
|
||||
const contextId = await this.createContextId();
|
||||
if (!contextId || !AIProvider.context) {
|
||||
throw new Error('Context not found');
|
||||
}
|
||||
// TODO: server side docIds calculation
|
||||
const docIds = this.docDisplayConfig.getCollectionPageIds(
|
||||
chip.collectionId
|
||||
);
|
||||
await AIProvider.context.addContextCollection({
|
||||
contextId,
|
||||
collectionId: chip.collectionId,
|
||||
docIds,
|
||||
});
|
||||
this._updateChip(chip, {
|
||||
state: 'finished',
|
||||
});
|
||||
} catch (e) {
|
||||
this._updateChip(chip, {
|
||||
state: 'failed',
|
||||
tooltip:
|
||||
e instanceof Error ? e.message : 'Add context collection error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _removeFromContext = async (
|
||||
chip: ChatChip
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const contextId = await this.createContextId();
|
||||
if (!contextId || !AIProvider.context) {
|
||||
return true;
|
||||
}
|
||||
if (isDocChip(chip)) {
|
||||
return await AIProvider.context.removeContextDoc({
|
||||
contextId,
|
||||
docId: chip.docId,
|
||||
});
|
||||
}
|
||||
if (isFileChip(chip) && chip.fileId) {
|
||||
return await AIProvider.context.removeContextFile({
|
||||
contextId,
|
||||
fileId: chip.fileId,
|
||||
});
|
||||
}
|
||||
if (isTagChip(chip)) {
|
||||
return await AIProvider.context.removeContextTag({
|
||||
contextId,
|
||||
tagId: chip.tagId,
|
||||
});
|
||||
}
|
||||
if (isCollectionChip(chip)) {
|
||||
return await AIProvider.context.removeContextCollection({
|
||||
contextId,
|
||||
collectionId: chip.collectionId,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _checkTokenLimit = (
|
||||
newChip: DocChip,
|
||||
newTokenCount: number
|
||||
@@ -544,44 +299,4 @@ export class ChatPanelChips extends SignalWatcher(
|
||||
this.referenceDocs = signal;
|
||||
this._cleanup = cleanup;
|
||||
};
|
||||
|
||||
private readonly _omitChip = (chips: ChatChip[], chip: ChatChip) => {
|
||||
return chips.filter(item => {
|
||||
if (isDocChip(chip)) {
|
||||
return !isDocChip(item) || item.docId !== chip.docId;
|
||||
}
|
||||
if (isFileChip(chip)) {
|
||||
return !isFileChip(item) || item.file !== chip.file;
|
||||
}
|
||||
if (isTagChip(chip)) {
|
||||
return !isTagChip(item) || item.tagId !== chip.tagId;
|
||||
}
|
||||
if (isCollectionChip(chip)) {
|
||||
return (
|
||||
!isCollectionChip(item) || item.collectionId !== chip.collectionId
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _findChipIndex = (chips: ChatChip[], chip: ChatChip) => {
|
||||
return chips.findIndex(item => {
|
||||
if (isDocChip(chip)) {
|
||||
return isDocChip(item) && item.docId === chip.docId;
|
||||
}
|
||||
if (isFileChip(chip)) {
|
||||
return isFileChip(item) && item.file === chip.file;
|
||||
}
|
||||
if (isTagChip(chip)) {
|
||||
return isTagChip(item) && item.tagId === chip.tagId;
|
||||
}
|
||||
if (isCollectionChip(chip)) {
|
||||
return (
|
||||
isCollectionChip(item) && item.collectionId === chip.collectionId
|
||||
);
|
||||
}
|
||||
return -1;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ export class ChatPanelDocChip extends SignalWatcher(
|
||||
@property({ attribute: false })
|
||||
accessor chip!: DocChip;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor independentMode: boolean | undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor addChip!: (chip: DocChip) => void;
|
||||
|
||||
@@ -81,7 +84,10 @@ export class ChatPanelDocChip extends SignalWatcher(
|
||||
state: 'processing',
|
||||
});
|
||||
const mode = this.docDisplayConfig.getDocPrimaryMode(this.chip.docId);
|
||||
track.$.chatPanel.chatPanelInput.addEmbeddingDoc({
|
||||
const page = this.independentMode
|
||||
? track.$.intelligence
|
||||
: track.$.chatPanel;
|
||||
page.chatPanelInput.addEmbeddingDoc({
|
||||
control: 'addButton',
|
||||
method: 'suggestion',
|
||||
type: mode,
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import type { TagMeta } from '@affine/core/components/page-list';
|
||||
import type {
|
||||
SearchCollectionMenuAction,
|
||||
SearchDocMenuAction,
|
||||
SearchTagMenuAction,
|
||||
} from '@affine/core/modules/search-menu/services';
|
||||
import type { DocMeta, Store } from '@blocksuite/affine/store';
|
||||
import type { LinkedMenuGroup } from '@blocksuite/affine/widgets/linked-doc';
|
||||
import type { Signal } from '@preact/signals-core';
|
||||
|
||||
export type ChipState = 'candidate' | 'processing' | 'finished' | 'failed';
|
||||
@@ -75,21 +69,3 @@ export interface DocDisplayConfig {
|
||||
};
|
||||
getCollectionPageIds: (collectionId: string) => string[];
|
||||
}
|
||||
|
||||
export interface SearchMenuConfig {
|
||||
getDocMenuGroup: (
|
||||
query: string,
|
||||
action: SearchDocMenuAction,
|
||||
abortSignal: AbortSignal
|
||||
) => LinkedMenuGroup;
|
||||
getTagMenuGroup: (
|
||||
query: string,
|
||||
action: SearchTagMenuAction,
|
||||
abortSignal: AbortSignal
|
||||
) => LinkedMenuGroup;
|
||||
getCollectionMenuGroup: (
|
||||
query: string,
|
||||
action: SearchCollectionMenuAction,
|
||||
abortSignal: AbortSignal
|
||||
) => LinkedMenuGroup;
|
||||
}
|
||||
|
||||
@@ -78,6 +78,42 @@ export function getChipKey(chip: ChatChip) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function omitChip(chips: ChatChip[], chip: ChatChip) {
|
||||
return chips.filter(item => {
|
||||
if (isDocChip(chip)) {
|
||||
return !isDocChip(item) || item.docId !== chip.docId;
|
||||
}
|
||||
if (isFileChip(chip)) {
|
||||
return !isFileChip(item) || item.file !== chip.file;
|
||||
}
|
||||
if (isTagChip(chip)) {
|
||||
return !isTagChip(item) || item.tagId !== chip.tagId;
|
||||
}
|
||||
if (isCollectionChip(chip)) {
|
||||
return !isCollectionChip(item) || item.collectionId !== chip.collectionId;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function findChipIndex(chips: ChatChip[], chip: ChatChip) {
|
||||
return chips.findIndex(item => {
|
||||
if (isDocChip(chip)) {
|
||||
return isDocChip(item) && item.docId === chip.docId;
|
||||
}
|
||||
if (isFileChip(chip)) {
|
||||
return isFileChip(item) && item.file === chip.file;
|
||||
}
|
||||
if (isTagChip(chip)) {
|
||||
return isTagChip(item) && item.tagId === chip.tagId;
|
||||
}
|
||||
if (isCollectionChip(chip)) {
|
||||
return isCollectionChip(item) && item.collectionId === chip.collectionId;
|
||||
}
|
||||
return -1;
|
||||
});
|
||||
}
|
||||
|
||||
export function estimateTokenCount(text: string): number {
|
||||
const chinese = text.match(/[\u4e00-\u9fa5]/g)?.length || 0;
|
||||
const english = text.replace(/[\u4e00-\u9fa5]/g, '');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import './ai-chat-composer-tip';
|
||||
|
||||
import type { AIDraftService } from '@affine/core/modules/ai-button';
|
||||
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||
import type {
|
||||
ContextEmbedStatus,
|
||||
@@ -12,20 +13,28 @@ import type {
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import type { EditorHost } from '@blocksuite/affine/std';
|
||||
import { ShadowlessElement } from '@blocksuite/affine/std';
|
||||
import { css, html } from 'lit';
|
||||
import type { NotificationService } from '@blocksuite/affine-shared/services';
|
||||
import { css, html, type PropertyValues } from 'lit';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
|
||||
import { AIProvider } from '../../provider';
|
||||
import type { SearchMenuConfig } from '../ai-chat-add-context';
|
||||
import type {
|
||||
ChatChip,
|
||||
CollectionChip,
|
||||
DocChip,
|
||||
DocDisplayConfig,
|
||||
FileChip,
|
||||
SearchMenuConfig,
|
||||
TagChip,
|
||||
} from '../ai-chat-chips';
|
||||
import { isCollectionChip, isDocChip, isTagChip } from '../ai-chat-chips';
|
||||
import {
|
||||
findChipIndex,
|
||||
isCollectionChip,
|
||||
isDocChip,
|
||||
isFileChip,
|
||||
isTagChip,
|
||||
omitChip,
|
||||
} from '../ai-chat-chips';
|
||||
import type {
|
||||
AIChatInputContext,
|
||||
AINetworkSearchConfig,
|
||||
@@ -51,7 +60,7 @@ export class AIChatComposer extends SignalWatcher(
|
||||
`;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor independentMode!: boolean;
|
||||
accessor independentMode: boolean | undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor host: EditorHost | null | undefined;
|
||||
@@ -77,9 +86,9 @@ export class AIChatComposer extends SignalWatcher(
|
||||
accessor updateContext!: (context: Partial<AIChatInputContext>) => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onEmbeddingProgressChange!: (
|
||||
count: Record<ContextEmbedStatus, number>
|
||||
) => void;
|
||||
accessor onEmbeddingProgressChange:
|
||||
| ((count: Record<ContextEmbedStatus, number>) => void)
|
||||
| undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor docDisplayConfig!: DocDisplayConfig;
|
||||
@@ -105,9 +114,18 @@ export class AIChatComposer extends SignalWatcher(
|
||||
@property({ attribute: false })
|
||||
accessor affineWorkspaceDialogService!: WorkspaceDialogService;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor notificationService!: NotificationService;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor aiDraftService!: AIDraftService;
|
||||
|
||||
@state()
|
||||
accessor chips: ChatChip[] = [];
|
||||
|
||||
@state()
|
||||
accessor isChipsCollapsed = false;
|
||||
|
||||
@state()
|
||||
accessor embeddingCompleted = false;
|
||||
|
||||
@@ -121,11 +139,13 @@ export class AIChatComposer extends SignalWatcher(
|
||||
return html`
|
||||
<chat-panel-chips
|
||||
.chips=${this.chips}
|
||||
.createContextId=${this._createContextId}
|
||||
.updateChips=${this.updateChips}
|
||||
.pollContextDocsAndFiles=${this._pollContextDocsAndFiles}
|
||||
.isCollapsed=${this.isChipsCollapsed}
|
||||
.independentMode=${this.independentMode}
|
||||
.addChip=${this.addChip}
|
||||
.updateChip=${this.updateChip}
|
||||
.removeChip=${this.removeChip}
|
||||
.toggleCollapse=${this.toggleChipsCollapse}
|
||||
.docDisplayConfig=${this.docDisplayConfig}
|
||||
.searchMenuConfig=${this.searchMenuConfig}
|
||||
.portalContainer=${this.portalContainer}
|
||||
.addImages=${this.addImages}
|
||||
></chat-panel-chips>
|
||||
@@ -136,15 +156,19 @@ export class AIChatComposer extends SignalWatcher(
|
||||
.docId=${this.docId}
|
||||
.session=${this.session}
|
||||
.chips=${this.chips}
|
||||
.addChip=${this.addChip}
|
||||
.addImages=${this.addImages}
|
||||
.createSession=${this.createSession}
|
||||
.chatContextValue=${this.chatContextValue}
|
||||
.updateContext=${this.updateContext}
|
||||
.networkSearchConfig=${this.networkSearchConfig}
|
||||
.reasoningConfig=${this.reasoningConfig}
|
||||
.docDisplayConfig=${this.docDisplayConfig}
|
||||
.searchMenuConfig=${this.searchMenuConfig}
|
||||
.aiDraftService=${this.aiDraftService}
|
||||
.portalContainer=${this.portalContainer}
|
||||
.onChatSuccess=${this.onChatSuccess}
|
||||
.trackOptions=${this.trackOptions}
|
||||
.addImages=${this.addImages}
|
||||
></ai-chat-input>
|
||||
<div class="chat-panel-footer">
|
||||
<ai-chat-composer-tip
|
||||
@@ -165,7 +189,7 @@ export class AIChatComposer extends SignalWatcher(
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._initComposer().catch(console.error);
|
||||
this.initComposer().catch(console.error);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
@@ -174,6 +198,17 @@ export class AIChatComposer extends SignalWatcher(
|
||||
this._abortPollEmbeddingStatus();
|
||||
}
|
||||
|
||||
protected override willUpdate(changedProperties: PropertyValues): void {
|
||||
if (
|
||||
changedProperties.has('chatContextValue') &&
|
||||
changedProperties.get('chatContextValue')?.status !== 'loading' &&
|
||||
this.chatContextValue.status === 'loading' &&
|
||||
this.isChipsCollapsed === false
|
||||
) {
|
||||
this.isChipsCollapsed = true;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly _getContextId = async () => {
|
||||
if (this._contextId) {
|
||||
return this._contextId;
|
||||
@@ -190,7 +225,7 @@ export class AIChatComposer extends SignalWatcher(
|
||||
return this._contextId;
|
||||
};
|
||||
|
||||
private readonly _createContextId = async () => {
|
||||
private readonly createContextId = async () => {
|
||||
if (this._contextId) {
|
||||
return this._contextId;
|
||||
}
|
||||
@@ -205,7 +240,7 @@ export class AIChatComposer extends SignalWatcher(
|
||||
return this._contextId;
|
||||
};
|
||||
|
||||
private readonly _initChips = async () => {
|
||||
private readonly initChips = async () => {
|
||||
// context not initialized
|
||||
const sessionId = this.session?.sessionId;
|
||||
const contextId = await this._getContextId();
|
||||
@@ -275,14 +310,206 @@ export class AIChatComposer extends SignalWatcher(
|
||||
this.chips = chips;
|
||||
};
|
||||
|
||||
private readonly updateChip = (
|
||||
chip: ChatChip,
|
||||
options: Partial<DocChip | FileChip>
|
||||
) => {
|
||||
const index = findChipIndex(this.chips, chip);
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
const nextChip: ChatChip = {
|
||||
...chip,
|
||||
...options,
|
||||
};
|
||||
this.updateChips([
|
||||
...this.chips.slice(0, index),
|
||||
nextChip,
|
||||
...this.chips.slice(index + 1),
|
||||
]);
|
||||
};
|
||||
|
||||
private readonly addChip = async (chip: ChatChip) => {
|
||||
this.isChipsCollapsed = false;
|
||||
// if already exists
|
||||
const index = findChipIndex(this.chips, chip);
|
||||
if (index !== -1) {
|
||||
this.notificationService.toast('chip already exists');
|
||||
return;
|
||||
}
|
||||
this.updateChips([...this.chips, chip]);
|
||||
await this.addToContext(chip);
|
||||
await this.pollContextDocsAndFiles();
|
||||
};
|
||||
|
||||
private readonly removeChip = async (chip: ChatChip) => {
|
||||
const chips = omitChip(this.chips, chip);
|
||||
this.updateChips(chips);
|
||||
await this.removeFromContext(chip);
|
||||
};
|
||||
|
||||
private readonly addToContext = async (chip: ChatChip) => {
|
||||
if (isDocChip(chip)) {
|
||||
return await this.addDocToContext(chip);
|
||||
}
|
||||
if (isFileChip(chip)) {
|
||||
return await this.addFileToContext(chip);
|
||||
}
|
||||
if (isTagChip(chip)) {
|
||||
return await this.addTagToContext(chip);
|
||||
}
|
||||
if (isCollectionChip(chip)) {
|
||||
return await this.addCollectionToContext(chip);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
private readonly addDocToContext = async (chip: DocChip) => {
|
||||
try {
|
||||
const contextId = await this.createContextId();
|
||||
if (!contextId || !AIProvider.context) {
|
||||
throw new Error('Context not found');
|
||||
}
|
||||
await AIProvider.context.addContextDoc({
|
||||
contextId,
|
||||
docId: chip.docId,
|
||||
});
|
||||
} catch (e) {
|
||||
this.updateChip(chip, {
|
||||
state: 'failed',
|
||||
tooltip: e instanceof Error ? e.message : 'Add context doc error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private readonly addFileToContext = async (chip: FileChip) => {
|
||||
try {
|
||||
const contextId = await this.createContextId();
|
||||
if (!contextId || !AIProvider.context) {
|
||||
throw new Error('Context not found');
|
||||
}
|
||||
const contextFile = await AIProvider.context.addContextFile(chip.file, {
|
||||
contextId,
|
||||
});
|
||||
this.updateChip(chip, {
|
||||
state: contextFile.status,
|
||||
blobId: contextFile.blobId,
|
||||
fileId: contextFile.id,
|
||||
});
|
||||
} catch (e) {
|
||||
this.updateChip(chip, {
|
||||
state: 'failed',
|
||||
tooltip: e instanceof Error ? e.message : 'Add context file error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private readonly addTagToContext = async (chip: TagChip) => {
|
||||
try {
|
||||
const contextId = await this.createContextId();
|
||||
if (!contextId || !AIProvider.context) {
|
||||
throw new Error('Context not found');
|
||||
}
|
||||
// TODO: server side docIds calculation
|
||||
const docIds = this.docDisplayConfig.getTagPageIds(chip.tagId);
|
||||
await AIProvider.context.addContextTag({
|
||||
contextId,
|
||||
tagId: chip.tagId,
|
||||
docIds,
|
||||
});
|
||||
this.updateChip(chip, {
|
||||
state: 'finished',
|
||||
});
|
||||
} catch (e) {
|
||||
this.updateChip(chip, {
|
||||
state: 'failed',
|
||||
tooltip: e instanceof Error ? e.message : 'Add context tag error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private readonly addCollectionToContext = async (chip: CollectionChip) => {
|
||||
try {
|
||||
const contextId = await this.createContextId();
|
||||
if (!contextId || !AIProvider.context) {
|
||||
throw new Error('Context not found');
|
||||
}
|
||||
// TODO: server side docIds calculation
|
||||
const docIds = this.docDisplayConfig.getCollectionPageIds(
|
||||
chip.collectionId
|
||||
);
|
||||
await AIProvider.context.addContextCollection({
|
||||
contextId,
|
||||
collectionId: chip.collectionId,
|
||||
docIds,
|
||||
});
|
||||
this.updateChip(chip, {
|
||||
state: 'finished',
|
||||
});
|
||||
} catch (e) {
|
||||
this.updateChip(chip, {
|
||||
state: 'failed',
|
||||
tooltip:
|
||||
e instanceof Error ? e.message : 'Add context collection error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private readonly removeFromContext = async (
|
||||
chip: ChatChip
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const contextId = await this.createContextId();
|
||||
if (!contextId || !AIProvider.context) {
|
||||
return true;
|
||||
}
|
||||
if (isDocChip(chip)) {
|
||||
return await AIProvider.context.removeContextDoc({
|
||||
contextId,
|
||||
docId: chip.docId,
|
||||
});
|
||||
}
|
||||
if (isFileChip(chip) && chip.fileId) {
|
||||
return await AIProvider.context.removeContextFile({
|
||||
contextId,
|
||||
fileId: chip.fileId,
|
||||
});
|
||||
}
|
||||
if (isTagChip(chip)) {
|
||||
return await AIProvider.context.removeContextTag({
|
||||
contextId,
|
||||
tagId: chip.tagId,
|
||||
});
|
||||
}
|
||||
if (isCollectionChip(chip)) {
|
||||
return await AIProvider.context.removeContextCollection({
|
||||
contextId,
|
||||
collectionId: chip.collectionId,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
private readonly toggleChipsCollapse = () => {
|
||||
this.isChipsCollapsed = !this.isChipsCollapsed;
|
||||
};
|
||||
|
||||
private readonly addImages = (images: File[]) => {
|
||||
const oldImages = this.chatContextValue.images;
|
||||
if (oldImages.length + images.length > MAX_IMAGE_COUNT) {
|
||||
this.notificationService.toast(
|
||||
`You can only upload up to ${MAX_IMAGE_COUNT} images`
|
||||
);
|
||||
}
|
||||
this.updateContext({
|
||||
images: [...oldImages, ...images].slice(0, MAX_IMAGE_COUNT),
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _pollContextDocsAndFiles = async () => {
|
||||
private readonly pollContextDocsAndFiles = async () => {
|
||||
const sessionId = this.session?.sessionId;
|
||||
const contextId = await this._getContextId();
|
||||
if (!sessionId || !contextId || !AIProvider.context) {
|
||||
@@ -302,7 +529,7 @@ export class AIChatComposer extends SignalWatcher(
|
||||
);
|
||||
};
|
||||
|
||||
private readonly _pollEmbeddingStatus = async () => {
|
||||
private readonly pollEmbeddingStatus = async () => {
|
||||
if (this._pollEmbeddingStatusAbortController) {
|
||||
this._pollEmbeddingStatusAbortController.abort();
|
||||
}
|
||||
@@ -317,12 +544,11 @@ export class AIChatComposer extends SignalWatcher(
|
||||
this.embeddingCompleted = false;
|
||||
return;
|
||||
}
|
||||
const prevCompleted = this.embeddingCompleted;
|
||||
const completed = status.embedded === status.total;
|
||||
this.embeddingCompleted = completed;
|
||||
if (completed) {
|
||||
this.embeddingCompleted = true;
|
||||
} else {
|
||||
this.embeddingCompleted = false;
|
||||
if (prevCompleted !== completed) {
|
||||
this.requestUpdate();
|
||||
}
|
||||
},
|
||||
signal
|
||||
@@ -383,7 +609,7 @@ export class AIChatComposer extends SignalWatcher(
|
||||
return chip;
|
||||
});
|
||||
this.updateChips(nextChips);
|
||||
this.onEmbeddingProgressChange(count);
|
||||
this.onEmbeddingProgressChange?.(count);
|
||||
if (count.processing === 0) {
|
||||
this._abortPoll();
|
||||
}
|
||||
@@ -399,18 +625,18 @@ export class AIChatComposer extends SignalWatcher(
|
||||
this._pollEmbeddingStatusAbortController = null;
|
||||
};
|
||||
|
||||
private readonly _initComposer = async () => {
|
||||
private readonly initComposer = async () => {
|
||||
const userId = (await AIProvider.userInfo)?.id;
|
||||
if (!userId || !this.session) return;
|
||||
|
||||
await this._initChips();
|
||||
await this.initChips();
|
||||
const needPoll = this.chips.some(
|
||||
chip =>
|
||||
chip.state === 'processing' || isTagChip(chip) || isCollectionChip(chip)
|
||||
);
|
||||
if (needPoll) {
|
||||
await this._pollContextDocsAndFiles();
|
||||
await this.pollContextDocsAndFiles();
|
||||
}
|
||||
await this._pollEmbeddingStatus();
|
||||
await this.pollEmbeddingStatus();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { AIDraftService } from '@affine/core/modules/ai-button';
|
||||
import type { AIDraftState } from '@affine/core/modules/ai-button/services/ai-draft';
|
||||
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';
|
||||
@@ -6,27 +8,22 @@ import type {
|
||||
CopilotChatHistoryFragment,
|
||||
} from '@affine/graphql';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import type { EditorHost } from '@blocksuite/affine/std';
|
||||
import { ShadowlessElement } from '@blocksuite/affine/std';
|
||||
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';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { pick } from 'lodash-es';
|
||||
|
||||
import { HISTORY_IMAGE_ACTIONS } from '../../chat-panel/const';
|
||||
import { type AIChatParams, AIProvider } from '../../provider/ai-provider';
|
||||
import { extractSelectedContent } from '../../utils/extract';
|
||||
import type { DocDisplayConfig, SearchMenuConfig } from '../ai-chat-chips';
|
||||
import type { SearchMenuConfig } from '../ai-chat-add-context';
|
||||
import type { DocDisplayConfig } from '../ai-chat-chips';
|
||||
import type {
|
||||
AINetworkSearchConfig,
|
||||
AIReasoningConfig,
|
||||
@@ -60,27 +57,10 @@ export class AIChatContent extends SignalWatcher(
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
|
||||
.ai-chat-title {
|
||||
background: var(--affine-background-primary-color);
|
||||
position: relative;
|
||||
padding: 8px 0px;
|
||||
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: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 0 var(--h-padding);
|
||||
transition:
|
||||
flex-grow 0.32s cubic-bezier(0.07, 0.83, 0.46, 1),
|
||||
padding-top 0.32s ease,
|
||||
@@ -99,31 +79,35 @@ export class AIChatContent extends SignalWatcher(
|
||||
container-name: chat-panel-split-view;
|
||||
}
|
||||
.chat-panel-main {
|
||||
--h-padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 8px 24px 0 24px;
|
||||
padding: 8px calc(24px - var(--h-padding)) 0 calc(24px - var(--h-padding));
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
ai-chat-composer {
|
||||
padding: 0 var(--h-padding);
|
||||
}
|
||||
|
||||
@container chat-panel-split-view (width < 540px) {
|
||||
.chat-panel-main {
|
||||
padding: 8px 12px 0 12px;
|
||||
padding: 8px calc(12px - var(--h-padding)) 0
|
||||
calc(12px - var(--h-padding));
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor independentMode!: boolean;
|
||||
accessor independentMode: boolean | undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onboardingOffsetY!: number;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor chatTitle: TemplateResult<1> | undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor host: EditorHost | null | undefined;
|
||||
|
||||
@@ -169,13 +153,19 @@ export class AIChatContent extends SignalWatcher(
|
||||
accessor notificationService!: NotificationService;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onEmbeddingProgressChange!: (
|
||||
count: Record<ContextEmbedStatus, number>
|
||||
) => void;
|
||||
accessor aiDraftService!: AIDraftService;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onEmbeddingProgressChange:
|
||||
| ((count: Record<ContextEmbedStatus, number>) => void)
|
||||
| undefined;
|
||||
|
||||
@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;
|
||||
|
||||
@@ -211,14 +201,7 @@ export class AIChatContent extends SignalWatcher(
|
||||
}
|
||||
|
||||
get showActions() {
|
||||
if (this.docId) {
|
||||
if (!this.session) {
|
||||
return true;
|
||||
}
|
||||
return this.session.docId === this.docId;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private readonly updateHistory = async () => {
|
||||
@@ -261,7 +244,7 @@ export class AIChatContent extends SignalWatcher(
|
||||
};
|
||||
|
||||
private readonly updateActions = async () => {
|
||||
if (!this.docId || !AIProvider.histories) {
|
||||
if (!this.docId || !AIProvider.histories || !this.showActions) {
|
||||
return;
|
||||
}
|
||||
const actions = await AIProvider.histories.actions(
|
||||
@@ -286,6 +269,19 @@ export class AIChatContent extends SignalWatcher(
|
||||
private readonly updateContext = (context: Partial<ChatContextValue>) => {
|
||||
this.chatContextValue = { ...this.chatContextValue, ...context };
|
||||
this.onContextChange?.(context);
|
||||
this.updateDraft(context).catch(console.error);
|
||||
};
|
||||
|
||||
private readonly updateDraft = async (context: Partial<ChatContextValue>) => {
|
||||
const draft: Partial<AIDraftState> = pick(context, [
|
||||
'quote',
|
||||
'images',
|
||||
'markdown',
|
||||
]);
|
||||
if (!Object.keys(draft).length) {
|
||||
return;
|
||||
}
|
||||
await this.aiDraftService.setDraft(draft);
|
||||
};
|
||||
|
||||
private readonly initChatContent = async () => {
|
||||
@@ -327,16 +323,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;
|
||||
@@ -355,8 +341,19 @@ export class AIChatContent extends SignalWatcher(
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this.initChatContent().catch(console.error);
|
||||
|
||||
this.aiDraftService
|
||||
.getDraft()
|
||||
.then(draft => {
|
||||
this.chatContextValue = {
|
||||
...this.chatContextValue,
|
||||
...draft,
|
||||
};
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
this._disposables.add(
|
||||
AIProvider.slots.actions.subscribe(({ event }) => {
|
||||
const { status } = this.chatContextValue;
|
||||
@@ -389,13 +386,10 @@ 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,
|
||||
'independent-mode': !!this.independentMode,
|
||||
'no-message': this.messages.length === 0,
|
||||
})}
|
||||
${ref(this.chatMessagesRef)}
|
||||
@@ -416,6 +410,8 @@ export class AIChatContent extends SignalWatcher(
|
||||
.width=${this.width}
|
||||
.independentMode=${this.independentMode}
|
||||
.messages=${this.messages}
|
||||
.docDisplayService=${this.docDisplayConfig}
|
||||
.onOpenDoc=${this.onOpenDoc}
|
||||
></ai-chat-messages>
|
||||
<ai-chat-composer
|
||||
style=${styleMap({
|
||||
@@ -436,6 +432,8 @@ export class AIChatContent extends SignalWatcher(
|
||||
.docDisplayConfig=${this.docDisplayConfig}
|
||||
.searchMenuConfig=${this.searchMenuConfig}
|
||||
.affineWorkspaceDialogService=${this.affineWorkspaceDialogService}
|
||||
.notificationService=${this.notificationService}
|
||||
.aiDraftService=${this.aiDraftService}
|
||||
.trackOptions=${{
|
||||
where: 'chat-panel',
|
||||
control: 'chat-send',
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { toast } from '@affine/component';
|
||||
import type { AIDraftService } from '@affine/core/modules/ai-button';
|
||||
import type { CopilotChatHistoryFragment } from '@affine/graphql';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
|
||||
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
|
||||
import { openFilesWith } from '@blocksuite/affine/shared/utils';
|
||||
import type { EditorHost } from '@blocksuite/affine/std';
|
||||
import { ShadowlessElement } from '@blocksuite/affine/std';
|
||||
import { ArrowUpBigIcon, CloseIcon, ImageIcon } from '@blocksuite/icons/lit';
|
||||
import { css, html, nothing } from 'lit';
|
||||
import { ArrowUpBigIcon, CloseIcon } from '@blocksuite/icons/lit';
|
||||
import { css, html, nothing, type PropertyValues } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
@@ -16,6 +15,7 @@ import { type AIError, AIProvider, type AISendParams } from '../../provider';
|
||||
import { reportResponse } from '../../utils/action-reporter';
|
||||
import { readBlobAsURL } from '../../utils/image';
|
||||
import { mergeStreamObjects } from '../../utils/stream-objects';
|
||||
import type { SearchMenuConfig } from '../ai-chat-add-context';
|
||||
import type { ChatChip, DocDisplayConfig } from '../ai-chat-chips/type';
|
||||
import { isDocChip } from '../ai-chat-chips/utils';
|
||||
import {
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
isChatMessage,
|
||||
StreamObjectSchema,
|
||||
} from '../ai-chat-messages';
|
||||
import { MAX_IMAGE_COUNT } from './const';
|
||||
import type {
|
||||
AIChatInputContext,
|
||||
AINetworkSearchConfig,
|
||||
@@ -292,7 +291,7 @@ export class AIChatInput extends SignalWatcher(
|
||||
`;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor independentMode!: boolean;
|
||||
accessor independentMode: boolean | undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor host: EditorHost | null | undefined;
|
||||
@@ -335,6 +334,12 @@ export class AIChatInput extends SignalWatcher(
|
||||
@property({ attribute: false })
|
||||
accessor updateContext!: (context: Partial<AIChatInputContext>) => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor addImages!: (images: File[]) => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor addChip!: (chip: ChatChip) => Promise<void>;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor networkSearchConfig!: AINetworkSearchConfig;
|
||||
|
||||
@@ -344,6 +349,12 @@ export class AIChatInput extends SignalWatcher(
|
||||
@property({ attribute: false })
|
||||
accessor docDisplayConfig!: DocDisplayConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor searchMenuConfig!: SearchMenuConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor aiDraftService!: AIDraftService;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor isRootSession: boolean = true;
|
||||
|
||||
@@ -357,7 +368,7 @@ export class AIChatInput extends SignalWatcher(
|
||||
accessor testId = 'chat-panel-input-container';
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor addImages!: (images: File[]) => void;
|
||||
accessor portalContainer: HTMLElement | null = null;
|
||||
|
||||
private get _isNetworkActive() {
|
||||
return (
|
||||
@@ -370,12 +381,9 @@ export class AIChatInput extends SignalWatcher(
|
||||
return !!this.reasoningConfig.enabled.value;
|
||||
}
|
||||
|
||||
private get _isImageUploadDisabled() {
|
||||
return this.chatContextValue.images.length >= MAX_IMAGE_COUNT;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this._disposables.add(
|
||||
AIProvider.slots.requestSendWithChat.subscribe(
|
||||
(params: AISendParams | null) => {
|
||||
@@ -396,6 +404,17 @@ export class AIChatInput extends SignalWatcher(
|
||||
);
|
||||
}
|
||||
|
||||
protected override firstUpdated(changedProperties: PropertyValues): void {
|
||||
super.firstUpdated(changedProperties);
|
||||
this.aiDraftService
|
||||
.getDraft()
|
||||
.then(draft => {
|
||||
this.textarea.value = draft.input;
|
||||
this.isInputEmpty = !this.textarea.value.trim();
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
protected override render() {
|
||||
const { images, status } = this.chatContextValue;
|
||||
const hasImages = images.length > 0;
|
||||
@@ -453,14 +472,16 @@ export class AIChatInput extends SignalWatcher(
|
||||
data-testid="chat-panel-input"
|
||||
></textarea>
|
||||
<div class="chat-panel-input-actions">
|
||||
<div
|
||||
class="chat-input-icon"
|
||||
data-testid="chat-panel-input-image-upload"
|
||||
aria-disabled=${this._isImageUploadDisabled}
|
||||
@click=${this._uploadImageFiles}
|
||||
>
|
||||
${ImageIcon()}
|
||||
<affine-tooltip>Upload</affine-tooltip>
|
||||
<div class="chat-input-icon">
|
||||
<ai-chat-add-context
|
||||
.docId=${this.docId}
|
||||
.independentMode=${this.independentMode}
|
||||
.addChip=${this.addChip}
|
||||
.addImages=${this.addImages}
|
||||
.docDisplayConfig=${this.docDisplayConfig}
|
||||
.searchMenuConfig=${this.searchMenuConfig}
|
||||
.portalContainer=${this.portalContainer}
|
||||
></ai-chat-add-context>
|
||||
</div>
|
||||
<div class="chat-input-footer-spacer"></div>
|
||||
<chat-input-preference
|
||||
@@ -501,9 +522,11 @@ export class AIChatInput extends SignalWatcher(
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _handleInput = () => {
|
||||
private readonly _handleInput = async () => {
|
||||
const { textarea } = this;
|
||||
this.isInputEmpty = !textarea.value.trim();
|
||||
const value = textarea.value.trim();
|
||||
this.isInputEmpty = !value;
|
||||
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = textarea.scrollHeight + 'px';
|
||||
let imagesHeight = this.imagePreviewGrid?.scrollHeight ?? 0;
|
||||
@@ -512,6 +535,10 @@ export class AIChatInput extends SignalWatcher(
|
||||
textarea.style.height = '148px';
|
||||
textarea.style.overflowY = 'scroll';
|
||||
}
|
||||
|
||||
await this.aiDraftService.setDraft({
|
||||
input: value,
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _handleKeyDown = async (evt: KeyboardEvent) => {
|
||||
@@ -555,18 +582,6 @@ export class AIChatInput extends SignalWatcher(
|
||||
this.updateContext({ images: newImages });
|
||||
};
|
||||
|
||||
private readonly _uploadImageFiles = async (_e: MouseEvent) => {
|
||||
if (this._isImageUploadDisabled) return;
|
||||
|
||||
const images = await openFilesWith('Images');
|
||||
if (!images) return;
|
||||
if (this.chatContextValue.images.length + images.length > MAX_IMAGE_COUNT) {
|
||||
toast(`You can only upload up to ${MAX_IMAGE_COUNT} images`);
|
||||
return;
|
||||
}
|
||||
this.addImages(images);
|
||||
};
|
||||
|
||||
private readonly _onTextareaSend = async (e: MouseEvent | KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -579,6 +594,9 @@ export class AIChatInput extends SignalWatcher(
|
||||
this.textarea.style.height = 'unset';
|
||||
|
||||
await this.send(value);
|
||||
await this.aiDraftService.setDraft({
|
||||
input: '',
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _handleModelChange = (modelId: string) => {
|
||||
|
||||
@@ -6,8 +6,7 @@ import {
|
||||
type FeatureFlagService,
|
||||
type NotificationService,
|
||||
} from '@blocksuite/affine/shared/services';
|
||||
import type { EditorHost } from '@blocksuite/affine/std';
|
||||
import { ShadowlessElement } from '@blocksuite/affine/std';
|
||||
import { type EditorHost, ShadowlessElement } from '@blocksuite/affine/std';
|
||||
import type { BaseSelection, ExtensionType } from '@blocksuite/affine/store';
|
||||
import { ArrowDownBigIcon as ArrowDownIcon } from '@blocksuite/icons/lit';
|
||||
import type { Signal } from '@preact/signals-core';
|
||||
@@ -21,6 +20,7 @@ import { AffineIcon } from '../../_common/icons';
|
||||
import { AIPreloadConfig } from '../../chat-panel/preload-config';
|
||||
import { type AIError, AIProvider, UnauthorizedError } from '../../provider';
|
||||
import { mergeStreamObjects } from '../../utils/stream-objects';
|
||||
import type { DocDisplayConfig } from '../ai-chat-chips';
|
||||
import { type ChatContextValue } from '../ai-chat-content/type';
|
||||
import type {
|
||||
AINetworkSearchConfig,
|
||||
@@ -43,9 +43,8 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
chat-panel-assistant-message,
|
||||
@@ -152,7 +151,7 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) {
|
||||
accessor avatarUrl = '';
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor independentMode!: boolean;
|
||||
accessor independentMode: boolean | undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor messages!: HistoryMessage[];
|
||||
@@ -204,6 +203,12 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) {
|
||||
@property({ attribute: false })
|
||||
accessor width: Signal<number | undefined> | undefined;
|
||||
|
||||
@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;
|
||||
|
||||
@@ -277,7 +282,7 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) {
|
||||
<div
|
||||
class=${classMap({
|
||||
'chat-panel-messages-container': true,
|
||||
'independent-mode': this.independentMode,
|
||||
'independent-mode': !!this.independentMode,
|
||||
})}
|
||||
data-testid="chat-panel-messages-container"
|
||||
@scroll=${() => this._debouncedOnScroll()}
|
||||
@@ -329,6 +334,9 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) {
|
||||
.notificationService=${this.notificationService}
|
||||
.retry=${() => this.retry()}
|
||||
.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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user