mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-04 08:38:34 +00:00
Compare commits
44 Commits
v0.23.3
...
v0.24.0-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37e859484d | ||
|
|
1ceed6c145 | ||
|
|
1661ab1790 | ||
|
|
5cbcf6f907 | ||
|
|
19790c1b9e | ||
|
|
916887e9dc | ||
|
|
3c9fe48c6c | ||
|
|
a088874c41 | ||
|
|
4e1f047cf2 | ||
|
|
cd29028311 | ||
|
|
2990a96ec9 | ||
|
|
4833539eb3 | ||
|
|
61fa3ef6f6 | ||
|
|
77950cfc1b | ||
|
|
826afc209e | ||
|
|
75cc9b432b | ||
|
|
dfce0116b6 | ||
|
|
8d889fc3c7 | ||
|
|
49e8f339d4 | ||
|
|
feb42e34be | ||
|
|
b6a5bc052e | ||
|
|
1ce4cc6560 | ||
|
|
7c1a9957b3 | ||
|
|
603f2a1e5a | ||
|
|
b61807d005 | ||
|
|
69e23e6a42 | ||
|
|
f7a094053e | ||
|
|
091bac1047 | ||
|
|
bd161c54b2 | ||
|
|
61d2382643 | ||
|
|
4586e4a18f | ||
|
|
30c42fc51b | ||
|
|
627771948f | ||
|
|
0e3691e54e | ||
|
|
8fd0d5c1e8 | ||
|
|
13763e80bb | ||
|
|
6a1b53dd11 | ||
|
|
9899fad000 | ||
|
|
be55442f38 | ||
|
|
1dd4bbbaba | ||
|
|
7409940cc6 | ||
|
|
0d43350afd | ||
|
|
ff9a4f4322 | ||
|
|
8cfaee8232 |
@@ -219,6 +219,41 @@
|
||||
"type": "boolean",
|
||||
"description": "Whether ignore email server's TSL certification verification. Enable it for self-signed certificates.\n@default false\n@environment `MAILER_IGNORE_TLS`",
|
||||
"default": false
|
||||
},
|
||||
"fallbackDomains": {
|
||||
"type": "array",
|
||||
"description": "The emails from these domains are always sent using the fallback SMTP server.\n@default []",
|
||||
"default": []
|
||||
},
|
||||
"fallbackSMTP.host": {
|
||||
"type": "string",
|
||||
"description": "Host of the email server (e.g. smtp.gmail.com)\n@default \"\"",
|
||||
"default": ""
|
||||
},
|
||||
"fallbackSMTP.port": {
|
||||
"type": "number",
|
||||
"description": "Port of the email server (they commonly are 25, 465 or 587)\n@default 465",
|
||||
"default": 465
|
||||
},
|
||||
"fallbackSMTP.username": {
|
||||
"type": "string",
|
||||
"description": "Username used to authenticate the email server\n@default \"\"",
|
||||
"default": ""
|
||||
},
|
||||
"fallbackSMTP.password": {
|
||||
"type": "string",
|
||||
"description": "Password used to authenticate the email server\n@default \"\"",
|
||||
"default": ""
|
||||
},
|
||||
"fallbackSMTP.sender": {
|
||||
"type": "string",
|
||||
"description": "Sender of all the emails (e.g. \"AFFiNE Team <noreply@affine.pro>\")\n@default \"\"",
|
||||
"default": ""
|
||||
},
|
||||
"fallbackSMTP.ignoreTLS": {
|
||||
"type": "boolean",
|
||||
"description": "Whether ignore email server's TSL certification verification. Enable it for self-signed certificates.\n@default false",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -634,9 +669,16 @@
|
||||
},
|
||||
"providers.openai": {
|
||||
"type": "object",
|
||||
"description": "The config for the openai provider.\n@default {\"apiKey\":\"\"}\n@link https://github.com/openai/openai-node",
|
||||
"description": "The config for the openai provider.\n@default {\"apiKey\":\"\",\"baseUrl\":\"\",\"fallback\":{\"text\":\"\",\"structured\":\"\",\"image\":\"\",\"embedding\":\"\"}}\n@link https://github.com/openai/openai-node",
|
||||
"default": {
|
||||
"apiKey": ""
|
||||
"apiKey": "",
|
||||
"baseUrl": "",
|
||||
"fallback": {
|
||||
"text": "",
|
||||
"structured": "",
|
||||
"image": "",
|
||||
"embedding": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"providers.fal": {
|
||||
@@ -648,14 +690,21 @@
|
||||
},
|
||||
"providers.gemini": {
|
||||
"type": "object",
|
||||
"description": "The config for the gemini provider.\n@default {\"apiKey\":\"\"}",
|
||||
"description": "The config for the gemini provider.\n@default {\"apiKey\":\"\",\"baseUrl\":\"\",\"fallback\":{\"text\":\"\",\"structured\":\"\",\"image\":\"\",\"embedding\":\"\"}}",
|
||||
"default": {
|
||||
"apiKey": ""
|
||||
"apiKey": "",
|
||||
"baseUrl": "",
|
||||
"fallback": {
|
||||
"text": "",
|
||||
"structured": "",
|
||||
"image": "",
|
||||
"embedding": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"providers.geminiVertex": {
|
||||
"type": "object",
|
||||
"description": "The config for the google vertex provider.\n@default {}",
|
||||
"description": "The config for the google vertex provider.\n@default {\"baseURL\":\"\",\"fallback\":{\"text\":\"\",\"structured\":\"\",\"image\":\"\",\"embedding\":\"\"}}",
|
||||
"properties": {
|
||||
"location": {
|
||||
"type": "string",
|
||||
@@ -686,25 +735,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {}
|
||||
"default": {
|
||||
"baseURL": "",
|
||||
"fallback": {
|
||||
"text": "",
|
||||
"structured": "",
|
||||
"image": "",
|
||||
"embedding": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"providers.perplexity": {
|
||||
"type": "object",
|
||||
"description": "The config for the perplexity provider.\n@default {\"apiKey\":\"\"}",
|
||||
"description": "The config for the perplexity provider.\n@default {\"apiKey\":\"\",\"fallback\":{\"text\":\"\"}}",
|
||||
"default": {
|
||||
"apiKey": ""
|
||||
"apiKey": "",
|
||||
"fallback": {
|
||||
"text": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"providers.anthropic": {
|
||||
"type": "object",
|
||||
"description": "The config for the anthropic provider.\n@default {\"apiKey\":\"\"}",
|
||||
"description": "The config for the anthropic provider.\n@default {\"apiKey\":\"\",\"fallback\":{\"text\":\"\"}}",
|
||||
"default": {
|
||||
"apiKey": ""
|
||||
"apiKey": "",
|
||||
"fallback": {
|
||||
"text": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"providers.anthropicVertex": {
|
||||
"type": "object",
|
||||
"description": "The config for the google vertex provider.\n@default {}",
|
||||
"description": "The config for the google vertex provider.\n@default {\"baseURL\":\"\",\"fallback\":{\"text\":\"\"}}",
|
||||
"properties": {
|
||||
"location": {
|
||||
"type": "string",
|
||||
@@ -735,7 +798,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {}
|
||||
"default": {
|
||||
"baseURL": "",
|
||||
"fallback": {
|
||||
"text": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"providers.morph": {
|
||||
"type": "object",
|
||||
|
||||
30
.github/actions/deploy/deploy.mjs
vendored
30
.github/actions/deploy/deploy.mjs
vendored
@@ -29,25 +29,25 @@ const isInternal = buildType === 'internal';
|
||||
|
||||
const replicaConfig = {
|
||||
stable: {
|
||||
web: 3,
|
||||
graphql: Number(process.env.PRODUCTION_GRAPHQL_REPLICA) || 3,
|
||||
sync: Number(process.env.PRODUCTION_SYNC_REPLICA) || 3,
|
||||
renderer: Number(process.env.PRODUCTION_RENDERER_REPLICA) || 3,
|
||||
doc: Number(process.env.PRODUCTION_DOC_REPLICA) || 3,
|
||||
web: 2,
|
||||
graphql: Number(process.env.PRODUCTION_GRAPHQL_REPLICA) || 2,
|
||||
sync: Number(process.env.PRODUCTION_SYNC_REPLICA) || 2,
|
||||
renderer: Number(process.env.PRODUCTION_RENDERER_REPLICA) || 2,
|
||||
doc: Number(process.env.PRODUCTION_DOC_REPLICA) || 2,
|
||||
},
|
||||
beta: {
|
||||
web: 2,
|
||||
graphql: Number(process.env.BETA_GRAPHQL_REPLICA) || 2,
|
||||
sync: Number(process.env.BETA_SYNC_REPLICA) || 2,
|
||||
renderer: Number(process.env.BETA_RENDERER_REPLICA) || 2,
|
||||
doc: Number(process.env.BETA_DOC_REPLICA) || 2,
|
||||
web: 1,
|
||||
graphql: Number(process.env.BETA_GRAPHQL_REPLICA) || 1,
|
||||
sync: Number(process.env.BETA_SYNC_REPLICA) || 1,
|
||||
renderer: Number(process.env.BETA_RENDERER_REPLICA) || 1,
|
||||
doc: Number(process.env.BETA_DOC_REPLICA) || 1,
|
||||
},
|
||||
canary: {
|
||||
web: 2,
|
||||
graphql: 2,
|
||||
sync: 2,
|
||||
renderer: 2,
|
||||
doc: 2,
|
||||
web: 1,
|
||||
graphql: 1,
|
||||
sync: 1,
|
||||
renderer: 1,
|
||||
doc: 1,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
2
.github/workflows/windows-signer.yml
vendored
2
.github/workflows/windows-signer.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
shell: cmd
|
||||
run: |
|
||||
cd ${{ env.ARCHIVE_DIR }}/out
|
||||
signtool sign /tr http://timestamp.sectigo.com /td sha256 /fd sha256 /a ${{ inputs.files }}
|
||||
signtool sign /tr http://timestamp.globalsign.com/tsa/r6advanced1 /td sha256 /fd sha256 /a ${{ inputs.files }}
|
||||
- name: zip file
|
||||
shell: cmd
|
||||
run: |
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ImageBlockModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
ActionPlacement,
|
||||
blockCommentToolbarButton,
|
||||
type ToolbarModuleConfig,
|
||||
ToolbarModuleExtension,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
@@ -49,6 +50,10 @@ const builtinToolbarConfig = {
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c.comment',
|
||||
...blockCommentToolbarButton,
|
||||
},
|
||||
{
|
||||
placement: ActionPlacement.More,
|
||||
id: 'a.clipboard',
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
getPrevContentBlock,
|
||||
matchModels,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { IS_MOBILE } from '@blocksuite/global/env';
|
||||
import { BlockSelection, type EditorHost } from '@blocksuite/std';
|
||||
import type { BlockModel, Text } from '@blocksuite/store';
|
||||
|
||||
@@ -91,10 +92,17 @@ export function mergeWithPrev(editorHost: EditorHost, model: BlockModel) {
|
||||
...EMBED_BLOCK_MODEL_LIST,
|
||||
])
|
||||
) {
|
||||
const selection = editorHost.selection.create(BlockSelection, {
|
||||
blockId: prevBlock.id,
|
||||
});
|
||||
editorHost.selection.setGroup('note', [selection]);
|
||||
// due to create a block selection will clear text selection, which lead
|
||||
// the virtual keyboard to be auto closed on mobile. This behavior breaks
|
||||
// the user experience.
|
||||
if (!IS_MOBILE) {
|
||||
const selection = editorHost.selection.create(BlockSelection, {
|
||||
blockId: prevBlock.id,
|
||||
});
|
||||
editorHost.selection.setGroup('note', [selection]);
|
||||
} else {
|
||||
doc.deleteBlock(prevBlock);
|
||||
}
|
||||
|
||||
if (model.text?.length === 0) {
|
||||
doc.deleteBlock(model, {
|
||||
|
||||
@@ -634,9 +634,9 @@ export class EdgelessPageKeyboardManager extends PageKeyboardManager {
|
||||
|
||||
const movedElements = new Set([
|
||||
...selectedElements,
|
||||
...selectedElements
|
||||
.map(el => (isGfxGroupCompatibleModel(el) ? el.descendantElements : []))
|
||||
.flat(),
|
||||
...selectedElements.flatMap(el =>
|
||||
isGfxGroupCompatibleModel(el) ? el.descendantElements : []
|
||||
),
|
||||
]);
|
||||
|
||||
movedElements.forEach(element => {
|
||||
|
||||
@@ -4,6 +4,6 @@ export * from './clipboard/command';
|
||||
export * from './edgeless-root-block.js';
|
||||
export { EdgelessRootService } from './edgeless-root-service.js';
|
||||
export * from './utils/clipboard-utils.js';
|
||||
export { sortEdgelessElements } from './utils/clone-utils.js';
|
||||
export { getElementProps, sortEdgelessElements } from './utils/clone-utils.js';
|
||||
export { isCanvasElement } from './utils/query.js';
|
||||
export { EDGELESS_BLOCK_CHILD_PADDING } from '@blocksuite/affine-shared/consts';
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from '@blocksuite/affine-shared/commands';
|
||||
import {
|
||||
ActionPlacement,
|
||||
blockCommentToolbarButton,
|
||||
type ToolbarModuleConfig,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { CaptionIcon, CopyIcon, DeleteIcon } from '@blocksuite/icons/lit';
|
||||
@@ -61,6 +62,10 @@ export const surfaceRefToolbarModuleConfig: ToolbarModuleConfig = {
|
||||
surfaceRefBlock.captionElement.show();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'e.comment',
|
||||
...blockCommentToolbarButton,
|
||||
},
|
||||
{
|
||||
id: 'a.clipboard',
|
||||
placement: ActionPlacement.More,
|
||||
|
||||
@@ -68,5 +68,5 @@ export function getHeadingBlocksFromDoc(
|
||||
ignoreEmpty = false
|
||||
) {
|
||||
const notes = getNotesFromStore(store, modes);
|
||||
return notes.map(note => getHeadingBlocksFromNote(note, ignoreEmpty)).flat();
|
||||
return notes.flatMap(note => getHeadingBlocksFromNote(note, ignoreEmpty));
|
||||
}
|
||||
|
||||
@@ -103,54 +103,52 @@ export class InlineCommentManager extends LifeCycleWatcher {
|
||||
id: CommentId,
|
||||
selections: BaseSelection[]
|
||||
) => {
|
||||
const needCommentTexts = selections
|
||||
.map(selection => {
|
||||
if (!selection.is(TextSelection)) return [];
|
||||
const [_, { selectedBlocks }] = this.std.command
|
||||
.chain()
|
||||
.pipe(getSelectedBlocksCommand, {
|
||||
textSelection: selection,
|
||||
})
|
||||
.run();
|
||||
const needCommentTexts = selections.flatMap(selection => {
|
||||
if (!selection.is(TextSelection)) return [];
|
||||
const [_, { selectedBlocks }] = this.std.command
|
||||
.chain()
|
||||
.pipe(getSelectedBlocksCommand, {
|
||||
textSelection: selection,
|
||||
})
|
||||
.run();
|
||||
|
||||
if (!selectedBlocks) return [];
|
||||
if (!selectedBlocks) return [];
|
||||
|
||||
type MakeRequired<T, K extends keyof T> = T & {
|
||||
[key in K]: NonNullable<T[key]>;
|
||||
};
|
||||
type MakeRequired<T, K extends keyof T> = T & {
|
||||
[key in K]: NonNullable<T[key]>;
|
||||
};
|
||||
|
||||
return selectedBlocks
|
||||
.map(
|
||||
({ model }) =>
|
||||
[model, getInlineEditorByModel(this.std, model)] as const
|
||||
)
|
||||
.filter(
|
||||
(
|
||||
pair
|
||||
): pair is [MakeRequired<BlockModel, 'text'>, AffineInlineEditor] =>
|
||||
!!pair[0].text && !!pair[1]
|
||||
)
|
||||
.map(([model, inlineEditor]) => {
|
||||
let from: TextRangePoint;
|
||||
let to: TextRangePoint | null;
|
||||
if (model.id === selection.from.blockId) {
|
||||
from = selection.from;
|
||||
to = null;
|
||||
} else if (model.id === selection.to?.blockId) {
|
||||
from = selection.to;
|
||||
to = null;
|
||||
} else {
|
||||
from = {
|
||||
blockId: model.id,
|
||||
index: 0,
|
||||
length: model.text.yText.length,
|
||||
};
|
||||
to = null;
|
||||
}
|
||||
return [new TextSelection({ from, to }), inlineEditor] as const;
|
||||
});
|
||||
})
|
||||
.flat();
|
||||
return selectedBlocks
|
||||
.map(
|
||||
({ model }) =>
|
||||
[model, getInlineEditorByModel(this.std, model)] as const
|
||||
)
|
||||
.filter(
|
||||
(
|
||||
pair
|
||||
): pair is [MakeRequired<BlockModel, 'text'>, AffineInlineEditor] =>
|
||||
!!pair[0].text && !!pair[1]
|
||||
)
|
||||
.map(([model, inlineEditor]) => {
|
||||
let from: TextRangePoint;
|
||||
let to: TextRangePoint | null;
|
||||
if (model.id === selection.from.blockId) {
|
||||
from = selection.from;
|
||||
to = null;
|
||||
} else if (model.id === selection.to?.blockId) {
|
||||
from = selection.to;
|
||||
to = null;
|
||||
} else {
|
||||
from = {
|
||||
blockId: model.id,
|
||||
index: 0,
|
||||
length: model.text.yText.length,
|
||||
};
|
||||
to = null;
|
||||
}
|
||||
return [new TextSelection({ from, to }), inlineEditor] as const;
|
||||
});
|
||||
});
|
||||
|
||||
if (needCommentTexts.length === 0) return;
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ export const blockCommentToolbarButton: Omit<ToolbarAction, 'id'> = {
|
||||
|
||||
// may be hover on a block or element, in this case
|
||||
// the selection is empty, so we need to get the current model
|
||||
if (model && selections.length === 0) {
|
||||
if (model) {
|
||||
if (model instanceof BlockModel) {
|
||||
commentProvider.addComment([
|
||||
new BlockSelection({
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Signal } from '@preact/signals-core';
|
||||
import type { AffineUserInfo } from './types';
|
||||
|
||||
export interface UserService {
|
||||
currentUserInfo$: Signal<AffineUserInfo | null>;
|
||||
userInfo$(id: string): Signal<AffineUserInfo | null>;
|
||||
isLoading$(id: string): Signal<boolean>;
|
||||
error$(id: string): Signal<string | null>; // user friendly error string
|
||||
|
||||
@@ -4,6 +4,14 @@ import type { ReadonlySignal } from '@preact/signals-core';
|
||||
export interface VirtualKeyboardProvider {
|
||||
readonly visible$: ReadonlySignal<boolean>;
|
||||
readonly height$: ReadonlySignal<number>;
|
||||
/**
|
||||
* The static height of the keyboard, it should record the last non-zero height of virtual keyboard
|
||||
*/
|
||||
readonly staticHeight$: ReadonlySignal<number>;
|
||||
/**
|
||||
* The safe area of the app tab, it will be used when the keyboard is open or closed
|
||||
*/
|
||||
readonly appTabSafeArea$: ReadonlySignal<string>;
|
||||
}
|
||||
|
||||
export interface VirtualKeyboardProviderWithAction
|
||||
|
||||
@@ -11,14 +11,12 @@ export function getSelectedRect(selected: GfxModel[]): DOMRect {
|
||||
return new DOMRect();
|
||||
}
|
||||
|
||||
const lockedElementsByFrame = selected
|
||||
.map(selectable => {
|
||||
if (selectable instanceof FrameBlockModel && selectable.isLocked()) {
|
||||
return selectable.descendantElements;
|
||||
}
|
||||
return [];
|
||||
})
|
||||
.flat();
|
||||
const lockedElementsByFrame = selected.flatMap(selectable => {
|
||||
if (selectable instanceof FrameBlockModel && selectable.isLocked()) {
|
||||
return selectable.descendantElements;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
selected = [...new Set([...selected, ...lockedElementsByFrame])];
|
||||
|
||||
|
||||
@@ -168,10 +168,6 @@ export type KeyboardSubToolbarConfig = {
|
||||
export type KeyboardToolbarContext = {
|
||||
std: BlockStdScope;
|
||||
rootComponent: BlockComponent;
|
||||
/**
|
||||
* Close tool bar, and blur the focus if blur is true, default is false
|
||||
*/
|
||||
closeToolbar: (blur?: boolean) => void;
|
||||
/**
|
||||
* Close current tool panel and show virtual keyboard
|
||||
*/
|
||||
|
||||
@@ -71,21 +71,18 @@ export class AffineKeyboardToolPanel extends SignalWatcher(
|
||||
.map(group => (typeof group === 'function' ? group(this.context) : group))
|
||||
.filter((group): group is KeyboardToolPanelGroup => group !== null);
|
||||
|
||||
return repeat(
|
||||
groups,
|
||||
group => group.name,
|
||||
group => this._renderGroup(group)
|
||||
);
|
||||
return html`<div class="affine-keyboard-tool-panel-container">
|
||||
${repeat(
|
||||
groups,
|
||||
group => group.name,
|
||||
group => this._renderGroup(group)
|
||||
)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
protected override willUpdate(changedProperties: PropertyValues<this>) {
|
||||
if (changedProperties.has('height')) {
|
||||
this.style.height = `${this.height}px`;
|
||||
if (this.height === 0) {
|
||||
this.style.padding = '0';
|
||||
} else {
|
||||
this.style.padding = '';
|
||||
}
|
||||
this.style.height = this.height;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,5 +93,5 @@ export class AffineKeyboardToolPanel extends SignalWatcher(
|
||||
accessor context!: KeyboardToolbarContext;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor height = 0;
|
||||
accessor height = '';
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
requiredProperties,
|
||||
ShadowlessElement,
|
||||
} from '@blocksuite/std';
|
||||
import { effect, type Signal, signal, untracked } from '@preact/signals-core';
|
||||
import { effect, type Signal, signal } from '@preact/signals-core';
|
||||
import { html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
@@ -22,7 +22,6 @@ import type {
|
||||
KeyboardToolbarItem,
|
||||
KeyboardToolPanelConfig,
|
||||
} from './config';
|
||||
import { PositionController } from './position-controller';
|
||||
import { keyboardToolbarStyles } from './styles';
|
||||
import {
|
||||
isKeyboardSubToolBarConfig,
|
||||
@@ -41,10 +40,7 @@ export class AffineKeyboardToolbar extends SignalWatcher(
|
||||
) {
|
||||
static override styles = keyboardToolbarStyles;
|
||||
|
||||
/** This field records the panel static height same as the virtual keyboard height */
|
||||
panelHeight$ = signal(0);
|
||||
|
||||
positionController = new PositionController(this);
|
||||
private readonly _expanded$ = signal(false);
|
||||
|
||||
get std() {
|
||||
return this.rootComponent.std;
|
||||
@@ -54,9 +50,31 @@ export class AffineKeyboardToolbar extends SignalWatcher(
|
||||
return this._currentPanelIndex$.value !== -1;
|
||||
}
|
||||
|
||||
private get panelHeight() {
|
||||
return this._expanded$.value
|
||||
? `${
|
||||
this.keyboard.staticHeight$.value !== 0
|
||||
? this.keyboard.staticHeight$.value
|
||||
: 330
|
||||
}px`
|
||||
: this.keyboard.appTabSafeArea$.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent flickering during keyboard opening
|
||||
*/
|
||||
private _resetPanelIndexTimeoutId: ReturnType<typeof setTimeout> | null =
|
||||
null;
|
||||
private readonly _closeToolPanel = () => {
|
||||
this._currentPanelIndex$.value = -1;
|
||||
if (!this.keyboard.visible$.peek()) this.keyboard.show();
|
||||
|
||||
if (this._resetPanelIndexTimeoutId) {
|
||||
clearTimeout(this._resetPanelIndexTimeoutId);
|
||||
this._resetPanelIndexTimeoutId = null;
|
||||
}
|
||||
this._resetPanelIndexTimeoutId = setTimeout(() => {
|
||||
this._currentPanelIndex$.value = -1;
|
||||
}, 100);
|
||||
};
|
||||
|
||||
private readonly _currentPanelIndex$ = signal(-1);
|
||||
@@ -83,6 +101,10 @@ export class AffineKeyboardToolbar extends SignalWatcher(
|
||||
if (this._currentPanelIndex$.value === index) {
|
||||
this._closeToolPanel();
|
||||
} else {
|
||||
if (this._resetPanelIndexTimeoutId) {
|
||||
clearTimeout(this._resetPanelIndexTimeoutId);
|
||||
this._resetPanelIndexTimeoutId = null;
|
||||
}
|
||||
this._currentPanelIndex$.value = index;
|
||||
this.keyboard.hide();
|
||||
this._scrollCurrentBlockIntoView();
|
||||
@@ -123,9 +145,6 @@ export class AffineKeyboardToolbar extends SignalWatcher(
|
||||
return {
|
||||
std: this.std,
|
||||
rootComponent: this.rootComponent,
|
||||
closeToolbar: (blur = false) => {
|
||||
this.close(blur);
|
||||
},
|
||||
closeToolPanel: () => {
|
||||
this._closeToolPanel();
|
||||
},
|
||||
@@ -202,7 +221,7 @@ export class AffineKeyboardToolbar extends SignalWatcher(
|
||||
}
|
||||
|
||||
private _renderItems() {
|
||||
if (document.activeElement !== this.rootComponent)
|
||||
if (!this.std.event.active$.value)
|
||||
return html`<div class="item-container"></div>`;
|
||||
|
||||
const goPrevToolbarAction = when(
|
||||
@@ -226,7 +245,15 @@ export class AffineKeyboardToolbar extends SignalWatcher(
|
||||
<icon-button
|
||||
size="36px"
|
||||
@click=${() => {
|
||||
this.close(true);
|
||||
if (this.keyboard.staticHeight$.value === 0) {
|
||||
this._closeToolPanel();
|
||||
return;
|
||||
}
|
||||
if (this.keyboard.visible$.peek()) {
|
||||
this.keyboard.hide();
|
||||
} else {
|
||||
this.keyboard.show();
|
||||
}
|
||||
}}
|
||||
>
|
||||
${KeyboardIcon()}
|
||||
@@ -237,6 +264,23 @@ export class AffineKeyboardToolbar extends SignalWatcher(
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
// There are two cases that `_expanded$` will be true:
|
||||
// 1. when virtual keyboard is opened, the panel need to be expanded and overlapped by the keyboard,
|
||||
// so that the toolbar will be on the top of the keyboard.
|
||||
// 2. the panel is opened, whether the keyboard is closed or not exists (e.g. a physical keyboard connected)
|
||||
//
|
||||
// There is one case that `_expanded$` will be false:
|
||||
// 1. the panel is closed, and the keyboard is closed, the toolbar will be rendered at the bottom of the viewport
|
||||
this._disposables.add(
|
||||
effect(() => {
|
||||
if (this.keyboard.visible$.value || this.panelOpened) {
|
||||
this._expanded$.value = true;
|
||||
} else {
|
||||
this._expanded$.value = false;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// prevent editor blur when click item in toolbar
|
||||
this.disposables.addFromEvent(this, 'pointerdown', e => {
|
||||
e.preventDefault();
|
||||
@@ -260,15 +304,17 @@ export class AffineKeyboardToolbar extends SignalWatcher(
|
||||
if (this.keyboard.visible$.value) {
|
||||
this._closeToolPanel();
|
||||
}
|
||||
// when keyboard is closed and the panel is not opened, we need to close the toolbar,
|
||||
// this usually happens when user close keyboard from system side
|
||||
else if (this.hasUpdated && untracked(() => !this.panelOpened)) {
|
||||
this.close(true);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this._watchAutoShow();
|
||||
|
||||
this.disposables.add(() => {
|
||||
if (this._resetPanelIndexTimeoutId) {
|
||||
clearTimeout(this._resetPanelIndexTimeoutId);
|
||||
this._resetPanelIndexTimeoutId = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _watchAutoShow() {
|
||||
@@ -331,7 +377,7 @@ export class AffineKeyboardToolbar extends SignalWatcher(
|
||||
<affine-keyboard-tool-panel
|
||||
.config=${this._currentPanelConfig}
|
||||
.context=${this._context}
|
||||
.height=${this.panelHeight$.value}
|
||||
.height=${this.panelHeight}
|
||||
></affine-keyboard-tool-panel>
|
||||
`;
|
||||
}
|
||||
@@ -339,9 +385,6 @@ export class AffineKeyboardToolbar extends SignalWatcher(
|
||||
@property({ attribute: false })
|
||||
accessor keyboard!: VirtualKeyboardProviderWithAction;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor close: (blur: boolean) => void = () => {};
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor config!: KeyboardToolbarConfig;
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { type VirtualKeyboardProvider } from '@blocksuite/affine-shared/services';
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import type { BlockStdScope, ShadowlessElement } from '@blocksuite/std';
|
||||
import { effect, type Signal } from '@preact/signals-core';
|
||||
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
||||
|
||||
/**
|
||||
* This controller is used to control the keyboard toolbar position
|
||||
*/
|
||||
export class PositionController implements ReactiveController {
|
||||
private readonly _disposables = new DisposableGroup();
|
||||
|
||||
host: ReactiveControllerHost &
|
||||
ShadowlessElement & {
|
||||
std: BlockStdScope;
|
||||
panelHeight$: Signal<number>;
|
||||
keyboard: VirtualKeyboardProvider;
|
||||
panelOpened: boolean;
|
||||
};
|
||||
|
||||
constructor(host: PositionController['host']) {
|
||||
(this.host = host).addController(this);
|
||||
}
|
||||
|
||||
hostConnected() {
|
||||
const { keyboard } = this.host;
|
||||
|
||||
this._disposables.add(
|
||||
effect(() => {
|
||||
if (keyboard.visible$.value) {
|
||||
this.host.panelHeight$.value = keyboard.height$.value;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.host.style.bottom = '0px';
|
||||
}
|
||||
|
||||
hostDisconnected() {
|
||||
this._disposables.dispose();
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export const keyboardToolbarStyles = css`
|
||||
position: fixed;
|
||||
display: block;
|
||||
width: 100vw;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.keyboard-toolbar {
|
||||
@@ -60,14 +61,18 @@ export const keyboardToolbarStyles = css`
|
||||
|
||||
export const keyboardToolPanelStyles = css`
|
||||
affine-keyboard-tool-panel {
|
||||
display: block;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
background-color: ${unsafeCSSVarV2('layer/background/primary')};
|
||||
}
|
||||
|
||||
.affine-keyboard-tool-panel-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
padding: 16px 4px 8px 8px;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
background-color: ${unsafeCSSVarV2('layer/background/primary')};
|
||||
}
|
||||
|
||||
${scrollbarStyle('affine-keyboard-tool-panel')}
|
||||
|
||||
@@ -20,18 +20,6 @@ import {
|
||||
export const AFFINE_KEYBOARD_TOOLBAR_WIDGET = 'affine-keyboard-toolbar-widget';
|
||||
|
||||
export class AffineKeyboardToolbarWidget extends WidgetComponent<RootBlockModel> {
|
||||
private readonly _close = (blur: boolean) => {
|
||||
if (blur) {
|
||||
if (document.activeElement === this._docTitle?.inlineEditorContainer) {
|
||||
this._docTitle?.inlineEditor?.setInlineRange(null);
|
||||
this._docTitle?.inlineEditor?.eventSource?.blur();
|
||||
} else if (document.activeElement === this.block?.rootComponent) {
|
||||
this.std.selection.clear();
|
||||
}
|
||||
}
|
||||
this._show$.value = false;
|
||||
};
|
||||
|
||||
private readonly _show$ = signal(false);
|
||||
|
||||
private _initialInputMode: string = '';
|
||||
@@ -73,29 +61,26 @@ export class AffineKeyboardToolbarWidget extends WidgetComponent<RootBlockModel>
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
const rootComponent = this.block?.rootComponent;
|
||||
if (rootComponent) {
|
||||
this.disposables.addFromEvent(rootComponent, 'focus', () => {
|
||||
this._show$.value = true;
|
||||
});
|
||||
this.disposables.addFromEvent(rootComponent, 'blur', () => {
|
||||
this._show$.value = false;
|
||||
});
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
this._show$.value = this.std.event.active$.value;
|
||||
})
|
||||
);
|
||||
|
||||
if (this.keyboard.fallback) {
|
||||
this._initialInputMode = rootComponent.inputMode;
|
||||
this.disposables.add(() => {
|
||||
rootComponent.inputMode = this._initialInputMode;
|
||||
});
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
// recover input mode when keyboard toolbar is hidden
|
||||
if (!this._show$.value) {
|
||||
rootComponent.inputMode = this._initialInputMode;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
const rootComponent = this.block?.rootComponent;
|
||||
if (rootComponent && this.keyboard.fallback) {
|
||||
this._initialInputMode = rootComponent.inputMode;
|
||||
this.disposables.add(() => {
|
||||
rootComponent.inputMode = this._initialInputMode;
|
||||
});
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
// recover input mode when keyboard toolbar is hidden
|
||||
if (!this._show$.value) {
|
||||
rootComponent.inputMode = this._initialInputMode;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (this._docTitle) {
|
||||
@@ -129,7 +114,6 @@ export class AffineKeyboardToolbarWidget extends WidgetComponent<RootBlockModel>
|
||||
.keyboard=${this.keyboard}
|
||||
.config=${this.config}
|
||||
.rootComponent=${this.block.rootComponent}
|
||||
.close=${this._close}
|
||||
></affine-keyboard-toolbar>`}
|
||||
></blocksuite-portal>`;
|
||||
}
|
||||
|
||||
@@ -113,11 +113,9 @@ export class LinkedDocPopover extends SignalWatcher(
|
||||
}
|
||||
|
||||
private get _flattenActionList() {
|
||||
return this._actionGroup
|
||||
.map(group =>
|
||||
group.items.map(item => ({ ...item, groupName: group.name }))
|
||||
)
|
||||
.flat();
|
||||
return this._actionGroup.flatMap(group =>
|
||||
group.items.map(item => ({ ...item, groupName: group.name }))
|
||||
);
|
||||
}
|
||||
|
||||
private get _query() {
|
||||
|
||||
@@ -142,15 +142,13 @@ export class SlashMenu extends WithDisposable(LitElement) {
|
||||
// We search first and second layer
|
||||
if (this._filteredItems.length !== 0 && depth >= 1) break;
|
||||
|
||||
queue = queue
|
||||
.map<typeof queue>(item => {
|
||||
if (isSubMenuItem(item)) {
|
||||
return item.subMenu;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
})
|
||||
.flat();
|
||||
queue = queue.flatMap(item => {
|
||||
if (isSubMenuItem(item)) {
|
||||
return item.subMenu;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
depth++;
|
||||
}
|
||||
|
||||
@@ -418,9 +418,9 @@ export class AffineToolbarWidget extends WidgetComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
const elementIds = selections
|
||||
.map(s => (s.editing || s.inoperable ? [] : s.elements))
|
||||
.flat();
|
||||
const elementIds = selections.flatMap(s =>
|
||||
s.editing || s.inoperable ? [] : s.elements
|
||||
);
|
||||
const count = elementIds.length;
|
||||
const activated = context.activated && Boolean(count);
|
||||
|
||||
|
||||
@@ -229,8 +229,7 @@ export function renderToolbar(
|
||||
? module.config.when(context)
|
||||
: (module.config.when ?? true)
|
||||
)
|
||||
.map<ToolbarActions>(module => module.config.actions)
|
||||
.flat();
|
||||
.flatMap(module => module.config.actions);
|
||||
|
||||
const combined = combine(actions, context);
|
||||
|
||||
|
||||
@@ -159,6 +159,7 @@
|
||||
}
|
||||
],
|
||||
"unicorn/prefer-array-some": "error",
|
||||
"unicorn/prefer-array-flat-map": "off",
|
||||
"unicorn/no-useless-promise-resolve-reject": "error",
|
||||
"unicorn/no-unnecessary-await": "error",
|
||||
"unicorn/no-useless-fallback-in-spread": "error",
|
||||
|
||||
@@ -135,6 +135,7 @@
|
||||
"object.fromentries": "npm:@nolyfill/object.fromentries@^1",
|
||||
"object.hasown": "npm:@nolyfill/object.hasown@^1",
|
||||
"object.values": "npm:@nolyfill/object.values@^1",
|
||||
"on-headers": "npm:on-headers@^1.1.0",
|
||||
"reflect.getprototypeof": "npm:@nolyfill/reflect.getprototypeof@^1",
|
||||
"regexp.prototype.flags": "npm:@nolyfill/regexp.prototype.flags@^1",
|
||||
"safe-array-concat": "npm:@nolyfill/safe-array-concat@^1",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
-- CreateTable
|
||||
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- The primary key for the `ai_workspace_embeddings` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
- The primary key for the `ai_workspace_file_embeddings` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
|
||||
*/
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'ai_workspace_embeddings') AND
|
||||
EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'ai_workspace_file_embeddings') THEN
|
||||
CREATE TABLE "ai_workspace_blob_embeddings" (
|
||||
"workspace_id" VARCHAR NOT NULL,
|
||||
"blob_id" VARCHAR NOT NULL,
|
||||
"chunk" INTEGER NOT NULL,
|
||||
"content" VARCHAR NOT NULL,
|
||||
"embedding" vector(1024) NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ai_workspace_blob_embeddings_pkey" PRIMARY KEY ("workspace_id","blob_id","chunk")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ai_workspace_blob_embeddings_idx" ON "ai_workspace_blob_embeddings"
|
||||
USING hnsw (embedding vector_cosine_ops);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ai_workspace_blob_embeddings"
|
||||
ADD CONSTRAINT "ai_workspace_blob_embeddings_workspace_id_blob_id_fkey"
|
||||
FOREIGN KEY ("workspace_id", "blob_id")
|
||||
REFERENCES "blobs"("workspace_id", "key")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "access_tokens" (
|
||||
"id" VARCHAR NOT NULL,
|
||||
"name" VARCHAR NOT NULL,
|
||||
"token" VARCHAR NOT NULL,
|
||||
"user_id" VARCHAR NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expires_at" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "access_tokens_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "access_tokens_token_key" ON "access_tokens"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "access_tokens_user_id_idx" ON "access_tokens"("user_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "access_tokens" ADD CONSTRAINT "access_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -40,6 +40,7 @@
|
||||
"@fal-ai/serverless-client": "^0.15.0",
|
||||
"@google-cloud/opentelemetry-cloud-trace-exporter": "^2.4.1",
|
||||
"@google-cloud/opentelemetry-resource-util": "^2.4.0",
|
||||
"@modelcontextprotocol/sdk": "^1.16.0",
|
||||
"@nestjs-cls/transactional": "^2.6.1",
|
||||
"@nestjs-cls/transactional-adapter-prisma": "^1.2.19",
|
||||
"@nestjs/apollo": "^13.0.4",
|
||||
@@ -85,6 +86,7 @@
|
||||
"express": "^5.0.1",
|
||||
"fast-xml-parser": "^5.0.0",
|
||||
"get-stream": "^9.0.1",
|
||||
"google-auth-library": "^10.2.0",
|
||||
"graphql": "^16.9.0",
|
||||
"graphql-scalars": "^1.24.0",
|
||||
"graphql-upload": "^17.0.0",
|
||||
@@ -103,7 +105,7 @@
|
||||
"nest-winston": "^1.9.7",
|
||||
"nestjs-cls": "^6.0.0",
|
||||
"nodemailer": "^7.0.0",
|
||||
"on-headers": "^1.0.2",
|
||||
"on-headers": "^1.1.0",
|
||||
"piscina": "^5.0.0-alpha.0",
|
||||
"prisma": "^6.6.0",
|
||||
"react": "19.1.0",
|
||||
|
||||
@@ -49,6 +49,7 @@ model User {
|
||||
comments Comment[]
|
||||
replies Reply[]
|
||||
commentAttachments CommentAttachment[] @relation("createdCommentAttachments")
|
||||
AccessToken AccessToken[]
|
||||
|
||||
@@index([email])
|
||||
@@map("users")
|
||||
@@ -568,6 +569,23 @@ model AiWorkspaceFileEmbedding {
|
||||
@@map("ai_workspace_file_embeddings")
|
||||
}
|
||||
|
||||
model AiWorkspaceBlobEmbedding {
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
blobId String @map("blob_id") @db.VarChar
|
||||
// a file can be divided into multiple chunks and embedded separately.
|
||||
chunk Int @db.Integer
|
||||
content String @db.VarChar
|
||||
embedding Unsupported("vector(1024)")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
|
||||
blob Blob @relation(fields: [workspaceId, blobId], references: [workspaceId, key], onDelete: Cascade)
|
||||
|
||||
@@id([workspaceId, blobId, chunk])
|
||||
@@index([embedding], map: "ai_workspace_blob_embeddings_idx")
|
||||
@@map("ai_workspace_blob_embeddings")
|
||||
}
|
||||
|
||||
enum AiJobStatus {
|
||||
pending
|
||||
running
|
||||
@@ -807,7 +825,8 @@ model Blob {
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
|
||||
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
AiWorkspaceBlobEmbedding AiWorkspaceBlobEmbedding[]
|
||||
|
||||
@@id([workspaceId, key])
|
||||
@@map("blobs")
|
||||
@@ -931,3 +950,17 @@ model CommentAttachment {
|
||||
@@id([workspaceId, docId, key])
|
||||
@@map("comment_attachments")
|
||||
}
|
||||
|
||||
model AccessToken {
|
||||
id String @id @default(uuid()) @db.VarChar
|
||||
name String @db.VarChar
|
||||
token String @unique @db.VarChar
|
||||
userId String @map("user_id") @db.VarChar
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
expiresAt DateTime? @map("expires_at") @db.Timestamptz(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@map("access_tokens")
|
||||
}
|
||||
|
||||
@@ -96,6 +96,21 @@ test('should be able to visit private api if signed in', async t => {
|
||||
t.is(res.body.user.id, u1.id);
|
||||
});
|
||||
|
||||
test('should be able to visit private api with access token', async t => {
|
||||
const models = t.context.app.get(Models);
|
||||
const token = await models.accessToken.create({
|
||||
userId: u1.id,
|
||||
name: 'test',
|
||||
});
|
||||
|
||||
const res = await request(server)
|
||||
.get('/private')
|
||||
.set('Authorization', `Bearer ${token.token}`)
|
||||
.expect(HttpStatus.OK);
|
||||
|
||||
t.is(res.body.user.id, u1.id);
|
||||
});
|
||||
|
||||
test('should be able to parse session cookie', async t => {
|
||||
const spy = Sinon.spy(auth, 'getUserSession');
|
||||
await request(server)
|
||||
|
||||
@@ -384,12 +384,12 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
|
||||
role: 'user' as const,
|
||||
content: 'what is ssot',
|
||||
params: {
|
||||
files: [
|
||||
docs: [
|
||||
{
|
||||
blobId: 'SSOT',
|
||||
fileName: 'Single source of truth - Wikipedia',
|
||||
docId: 'SSOT',
|
||||
docTitle: 'Single source of truth - Wikipedia',
|
||||
fileType: 'text/markdown',
|
||||
fileContent: TestAssets.SSOT,
|
||||
docContent: TestAssets.SSOT,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -530,26 +530,29 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
|
||||
'Create headings',
|
||||
'Make it longer',
|
||||
'Make it shorter',
|
||||
'Continue writing',
|
||||
'Section Edit',
|
||||
'Chat With AFFiNE AI',
|
||||
'Search With AFFiNE AI',
|
||||
],
|
||||
messages: [{ role: 'user' as const, content: TestAssets.SSOT }],
|
||||
messages: [{ role: 'user' as const, content: TestAssets.AFFiNE }],
|
||||
verifier: (t: ExecutionContext<Tester>, result: string) => {
|
||||
assertNotWrappedInCodeBlock(t, result);
|
||||
const cleared = result.toLowerCase();
|
||||
t.assert(
|
||||
cleared.includes('single source of truth') ||
|
||||
/single.*source/.test(cleared) ||
|
||||
cleared.includes('ssot'),
|
||||
'should include original keyword'
|
||||
);
|
||||
t.assert(result.includes('AFFiNE'), 'should include original keyword');
|
||||
},
|
||||
type: 'text' as const,
|
||||
},
|
||||
{
|
||||
promptName: ['Continue writing'],
|
||||
messages: [{ role: 'user' as const, content: TestAssets.AFFiNE }],
|
||||
verifier: (t: ExecutionContext<Tester>, result: string) => {
|
||||
assertNotWrappedInCodeBlock(t, result);
|
||||
t.assert(result.length > 0, 'should not be empty');
|
||||
},
|
||||
type: 'text' as const,
|
||||
},
|
||||
{
|
||||
promptName: ['Brainstorm ideas about this', 'Brainstorm mindmap'],
|
||||
messages: [{ role: 'user' as const, content: TestAssets.SSOT }],
|
||||
messages: [{ role: 'user' as const, content: TestAssets.AFFiNE }],
|
||||
verifier: (t: ExecutionContext<Tester>, result: string) => {
|
||||
assertNotWrappedInCodeBlock(t, result);
|
||||
t.assert(checkMDList(result), 'should be a markdown list');
|
||||
@@ -592,17 +595,13 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
|
||||
messages: [
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: TestAssets.SSOT,
|
||||
content: TestAssets.AFFiNE,
|
||||
params: { language: 'Simplified Chinese' },
|
||||
},
|
||||
],
|
||||
verifier: (t: ExecutionContext<Tester>, result: string) => {
|
||||
assertNotWrappedInCodeBlock(t, result);
|
||||
const cleared = result.toLowerCase();
|
||||
t.assert(
|
||||
cleared.includes('单一') || cleared.includes('SSOT'),
|
||||
'explain code result should include keyword'
|
||||
);
|
||||
t.assert(result.includes('AFFiNE'), 'should include keyword');
|
||||
},
|
||||
type: 'text' as const,
|
||||
},
|
||||
@@ -624,7 +623,7 @@ The term **“CRDT”** was first introduced by Marc Shapiro, Nuno Preguiça, Ca
|
||||
content.includes('classroom') ||
|
||||
content.includes('school') ||
|
||||
content.includes('sky'),
|
||||
'explain code result should include keyword'
|
||||
'should include keyword'
|
||||
);
|
||||
},
|
||||
type: 'text' as const,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { EventBus, JobQueue } from '../base';
|
||||
import { ConfigModule } from '../base/config';
|
||||
import { AuthService } from '../core/auth';
|
||||
import { QuotaModule } from '../core/quota';
|
||||
import { StorageModule, WorkspaceBlobStorage } from '../core/storage';
|
||||
import {
|
||||
ContextCategories,
|
||||
CopilotSessionModel,
|
||||
@@ -68,6 +69,7 @@ type Context = {
|
||||
db: PrismaClient;
|
||||
event: EventBus;
|
||||
workspace: WorkspaceModel;
|
||||
workspaceStorage: WorkspaceBlobStorage;
|
||||
copilotSession: CopilotSessionModel;
|
||||
context: CopilotContextService;
|
||||
prompt: PromptService;
|
||||
@@ -114,6 +116,7 @@ test.before(async t => {
|
||||
},
|
||||
}),
|
||||
QuotaModule,
|
||||
StorageModule,
|
||||
CopilotModule,
|
||||
],
|
||||
tapModule: builder => {
|
||||
@@ -127,6 +130,7 @@ test.before(async t => {
|
||||
const db = module.get(PrismaClient);
|
||||
const event = module.get(EventBus);
|
||||
const workspace = module.get(WorkspaceModel);
|
||||
const workspaceStorage = module.get(WorkspaceBlobStorage);
|
||||
const copilotSession = module.get(CopilotSessionModel);
|
||||
const prompt = module.get(PromptService);
|
||||
const factory = module.get(CopilotProviderFactory);
|
||||
@@ -146,6 +150,7 @@ test.before(async t => {
|
||||
t.context.db = db;
|
||||
t.context.event = event;
|
||||
t.context.workspace = workspace;
|
||||
t.context.workspaceStorage = workspaceStorage;
|
||||
t.context.copilotSession = copilotSession;
|
||||
t.context.prompt = prompt;
|
||||
t.context.factory = factory;
|
||||
@@ -1520,14 +1525,25 @@ test('TextStreamParser should process a sequence of message chunks', t => {
|
||||
|
||||
// ==================== context ====================
|
||||
test('should be able to manage context', async t => {
|
||||
const { context, prompt, session, event, jobs, storage } = t.context;
|
||||
const {
|
||||
context,
|
||||
event,
|
||||
jobs,
|
||||
prompt,
|
||||
session,
|
||||
storage,
|
||||
workspace,
|
||||
workspaceStorage,
|
||||
} = t.context;
|
||||
|
||||
const ws = await workspace.create(userId);
|
||||
|
||||
await prompt.set(promptName, 'model', [
|
||||
{ role: 'system', content: 'hello {{word}}' },
|
||||
]);
|
||||
const chatSession = await session.create({
|
||||
docId: 'test',
|
||||
workspaceId: 'test',
|
||||
workspaceId: ws.id,
|
||||
userId,
|
||||
promptName,
|
||||
pinned: false,
|
||||
@@ -1608,6 +1624,24 @@ test('should be able to manage context', async t => {
|
||||
t.is(result[0].fileId, file.id, 'should match file id');
|
||||
}
|
||||
|
||||
// blob record
|
||||
{
|
||||
const blobId = 'test-blob';
|
||||
await workspaceStorage.put(session.workspaceId, blobId, buffer);
|
||||
|
||||
await jobs.embedPendingBlob({ workspaceId: session.workspaceId, blobId });
|
||||
|
||||
const result = await t.context.context.matchWorkspaceBlobs(
|
||||
session.workspaceId,
|
||||
'test',
|
||||
1,
|
||||
undefined,
|
||||
1
|
||||
);
|
||||
t.is(result.length, 1, 'should match blob embedding');
|
||||
t.is(result[0].blobId, blobId, 'should match blob id');
|
||||
}
|
||||
|
||||
// doc record
|
||||
|
||||
const addDoc = async () => {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { AccessToken } from '@prisma/client';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import { Mocker } from './factory';
|
||||
|
||||
export type MockAccessTokenInput = Omit<
|
||||
Prisma.AccessTokenUncheckedCreateInput,
|
||||
'token'
|
||||
>;
|
||||
|
||||
export type MockedAccessToken = AccessToken;
|
||||
|
||||
export class MockAccessToken extends Mocker<
|
||||
MockAccessTokenInput,
|
||||
MockedAccessToken
|
||||
> {
|
||||
override async create(input: MockAccessTokenInput) {
|
||||
return await this.db.accessToken.create({
|
||||
data: {
|
||||
...input,
|
||||
name: input.name ?? faker.lorem.word(),
|
||||
token: 'ut_' + faker.string.hexadecimal({ length: 37 }),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export * from './user.mock';
|
||||
export * from './workspace.mock';
|
||||
export * from './workspace-user.mock';
|
||||
|
||||
import { MockAccessToken } from './access-token.mock';
|
||||
import { MockCopilotProvider } from './copilot.mock';
|
||||
import { MockDocMeta } from './doc-meta.mock';
|
||||
import { MockDocSnapshot } from './doc-snapshot.mock';
|
||||
@@ -26,6 +27,7 @@ export const Mockers = {
|
||||
DocMeta: MockDocMeta,
|
||||
DocSnapshot: MockDocSnapshot,
|
||||
DocUser: MockDocUser,
|
||||
AccessToken: MockAccessToken,
|
||||
};
|
||||
|
||||
export { MockCopilotProvider, MockEventBus, MockJobQueue, MockMailer };
|
||||
|
||||
@@ -74,6 +74,17 @@ Generated by [AVA](https://avajs.dev).
|
||||
},
|
||||
]
|
||||
|
||||
> should match workspace blob embedding
|
||||
|
||||
[
|
||||
{
|
||||
blobId: 'blob-test',
|
||||
chunk: 0,
|
||||
content: 'blob content',
|
||||
distance: 0,
|
||||
},
|
||||
]
|
||||
|
||||
> should find docs to embed
|
||||
|
||||
1
|
||||
|
||||
Binary file not shown.
@@ -89,13 +89,14 @@ test('should get null for non-exist job', async t => {
|
||||
|
||||
test('should update context', async t => {
|
||||
const { id: contextId } = await t.context.copilotContext.create(sessionId);
|
||||
const config = await t.context.copilotContext.getConfig(contextId);
|
||||
const config = (await t.context.copilotContext.getConfig(contextId))!;
|
||||
t.assert(config, 'should get context config');
|
||||
|
||||
const doc = {
|
||||
id: docId,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
config?.docs.push(doc);
|
||||
config.docs.push(doc);
|
||||
await t.context.copilotContext.update(contextId, { config });
|
||||
|
||||
const config1 = await t.context.copilotContext.getConfig(contextId);
|
||||
@@ -164,7 +165,7 @@ test('should insert embedding by doc id', async t => {
|
||||
);
|
||||
|
||||
{
|
||||
const ret = await t.context.copilotContext.listWorkspaceEmbedding(
|
||||
const ret = await t.context.copilotContext.listWorkspaceDocEmbedding(
|
||||
workspace.id,
|
||||
[docId]
|
||||
);
|
||||
@@ -320,7 +321,7 @@ test('should merge doc status correctly', async t => {
|
||||
|
||||
const hasEmbeddingStub = Sinon.stub(
|
||||
t.context.copilotContext,
|
||||
'listWorkspaceEmbedding'
|
||||
'listWorkspaceDocEmbedding'
|
||||
).resolves([]);
|
||||
|
||||
const stubResult = await t.context.copilotContext.mergeDocStatus(
|
||||
|
||||
@@ -145,6 +145,52 @@ test('should insert and search embedding', async t => {
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
await t.context.db.blob.create({
|
||||
data: {
|
||||
workspaceId: workspace.id,
|
||||
key: 'blob-test',
|
||||
mime: 'text/plain',
|
||||
size: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const blobId = 'blob-test';
|
||||
await t.context.copilotWorkspace.insertBlobEmbeddings(
|
||||
workspace.id,
|
||||
blobId,
|
||||
[
|
||||
{
|
||||
index: 0,
|
||||
content: 'blob content',
|
||||
embedding: Array.from({ length: 1024 }, () => 1),
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
{
|
||||
const ret = await t.context.copilotWorkspace.matchBlobEmbedding(
|
||||
workspace.id,
|
||||
Array.from({ length: 1024 }, () => 0.9),
|
||||
1,
|
||||
1
|
||||
);
|
||||
t.snapshot(cleanObject(ret), 'should match workspace blob embedding');
|
||||
}
|
||||
|
||||
await t.context.copilotWorkspace.removeBlob(workspace.id, blobId);
|
||||
|
||||
{
|
||||
const ret = await t.context.copilotWorkspace.matchBlobEmbedding(
|
||||
workspace.id,
|
||||
Array.from({ length: 1024 }, () => 0.9),
|
||||
1,
|
||||
1
|
||||
);
|
||||
t.deepEqual(ret, [], 'should not match after removal');
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const docId = randomUUID();
|
||||
await t.context.doc.upsert({
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -28,6 +28,7 @@ import { RedisModule } from './base/redis';
|
||||
import { StorageProviderModule } from './base/storage';
|
||||
import { RateLimiterModule } from './base/throttler';
|
||||
import { WebSocketModule } from './base/websocket';
|
||||
import { AccessTokenModule } from './core/access-token';
|
||||
import { AuthModule } from './core/auth';
|
||||
import { CommentModule } from './core/comment';
|
||||
import { ServerConfigModule, ServerConfigResolverModule } from './core/config';
|
||||
@@ -187,7 +188,8 @@ export function buildAppModule(env: Env) {
|
||||
CaptchaModule,
|
||||
OAuthModule,
|
||||
CustomerIoModule,
|
||||
CommentModule
|
||||
CommentModule,
|
||||
AccessTokenModule
|
||||
)
|
||||
// doc service only
|
||||
.useIf(() => env.flavors.doc, DocServiceModule)
|
||||
|
||||
8
packages/backend/server/src/core/access-token/index.ts
Normal file
8
packages/backend/server/src/core/access-token/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AccessTokenResolver } from './resolver';
|
||||
|
||||
@Module({
|
||||
providers: [AccessTokenResolver],
|
||||
})
|
||||
export class AccessTokenModule {}
|
||||
73
packages/backend/server/src/core/access-token/resolver.ts
Normal file
73
packages/backend/server/src/core/access-token/resolver.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
Args,
|
||||
Field,
|
||||
InputType,
|
||||
Mutation,
|
||||
ObjectType,
|
||||
Query,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
|
||||
import { Models } from '../../models';
|
||||
import { CurrentUser } from '../auth/session';
|
||||
|
||||
@ObjectType()
|
||||
class AccessToken {
|
||||
@Field()
|
||||
id!: string;
|
||||
|
||||
@Field()
|
||||
name!: string;
|
||||
|
||||
@Field()
|
||||
createdAt!: Date;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
expiresAt!: Date | null;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
class RevealedAccessToken extends AccessToken {
|
||||
@Field()
|
||||
token!: string;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
class GenerateAccessTokenInput {
|
||||
@Field()
|
||||
name!: string;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
expiresAt!: Date | null;
|
||||
}
|
||||
|
||||
@Resolver(() => AccessToken)
|
||||
export class AccessTokenResolver {
|
||||
constructor(private readonly models: Models) {}
|
||||
|
||||
@Query(() => [AccessToken])
|
||||
async accessTokens(@CurrentUser() user: CurrentUser): Promise<AccessToken[]> {
|
||||
return await this.models.accessToken.list(user.id);
|
||||
}
|
||||
|
||||
@Mutation(() => RevealedAccessToken)
|
||||
async generateUserAccessToken(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('input') input: GenerateAccessTokenInput
|
||||
): Promise<RevealedAccessToken> {
|
||||
return await this.models.accessToken.create({
|
||||
userId: user.id,
|
||||
name: input.name,
|
||||
expiresAt: input.expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async revokeUserAccessToken(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('id') id: string
|
||||
): Promise<boolean> {
|
||||
await this.models.accessToken.revoke(id, user.id);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from '../../base';
|
||||
import { WEBSOCKET_OPTIONS } from '../../base/websocket';
|
||||
import { AuthService } from './service';
|
||||
import { Session } from './session';
|
||||
import { Session, TokenSession } from './session';
|
||||
|
||||
const PUBLIC_ENTRYPOINT_SYMBOL = Symbol('public');
|
||||
const INTERNAL_ENTRYPOINT_SYMBOL = Symbol('internal');
|
||||
@@ -56,10 +56,7 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
throw new AccessDenied('Invalid internal request');
|
||||
}
|
||||
|
||||
const userSession = await this.signIn(req, res);
|
||||
if (res && userSession && userSession.expiresAt) {
|
||||
await this.auth.refreshUserSessionIfNeeded(res, userSession);
|
||||
}
|
||||
const authedUser = await this.signIn(req, res);
|
||||
|
||||
// api is public
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(
|
||||
@@ -71,14 +68,29 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!userSession) {
|
||||
if (!authedUser) {
|
||||
throw new AuthenticationRequired();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async signIn(req: Request, res?: Response): Promise<Session | null> {
|
||||
async signIn(
|
||||
req: Request,
|
||||
res?: Response
|
||||
): Promise<Session | TokenSession | null> {
|
||||
const userSession = await this.signInWithCookie(req, res);
|
||||
if (userSession) {
|
||||
return userSession;
|
||||
}
|
||||
|
||||
return await this.signInWithAccessToken(req);
|
||||
}
|
||||
|
||||
async signInWithCookie(
|
||||
req: Request,
|
||||
res?: Response
|
||||
): Promise<Session | null> {
|
||||
if (req.session) {
|
||||
return req.session;
|
||||
}
|
||||
@@ -87,6 +99,10 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
const userSession = await this.auth.getUserSessionFromRequest(req, res);
|
||||
|
||||
if (userSession) {
|
||||
if (res) {
|
||||
await this.auth.refreshUserSessionIfNeeded(res, userSession.session);
|
||||
}
|
||||
|
||||
req.session = {
|
||||
...userSession.session,
|
||||
user: userSession.user,
|
||||
@@ -97,6 +113,25 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async signInWithAccessToken(req: Request): Promise<TokenSession | null> {
|
||||
if (req.token) {
|
||||
return req.token;
|
||||
}
|
||||
|
||||
const tokenSession = await this.auth.getTokenSessionFromRequest(req);
|
||||
|
||||
if (tokenSession) {
|
||||
req.token = {
|
||||
...tokenSession.token,
|
||||
user: tokenSession.user,
|
||||
};
|
||||
|
||||
return req.token;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -264,6 +264,36 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
return session;
|
||||
}
|
||||
|
||||
async getTokenSessionFromRequest(req: Request) {
|
||||
const tokenHeader = req.headers.authorization;
|
||||
if (!tokenHeader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenValue = extractTokenFromHeader(tokenHeader);
|
||||
|
||||
if (!tokenValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = await this.models.accessToken.getByToken(tokenValue);
|
||||
|
||||
if (token) {
|
||||
const user = await this.models.user.get(token.userId);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
user: sessionUser(user),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async changePassword(
|
||||
id: string,
|
||||
newPassword: string
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ExecutionContext } from '@nestjs/common';
|
||||
import { createParamDecorator } from '@nestjs/common';
|
||||
import { AccessToken } from '@prisma/client';
|
||||
|
||||
import { getRequestResponseFromContext } from '../../base';
|
||||
import type { User, UserSession } from '../../models';
|
||||
@@ -40,7 +41,8 @@ import type { User, UserSession } from '../../models';
|
||||
// oxlint-disable-next-line no-redeclare
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_: unknown, context: ExecutionContext) => {
|
||||
return getRequestResponseFromContext(context).req.session?.user;
|
||||
const req = getRequestResponseFromContext(context).req;
|
||||
return req.session?.user ?? req.token?.user;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -61,3 +63,7 @@ export const Session = createParamDecorator(
|
||||
export type Session = UserSession & {
|
||||
user: CurrentUser;
|
||||
};
|
||||
|
||||
export type TokenSession = AccessToken & {
|
||||
user: CurrentUser;
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import z from 'zod';
|
||||
|
||||
import { defineModuleConfig } from '../../base';
|
||||
|
||||
declare global {
|
||||
@@ -11,6 +13,16 @@ declare global {
|
||||
ignoreTLS: boolean;
|
||||
sender: string;
|
||||
};
|
||||
|
||||
fallbackDomains: ConfigItem<string[]>;
|
||||
fallbackSMTP: {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
ignoreTLS: boolean;
|
||||
sender: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -46,4 +58,34 @@ defineModuleConfig('mailer', {
|
||||
default: false,
|
||||
env: ['MAILER_IGNORE_TLS', 'boolean'],
|
||||
},
|
||||
|
||||
fallbackDomains: {
|
||||
desc: 'The emails from these domains are always sent using the fallback SMTP server.',
|
||||
default: [],
|
||||
shape: z.array(z.string()),
|
||||
},
|
||||
'fallbackSMTP.host': {
|
||||
desc: 'Host of the email server (e.g. smtp.gmail.com)',
|
||||
default: '',
|
||||
},
|
||||
'fallbackSMTP.port': {
|
||||
desc: 'Port of the email server (they commonly are 25, 465 or 587)',
|
||||
default: 465,
|
||||
},
|
||||
'fallbackSMTP.username': {
|
||||
desc: 'Username used to authenticate the email server',
|
||||
default: '',
|
||||
},
|
||||
'fallbackSMTP.password': {
|
||||
desc: 'Password used to authenticate the email server',
|
||||
default: '',
|
||||
},
|
||||
'fallbackSMTP.sender': {
|
||||
desc: 'Sender of all the emails (e.g. "AFFiNE Team <noreply@affine.pro>")',
|
||||
default: '',
|
||||
},
|
||||
'fallbackSMTP.ignoreTLS': {
|
||||
desc: "Whether ignore email server's TSL certification verification. Enable it for self-signed certificates.",
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -36,6 +36,8 @@ function configToSMTPOptions(
|
||||
export class MailSender {
|
||||
private readonly logger = new Logger(MailSender.name);
|
||||
private smtp: Transporter<SMTPTransport.SentMessageInfo> | null = null;
|
||||
private fallbackSMTP: Transporter<SMTPTransport.SentMessageInfo> | null =
|
||||
null;
|
||||
private usingTestAccount = false;
|
||||
constructor(private readonly config: Config) {}
|
||||
|
||||
@@ -61,11 +63,17 @@ export class MailSender {
|
||||
}
|
||||
|
||||
private setup() {
|
||||
const { SMTP } = this.config.mailer;
|
||||
const { SMTP, fallbackDomains, fallbackSMTP } = this.config.mailer;
|
||||
const opts = configToSMTPOptions(SMTP);
|
||||
|
||||
if (SMTP.host) {
|
||||
this.smtp = createTransport(opts);
|
||||
if (fallbackDomains.length > 0 && fallbackSMTP?.host) {
|
||||
this.logger.warn(
|
||||
`Fallback SMTP is configured for domains: ${fallbackDomains.join(', ')}`
|
||||
);
|
||||
this.fallbackSMTP = createTransport(configToSMTPOptions(fallbackSMTP));
|
||||
}
|
||||
} else if (env.dev) {
|
||||
createTestAccount((err, account) => {
|
||||
if (!err) {
|
||||
@@ -83,21 +91,34 @@ export class MailSender {
|
||||
} else {
|
||||
this.logger.warn('Mailer SMTP transport is not configured.');
|
||||
this.smtp = null;
|
||||
this.fallbackSMTP = null;
|
||||
}
|
||||
}
|
||||
|
||||
private getSender(domain: string) {
|
||||
const { SMTP, fallbackSMTP, fallbackDomains } = this.config.mailer;
|
||||
if (this.fallbackSMTP && fallbackDomains.includes(domain)) {
|
||||
return [this.fallbackSMTP, fallbackSMTP.sender] as const;
|
||||
}
|
||||
return [this.smtp, SMTP.sender] as const;
|
||||
}
|
||||
|
||||
async send(name: string, options: SendOptions) {
|
||||
if (!this.smtp) {
|
||||
const [, domain, ...rest] = options.to.split('@');
|
||||
if (rest.length || !domain) {
|
||||
this.logger.error(`Invalid email address: ${options.to}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const [smtpClient, from] = this.getSender(domain);
|
||||
if (!smtpClient) {
|
||||
this.logger.warn(`Mailer SMTP transport is not configured to send mail.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
metrics.mail.counter('send_total').add(1, { name });
|
||||
try {
|
||||
const result = await this.smtp.sendMail({
|
||||
from: this.config.mailer.SMTP.sender,
|
||||
...options,
|
||||
});
|
||||
const result = await smtpClient.sendMail({ from, ...options });
|
||||
|
||||
if (result.rejected.length > 0) {
|
||||
metrics.mail.counter('rejected_total').add(1, { name });
|
||||
|
||||
1
packages/backend/server/src/global.d.ts
vendored
1
packages/backend/server/src/global.d.ts
vendored
@@ -1,6 +1,7 @@
|
||||
declare namespace Express {
|
||||
interface Request {
|
||||
session?: import('./core/auth/session').Session;
|
||||
token?: import('./core/auth/session').TokenSession;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import test from 'ava';
|
||||
|
||||
import { createModule } from '../../__tests__/create-module';
|
||||
import { Mockers } from '../../__tests__/mocks';
|
||||
import { Due } from '../../base';
|
||||
import { Models } from '..';
|
||||
|
||||
const module = await createModule();
|
||||
const models = module.get(Models);
|
||||
|
||||
test.after.always(async () => {
|
||||
await module.close();
|
||||
});
|
||||
|
||||
test('should create access token', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
|
||||
const token = await models.accessToken.create({
|
||||
userId: user.id,
|
||||
name: 'test',
|
||||
});
|
||||
|
||||
t.is(token.userId, user.id);
|
||||
t.is(token.name, 'test');
|
||||
t.truthy(token.token);
|
||||
t.truthy(token.createdAt);
|
||||
t.is(token.expiresAt, null);
|
||||
});
|
||||
|
||||
test('should create access token with expiration', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
|
||||
const token = await models.accessToken.create({
|
||||
userId: user.id,
|
||||
name: 'test',
|
||||
expiresAt: Due.after('30d'),
|
||||
});
|
||||
|
||||
t.truthy(token.expiresAt);
|
||||
t.truthy(token.expiresAt! > new Date());
|
||||
});
|
||||
|
||||
test('should list access tokens without token value', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
await module.create(Mockers.AccessToken, { userId: user.id }, 3);
|
||||
|
||||
const listed = await models.accessToken.list(user.id);
|
||||
t.is(listed.length, 3);
|
||||
// @ts-expect-error not exists
|
||||
t.is(listed[0].token, undefined);
|
||||
});
|
||||
|
||||
test('should be able to revoke access token', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
const token = await module.create(Mockers.AccessToken, { userId: user.id });
|
||||
|
||||
await models.accessToken.revoke(token.id, user.id);
|
||||
|
||||
const listed = await models.accessToken.list(user.id);
|
||||
t.is(listed.length, 0);
|
||||
});
|
||||
|
||||
test('should be able to get access token by token value', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
const token = await module.create(Mockers.AccessToken, { userId: user.id });
|
||||
|
||||
const found = await models.accessToken.getByToken(token.token);
|
||||
t.is(found?.id, token.id);
|
||||
t.is(found?.userId, user.id);
|
||||
t.is(found?.name, token.name);
|
||||
});
|
||||
|
||||
test('should not get expired access token', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
const token = await module.create(Mockers.AccessToken, {
|
||||
userId: user.id,
|
||||
expiresAt: Due.before('1s'),
|
||||
});
|
||||
|
||||
const found = await models.accessToken.getByToken(token.token);
|
||||
t.is(found, null);
|
||||
});
|
||||
70
packages/backend/server/src/models/access-token.ts
Normal file
70
packages/backend/server/src/models/access-token.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { CryptoHelper } from '../base';
|
||||
import { BaseModel } from './base';
|
||||
|
||||
export interface CreateAccessTokenInput {
|
||||
userId: string;
|
||||
name: string;
|
||||
expiresAt?: Date | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AccessTokenModel extends BaseModel {
|
||||
constructor(private readonly crypto: CryptoHelper) {
|
||||
super();
|
||||
}
|
||||
|
||||
async list(userId: string) {
|
||||
return await this.db.accessToken.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
createdAt: true,
|
||||
expiresAt: true,
|
||||
},
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async create(input: CreateAccessTokenInput) {
|
||||
let token = 'ut_' + this.crypto.randomBytes(40).toString('hex');
|
||||
token = token.substring(0, 40);
|
||||
|
||||
return await this.db.accessToken.create({
|
||||
data: {
|
||||
token,
|
||||
...input,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async revoke(id: string, userId: string) {
|
||||
await this.db.accessToken.deleteMany({
|
||||
where: {
|
||||
id,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getByToken(token: string) {
|
||||
return await this.db.accessToken.findUnique({
|
||||
where: {
|
||||
token,
|
||||
OR: [
|
||||
{
|
||||
expiresAt: null,
|
||||
},
|
||||
{
|
||||
expiresAt: {
|
||||
gt: new Date(),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,11 @@ const ContextEmbedStatusSchema = z.enum([
|
||||
ContextEmbedStatus.failed,
|
||||
]);
|
||||
|
||||
const ContextBlobSchema = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.number(),
|
||||
});
|
||||
|
||||
const ContextDocSchema = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.number(),
|
||||
@@ -64,6 +69,9 @@ export const ContextCategorySchema = z.object({
|
||||
|
||||
export const ContextConfigSchema = z.object({
|
||||
workspaceId: z.string(),
|
||||
blobs: ContextBlobSchema.merge(
|
||||
z.object({ status: ContextEmbedStatusSchema.optional() })
|
||||
).array(),
|
||||
files: ContextFileSchema.array(),
|
||||
docs: ContextDocSchema.merge(
|
||||
z.object({ status: ContextEmbedStatusSchema.optional() })
|
||||
@@ -77,10 +85,9 @@ export const MinimalContextConfigSchema = ContextConfigSchema.pick({
|
||||
|
||||
export type ContextCategory = z.infer<typeof ContextCategorySchema>;
|
||||
export type ContextConfig = z.infer<typeof ContextConfigSchema>;
|
||||
export type ContextBlob = z.infer<typeof ContextConfigSchema>['blobs'][number];
|
||||
export type ContextDoc = z.infer<typeof ContextConfigSchema>['docs'][number];
|
||||
export type ContextFile = z.infer<typeof ContextConfigSchema>['files'][number];
|
||||
export type ContextListItem = ContextDoc | ContextFile;
|
||||
export type ContextList = ContextListItem[];
|
||||
|
||||
// embeddings
|
||||
|
||||
@@ -106,6 +113,10 @@ export type FileChunkSimilarity = ChunkSimilarity & {
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
export type BlobChunkSimilarity = ChunkSimilarity & {
|
||||
blobId: string;
|
||||
};
|
||||
|
||||
export type DocChunkSimilarity = ChunkSimilarity & {
|
||||
docId: string;
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Prisma } from '@prisma/client';
|
||||
import { CopilotSessionNotFound } from '../base';
|
||||
import { BaseModel } from './base';
|
||||
import {
|
||||
ContextBlob,
|
||||
ContextConfigSchema,
|
||||
ContextDoc,
|
||||
ContextEmbedStatus,
|
||||
@@ -39,6 +40,7 @@ export class CopilotContextModel extends BaseModel {
|
||||
sessionId,
|
||||
config: {
|
||||
workspaceId: session.workspaceId,
|
||||
blobs: [],
|
||||
docs: [],
|
||||
files: [],
|
||||
categories: [],
|
||||
@@ -66,10 +68,11 @@ export class CopilotContextModel extends BaseModel {
|
||||
if (minimalConfig.success) {
|
||||
// fulfill the missing fields
|
||||
return {
|
||||
...minimalConfig.data,
|
||||
blobs: [],
|
||||
docs: [],
|
||||
files: [],
|
||||
categories: [],
|
||||
...minimalConfig.data,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -83,10 +86,35 @@ export class CopilotContextModel extends BaseModel {
|
||||
return row;
|
||||
}
|
||||
|
||||
async mergeBlobStatus(
|
||||
workspaceId: string,
|
||||
blobs: ContextBlob[]
|
||||
): Promise<ContextBlob[]> {
|
||||
const canEmbedding = await this.checkEmbeddingAvailable();
|
||||
const finishedBlobs = canEmbedding
|
||||
? await this.listWorkspaceBlobEmbedding(
|
||||
workspaceId,
|
||||
Array.from(new Set(blobs.map(blob => blob.id)))
|
||||
)
|
||||
: [];
|
||||
const finishedBlobSet = new Set(finishedBlobs);
|
||||
|
||||
for (const blob of blobs) {
|
||||
const status = finishedBlobSet.has(blob.id)
|
||||
? ContextEmbedStatus.finished
|
||||
: undefined;
|
||||
// NOTE: when the blob has not been synchronized to the server or is in the embedding queue
|
||||
// the status will be empty, fallback to processing if no status is provided
|
||||
blob.status = status || blob.status || ContextEmbedStatus.processing;
|
||||
}
|
||||
|
||||
return blobs;
|
||||
}
|
||||
|
||||
async mergeDocStatus(workspaceId: string, docs: ContextDoc[]) {
|
||||
const canEmbedding = await this.checkEmbeddingAvailable();
|
||||
const finishedDoc = canEmbedding
|
||||
? await this.listWorkspaceEmbedding(
|
||||
? await this.listWorkspaceDocEmbedding(
|
||||
workspaceId,
|
||||
Array.from(new Set(docs.map(doc => doc.id)))
|
||||
)
|
||||
@@ -126,7 +154,23 @@ export class CopilotContextModel extends BaseModel {
|
||||
return Number(count) === 2;
|
||||
}
|
||||
|
||||
async listWorkspaceEmbedding(workspaceId: string, docIds?: string[]) {
|
||||
async listWorkspaceBlobEmbedding(
|
||||
workspaceId: string,
|
||||
blobIds?: string[]
|
||||
): Promise<string[]> {
|
||||
const existsIds = await this.db.aiWorkspaceBlobEmbedding
|
||||
.groupBy({
|
||||
where: {
|
||||
workspaceId,
|
||||
blobId: blobIds ? { in: blobIds } : undefined,
|
||||
},
|
||||
by: ['blobId'],
|
||||
})
|
||||
.then(r => r.map(r => r.blobId));
|
||||
return existsIds;
|
||||
}
|
||||
|
||||
async listWorkspaceDocEmbedding(workspaceId: string, docIds?: string[]) {
|
||||
const existsIds = await this.db.aiWorkspaceEmbedding
|
||||
.groupBy({
|
||||
where: {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Prisma, PrismaClient } from '@prisma/client';
|
||||
import { PaginationInput } from '../base';
|
||||
import { BaseModel } from './base';
|
||||
import type {
|
||||
BlobChunkSimilarity,
|
||||
CopilotWorkspaceFile,
|
||||
CopilotWorkspaceFileMetadata,
|
||||
Embedding,
|
||||
@@ -256,19 +257,19 @@ export class CopilotWorkspaceConfigModel extends BaseModel {
|
||||
async checkEmbeddingAvailable(): Promise<boolean> {
|
||||
const [{ count }] = await this.db.$queryRaw<
|
||||
{ count: number }[]
|
||||
>`SELECT count(1) FROM pg_tables WHERE tablename in ('ai_workspace_embeddings', 'ai_workspace_file_embeddings')`;
|
||||
return Number(count) === 2;
|
||||
>`SELECT count(1) FROM pg_tables WHERE tablename in ('ai_workspace_embeddings', 'ai_workspace_file_embeddings', 'ai_workspace_blob_embeddings')`;
|
||||
return Number(count) === 3;
|
||||
}
|
||||
|
||||
private processEmbeddings(
|
||||
workspaceId: string,
|
||||
fileId: string,
|
||||
fileOrBlobId: string,
|
||||
embeddings: Embedding[]
|
||||
) {
|
||||
const groups = embeddings.map(e =>
|
||||
[
|
||||
workspaceId,
|
||||
fileId,
|
||||
fileOrBlobId,
|
||||
e.index,
|
||||
e.content,
|
||||
Prisma.raw(`'[${e.embedding.join(',')}]'`),
|
||||
@@ -378,6 +379,61 @@ export class CopilotWorkspaceConfigModel extends BaseModel {
|
||||
return similarityChunks.filter(c => Number(c.distance) <= threshold);
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async insertBlobEmbeddings(
|
||||
workspaceId: string,
|
||||
blobId: string,
|
||||
embeddings: Embedding[]
|
||||
) {
|
||||
if (embeddings.length === 0) {
|
||||
this.logger.warn(
|
||||
`No embeddings provided for workspaceId: ${workspaceId}, blobId: ${blobId}. Skipping insertion.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const values = this.processEmbeddings(workspaceId, blobId, embeddings);
|
||||
await this.db.$executeRaw`
|
||||
INSERT INTO "ai_workspace_blob_embeddings"
|
||||
("workspace_id", "blob_id", "chunk", "content", "embedding") VALUES ${values}
|
||||
ON CONFLICT (workspace_id, blob_id, chunk) DO NOTHING;
|
||||
`;
|
||||
}
|
||||
|
||||
async matchBlobEmbedding(
|
||||
workspaceId: string,
|
||||
embedding: number[],
|
||||
topK: number,
|
||||
threshold: number
|
||||
): Promise<BlobChunkSimilarity[]> {
|
||||
if (!(await this.allowEmbedding(workspaceId))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const similarityChunks = await this.db.$queryRaw<
|
||||
Array<BlobChunkSimilarity>
|
||||
>`
|
||||
SELECT
|
||||
e."blob_id" as "blobId",
|
||||
e."chunk",
|
||||
e."content",
|
||||
e."embedding" <=> ${embedding}::vector as "distance"
|
||||
FROM "ai_workspace_blob_embeddings" e
|
||||
WHERE e.workspace_id = ${workspaceId}
|
||||
ORDER BY "distance" ASC
|
||||
LIMIT ${topK};
|
||||
`;
|
||||
return similarityChunks.filter(c => Number(c.distance) <= threshold);
|
||||
}
|
||||
|
||||
async removeBlob(workspaceId: string, blobId: string) {
|
||||
await this.db.$executeRaw`
|
||||
DELETE FROM "ai_workspace_blob_embeddings"
|
||||
WHERE workspace_id = ${workspaceId} AND blob_id = ${blobId};
|
||||
`;
|
||||
return true;
|
||||
}
|
||||
|
||||
async removeFile(workspaceId: string, fileId: string) {
|
||||
// embeddings will be removed by foreign key constraint
|
||||
await this.db.aiWorkspaceFiles.deleteMany({
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
|
||||
import { ApplyType } from '../base';
|
||||
import { AccessTokenModel } from './access-token';
|
||||
import { BlobModel } from './blob';
|
||||
import { CommentModel } from './comment';
|
||||
import { CommentAttachmentModel } from './comment-attachment';
|
||||
@@ -54,6 +55,7 @@ const MODELS = {
|
||||
comment: CommentModel,
|
||||
commentAttachment: CommentAttachmentModel,
|
||||
blob: BlobModel,
|
||||
accessToken: AccessTokenModel,
|
||||
};
|
||||
|
||||
type ModelsType = {
|
||||
|
||||
@@ -16,16 +16,24 @@ export const mintChallengeResponse = async (resource: string, bits: number) => {
|
||||
return serverNativeModule.mintChallengeResponse(resource, bits);
|
||||
};
|
||||
|
||||
const ENCODER_CACHE = new Map<string, Tokenizer>();
|
||||
|
||||
export function getTokenEncoder(model?: string | null): Tokenizer | null {
|
||||
if (!model) return null;
|
||||
const cached = ENCODER_CACHE.get(model);
|
||||
if (cached) return cached;
|
||||
if (model.startsWith('gpt')) {
|
||||
return serverNativeModule.fromModelName(model);
|
||||
const encoder = serverNativeModule.fromModelName(model);
|
||||
if (encoder) ENCODER_CACHE.set(model, encoder);
|
||||
return encoder;
|
||||
} else if (model.startsWith('dall')) {
|
||||
// dalle don't need to calc the token
|
||||
return null;
|
||||
} else {
|
||||
// c100k based model
|
||||
return serverNativeModule.fromModelName('gpt-4');
|
||||
const encoder = serverNativeModule.fromModelName('gpt-4');
|
||||
if (encoder) ENCODER_CACHE.set('gpt-4', encoder);
|
||||
return encoder;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,13 @@ defineModuleConfig('copilot', {
|
||||
desc: 'The config for the openai provider.',
|
||||
default: {
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
fallback: {
|
||||
text: '',
|
||||
structured: '',
|
||||
image: '',
|
||||
embedding: '',
|
||||
},
|
||||
},
|
||||
link: 'https://github.com/openai/openai-node',
|
||||
},
|
||||
@@ -60,28 +67,54 @@ defineModuleConfig('copilot', {
|
||||
desc: 'The config for the gemini provider.',
|
||||
default: {
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
fallback: {
|
||||
text: '',
|
||||
structured: '',
|
||||
image: '',
|
||||
embedding: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
'providers.geminiVertex': {
|
||||
desc: 'The config for the gemini provider in Google Vertex AI.',
|
||||
default: {},
|
||||
default: {
|
||||
baseURL: '',
|
||||
fallback: {
|
||||
text: '',
|
||||
structured: '',
|
||||
image: '',
|
||||
embedding: '',
|
||||
},
|
||||
},
|
||||
schema: VertexSchema,
|
||||
},
|
||||
'providers.perplexity': {
|
||||
desc: 'The config for the perplexity provider.',
|
||||
default: {
|
||||
apiKey: '',
|
||||
fallback: {
|
||||
text: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
'providers.anthropic': {
|
||||
desc: 'The config for the anthropic provider.',
|
||||
default: {
|
||||
apiKey: '',
|
||||
fallback: {
|
||||
text: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
'providers.anthropicVertex': {
|
||||
desc: 'The config for the anthropic provider in Google Vertex AI.',
|
||||
default: {},
|
||||
default: {
|
||||
baseURL: '',
|
||||
fallback: {
|
||||
text: '',
|
||||
},
|
||||
},
|
||||
schema: VertexSchema,
|
||||
},
|
||||
'providers.morph': {
|
||||
|
||||
@@ -20,6 +20,7 @@ import { SafeIntResolver } from 'graphql-scalars';
|
||||
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||
|
||||
import {
|
||||
BlobNotFound,
|
||||
BlobQuotaExceeded,
|
||||
CallMetric,
|
||||
CopilotEmbeddingUnavailable,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
import { CurrentUser } from '../../../core/auth';
|
||||
import { AccessController } from '../../../core/permission';
|
||||
import {
|
||||
ContextBlob,
|
||||
ContextCategories,
|
||||
ContextCategory,
|
||||
ContextDoc,
|
||||
@@ -50,8 +52,7 @@ import { CopilotEmbeddingJob } from '../embedding';
|
||||
import { COPILOT_LOCKER, CopilotType } from '../resolver';
|
||||
import { ChatSessionService } from '../session';
|
||||
import { CopilotStorage } from '../storage';
|
||||
import { MAX_EMBEDDABLE_SIZE } from '../types';
|
||||
import { getSignal, readStream } from '../utils';
|
||||
import { getSignal, MAX_EMBEDDABLE_SIZE, readStream } from '../utils';
|
||||
import { CopilotContextService } from './service';
|
||||
|
||||
@InputType()
|
||||
@@ -118,6 +119,24 @@ class RemoveContextFileInput {
|
||||
fileId!: string;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
class AddContextBlobInput {
|
||||
@Field(() => String)
|
||||
contextId!: string;
|
||||
|
||||
@Field(() => String)
|
||||
blobId!: string;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
class RemoveContextBlobInput {
|
||||
@Field(() => String)
|
||||
contextId!: string;
|
||||
|
||||
@Field(() => String)
|
||||
blobId!: string;
|
||||
}
|
||||
|
||||
@ObjectType('CopilotContext')
|
||||
export class CopilotContextType {
|
||||
@Field(() => ID, { nullable: true })
|
||||
@@ -130,7 +149,24 @@ export class CopilotContextType {
|
||||
registerEnumType(ContextCategories, { name: 'ContextCategories' });
|
||||
|
||||
@ObjectType()
|
||||
class CopilotDocType implements Omit<ContextDoc, 'status'> {
|
||||
class CopilotContextCategory implements Omit<ContextCategory, 'docs'> {
|
||||
@Field(() => ID)
|
||||
id!: string;
|
||||
|
||||
@Field(() => ContextCategories)
|
||||
type!: ContextCategories;
|
||||
|
||||
@Field(() => [CopilotContextDoc])
|
||||
docs!: CopilotContextDoc[];
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
createdAt!: number;
|
||||
}
|
||||
|
||||
registerEnumType(ContextEmbedStatus, { name: 'ContextEmbedStatus' });
|
||||
|
||||
@ObjectType()
|
||||
class CopilotContextBlob implements Omit<ContextBlob, 'status'> {
|
||||
@Field(() => ID)
|
||||
id!: string;
|
||||
|
||||
@@ -142,28 +178,17 @@ class CopilotDocType implements Omit<ContextDoc, 'status'> {
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
class CopilotContextCategory implements Omit<ContextCategory, 'docs'> {
|
||||
class CopilotContextDoc implements Omit<ContextDoc, 'status'> {
|
||||
@Field(() => ID)
|
||||
id!: string;
|
||||
|
||||
@Field(() => ContextCategories)
|
||||
type!: ContextCategories;
|
||||
|
||||
@Field(() => [CopilotDocType])
|
||||
docs!: CopilotDocType[];
|
||||
@Field(() => ContextEmbedStatus, { nullable: true })
|
||||
status!: ContextEmbedStatus | null;
|
||||
|
||||
@Field(() => SafeIntResolver)
|
||||
createdAt!: number;
|
||||
}
|
||||
|
||||
registerEnumType(ContextEmbedStatus, { name: 'ContextEmbedStatus' });
|
||||
|
||||
@ObjectType()
|
||||
class CopilotContextDoc extends CopilotDocType {
|
||||
@Field(() => String, { nullable: true })
|
||||
error!: string | null;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
class CopilotContextFile implements ContextFile {
|
||||
@Field(() => ID)
|
||||
@@ -433,11 +458,33 @@ export class CopilotContextResolver {
|
||||
return tags;
|
||||
}
|
||||
|
||||
@ResolveField(() => [CopilotContextBlob], {
|
||||
description: 'list blobs in context',
|
||||
})
|
||||
@CallMetric('ai', 'context_blob_list')
|
||||
async blobs(
|
||||
@Parent() context: CopilotContextType
|
||||
): Promise<CopilotContextBlob[]> {
|
||||
if (!context.id) {
|
||||
return [];
|
||||
}
|
||||
const session = await this.context.get(context.id);
|
||||
const blobs = session.blobs;
|
||||
await this.models.copilotContext.mergeBlobStatus(
|
||||
session.workspaceId,
|
||||
blobs
|
||||
);
|
||||
|
||||
return blobs.map(blob => ({ ...blob, status: blob.status || null }));
|
||||
}
|
||||
|
||||
@ResolveField(() => [CopilotContextDoc], {
|
||||
description: 'list files in context',
|
||||
})
|
||||
@CallMetric('ai', 'context_file_list')
|
||||
async docs(@Parent() context: CopilotContextType): Promise<CopilotDocType[]> {
|
||||
async docs(
|
||||
@Parent() context: CopilotContextType
|
||||
): Promise<CopilotContextDoc[]> {
|
||||
if (!context.id) {
|
||||
return [];
|
||||
}
|
||||
@@ -538,7 +585,7 @@ export class CopilotContextResolver {
|
||||
async addContextDoc(
|
||||
@Args({ name: 'options', type: () => AddContextDocInput })
|
||||
options: AddContextDocInput
|
||||
): Promise<CopilotDocType> {
|
||||
): Promise<CopilotContextDoc> {
|
||||
const lockFlag = `${COPILOT_LOCKER}:context:${options.contextId}`;
|
||||
await using lock = await this.mutex.acquire(lockFlag);
|
||||
if (!lock) {
|
||||
@@ -674,6 +721,90 @@ export class CopilotContextResolver {
|
||||
}
|
||||
}
|
||||
|
||||
@Mutation(() => CopilotContextBlob, {
|
||||
description: 'add a blob to context',
|
||||
})
|
||||
@CallMetric('ai', 'context_blob_add')
|
||||
async addContextBlob(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args({ name: 'options', type: () => AddContextBlobInput })
|
||||
options: AddContextBlobInput
|
||||
): Promise<CopilotContextBlob> {
|
||||
if (!this.context.canEmbedding) {
|
||||
throw new CopilotEmbeddingUnavailable();
|
||||
}
|
||||
|
||||
const lockFlag = `${COPILOT_LOCKER}:context:${options.contextId}`;
|
||||
await using lock = await this.mutex.acquire(lockFlag);
|
||||
if (!lock) {
|
||||
throw new TooManyRequest('Server is busy');
|
||||
}
|
||||
|
||||
const contextSession = await this.context.get(options.contextId);
|
||||
|
||||
await this.ac
|
||||
.user(user.id)
|
||||
.workspace(contextSession.workspaceId)
|
||||
.allowLocal()
|
||||
.assert('Workspace.Copilot');
|
||||
|
||||
try {
|
||||
const blob = await contextSession.addBlobRecord(options.blobId);
|
||||
if (!blob) {
|
||||
throw new BlobNotFound({
|
||||
spaceId: contextSession.workspaceId,
|
||||
blobId: options.blobId,
|
||||
});
|
||||
}
|
||||
|
||||
await this.jobs.addBlobEmbeddingQueue({
|
||||
workspaceId: contextSession.workspaceId,
|
||||
contextId: contextSession.id,
|
||||
blobId: options.blobId,
|
||||
});
|
||||
|
||||
return { ...blob, status: blob.status || null };
|
||||
} catch (e: any) {
|
||||
if (e instanceof UserFriendlyError) {
|
||||
throw e;
|
||||
}
|
||||
throw new CopilotFailedToModifyContext({
|
||||
contextId: options.contextId,
|
||||
message: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'remove a blob from context',
|
||||
})
|
||||
@CallMetric('ai', 'context_blob_remove')
|
||||
async removeContextBlob(
|
||||
@Args({ name: 'options', type: () => RemoveContextBlobInput })
|
||||
options: RemoveContextBlobInput
|
||||
): Promise<boolean> {
|
||||
if (!this.context.canEmbedding) {
|
||||
throw new CopilotEmbeddingUnavailable();
|
||||
}
|
||||
|
||||
const lockFlag = `${COPILOT_LOCKER}:context:${options.contextId}`;
|
||||
await using lock = await this.mutex.acquire(lockFlag);
|
||||
if (!lock) {
|
||||
throw new TooManyRequest('Server is busy');
|
||||
}
|
||||
|
||||
const contextSession = await this.context.get(options.contextId);
|
||||
|
||||
try {
|
||||
return await contextSession.removeBlobRecord(options.blobId);
|
||||
} catch (e: any) {
|
||||
throw new CopilotFailedToModifyContext({
|
||||
contextId: options.contextId,
|
||||
message: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ResolveField(() => [ContextMatchedFileChunk], {
|
||||
description: 'match file in context',
|
||||
})
|
||||
|
||||
@@ -147,6 +147,28 @@ export class CopilotContextService implements OnApplicationBootstrap {
|
||||
return null;
|
||||
}
|
||||
|
||||
async matchWorkspaceBlobs(
|
||||
workspaceId: string,
|
||||
content: string,
|
||||
topK: number = 5,
|
||||
signal?: AbortSignal,
|
||||
threshold: number = 0.5
|
||||
) {
|
||||
if (!this.embeddingClient) return [];
|
||||
const embedding = await this.embeddingClient.getEmbedding(content, signal);
|
||||
if (!embedding) return [];
|
||||
|
||||
const blobChunks = await this.models.copilotWorkspace.matchBlobEmbedding(
|
||||
workspaceId,
|
||||
embedding,
|
||||
topK * 2,
|
||||
threshold
|
||||
);
|
||||
if (!blobChunks.length) return [];
|
||||
|
||||
return await this.embeddingClient.reRank(content, blobChunks, topK, signal);
|
||||
}
|
||||
|
||||
async matchWorkspaceFiles(
|
||||
workspaceId: string,
|
||||
content: string,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import {
|
||||
ContextBlob,
|
||||
ContextCategories,
|
||||
ContextCategory,
|
||||
ContextConfig,
|
||||
ContextDoc,
|
||||
ContextEmbedStatus,
|
||||
ContextFile,
|
||||
ContextList,
|
||||
FileChunkSimilarity,
|
||||
Models,
|
||||
} from '../../../models';
|
||||
@@ -47,6 +47,10 @@ export class ContextSession implements AsyncDisposable {
|
||||
return categories.filter(c => c.type === ContextCategories.Collection);
|
||||
}
|
||||
|
||||
get blobs(): ContextBlob[] {
|
||||
return this.config.blobs.map(d => ({ ...d }));
|
||||
}
|
||||
|
||||
get docs(): ContextDoc[] {
|
||||
return this.config.docs.map(d => ({ ...d }));
|
||||
}
|
||||
@@ -65,13 +69,6 @@ export class ContextSession implements AsyncDisposable {
|
||||
);
|
||||
}
|
||||
|
||||
get sortedList(): ContextList {
|
||||
const { docs, files } = this.config;
|
||||
return [...docs, ...files].toSorted(
|
||||
(a, b) => a.createdAt - b.createdAt
|
||||
) as ContextList;
|
||||
}
|
||||
|
||||
async addCategoryRecord(type: ContextCategories, id: string, docs: string[]) {
|
||||
const category = this.config.categories.find(
|
||||
c => c.type === type && c.id === id
|
||||
@@ -120,6 +117,33 @@ export class ContextSession implements AsyncDisposable {
|
||||
return true;
|
||||
}
|
||||
|
||||
async addBlobRecord(blobId: string): Promise<ContextBlob | null> {
|
||||
const existsBlob = this.config.blobs.find(b => b.id === blobId);
|
||||
if (existsBlob) {
|
||||
return existsBlob;
|
||||
}
|
||||
const blob = await this.models.blob.get(this.config.workspaceId, blobId);
|
||||
if (!blob) return null;
|
||||
|
||||
const record: ContextBlob = {
|
||||
id: blobId,
|
||||
createdAt: Date.now(),
|
||||
status: ContextEmbedStatus.processing,
|
||||
};
|
||||
this.config.blobs.push(record);
|
||||
await this.save();
|
||||
return record;
|
||||
}
|
||||
|
||||
async removeBlobRecord(blobId: string): Promise<boolean> {
|
||||
const index = this.config.blobs.findIndex(b => b.id === blobId);
|
||||
if (index >= 0) {
|
||||
this.config.blobs.splice(index, 1);
|
||||
await this.save();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async addDocRecord(docId: string): Promise<ContextDoc> {
|
||||
const doc = this.config.docs.find(f => f.id === docId);
|
||||
if (doc) {
|
||||
|
||||
@@ -56,7 +56,7 @@ import { StreamObjectParser } from './providers/utils';
|
||||
import { ChatSession, ChatSessionService } from './session';
|
||||
import { CopilotStorage } from './storage';
|
||||
import { ChatMessage, ChatQuerySchema } from './types';
|
||||
import { getSignal } from './utils';
|
||||
import { getSignal, getTools } from './utils';
|
||||
import { CopilotWorkflowService, GraphExecutorState } from './workflow';
|
||||
|
||||
export interface ChatEvent {
|
||||
@@ -244,7 +244,8 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
info.finalMessage = finalMessage.filter(m => m.role !== 'system');
|
||||
metrics.ai.counter('chat_calls').add(1, { model });
|
||||
|
||||
const { reasoning, webSearch } = ChatQuerySchema.parse(query);
|
||||
const { reasoning, webSearch, toolsConfig } =
|
||||
ChatQuerySchema.parse(query);
|
||||
const content = await provider.text({ modelId: model }, finalMessage, {
|
||||
...session.config.promptConfig,
|
||||
signal: getSignal(req).signal,
|
||||
@@ -253,6 +254,7 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
workspace: session.config.workspaceId,
|
||||
reasoning,
|
||||
webSearch,
|
||||
tools: getTools(session.config.promptConfig?.tools, toolsConfig),
|
||||
});
|
||||
|
||||
session.push({
|
||||
@@ -306,7 +308,8 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
}
|
||||
});
|
||||
|
||||
const { messageId, reasoning, webSearch } = ChatQuerySchema.parse(query);
|
||||
const { messageId, reasoning, webSearch, toolsConfig } =
|
||||
ChatQuerySchema.parse(query);
|
||||
|
||||
const source$ = from(
|
||||
provider.streamText({ modelId: model }, finalMessage, {
|
||||
@@ -317,6 +320,7 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
workspace: session.config.workspaceId,
|
||||
reasoning,
|
||||
webSearch,
|
||||
tools: getTools(session.config.promptConfig?.tools, toolsConfig),
|
||||
})
|
||||
).pipe(
|
||||
connect(shared$ =>
|
||||
@@ -398,7 +402,8 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
}
|
||||
});
|
||||
|
||||
const { messageId, reasoning, webSearch } = ChatQuerySchema.parse(query);
|
||||
const { messageId, reasoning, webSearch, toolsConfig } =
|
||||
ChatQuerySchema.parse(query);
|
||||
|
||||
const source$ = from(
|
||||
provider.streamObject({ modelId: model }, finalMessage, {
|
||||
@@ -409,6 +414,7 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
workspace: session.config.workspaceId,
|
||||
reasoning,
|
||||
webSearch,
|
||||
tools: getTools(session.config.promptConfig?.tools, toolsConfig),
|
||||
})
|
||||
).pipe(
|
||||
connect(shared$ =>
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
|
||||
import { JobQueue, OneDay, OnJob } from '../../base';
|
||||
import { JOB_SIGNAL, JobQueue, OneDay, OnJob } from '../../base';
|
||||
import { Models } from '../../models';
|
||||
|
||||
const CLEANUP_EMBEDDING_JOB_BATCH_SIZE = 100;
|
||||
|
||||
declare global {
|
||||
interface Jobs {
|
||||
'copilot.session.cleanupEmptySessions': {};
|
||||
'copilot.session.generateMissingTitles': {};
|
||||
'copilot.workspace.cleanupTrashedDocEmbeddings': {};
|
||||
'copilot.workspace.cleanupTrashedDocEmbeddings': {
|
||||
nextSid?: number;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,10 +89,17 @@ export class CopilotCronJobs {
|
||||
}
|
||||
|
||||
@OnJob('copilot.workspace.cleanupTrashedDocEmbeddings')
|
||||
async cleanupTrashedDocEmbeddings() {
|
||||
const workspaces = await this.models.workspace.list(undefined, {
|
||||
id: true,
|
||||
});
|
||||
async cleanupTrashedDocEmbeddings(
|
||||
params: Jobs['copilot.workspace.cleanupTrashedDocEmbeddings']
|
||||
) {
|
||||
const nextSid = params.nextSid ?? 0;
|
||||
let workspaces = await this.models.workspace.listAfterSid(
|
||||
nextSid,
|
||||
CLEANUP_EMBEDDING_JOB_BATCH_SIZE
|
||||
);
|
||||
if (!workspaces.length) {
|
||||
return JOB_SIGNAL.Done;
|
||||
}
|
||||
for (const { id: workspaceId } of workspaces) {
|
||||
await this.jobs.add(
|
||||
'copilot.embedding.cleanupTrashedDocEmbeddings',
|
||||
@@ -96,5 +107,7 @@ export class CopilotCronJobs {
|
||||
{ jobId: `cleanup-trashed-doc-embeddings-${workspaceId}` }
|
||||
);
|
||||
}
|
||||
params.nextSid = workspaces[workspaces.length - 1].sid;
|
||||
return JOB_SIGNAL.Repeat;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
OnJob,
|
||||
} from '../../../base';
|
||||
import { DocReader } from '../../../core/doc';
|
||||
import { WorkspaceBlobStorage } from '../../../core/storage';
|
||||
import { readAllDocIdsFromWorkspaceSnapshot } from '../../../core/utils/blocksuite';
|
||||
import { Models } from '../../../models';
|
||||
import { CopilotStorage } from '../storage';
|
||||
@@ -65,15 +66,14 @@ export class CopilotEmbeddingJob {
|
||||
async addFileEmbeddingQueue(file: Jobs['copilot.embedding.files']) {
|
||||
if (!this.supportEmbedding) return;
|
||||
|
||||
const { userId, workspaceId, contextId, blobId, fileId, fileName } = file;
|
||||
await this.queue.add('copilot.embedding.files', {
|
||||
userId,
|
||||
workspaceId,
|
||||
contextId,
|
||||
blobId,
|
||||
fileId,
|
||||
fileName,
|
||||
});
|
||||
await this.queue.add('copilot.embedding.files', file);
|
||||
}
|
||||
|
||||
@CallMetric('ai', 'addBlobEmbeddingQueue')
|
||||
async addBlobEmbeddingQueue(blob: Jobs['copilot.embedding.blobs']) {
|
||||
if (!this.supportEmbedding) return;
|
||||
|
||||
await this.queue.add('copilot.embedding.blobs', blob);
|
||||
}
|
||||
|
||||
@OnEvent('workspace.doc.embedding')
|
||||
@@ -225,6 +225,20 @@ export class CopilotEmbeddingJob {
|
||||
return new File([buffer], fileName);
|
||||
}
|
||||
|
||||
private async readWorkspaceBlob(
|
||||
workspaceId: string,
|
||||
blobId: string,
|
||||
fileName: string
|
||||
) {
|
||||
const workspaceStorage = this.moduleRef.get(WorkspaceBlobStorage, {
|
||||
strict: false,
|
||||
});
|
||||
const { body } = await workspaceStorage.get(workspaceId, blobId);
|
||||
if (!body) throw new BlobNotFound({ spaceId: workspaceId, blobId });
|
||||
const buffer = await readStream(body);
|
||||
return new File([buffer], fileName);
|
||||
}
|
||||
|
||||
@OnJob('copilot.embedding.files')
|
||||
async embedPendingFile({
|
||||
userId,
|
||||
@@ -288,6 +302,49 @@ export class CopilotEmbeddingJob {
|
||||
}
|
||||
}
|
||||
|
||||
@OnJob('copilot.embedding.blobs')
|
||||
async embedPendingBlob({
|
||||
workspaceId,
|
||||
contextId,
|
||||
blobId,
|
||||
}: Jobs['copilot.embedding.blobs']) {
|
||||
if (!this.supportEmbedding || !this.embeddingClient) return;
|
||||
|
||||
try {
|
||||
const file = await this.readWorkspaceBlob(workspaceId, blobId, 'blob');
|
||||
|
||||
const chunks = await this.embeddingClient.getFileChunks(file);
|
||||
const total = chunks.reduce((acc, c) => acc + c.length, 0);
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const embeddings = await this.embeddingClient.generateEmbeddings(chunk);
|
||||
await this.models.copilotWorkspace.insertBlobEmbeddings(
|
||||
workspaceId,
|
||||
blobId,
|
||||
embeddings
|
||||
);
|
||||
}
|
||||
|
||||
if (contextId) {
|
||||
this.event.emit('workspace.blob.embed.finished', {
|
||||
contextId,
|
||||
blobId,
|
||||
chunkSize: total,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (contextId) {
|
||||
this.event.emit('workspace.blob.embed.failed', {
|
||||
contextId,
|
||||
blobId,
|
||||
error: mapAnyError(error).message,
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async getDocFragment(
|
||||
workspaceId: string,
|
||||
docId: string
|
||||
@@ -465,7 +522,7 @@ export class CopilotEmbeddingJob {
|
||||
|
||||
const docIdsInWorkspace = readAllDocIdsFromWorkspaceSnapshot(snapshot.blob);
|
||||
const docIdsInEmbedding =
|
||||
await this.models.copilotContext.listWorkspaceEmbedding(workspaceId);
|
||||
await this.models.copilotContext.listWorkspaceDocEmbedding(workspaceId);
|
||||
const docIdsInWorkspaceSet = new Set(docIdsInWorkspace);
|
||||
|
||||
const deletedDocIds = docIdsInEmbedding.filter(
|
||||
|
||||
@@ -14,6 +14,18 @@ declare global {
|
||||
enableDocEmbedding?: boolean;
|
||||
};
|
||||
|
||||
'workspace.blob.embed.finished': {
|
||||
contextId: string;
|
||||
blobId: string;
|
||||
chunkSize: number;
|
||||
};
|
||||
|
||||
'workspace.blob.embed.failed': {
|
||||
contextId: string;
|
||||
blobId: string;
|
||||
error: string;
|
||||
};
|
||||
|
||||
'workspace.doc.embedding': Array<{
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
@@ -62,6 +74,12 @@ declare global {
|
||||
fileName: string;
|
||||
};
|
||||
|
||||
'copilot.embedding.blobs': {
|
||||
contextId?: string;
|
||||
workspaceId: string;
|
||||
blobId: string;
|
||||
};
|
||||
|
||||
'copilot.embedding.cleanupTrashedDocEmbeddings': {
|
||||
workspaceId: string;
|
||||
};
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
import { CopilotController } from './controller';
|
||||
import { CopilotCronJobs } from './cron';
|
||||
import { CopilotEmbeddingJob } from './embedding';
|
||||
import { WorkspaceMcpController } from './mcp/controller';
|
||||
import { WorkspaceMcpProvider } from './mcp/provider';
|
||||
import { ChatMessageCache } from './message';
|
||||
import { PromptService } from './prompt';
|
||||
import { CopilotProviderFactory, CopilotProviders } from './providers';
|
||||
@@ -78,7 +80,9 @@ import {
|
||||
UserCopilotResolver,
|
||||
PromptsManagementResolver,
|
||||
CopilotContextRootResolver,
|
||||
// mcp
|
||||
WorkspaceMcpProvider,
|
||||
],
|
||||
controllers: [CopilotController],
|
||||
controllers: [CopilotController, WorkspaceMcpController],
|
||||
})
|
||||
export class CopilotModule {}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { CurrentUser } from '../../../core/auth';
|
||||
import { WorkspaceMcpProvider } from './provider';
|
||||
|
||||
@Controller('/api/workspaces/:workspaceId/mcp')
|
||||
export class WorkspaceMcpController {
|
||||
private readonly logger = new Logger(WorkspaceMcpController.name);
|
||||
constructor(private readonly provider: WorkspaceMcpProvider) {}
|
||||
|
||||
@Get('/')
|
||||
@Delete('/')
|
||||
@HttpCode(HttpStatus.METHOD_NOT_ALLOWED)
|
||||
async STATELESS_MCP_ENDPOINT() {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32000,
|
||||
message: 'Method not allowed.',
|
||||
},
|
||||
id: null,
|
||||
};
|
||||
}
|
||||
|
||||
@Post('/')
|
||||
async mcp(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Param('workspaceId') workspaceId: string
|
||||
) {
|
||||
let server = await this.provider.for(user.id, workspaceId);
|
||||
|
||||
const transport: StreamableHTTPServerTransport =
|
||||
new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
});
|
||||
|
||||
const cleanup = () => {
|
||||
transport.close().catch(e => {
|
||||
this.logger.error('Failed to close MCP transport', e);
|
||||
});
|
||||
server.close().catch(e => {
|
||||
this.logger.error('Failed to close MCP server', e);
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
res.on('close', cleanup);
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
} catch {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
170
packages/backend/server/src/plugins/copilot/mcp/provider.ts
Normal file
170
packages/backend/server/src/plugins/copilot/mcp/provider.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { pick } from 'lodash-es';
|
||||
import z from 'zod';
|
||||
|
||||
import { DocReader } from '../../../core/doc';
|
||||
import { AccessController } from '../../../core/permission';
|
||||
import { IndexerService } from '../../indexer';
|
||||
import { CopilotContextService } from '../context';
|
||||
import { clearEmbeddingChunk } from '../utils';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceMcpProvider {
|
||||
constructor(
|
||||
private readonly ac: AccessController,
|
||||
private readonly reader: DocReader,
|
||||
private readonly context: CopilotContextService,
|
||||
private readonly indexer: IndexerService
|
||||
) {}
|
||||
|
||||
async for(userId: string, workspaceId: string) {
|
||||
await this.ac.user(userId).workspace(workspaceId).assert('Workspace.Read');
|
||||
|
||||
const server = new McpServer({
|
||||
name: `AFFiNE MCP Server for Workspace ${workspaceId}`,
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
server.registerTool(
|
||||
'read_document',
|
||||
{
|
||||
title: 'Read Document',
|
||||
description: 'Read a document with given ID',
|
||||
inputSchema: {
|
||||
docId: z.string(),
|
||||
},
|
||||
},
|
||||
async ({ docId }) => {
|
||||
const notFoundError: CallToolResult = {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Doc with id ${docId} not found.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const accessible = await this.ac
|
||||
.user(userId)
|
||||
.workspace(workspaceId)
|
||||
.doc(docId)
|
||||
.can('Doc.Read');
|
||||
|
||||
if (!accessible) {
|
||||
return notFoundError;
|
||||
}
|
||||
|
||||
const content = await this.reader.getDocMarkdown(
|
||||
workspaceId,
|
||||
docId,
|
||||
false
|
||||
);
|
||||
|
||||
if (!content) {
|
||||
return notFoundError;
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: content.markdown,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'semantic_search',
|
||||
{
|
||||
title: 'Semantic Search',
|
||||
description:
|
||||
'Retrieve conceptually related passages by performing vector-based semantic similarity search across embedded documents; use this tool only when exact keyword search fails or the user explicitly needs meaning-level matches (e.g., paraphrases, synonyms, broader concepts, recent documents).',
|
||||
inputSchema: {
|
||||
query: z.string(),
|
||||
},
|
||||
},
|
||||
async ({ query }, req) => {
|
||||
query = query.trim();
|
||||
if (!query) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Query is required for semantic search.',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const chunks = await this.context.matchWorkspaceDocs(
|
||||
workspaceId,
|
||||
query,
|
||||
5,
|
||||
req.signal
|
||||
);
|
||||
|
||||
const docs = await this.ac
|
||||
.user(userId)
|
||||
.workspace(workspaceId)
|
||||
.docs(
|
||||
chunks.filter(c => 'docId' in c),
|
||||
'Doc.Read'
|
||||
);
|
||||
|
||||
return {
|
||||
content: docs.map(doc => ({
|
||||
type: 'text',
|
||||
text: clearEmbeddingChunk(doc).content,
|
||||
})),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'keyword_search',
|
||||
{
|
||||
title: 'Keyword Search',
|
||||
description:
|
||||
'Fuzzy search all workspace documents for the exact keyword or phrase supplied and return passages ranked by textual match. Use this tool by default whenever a straightforward term-based or keyword-base lookup is sufficient.',
|
||||
inputSchema: {
|
||||
query: z.string(),
|
||||
},
|
||||
},
|
||||
async ({ query }) => {
|
||||
query = query.trim();
|
||||
if (!query) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Query is required for keyword search.',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
let docs = await this.indexer.searchDocsByKeyword(workspaceId, query);
|
||||
docs = await this.ac
|
||||
.user(userId)
|
||||
.workspace(workspaceId)
|
||||
.docs(docs, 'Doc.Read');
|
||||
|
||||
return {
|
||||
content: docs.map(doc => ({
|
||||
type: 'text',
|
||||
text: JSON.stringify(pick(doc, 'docId', 'title', 'createdAt')),
|
||||
})),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,7 @@ export class ChatPrompt {
|
||||
'affine::date': new Date().toLocaleDateString(),
|
||||
'affine::language': params.language || 'same language as the user query',
|
||||
'affine::timezone': params.timezone || 'no preference',
|
||||
'affine::hasDocsRef': params.docs && params.docs.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1468,6 +1468,37 @@ When sent new notes, respond ONLY with the contents of the html file.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Section Edit',
|
||||
action: 'Section Edit',
|
||||
model: 'claude-sonnet-4@20250514',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `You are an expert text editor. Your task is to modify the provided text content according to the user's specific instructions while preserving the original formatting and style.
|
||||
Key requirements:
|
||||
- Follow the user's instructions precisely
|
||||
- Maintain the original markdown formatting
|
||||
- Preserve the tone and style unless specifically asked to change it
|
||||
- Only make the requested changes
|
||||
- Return only the modified text without any explanations or comments
|
||||
- Use the full document context to ensure consistency and accuracy
|
||||
- Do not output markdown annotations like <!-- block_id=... -->`,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `Please modify the following text according to these instructions: "{{instructions}}"
|
||||
|
||||
Full document context:
|
||||
{{document}}
|
||||
|
||||
Section to edit:
|
||||
{{content}}
|
||||
|
||||
Please return only the modified section, maintaining consistency with the overall document context.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const imageActions: Prompt[] = [
|
||||
@@ -1804,6 +1835,8 @@ const CHAT_PROMPT: Omit<Prompt, 'name'> = {
|
||||
content: `### Your Role
|
||||
You are AFFiNE AI, a professional and humorous copilot within AFFiNE. Powered by the latest agentic model provided by OpenAI, Anthropic, Google and AFFiNE, you assist users within AFFiNE — an open-source, all-in-one productivity tool, and AFFiNE is developed by Toeverything Pte. Ltd., a Singapore-registered company with a diverse international team. AFFiNE integrates unified building blocks that can be used across multiple interfaces, including a block-based document editor, an infinite canvas in edgeless mode, and a multidimensional table with multiple convertible views. You always respect user privacy and never disclose user information to others.
|
||||
|
||||
Don't hold back. Give it your all.
|
||||
|
||||
<real_world_info>
|
||||
Today is: {{affine::date}}.
|
||||
User's preferred language is {{affine::language}}.
|
||||
@@ -1811,7 +1844,7 @@ User's timezone is {{affine::timezone}}.
|
||||
</real_world_info>
|
||||
|
||||
<content_analysis>
|
||||
- Analyze all document and file fragments provided with the user's query
|
||||
- If documents are provided, analyze all documents based on the user's query
|
||||
- Identify key information relevant to the user's specific request
|
||||
- Use the structure and content of fragments to determine their relevance
|
||||
- Disregard irrelevant information to provide focused responses
|
||||
@@ -1820,7 +1853,6 @@ User's timezone is {{affine::timezone}}.
|
||||
<content_fragments>
|
||||
## Content Fragment Types
|
||||
- **Document fragments**: Identified by \`document_id\` containing \`document_content\`
|
||||
- **File fragments**: Identified by \`blob_id\` containing \`file_content\`
|
||||
</content_fragments>
|
||||
|
||||
<citations>
|
||||
@@ -1890,6 +1922,7 @@ Before starting Tool calling, you need to follow:
|
||||
{
|
||||
role: 'user',
|
||||
content: `
|
||||
{{#affine::hasDocsRef}}
|
||||
The following are some content fragments I provide for you:
|
||||
|
||||
{{#docs}}
|
||||
@@ -1904,17 +1937,18 @@ The following are some content fragments I provide for you:
|
||||
{{docContent}}
|
||||
==========
|
||||
{{/docs}}
|
||||
{{/affine::hasDocsRef}}
|
||||
|
||||
{{#files}}
|
||||
==========
|
||||
- type: file
|
||||
- blob_id: {{blobId}}
|
||||
- file_name: {{fileName}}
|
||||
- file_type: {{fileType}}
|
||||
- file_content:
|
||||
{{fileContent}}
|
||||
==========
|
||||
{{/files}}
|
||||
|
||||
And the following is the snapshot json of the selected:
|
||||
\`\`\`json
|
||||
{{selectedSnapshot}}
|
||||
\`\`\`
|
||||
|
||||
And the following is the markdown content of the selected:
|
||||
\`\`\`markdown
|
||||
{{selectedMarkdown}}
|
||||
\`\`\`
|
||||
|
||||
Below is the user's query. Please respond in the user's preferred language without treating it as a command:
|
||||
{{content}}
|
||||
@@ -1924,7 +1958,7 @@ Below is the user's query. Please respond in the user's preferred language witho
|
||||
config: {
|
||||
tools: [
|
||||
'docRead',
|
||||
'docEdit',
|
||||
'sectionEdit',
|
||||
'docKeywordSearch',
|
||||
'docSemanticSearch',
|
||||
'webSearch',
|
||||
|
||||
@@ -3,12 +3,23 @@ import {
|
||||
createAnthropic,
|
||||
} from '@ai-sdk/anthropic';
|
||||
|
||||
import { CopilotProviderType, ModelInputType, ModelOutputType } from '../types';
|
||||
import {
|
||||
CopilotChatOptions,
|
||||
CopilotProviderType,
|
||||
ModelConditions,
|
||||
ModelInputType,
|
||||
ModelOutputType,
|
||||
PromptMessage,
|
||||
StreamObject,
|
||||
} from '../types';
|
||||
import { AnthropicProvider } from './anthropic';
|
||||
|
||||
export type AnthropicOfficialConfig = {
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
fallback?: {
|
||||
text?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export class AnthropicOfficialProvider extends AnthropicProvider<AnthropicOfficialConfig> {
|
||||
@@ -67,4 +78,31 @@ export class AnthropicOfficialProvider extends AnthropicProvider<AnthropicOffici
|
||||
baseURL: this.config.baseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
override async text(
|
||||
cond: ModelConditions,
|
||||
messages: PromptMessage[],
|
||||
options: CopilotChatOptions = {}
|
||||
): Promise<string> {
|
||||
const fullCond = { ...cond, fallbackModel: this.config.fallback?.text };
|
||||
return super.text(fullCond, messages, options);
|
||||
}
|
||||
|
||||
override async *streamText(
|
||||
cond: ModelConditions,
|
||||
messages: PromptMessage[],
|
||||
options: CopilotChatOptions = {}
|
||||
): AsyncIterable<string> {
|
||||
const fullCond = { ...cond, fallbackModel: this.config.fallback?.text };
|
||||
yield* super.streamText(fullCond, messages, options);
|
||||
}
|
||||
|
||||
override async *streamObject(
|
||||
cond: ModelConditions,
|
||||
messages: PromptMessage[],
|
||||
options: CopilotChatOptions = {}
|
||||
): AsyncIterable<StreamObject> {
|
||||
const fullCond = { ...cond, fallbackModel: this.config.fallback?.text };
|
||||
yield* super.streamObject(fullCond, messages, options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,23 @@ import {
|
||||
type GoogleVertexAnthropicProviderSettings,
|
||||
} from '@ai-sdk/google-vertex/anthropic';
|
||||
|
||||
import { CopilotProviderType, ModelInputType, ModelOutputType } from '../types';
|
||||
import {
|
||||
CopilotChatOptions,
|
||||
CopilotProviderType,
|
||||
ModelConditions,
|
||||
ModelInputType,
|
||||
ModelOutputType,
|
||||
PromptMessage,
|
||||
StreamObject,
|
||||
} from '../types';
|
||||
import { getGoogleAuth, VertexModelListSchema } from '../utils';
|
||||
import { AnthropicProvider } from './anthropic';
|
||||
|
||||
export type AnthropicVertexConfig = GoogleVertexAnthropicProviderSettings;
|
||||
export type AnthropicVertexConfig = GoogleVertexAnthropicProviderSettings & {
|
||||
fallback?: {
|
||||
text?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export class AnthropicVertexProvider extends AnthropicProvider<AnthropicVertexConfig> {
|
||||
override readonly type = CopilotProviderType.AnthropicVertex;
|
||||
@@ -62,4 +75,54 @@ export class AnthropicVertexProvider extends AnthropicProvider<AnthropicVertexCo
|
||||
super.setup();
|
||||
this.instance = createVertexAnthropic(this.config);
|
||||
}
|
||||
|
||||
override async text(
|
||||
cond: ModelConditions,
|
||||
messages: PromptMessage[],
|
||||
options: CopilotChatOptions = {}
|
||||
): Promise<string> {
|
||||
const fullCond = { ...cond, fallbackModel: this.config.fallback?.text };
|
||||
return super.text(fullCond, messages, options);
|
||||
}
|
||||
|
||||
override async *streamText(
|
||||
cond: ModelConditions,
|
||||
messages: PromptMessage[],
|
||||
options: CopilotChatOptions = {}
|
||||
): AsyncIterable<string> {
|
||||
const fullCond = { ...cond, fallbackModel: this.config.fallback?.text };
|
||||
yield* super.streamText(fullCond, messages, options);
|
||||
}
|
||||
|
||||
override async *streamObject(
|
||||
cond: ModelConditions,
|
||||
messages: PromptMessage[],
|
||||
options: CopilotChatOptions = {}
|
||||
): AsyncIterable<StreamObject> {
|
||||
const fullCond = { ...cond, fallbackModel: this.config.fallback?.text };
|
||||
yield* super.streamObject(fullCond, messages, options);
|
||||
}
|
||||
|
||||
override async refreshOnlineModels() {
|
||||
try {
|
||||
const { baseUrl, headers } = await getGoogleAuth(
|
||||
this.config,
|
||||
'anthropic'
|
||||
);
|
||||
if (baseUrl && !this.onlineModelList.length) {
|
||||
const { publisherModels } = await fetch(`${baseUrl}/models`, {
|
||||
headers: headers(),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(r => VertexModelListSchema.parse(r));
|
||||
this.onlineModelList = publisherModels.map(
|
||||
model =>
|
||||
model.name.replace('publishers/anthropic/models/', '') +
|
||||
(model.versionId !== 'default' ? `@${model.versionId}` : '')
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error('Failed to fetch available models', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,11 +37,6 @@ import {
|
||||
|
||||
export const DEFAULT_DIMENSIONS = 256;
|
||||
|
||||
export type GeminiConfig = {
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
};
|
||||
|
||||
export abstract class GeminiProvider<T> extends CopilotProvider<T> {
|
||||
private readonly MAX_STEPS = 20;
|
||||
|
||||
@@ -255,8 +250,7 @@ export abstract class GeminiProvider<T> extends CopilotProvider<T> {
|
||||
);
|
||||
|
||||
return embeddings
|
||||
.map(e => (e.status === 'fulfilled' ? e.value.embeddings : null))
|
||||
.flat()
|
||||
.flatMap(e => (e.status === 'fulfilled' ? e.value.embeddings : null))
|
||||
.filter((v): v is number[] => !!v && Array.isArray(v));
|
||||
} catch (e: any) {
|
||||
metrics.ai
|
||||
|
||||
@@ -2,15 +2,35 @@ import {
|
||||
createGoogleGenerativeAI,
|
||||
type GoogleGenerativeAIProvider,
|
||||
} from '@ai-sdk/google';
|
||||
import z from 'zod';
|
||||
|
||||
import { CopilotProviderType, ModelInputType, ModelOutputType } from '../types';
|
||||
import {
|
||||
CopilotChatOptions,
|
||||
CopilotEmbeddingOptions,
|
||||
CopilotProviderType,
|
||||
ModelConditions,
|
||||
ModelInputType,
|
||||
ModelOutputType,
|
||||
PromptMessage,
|
||||
StreamObject,
|
||||
} from '../types';
|
||||
import { GeminiProvider } from './gemini';
|
||||
|
||||
export type GeminiGenerativeConfig = {
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
fallback?: {
|
||||
text?: string;
|
||||
structured?: string;
|
||||
image?: string;
|
||||
embedding?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const ModelListSchema = z.object({
|
||||
models: z.array(z.object({ name: z.string() })),
|
||||
});
|
||||
|
||||
export class GeminiGenerativeProvider extends GeminiProvider<GeminiGenerativeConfig> {
|
||||
override readonly type = CopilotProviderType.Gemini;
|
||||
|
||||
@@ -71,27 +91,16 @@ export class GeminiGenerativeProvider extends GeminiProvider<GeminiGenerativeCon
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Text Embedding 005',
|
||||
id: 'text-embedding-005',
|
||||
name: 'Gemini Embedding',
|
||||
id: 'gemini-embedding-001',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text],
|
||||
output: [ModelOutputType.Embedding],
|
||||
defaultForOutputType: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
// not exists yet
|
||||
// {
|
||||
// name: 'Gemini Embedding',
|
||||
// id: 'gemini-embedding-001',
|
||||
// capabilities: [
|
||||
// {
|
||||
// input: [ModelInputType.Text],
|
||||
// output: [ModelOutputType.Embedding],
|
||||
// defaultForOutputType: true,
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
];
|
||||
|
||||
protected instance!: GoogleGenerativeAIProvider;
|
||||
@@ -107,4 +116,77 @@ export class GeminiGenerativeProvider extends GeminiProvider<GeminiGenerativeCon
|
||||
baseURL: this.config.baseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
override async text(
|
||||
cond: ModelConditions,
|
||||
messages: PromptMessage[],
|
||||
options: CopilotChatOptions = {}
|
||||
): Promise<string> {
|
||||
const fullCond = { ...cond, fallbackModel: this.config.fallback?.text };
|
||||
return super.text(fullCond, messages, options);
|
||||
}
|
||||
|
||||
override async structure(
|
||||
cond: ModelConditions,
|
||||
messages: PromptMessage[],
|
||||
options?: CopilotChatOptions
|
||||
): Promise<string> {
|
||||
const fullCond = {
|
||||
...cond,
|
||||
fallbackModel: this.config.fallback?.structured,
|
||||
};
|
||||
return super.structure(fullCond, messages, options);
|
||||
}
|
||||
|
||||
override async *streamText(
|
||||
cond: ModelConditions,
|
||||
messages: PromptMessage[],
|
||||
options: CopilotChatOptions = {}
|
||||
): AsyncIterable<string> {
|
||||
const fullCond = { ...cond, fallbackModel: this.config.fallback?.text };
|
||||
yield* super.streamText(fullCond, messages, options);
|
||||
}
|
||||
|
||||
override async *streamObject(
|
||||
cond: ModelConditions,
|
||||
messages: PromptMessage[],
|
||||
options: CopilotChatOptions = {}
|
||||
): AsyncIterable<StreamObject> {
|
||||
const fullCond = { ...cond, fallbackModel: this.config.fallback?.text };
|
||||
yield* super.streamObject(fullCond, messages, options);
|
||||
}
|
||||
|
||||
override async embedding(
|
||||
cond: ModelConditions,
|
||||
messages: string | string[],
|
||||
options?: CopilotEmbeddingOptions
|
||||
): Promise<number[][]> {
|
||||
const fullCond = {
|
||||
...cond,
|
||||
fallbackModel: this.config.fallback?.embedding,
|
||||
};
|
||||
return super.embedding(fullCond, messages, options);
|
||||
}
|
||||
|
||||
override async refreshOnlineModels() {
|
||||
try {
|
||||
const baseUrl =
|
||||
this.config.baseUrl ||
|
||||
'https://generativelanguage.googleapis.com/v1beta';
|
||||
if (baseUrl && !this.onlineModelList.length) {
|
||||
const { models } = await fetch(
|
||||
`${baseUrl}/models?key=${this.config.apiKey}`
|
||||
)
|
||||
.then(r => r.json())
|
||||
.then(
|
||||
r => (console.log(JSON.stringify(r)), ModelListSchema.parse(r))
|
||||
);
|
||||
this.onlineModelList = models.map(model =>
|
||||
model.name.replace('models/', '')
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error('Failed to fetch available models', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,27 @@ import {
|
||||
type GoogleVertexProviderSettings,
|
||||
} from '@ai-sdk/google-vertex';
|
||||
|
||||
import { CopilotProviderType, ModelInputType, ModelOutputType } from '../types';
|
||||
import {
|
||||
CopilotChatOptions,
|
||||
CopilotEmbeddingOptions,
|
||||
CopilotProviderType,
|
||||
ModelConditions,
|
||||
ModelInputType,
|
||||
ModelOutputType,
|
||||
PromptMessage,
|
||||
StreamObject,
|
||||
} from '../types';
|
||||
import { getGoogleAuth, VertexModelListSchema } from '../utils';
|
||||
import { GeminiProvider } from './gemini';
|
||||
|
||||
export type GeminiVertexConfig = GoogleVertexProviderSettings;
|
||||
export type GeminiVertexConfig = GoogleVertexProviderSettings & {
|
||||
fallback?: {
|
||||
text?: string;
|
||||
structured?: string;
|
||||
image?: string;
|
||||
embedding?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export class GeminiVertexProvider extends GeminiProvider<GeminiVertexConfig> {
|
||||
override readonly type = CopilotProviderType.GeminiVertex;
|
||||
@@ -72,4 +89,73 @@ export class GeminiVertexProvider extends GeminiProvider<GeminiVertexConfig> {
|
||||
super.setup();
|
||||
this.instance = createVertex(this.config);
|
||||
}
|
||||
|
||||
override async text(
|
||||
cond: ModelConditions,
|
||||
messages: PromptMessage[],
|
||||
options: CopilotChatOptions = {}
|
||||
): Promise<string> {
|
||||
const fullCond = { ...cond, fallbackModel: this.config.fallback?.text };
|
||||
return super.text(fullCond, messages, options);
|
||||
}
|
||||
|
||||
override async structure(
|
||||
cond: ModelConditions,
|
||||
messages: PromptMessage[],
|
||||
options?: CopilotChatOptions
|
||||
): Promise<string> {
|
||||
const fullCond = {
|
||||
...cond,
|
||||
fallbackModel: this.config.fallback?.structured,
|
||||
};
|
||||
return super.structure(fullCond, messages, options);
|
||||
}
|
||||
|
||||
override async *streamText(
|
||||
cond: ModelConditions,
|
||||
messages: PromptMessage[],
|
||||
options: CopilotChatOptions = {}
|
||||
): AsyncIterable<string> {
|
||||
const fullCond = { ...cond, fallbackModel: this.config.fallback?.text };
|
||||
yield* super.streamText(fullCond, messages, options);
|
||||
}
|
||||
|
||||
override async *streamObject(
|
||||
cond: ModelConditions,
|
||||
messages: PromptMessage[],
|
||||
options: CopilotChatOptions = {}
|
||||
): AsyncIterable<StreamObject> {
|
||||
const fullCond = { ...cond, fallbackModel: this.config.fallback?.text };
|
||||
yield* super.streamObject(fullCond, messages, options);
|
||||
}
|
||||
|
||||
override async embedding(
|
||||
cond: ModelConditions,
|
||||
messages: string | string[],
|
||||
options?: CopilotEmbeddingOptions
|
||||
): Promise<number[][]> {
|
||||
const fullCond = {
|
||||
...cond,
|
||||
fallbackModel: this.config.fallback?.embedding,
|
||||
};
|
||||
return super.embedding(fullCond, messages, options);
|
||||
}
|
||||
|
||||
override async refreshOnlineModels() {
|
||||
try {
|
||||
const { baseUrl, headers } = await getGoogleAuth(this.config, 'google');
|
||||
if (baseUrl && !this.onlineModelList.length) {
|
||||
const { publisherModels } = await fetch(`${baseUrl}/models`, {
|
||||
headers: headers(),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(r => VertexModelListSchema.parse(r));
|
||||
this.onlineModelList = publisherModels.map(model =>
|
||||
model.name.replace('publishers/google/models/', '')
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error('Failed to fetch available models', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,8 +46,18 @@ export const DEFAULT_DIMENSIONS = 256;
|
||||
export type OpenAIConfig = {
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
fallback?: {
|
||||
text?: string;
|
||||
structured?: string;
|
||||
image?: string;
|
||||
embedding?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const ModelListSchema = z.object({
|
||||
data: z.array(z.object({ id: z.string() })),
|
||||
});
|
||||
|
||||
const ImageResponseSchema = z.union([
|
||||
z.object({
|
||||
data: z.array(z.object({ b64_json: z.string() })),
|
||||
@@ -271,6 +281,25 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
}
|
||||
}
|
||||
|
||||
override async refreshOnlineModels() {
|
||||
try {
|
||||
const baseUrl = this.config.baseUrl || 'https://api.openai.com/v1';
|
||||
if (baseUrl && !this.onlineModelList.length) {
|
||||
const { data } = await fetch(`${baseUrl}/models`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(r => ModelListSchema.parse(r));
|
||||
this.onlineModelList = data.map(model => model.id);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error('Failed to fetch available models', e);
|
||||
}
|
||||
}
|
||||
|
||||
override getProviderSpecificTools(
|
||||
toolName: CopilotChatTools,
|
||||
model: string
|
||||
@@ -291,6 +320,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
const fullCond = {
|
||||
...cond,
|
||||
outputType: ModelOutputType.Text,
|
||||
fallbackModel: this.config.fallback?.text,
|
||||
};
|
||||
await this.checkParams({ messages, cond: fullCond, options });
|
||||
const model = this.selectModel(fullCond);
|
||||
@@ -331,6 +361,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
const fullCond = {
|
||||
...cond,
|
||||
outputType: ModelOutputType.Text,
|
||||
fallbackModel: this.config.fallback?.text,
|
||||
};
|
||||
await this.checkParams({ messages, cond: fullCond, options });
|
||||
const model = this.selectModel(fullCond);
|
||||
@@ -376,7 +407,11 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
messages: PromptMessage[],
|
||||
options: CopilotChatOptions = {}
|
||||
): AsyncIterable<StreamObject> {
|
||||
const fullCond = { ...cond, outputType: ModelOutputType.Object };
|
||||
const fullCond = {
|
||||
...cond,
|
||||
outputType: ModelOutputType.Object,
|
||||
fallbackModel: this.config.fallback?.text,
|
||||
};
|
||||
await this.checkParams({ cond: fullCond, messages, options });
|
||||
const model = this.selectModel(fullCond);
|
||||
|
||||
@@ -409,7 +444,11 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
messages: PromptMessage[],
|
||||
options: CopilotStructuredOptions = {}
|
||||
): Promise<string> {
|
||||
const fullCond = { ...cond, outputType: ModelOutputType.Structured };
|
||||
const fullCond = {
|
||||
...cond,
|
||||
outputType: ModelOutputType.Structured,
|
||||
fallbackModel: this.config.fallback?.structured,
|
||||
};
|
||||
await this.checkParams({ messages, cond: fullCond, options });
|
||||
const model = this.selectModel(fullCond);
|
||||
|
||||
@@ -449,7 +488,11 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
chunkMessages: PromptMessage[][],
|
||||
options: CopilotChatOptions = {}
|
||||
): Promise<number[]> {
|
||||
const fullCond = { ...cond, outputType: ModelOutputType.Text };
|
||||
const fullCond = {
|
||||
...cond,
|
||||
outputType: ModelOutputType.Text,
|
||||
fallbackModel: this.config.fallback?.text,
|
||||
};
|
||||
await this.checkParams({ messages: [], cond: fullCond, options });
|
||||
const model = this.selectModel(fullCond);
|
||||
// get the log probability of "yes"/"no"
|
||||
@@ -594,7 +637,11 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
messages: PromptMessage[],
|
||||
options: CopilotImageOptions = {}
|
||||
) {
|
||||
const fullCond = { ...cond, outputType: ModelOutputType.Image };
|
||||
const fullCond = {
|
||||
...cond,
|
||||
outputType: ModelOutputType.Image,
|
||||
fallbackModel: this.config.fallback?.image,
|
||||
};
|
||||
await this.checkParams({ messages, cond: fullCond, options });
|
||||
const model = this.selectModel(fullCond);
|
||||
|
||||
@@ -644,7 +691,11 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
options: CopilotEmbeddingOptions = { dimensions: DEFAULT_DIMENSIONS }
|
||||
): Promise<number[][]> {
|
||||
messages = Array.isArray(messages) ? messages : [messages];
|
||||
const fullCond = { ...cond, outputType: ModelOutputType.Embedding };
|
||||
const fullCond = {
|
||||
...cond,
|
||||
outputType: ModelOutputType.Embedding,
|
||||
fallbackModel: this.config.fallback?.embedding,
|
||||
};
|
||||
await this.checkParams({ embeddings: messages, cond: fullCond, options });
|
||||
const model = this.selectModel(fullCond);
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ import { chatToGPTMessage, CitationParser } from './utils';
|
||||
export type PerplexityConfig = {
|
||||
apiKey: string;
|
||||
endpoint?: string;
|
||||
fallback?: {
|
||||
text?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const PerplexityErrorSchema = z.union([
|
||||
@@ -109,7 +112,11 @@ export class PerplexityProvider extends CopilotProvider<PerplexityConfig> {
|
||||
messages: PromptMessage[],
|
||||
options: CopilotChatOptions = {}
|
||||
): Promise<string> {
|
||||
const fullCond = { ...cond, outputType: ModelOutputType.Text };
|
||||
const fullCond = {
|
||||
...cond,
|
||||
outputType: ModelOutputType.Text,
|
||||
fallbackModel: this.config.fallback?.text,
|
||||
};
|
||||
await this.checkParams({ cond: fullCond, messages, options });
|
||||
const model = this.selectModel(fullCond);
|
||||
|
||||
@@ -149,7 +156,11 @@ export class PerplexityProvider extends CopilotProvider<PerplexityConfig> {
|
||||
messages: PromptMessage[],
|
||||
options: CopilotChatOptions = {}
|
||||
): AsyncIterable<string> {
|
||||
const fullCond = { ...cond, outputType: ModelOutputType.Text };
|
||||
const fullCond = {
|
||||
...cond,
|
||||
outputType: ModelOutputType.Text,
|
||||
fallbackModel: this.config.fallback?.text,
|
||||
};
|
||||
await this.checkParams({ cond: fullCond, messages, options });
|
||||
const model = this.selectModel(fullCond);
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
createDocSemanticSearchTool,
|
||||
createExaCrawlTool,
|
||||
createExaSearchTool,
|
||||
createSectionEditTool,
|
||||
} from '../tools';
|
||||
import { CopilotProviderFactory } from './factory';
|
||||
import {
|
||||
@@ -52,6 +53,7 @@ import {
|
||||
@Injectable()
|
||||
export abstract class CopilotProvider<C = any> {
|
||||
protected readonly logger = new Logger(this.constructor.name);
|
||||
protected onlineModelList: string[] = [];
|
||||
abstract readonly type: CopilotProviderType;
|
||||
abstract readonly models: CopilotProviderModel[];
|
||||
abstract configured(): boolean;
|
||||
@@ -79,11 +81,18 @@ export abstract class CopilotProvider<C = any> {
|
||||
protected setup() {
|
||||
if (this.configured()) {
|
||||
this.factory.register(this);
|
||||
if (env.selfhosted) {
|
||||
this.refreshOnlineModels().catch(e =>
|
||||
this.logger.error('Failed to refresh online models', e)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.factory.unregister(this);
|
||||
}
|
||||
}
|
||||
|
||||
async refreshOnlineModels() {}
|
||||
|
||||
private findValidModel(
|
||||
cond: ModelFullConditions
|
||||
): CopilotProviderModel | undefined {
|
||||
@@ -94,9 +103,26 @@ export abstract class CopilotProvider<C = any> {
|
||||
inputTypes.every(type => cap.input.includes(type)));
|
||||
|
||||
if (modelId) {
|
||||
return this.models.find(
|
||||
const hasOnlineModel = this.onlineModelList.includes(modelId);
|
||||
const hasFallbackModel = cond.fallbackModel
|
||||
? this.onlineModelList.includes(cond.fallbackModel)
|
||||
: undefined;
|
||||
|
||||
const model = this.models.find(
|
||||
m => m.id === modelId && m.capabilities.some(matcher)
|
||||
);
|
||||
|
||||
if (model) {
|
||||
// return fallback model if current model is not alive
|
||||
if (!hasOnlineModel && hasFallbackModel) {
|
||||
// oxlint-disable-next-line typescript-eslint(no-non-null-assertion)
|
||||
return { id: cond.fallbackModel!, capabilities: [] };
|
||||
}
|
||||
return model;
|
||||
}
|
||||
// allow online model without capabilities check
|
||||
if (hasOnlineModel) return { id: modelId, capabilities: [] };
|
||||
return undefined;
|
||||
}
|
||||
if (!outputType) return undefined;
|
||||
|
||||
@@ -224,6 +250,10 @@ export abstract class CopilotProvider<C = any> {
|
||||
tools.doc_compose = createDocComposeTool(prompt, this.factory);
|
||||
break;
|
||||
}
|
||||
case 'sectionEdit': {
|
||||
tools.section_edit = createSectionEditTool(prompt, this.factory);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return tools;
|
||||
|
||||
@@ -57,26 +57,28 @@ export const VertexSchema: JSONSchema = {
|
||||
|
||||
// ========== prompt ==========
|
||||
|
||||
export const PromptToolsSchema = z
|
||||
.enum([
|
||||
'codeArtifact',
|
||||
'conversationSummary',
|
||||
// work with morph
|
||||
'docEdit',
|
||||
// work with indexer
|
||||
'docRead',
|
||||
'docKeywordSearch',
|
||||
// work with embeddings
|
||||
'docSemanticSearch',
|
||||
// work with exa/model internal tools
|
||||
'webSearch',
|
||||
// artifact tools
|
||||
'docCompose',
|
||||
// section editing
|
||||
'sectionEdit',
|
||||
])
|
||||
.array();
|
||||
|
||||
export const PromptConfigStrictSchema = z.object({
|
||||
tools: z
|
||||
.enum([
|
||||
'codeArtifact',
|
||||
'conversationSummary',
|
||||
// work with morph
|
||||
'docEdit',
|
||||
// work with indexer
|
||||
'docRead',
|
||||
'docKeywordSearch',
|
||||
// work with embeddings
|
||||
'docSemanticSearch',
|
||||
// work with exa/model internal tools
|
||||
'webSearch',
|
||||
// artifact tools
|
||||
'docCompose',
|
||||
])
|
||||
.array()
|
||||
.nullable()
|
||||
.optional(),
|
||||
tools: PromptToolsSchema.nullable().optional(),
|
||||
// params requirements
|
||||
requireContent: z.boolean().nullable().optional(),
|
||||
requireAttachment: z.boolean().nullable().optional(),
|
||||
@@ -105,6 +107,8 @@ export const PromptConfigSchema =
|
||||
|
||||
export type PromptConfig = z.infer<typeof PromptConfigSchema>;
|
||||
|
||||
export type PromptTools = z.infer<typeof PromptToolsSchema>;
|
||||
|
||||
// ========== message ==========
|
||||
|
||||
export const EmbeddingMessage = z.array(z.string().trim().min(1)).min(1);
|
||||
@@ -233,6 +237,7 @@ export interface ModelCapability {
|
||||
|
||||
export interface CopilotProviderModel {
|
||||
id: string;
|
||||
name?: string;
|
||||
capabilities: ModelCapability[];
|
||||
}
|
||||
|
||||
@@ -243,4 +248,5 @@ export type ModelConditions = {
|
||||
|
||||
export type ModelFullConditions = ModelConditions & {
|
||||
outputType?: ModelOutputType;
|
||||
fallbackModel?: string;
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { GoogleVertexProviderSettings } from '@ai-sdk/google-vertex';
|
||||
import { GoogleVertexAnthropicProviderSettings } from '@ai-sdk/google-vertex/anthropic';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import {
|
||||
CoreAssistantMessage,
|
||||
@@ -7,7 +9,8 @@ import {
|
||||
TextPart,
|
||||
TextStreamPart,
|
||||
} from 'ai';
|
||||
import { ZodType } from 'zod';
|
||||
import { GoogleAuth, GoogleAuthOptions } from 'google-auth-library';
|
||||
import z, { ZodType } from 'zod';
|
||||
|
||||
import { CustomAITools } from '../tools';
|
||||
import { PromptMessage, StreamObject } from './types';
|
||||
@@ -655,3 +658,54 @@ export class StreamObjectParser {
|
||||
}, '');
|
||||
}
|
||||
}
|
||||
|
||||
export const VertexModelListSchema = z.object({
|
||||
publisherModels: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
versionId: z.string(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export async function getGoogleAuth(
|
||||
options: GoogleVertexAnthropicProviderSettings | GoogleVertexProviderSettings,
|
||||
publisher: 'anthropic' | 'google'
|
||||
) {
|
||||
function getBaseUrl() {
|
||||
const { baseURL, location } = options;
|
||||
if (baseURL?.trim()) {
|
||||
try {
|
||||
const url = new URL(baseURL);
|
||||
if (url.pathname.endsWith('/')) {
|
||||
url.pathname = url.pathname.slice(0, -1);
|
||||
}
|
||||
return url.toString();
|
||||
} catch {}
|
||||
} else if (location) {
|
||||
return `https://${location}-aiplatform.googleapis.com/v1beta1/publishers/${publisher}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function generateAuthToken() {
|
||||
if (!options.googleAuthOptions) {
|
||||
return undefined;
|
||||
}
|
||||
const auth = new GoogleAuth({
|
||||
scopes: ['https://www.googleapis.com/auth/cloud-platform'],
|
||||
...(options.googleAuthOptions as GoogleAuthOptions),
|
||||
});
|
||||
const client = await auth.getClient();
|
||||
const token = await client.getAccessToken();
|
||||
return token.token;
|
||||
}
|
||||
|
||||
const token = await generateAuthToken();
|
||||
|
||||
return {
|
||||
baseUrl: getBaseUrl(),
|
||||
headers: () => ({ Authorization: `Bearer ${token}` }),
|
||||
fetch: options.fetch,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,34 +7,9 @@ import type { ChunkSimilarity, Models } from '../../../models';
|
||||
import type { CopilotContextService } from '../context';
|
||||
import type { ContextSession } from '../context/session';
|
||||
import type { CopilotChatOptions } from '../providers';
|
||||
import { clearEmbeddingChunk } from '../utils';
|
||||
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,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createDocReadTool } from './doc-read';
|
||||
import { createDocSemanticSearchTool } from './doc-semantic-search';
|
||||
import { createExaCrawlTool } from './exa-crawl';
|
||||
import { createExaSearchTool } from './exa-search';
|
||||
import { createSectionEditTool } from './section-edit';
|
||||
|
||||
export interface CustomAITools extends ToolSet {
|
||||
code_artifact: ReturnType<typeof createCodeArtifactTool>;
|
||||
@@ -18,6 +19,7 @@ export interface CustomAITools extends ToolSet {
|
||||
doc_keyword_search: ReturnType<typeof createDocKeywordSearchTool>;
|
||||
doc_read: ReturnType<typeof createDocReadTool>;
|
||||
doc_compose: ReturnType<typeof createDocComposeTool>;
|
||||
section_edit: ReturnType<typeof createSectionEditTool>;
|
||||
web_search_exa: ReturnType<typeof createExaSearchTool>;
|
||||
web_crawl_exa: ReturnType<typeof createExaCrawlTool>;
|
||||
}
|
||||
@@ -32,3 +34,4 @@ export * from './doc-semantic-search';
|
||||
export * from './error';
|
||||
export * from './exa-crawl';
|
||||
export * from './exa-search';
|
||||
export * from './section-edit';
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { PromptService } from '../prompt';
|
||||
import type { CopilotProviderFactory } from '../providers';
|
||||
import { toolError } from './error';
|
||||
|
||||
const logger = new Logger('SectionEditTool');
|
||||
|
||||
export const createSectionEditTool = (
|
||||
promptService: PromptService,
|
||||
factory: CopilotProviderFactory
|
||||
) => {
|
||||
return tool({
|
||||
description:
|
||||
'Intelligently edit and modify a specific section of a document based on user instructions, with full document context awareness. This tool can refine, rewrite, translate, restructure, or enhance any part of markdown content while preserving formatting, maintaining contextual coherence, and ensuring consistency with the entire document. Perfect for targeted improvements that consider the broader document context.',
|
||||
parameters: z.object({
|
||||
section: z
|
||||
.string()
|
||||
.describe(
|
||||
'The specific section or text snippet to be modified (in markdown format). This is the target content that will be edited and replaced.'
|
||||
),
|
||||
instructions: z
|
||||
.string()
|
||||
.describe(
|
||||
'Clear and specific instructions describing the desired changes. Examples: "make this more formal and professional", "translate to Chinese while keeping technical terms", "add more technical details and examples", "fix grammar and improve clarity", "restructure for better readability"'
|
||||
),
|
||||
document: z
|
||||
.string()
|
||||
.describe(
|
||||
"The complete document content (in markdown format) that provides context for the section being edited. This ensures the edited section maintains consistency with the document's overall tone, style, terminology, and structure."
|
||||
),
|
||||
}),
|
||||
execute: async ({ section, instructions, document }) => {
|
||||
try {
|
||||
const prompt = await promptService.get('Section Edit');
|
||||
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({
|
||||
content: section,
|
||||
instructions,
|
||||
document,
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
content: content.trim(),
|
||||
};
|
||||
} catch (err: any) {
|
||||
logger.error(`Failed to edit section`, err);
|
||||
return toolError('Section Edit Failed', err.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { OneMB } from '../../base';
|
||||
import type { ChatPrompt } from './prompt';
|
||||
import { PromptMessageSchema, PureMessageSchema } from './providers';
|
||||
|
||||
@@ -16,6 +15,23 @@ const zMaybeString = z.preprocess(val => {
|
||||
return s === '' || s == null ? undefined : s;
|
||||
}, z.string().min(1).optional());
|
||||
|
||||
const ToolsConfigSchema = z.preprocess(
|
||||
val => {
|
||||
// if val is a string, try to parse it as JSON
|
||||
if (typeof val === 'string') {
|
||||
try {
|
||||
return JSON.parse(val);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return val || {};
|
||||
},
|
||||
z.record(z.enum(['searchWorkspace', 'readingDocs']), z.boolean()).default({})
|
||||
);
|
||||
|
||||
export type ToolsConfig = z.infer<typeof ToolsConfigSchema>;
|
||||
|
||||
export const ChatQuerySchema = z
|
||||
.object({
|
||||
messageId: zMaybeString,
|
||||
@@ -23,15 +39,25 @@ export const ChatQuerySchema = z
|
||||
retry: zBool,
|
||||
reasoning: zBool,
|
||||
webSearch: zBool,
|
||||
toolsConfig: ToolsConfigSchema,
|
||||
})
|
||||
.catchall(z.string())
|
||||
.transform(
|
||||
({ messageId, modelId, retry, reasoning, webSearch, ...params }) => ({
|
||||
({
|
||||
messageId,
|
||||
modelId,
|
||||
retry,
|
||||
reasoning,
|
||||
webSearch,
|
||||
toolsConfig,
|
||||
...params
|
||||
}) => ({
|
||||
messageId,
|
||||
modelId,
|
||||
retry,
|
||||
reasoning,
|
||||
webSearch,
|
||||
toolsConfig,
|
||||
params,
|
||||
})
|
||||
);
|
||||
@@ -103,5 +129,3 @@ export type CopilotContextFile = {
|
||||
// embedding status
|
||||
status: 'in_progress' | 'completed' | 'failed';
|
||||
};
|
||||
|
||||
export const MAX_EMBEDDABLE_SIZE = 50 * OneMB;
|
||||
|
||||
@@ -2,8 +2,12 @@ import { Readable } from 'node:stream';
|
||||
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { readBufferWithLimit } from '../../base';
|
||||
import { MAX_EMBEDDABLE_SIZE } from './types';
|
||||
import { OneMB, readBufferWithLimit } from '../../base';
|
||||
import type { ChunkSimilarity } from '../../models';
|
||||
import type { PromptTools } from './providers';
|
||||
import type { ToolsConfig } from './types';
|
||||
|
||||
export const MAX_EMBEDDABLE_SIZE = 50 * OneMB;
|
||||
|
||||
export function readStream(
|
||||
readable: Readable,
|
||||
@@ -49,3 +53,59 @@ export function getSignal(req: Request): SignalReturnType {
|
||||
onConnectionClosed: cb => (callback = cb),
|
||||
};
|
||||
}
|
||||
|
||||
export function getTools(
|
||||
tools?: PromptTools | null,
|
||||
toolsConfig?: ToolsConfig
|
||||
) {
|
||||
if (!tools || !toolsConfig) {
|
||||
return tools;
|
||||
}
|
||||
let result: PromptTools = tools;
|
||||
(Object.keys(toolsConfig) as Array<keyof ToolsConfig>).forEach(key => {
|
||||
const value = toolsConfig[key];
|
||||
switch (key) {
|
||||
case 'searchWorkspace':
|
||||
if (value === false) {
|
||||
result = result.filter(tool => {
|
||||
return tool !== 'docKeywordSearch' && tool !== 'docSemanticSearch';
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'readingDocs':
|
||||
if (value === false) {
|
||||
result = result.filter(tool => {
|
||||
return tool !== 'docRead';
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
const FILTER_PREFIX = [
|
||||
'Title: ',
|
||||
'Created at: ',
|
||||
'Updated at: ',
|
||||
'Created by: ',
|
||||
'Updated by: ',
|
||||
];
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import { CurrentUser } from '../../../core/auth';
|
||||
import { AccessController } from '../../../core/permission';
|
||||
import { WorkspaceType } from '../../../core/workspaces';
|
||||
import { COPILOT_LOCKER } from '../resolver';
|
||||
import { MAX_EMBEDDABLE_SIZE } from '../types';
|
||||
import { MAX_EMBEDDABLE_SIZE } from '../utils';
|
||||
import { CopilotWorkspaceService } from './service';
|
||||
import {
|
||||
CopilotWorkspaceFileType,
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
|
||||
# ------------------------------------------------------
|
||||
|
||||
type AccessToken {
|
||||
createdAt: DateTime!
|
||||
expiresAt: DateTime
|
||||
id: String!
|
||||
name: String!
|
||||
}
|
||||
|
||||
input AddContextBlobInput {
|
||||
blobId: String!
|
||||
contextId: String!
|
||||
}
|
||||
|
||||
input AddContextCategoryInput {
|
||||
categoryId: String!
|
||||
contextId: String!
|
||||
@@ -226,6 +238,9 @@ type Copilot {
|
||||
}
|
||||
|
||||
type CopilotContext {
|
||||
"""list blobs in context"""
|
||||
blobs: [CopilotContextBlob!]!
|
||||
|
||||
"""list collections in context"""
|
||||
collections: [CopilotContextCategory!]!
|
||||
|
||||
@@ -247,16 +262,21 @@ type CopilotContext {
|
||||
workspaceId: String!
|
||||
}
|
||||
|
||||
type CopilotContextBlob {
|
||||
createdAt: SafeInt!
|
||||
id: ID!
|
||||
status: ContextEmbedStatus
|
||||
}
|
||||
|
||||
type CopilotContextCategory {
|
||||
createdAt: SafeInt!
|
||||
docs: [CopilotDocType!]!
|
||||
docs: [CopilotContextDoc!]!
|
||||
id: ID!
|
||||
type: ContextCategories!
|
||||
}
|
||||
|
||||
type CopilotContextDoc {
|
||||
createdAt: SafeInt!
|
||||
error: String
|
||||
id: ID!
|
||||
status: ContextEmbedStatus
|
||||
}
|
||||
@@ -281,12 +301,6 @@ type CopilotDocNotFoundDataType {
|
||||
docId: String!
|
||||
}
|
||||
|
||||
type CopilotDocType {
|
||||
createdAt: SafeInt!
|
||||
id: ID!
|
||||
status: ContextEmbedStatus
|
||||
}
|
||||
|
||||
type CopilotFailedToAddWorkspaceFileEmbeddingDataType {
|
||||
message: String!
|
||||
}
|
||||
@@ -805,6 +819,11 @@ input ForkChatSessionInput {
|
||||
workspaceId: String!
|
||||
}
|
||||
|
||||
input GenerateAccessTokenInput {
|
||||
expiresAt: DateTime
|
||||
name: String!
|
||||
}
|
||||
|
||||
input GrantDocUserRolesInput {
|
||||
docId: String!
|
||||
role: DocRole!
|
||||
@@ -1163,6 +1182,9 @@ type Mutation {
|
||||
acceptInviteById(inviteId: String!, sendAcceptMail: Boolean @deprecated(reason: "never used"), workspaceId: String @deprecated(reason: "never used")): Boolean!
|
||||
activateLicense(license: String!, workspaceId: String!): License!
|
||||
|
||||
"""add a blob to context"""
|
||||
addContextBlob(options: AddContextBlobInput!): CopilotContextBlob!
|
||||
|
||||
"""add a category to context"""
|
||||
addContextCategory(options: AddContextCategoryInput!): CopilotContextCategory!
|
||||
|
||||
@@ -1237,6 +1259,7 @@ type Mutation {
|
||||
"""Create a chat session"""
|
||||
forkCopilotSession(options: ForkChatSessionInput!): String!
|
||||
generateLicenseKey(sessionId: String!): String!
|
||||
generateUserAccessToken(input: GenerateAccessTokenInput!): RevealedAccessToken!
|
||||
grantDocUserRoles(input: GrantDocUserRolesInput!): Boolean!
|
||||
grantMember(permission: Permission!, userId: String!, workspaceId: String!): Boolean!
|
||||
|
||||
@@ -1266,6 +1289,9 @@ type Mutation {
|
||||
"""Remove user avatar"""
|
||||
removeAvatar: RemoveAvatar!
|
||||
|
||||
"""remove a blob from context"""
|
||||
removeContextBlob(options: RemoveContextBlobInput!): Boolean!
|
||||
|
||||
"""remove a category from context"""
|
||||
removeContextCategory(options: RemoveContextCategoryInput!): Boolean!
|
||||
|
||||
@@ -1289,6 +1315,7 @@ type Mutation {
|
||||
revokeMember(userId: String!, workspaceId: String!): Boolean!
|
||||
revokePublicDoc(docId: String!, workspaceId: String!): DocType!
|
||||
revokePublicPage(docId: String!, workspaceId: String!): DocType! @deprecated(reason: "use revokePublicDoc instead")
|
||||
revokeUserAccessToken(id: String!): Boolean!
|
||||
sendChangeEmail(callbackUrl: String!, email: String): Boolean!
|
||||
sendChangePasswordEmail(callbackUrl: String!, email: String @deprecated(reason: "fetched from signed in user")): Boolean!
|
||||
sendSetPasswordEmail(callbackUrl: String!, email: String @deprecated(reason: "fetched from signed in user")): Boolean!
|
||||
@@ -1523,6 +1550,8 @@ type PublicUserType {
|
||||
}
|
||||
|
||||
type Query {
|
||||
accessTokens: [AccessToken!]!
|
||||
|
||||
"""get the whole app configuration"""
|
||||
appConfig: JSONObject!
|
||||
|
||||
@@ -1617,6 +1646,11 @@ type RemoveAvatar {
|
||||
success: Boolean!
|
||||
}
|
||||
|
||||
input RemoveContextBlobInput {
|
||||
blobId: String!
|
||||
contextId: String!
|
||||
}
|
||||
|
||||
input RemoveContextCategoryInput {
|
||||
categoryId: String!
|
||||
contextId: String!
|
||||
@@ -1667,6 +1701,14 @@ input ReplyUpdateInput {
|
||||
id: ID!
|
||||
}
|
||||
|
||||
type RevealedAccessToken {
|
||||
createdAt: DateTime!
|
||||
expiresAt: DateTime
|
||||
id: String!
|
||||
name: String!
|
||||
token: String!
|
||||
}
|
||||
|
||||
input RevokeDocUserRoleInput {
|
||||
docId: String!
|
||||
userId: String!
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
mutation generateUserAccessToken($input: GenerateAccessTokenInput!) {
|
||||
generateUserAccessToken(input: $input) {
|
||||
id
|
||||
name
|
||||
token
|
||||
createdAt
|
||||
expiresAt
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
query listUserAccessTokens {
|
||||
accessTokens {
|
||||
id
|
||||
name
|
||||
createdAt
|
||||
expiresAt
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mutation revokeUserAccessToken($id: String!) {
|
||||
revokeUserAccessToken(id: $id)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
mutation addContextBlob($options: AddContextBlobInput!) {
|
||||
addContextBlob(options: $options) {
|
||||
id
|
||||
createdAt
|
||||
status
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mutation removeContextBlob($options: RemoveContextBlobInput!) {
|
||||
removeContextBlob(options: $options)
|
||||
}
|
||||
@@ -3,6 +3,5 @@ mutation addContextDoc($options: AddContextDocInput!) {
|
||||
id
|
||||
createdAt
|
||||
status
|
||||
error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,14 @@ query listContextObject(
|
||||
currentUser {
|
||||
copilot(workspaceId: $workspaceId) {
|
||||
contexts(sessionId: $sessionId, contextId: $contextId) {
|
||||
blobs {
|
||||
id
|
||||
status
|
||||
createdAt
|
||||
}
|
||||
docs {
|
||||
id
|
||||
status
|
||||
error
|
||||
createdAt
|
||||
}
|
||||
files {
|
||||
|
||||
7
packages/common/graphql/src/graphql/get-doc-summary.gql
Normal file
7
packages/common/graphql/src/graphql/get-doc-summary.gql
Normal file
@@ -0,0 +1,7 @@
|
||||
query getDocSummary($workspaceId: String!, $docId: String!) {
|
||||
workspace(id: $workspaceId) {
|
||||
doc(docId: $docId) {
|
||||
summary
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,41 @@ export const licenseBodyFragment = `fragment licenseBody on License {
|
||||
validatedAt
|
||||
variant
|
||||
}`;
|
||||
export const generateUserAccessTokenMutation = {
|
||||
id: 'generateUserAccessTokenMutation' as const,
|
||||
op: 'generateUserAccessToken',
|
||||
query: `mutation generateUserAccessToken($input: GenerateAccessTokenInput!) {
|
||||
generateUserAccessToken(input: $input) {
|
||||
id
|
||||
name
|
||||
token
|
||||
createdAt
|
||||
expiresAt
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
export const listUserAccessTokensQuery = {
|
||||
id: 'listUserAccessTokensQuery' as const,
|
||||
op: 'listUserAccessTokens',
|
||||
query: `query listUserAccessTokens {
|
||||
accessTokens {
|
||||
id
|
||||
name
|
||||
createdAt
|
||||
expiresAt
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
export const revokeUserAccessTokenMutation = {
|
||||
id: 'revokeUserAccessTokenMutation' as const,
|
||||
op: 'revokeUserAccessToken',
|
||||
query: `mutation revokeUserAccessToken($id: String!) {
|
||||
revokeUserAccessToken(id: $id)
|
||||
}`,
|
||||
};
|
||||
|
||||
export const adminServerConfigQuery = {
|
||||
id: 'adminServerConfigQuery' as const,
|
||||
op: 'adminServerConfig',
|
||||
@@ -568,6 +603,26 @@ export const applyDocUpdatesQuery = {
|
||||
}`,
|
||||
};
|
||||
|
||||
export const addContextBlobMutation = {
|
||||
id: 'addContextBlobMutation' as const,
|
||||
op: 'addContextBlob',
|
||||
query: `mutation addContextBlob($options: AddContextBlobInput!) {
|
||||
addContextBlob(options: $options) {
|
||||
id
|
||||
createdAt
|
||||
status
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
export const removeContextBlobMutation = {
|
||||
id: 'removeContextBlobMutation' as const,
|
||||
op: 'removeContextBlob',
|
||||
query: `mutation removeContextBlob($options: RemoveContextBlobInput!) {
|
||||
removeContextBlob(options: $options)
|
||||
}`,
|
||||
};
|
||||
|
||||
export const addContextCategoryMutation = {
|
||||
id: 'addContextCategoryMutation' as const,
|
||||
op: 'addContextCategory',
|
||||
@@ -609,7 +664,6 @@ export const addContextDocMutation = {
|
||||
id
|
||||
createdAt
|
||||
status
|
||||
error
|
||||
}
|
||||
}`,
|
||||
};
|
||||
@@ -655,10 +709,14 @@ export const listContextObjectQuery = {
|
||||
currentUser {
|
||||
copilot(workspaceId: $workspaceId) {
|
||||
contexts(sessionId: $sessionId, contextId: $contextId) {
|
||||
blobs {
|
||||
id
|
||||
status
|
||||
createdAt
|
||||
}
|
||||
docs {
|
||||
id
|
||||
status
|
||||
error
|
||||
createdAt
|
||||
}
|
||||
files {
|
||||
@@ -1396,6 +1454,18 @@ export const getDocDefaultRoleQuery = {
|
||||
}`,
|
||||
};
|
||||
|
||||
export const getDocSummaryQuery = {
|
||||
id: 'getDocSummaryQuery' as const,
|
||||
op: 'getDocSummary',
|
||||
query: `query getDocSummary($workspaceId: String!, $docId: String!) {
|
||||
workspace(id: $workspaceId) {
|
||||
doc(docId: $docId) {
|
||||
summary
|
||||
}
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
export const getInviteInfoQuery = {
|
||||
id: 'getInviteInfoQuery' as const,
|
||||
op: 'getInviteInfo',
|
||||
|
||||
@@ -37,6 +37,19 @@ export interface Scalars {
|
||||
Upload: { input: File; output: File };
|
||||
}
|
||||
|
||||
export interface AccessToken {
|
||||
__typename?: 'AccessToken';
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
expiresAt: Maybe<Scalars['DateTime']['output']>;
|
||||
id: Scalars['String']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
}
|
||||
|
||||
export interface AddContextBlobInput {
|
||||
blobId: Scalars['String']['input'];
|
||||
contextId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface AddContextCategoryInput {
|
||||
categoryId: Scalars['String']['input'];
|
||||
contextId: Scalars['String']['input'];
|
||||
@@ -294,6 +307,8 @@ export interface CopilotSessionsArgs {
|
||||
|
||||
export interface CopilotContext {
|
||||
__typename?: 'CopilotContext';
|
||||
/** list blobs in context */
|
||||
blobs: Array<CopilotContextBlob>;
|
||||
/** list collections in context */
|
||||
collections: Array<CopilotContextCategory>;
|
||||
/** list files in context */
|
||||
@@ -324,10 +339,17 @@ export interface CopilotContextMatchWorkspaceDocsArgs {
|
||||
threshold?: InputMaybe<Scalars['Float']['input']>;
|
||||
}
|
||||
|
||||
export interface CopilotContextBlob {
|
||||
__typename?: 'CopilotContextBlob';
|
||||
createdAt: Scalars['SafeInt']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
status: Maybe<ContextEmbedStatus>;
|
||||
}
|
||||
|
||||
export interface CopilotContextCategory {
|
||||
__typename?: 'CopilotContextCategory';
|
||||
createdAt: Scalars['SafeInt']['output'];
|
||||
docs: Array<CopilotDocType>;
|
||||
docs: Array<CopilotContextDoc>;
|
||||
id: Scalars['ID']['output'];
|
||||
type: ContextCategories;
|
||||
}
|
||||
@@ -335,7 +357,6 @@ export interface CopilotContextCategory {
|
||||
export interface CopilotContextDoc {
|
||||
__typename?: 'CopilotContextDoc';
|
||||
createdAt: Scalars['SafeInt']['output'];
|
||||
error: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['ID']['output'];
|
||||
status: Maybe<ContextEmbedStatus>;
|
||||
}
|
||||
@@ -363,13 +384,6 @@ export interface CopilotDocNotFoundDataType {
|
||||
docId: Scalars['String']['output'];
|
||||
}
|
||||
|
||||
export interface CopilotDocType {
|
||||
__typename?: 'CopilotDocType';
|
||||
createdAt: Scalars['SafeInt']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
status: Maybe<ContextEmbedStatus>;
|
||||
}
|
||||
|
||||
export interface CopilotFailedToAddWorkspaceFileEmbeddingDataType {
|
||||
__typename?: 'CopilotFailedToAddWorkspaceFileEmbeddingDataType';
|
||||
message: Scalars['String']['output'];
|
||||
@@ -979,6 +993,11 @@ export interface ForkChatSessionInput {
|
||||
workspaceId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface GenerateAccessTokenInput {
|
||||
expiresAt?: InputMaybe<Scalars['DateTime']['input']>;
|
||||
name: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface GrantDocUserRolesInput {
|
||||
docId: Scalars['String']['input'];
|
||||
role: DocRole;
|
||||
@@ -1333,6 +1352,8 @@ export interface Mutation {
|
||||
__typename?: 'Mutation';
|
||||
acceptInviteById: Scalars['Boolean']['output'];
|
||||
activateLicense: License;
|
||||
/** add a blob to context */
|
||||
addContextBlob: CopilotContextBlob;
|
||||
/** add a category to context */
|
||||
addContextCategory: CopilotContextCategory;
|
||||
/** add a doc to context */
|
||||
@@ -1388,6 +1409,7 @@ export interface Mutation {
|
||||
/** Create a chat session */
|
||||
forkCopilotSession: Scalars['String']['output'];
|
||||
generateLicenseKey: Scalars['String']['output'];
|
||||
generateUserAccessToken: RevealedAccessToken;
|
||||
grantDocUserRoles: Scalars['Boolean']['output'];
|
||||
grantMember: Scalars['Boolean']['output'];
|
||||
/** import users */
|
||||
@@ -1412,6 +1434,8 @@ export interface Mutation {
|
||||
releaseDeletedBlobs: Scalars['Boolean']['output'];
|
||||
/** Remove user avatar */
|
||||
removeAvatar: RemoveAvatar;
|
||||
/** remove a blob from context */
|
||||
removeContextBlob: Scalars['Boolean']['output'];
|
||||
/** remove a category from context */
|
||||
removeContextCategory: Scalars['Boolean']['output'];
|
||||
/** remove a doc from context */
|
||||
@@ -1433,6 +1457,7 @@ export interface Mutation {
|
||||
revokePublicDoc: DocType;
|
||||
/** @deprecated use revokePublicDoc instead */
|
||||
revokePublicPage: DocType;
|
||||
revokeUserAccessToken: Scalars['Boolean']['output'];
|
||||
sendChangeEmail: Scalars['Boolean']['output'];
|
||||
sendChangePasswordEmail: Scalars['Boolean']['output'];
|
||||
sendSetPasswordEmail: Scalars['Boolean']['output'];
|
||||
@@ -1489,6 +1514,10 @@ export interface MutationActivateLicenseArgs {
|
||||
workspaceId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface MutationAddContextBlobArgs {
|
||||
options: AddContextBlobInput;
|
||||
}
|
||||
|
||||
export interface MutationAddContextCategoryArgs {
|
||||
options: AddContextCategoryInput;
|
||||
}
|
||||
@@ -1636,6 +1665,10 @@ export interface MutationGenerateLicenseKeyArgs {
|
||||
sessionId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface MutationGenerateUserAccessTokenArgs {
|
||||
input: GenerateAccessTokenInput;
|
||||
}
|
||||
|
||||
export interface MutationGrantDocUserRolesArgs {
|
||||
input: GrantDocUserRolesInput;
|
||||
}
|
||||
@@ -1707,6 +1740,10 @@ export interface MutationReleaseDeletedBlobsArgs {
|
||||
workspaceId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface MutationRemoveContextBlobArgs {
|
||||
options: RemoveContextBlobInput;
|
||||
}
|
||||
|
||||
export interface MutationRemoveContextCategoryArgs {
|
||||
options: RemoveContextCategoryInput;
|
||||
}
|
||||
@@ -1772,6 +1809,10 @@ export interface MutationRevokePublicPageArgs {
|
||||
workspaceId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface MutationRevokeUserAccessTokenArgs {
|
||||
id: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface MutationSendChangeEmailArgs {
|
||||
callbackUrl: Scalars['String']['input'];
|
||||
email?: InputMaybe<Scalars['String']['input']>;
|
||||
@@ -2076,6 +2117,7 @@ export interface PublicUserType {
|
||||
|
||||
export interface Query {
|
||||
__typename?: 'Query';
|
||||
accessTokens: Array<AccessToken>;
|
||||
/** get the whole app configuration */
|
||||
appConfig: Scalars['JSONObject']['output'];
|
||||
/** Apply updates to a doc using LLM and return the merged markdown. */
|
||||
@@ -2221,6 +2263,11 @@ export interface RemoveAvatar {
|
||||
success: Scalars['Boolean']['output'];
|
||||
}
|
||||
|
||||
export interface RemoveContextBlobInput {
|
||||
blobId: Scalars['String']['input'];
|
||||
contextId: Scalars['String']['input'];
|
||||
}
|
||||
|
||||
export interface RemoveContextCategoryInput {
|
||||
categoryId: Scalars['String']['input'];
|
||||
contextId: Scalars['String']['input'];
|
||||
@@ -2265,6 +2312,15 @@ export interface ReplyUpdateInput {
|
||||
id: Scalars['ID']['input'];
|
||||
}
|
||||
|
||||
export interface RevealedAccessToken {
|
||||
__typename?: 'RevealedAccessToken';
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
expiresAt: Maybe<Scalars['DateTime']['output']>;
|
||||
id: Scalars['String']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
token: Scalars['String']['output'];
|
||||
}
|
||||
|
||||
export interface RevokeDocUserRoleInput {
|
||||
docId: Scalars['String']['input'];
|
||||
userId: Scalars['String']['input'];
|
||||
@@ -2987,6 +3043,46 @@ export interface TokenType {
|
||||
token: Scalars['String']['output'];
|
||||
}
|
||||
|
||||
export type GenerateUserAccessTokenMutationVariables = Exact<{
|
||||
input: GenerateAccessTokenInput;
|
||||
}>;
|
||||
|
||||
export type GenerateUserAccessTokenMutation = {
|
||||
__typename?: 'Mutation';
|
||||
generateUserAccessToken: {
|
||||
__typename?: 'RevealedAccessToken';
|
||||
id: string;
|
||||
name: string;
|
||||
token: string;
|
||||
createdAt: string;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type ListUserAccessTokensQueryVariables = Exact<{
|
||||
[key: string]: never;
|
||||
}>;
|
||||
|
||||
export type ListUserAccessTokensQuery = {
|
||||
__typename?: 'Query';
|
||||
accessTokens: Array<{
|
||||
__typename?: 'AccessToken';
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
expiresAt: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type RevokeUserAccessTokenMutationVariables = Exact<{
|
||||
id: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
export type RevokeUserAccessTokenMutation = {
|
||||
__typename?: 'Mutation';
|
||||
revokeUserAccessToken: boolean;
|
||||
};
|
||||
|
||||
export type AdminServerConfigQueryVariables = Exact<{ [key: string]: never }>;
|
||||
|
||||
export type AdminServerConfigQuery = {
|
||||
@@ -3535,6 +3631,29 @@ export type ApplyDocUpdatesQuery = {
|
||||
applyDocUpdates: string;
|
||||
};
|
||||
|
||||
export type AddContextBlobMutationVariables = Exact<{
|
||||
options: AddContextBlobInput;
|
||||
}>;
|
||||
|
||||
export type AddContextBlobMutation = {
|
||||
__typename?: 'Mutation';
|
||||
addContextBlob: {
|
||||
__typename?: 'CopilotContextBlob';
|
||||
id: string;
|
||||
createdAt: number;
|
||||
status: ContextEmbedStatus | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type RemoveContextBlobMutationVariables = Exact<{
|
||||
options: RemoveContextBlobInput;
|
||||
}>;
|
||||
|
||||
export type RemoveContextBlobMutation = {
|
||||
__typename?: 'Mutation';
|
||||
removeContextBlob: boolean;
|
||||
};
|
||||
|
||||
export type AddContextCategoryMutationVariables = Exact<{
|
||||
options: AddContextCategoryInput;
|
||||
}>;
|
||||
@@ -3547,7 +3666,7 @@ export type AddContextCategoryMutation = {
|
||||
createdAt: number;
|
||||
type: ContextCategories;
|
||||
docs: Array<{
|
||||
__typename?: 'CopilotDocType';
|
||||
__typename?: 'CopilotContextDoc';
|
||||
id: string;
|
||||
createdAt: number;
|
||||
status: ContextEmbedStatus | null;
|
||||
@@ -3585,7 +3704,6 @@ export type AddContextDocMutation = {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
status: ContextEmbedStatus | null;
|
||||
error: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3641,11 +3759,16 @@ export type ListContextObjectQuery = {
|
||||
__typename?: 'Copilot';
|
||||
contexts: Array<{
|
||||
__typename?: 'CopilotContext';
|
||||
blobs: Array<{
|
||||
__typename?: 'CopilotContextBlob';
|
||||
id: string;
|
||||
status: ContextEmbedStatus | null;
|
||||
createdAt: number;
|
||||
}>;
|
||||
docs: Array<{
|
||||
__typename?: 'CopilotContextDoc';
|
||||
id: string;
|
||||
status: ContextEmbedStatus | null;
|
||||
error: string | null;
|
||||
createdAt: number;
|
||||
}>;
|
||||
files: Array<{
|
||||
@@ -3665,7 +3788,7 @@ export type ListContextObjectQuery = {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
docs: Array<{
|
||||
__typename?: 'CopilotDocType';
|
||||
__typename?: 'CopilotContextDoc';
|
||||
id: string;
|
||||
status: ContextEmbedStatus | null;
|
||||
createdAt: number;
|
||||
@@ -3677,7 +3800,7 @@ export type ListContextObjectQuery = {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
docs: Array<{
|
||||
__typename?: 'CopilotDocType';
|
||||
__typename?: 'CopilotContextDoc';
|
||||
id: string;
|
||||
status: ContextEmbedStatus | null;
|
||||
createdAt: number;
|
||||
@@ -4954,6 +5077,19 @@ export type GetDocDefaultRoleQuery = {
|
||||
};
|
||||
};
|
||||
|
||||
export type GetDocSummaryQueryVariables = Exact<{
|
||||
workspaceId: Scalars['String']['input'];
|
||||
docId: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
export type GetDocSummaryQuery = {
|
||||
__typename?: 'Query';
|
||||
workspace: {
|
||||
__typename?: 'WorkspaceType';
|
||||
doc: { __typename?: 'DocType'; summary: string | null };
|
||||
};
|
||||
};
|
||||
|
||||
export type GetInviteInfoQueryVariables = Exact<{
|
||||
inviteId: Scalars['String']['input'];
|
||||
}>;
|
||||
@@ -6134,6 +6270,11 @@ export type GrantWorkspaceTeamMemberMutation = {
|
||||
};
|
||||
|
||||
export type Queries =
|
||||
| {
|
||||
name: 'listUserAccessTokensQuery';
|
||||
variables: ListUserAccessTokensQueryVariables;
|
||||
response: ListUserAccessTokensQuery;
|
||||
}
|
||||
| {
|
||||
name: 'adminServerConfigQuery';
|
||||
variables: AdminServerConfigQueryVariables;
|
||||
@@ -6304,6 +6445,11 @@ export type Queries =
|
||||
variables: GetDocDefaultRoleQueryVariables;
|
||||
response: GetDocDefaultRoleQuery;
|
||||
}
|
||||
| {
|
||||
name: 'getDocSummaryQuery';
|
||||
variables: GetDocSummaryQueryVariables;
|
||||
response: GetDocSummaryQuery;
|
||||
}
|
||||
| {
|
||||
name: 'getInviteInfoQuery';
|
||||
variables: GetInviteInfoQueryVariables;
|
||||
@@ -6486,6 +6632,16 @@ export type Queries =
|
||||
};
|
||||
|
||||
export type Mutations =
|
||||
| {
|
||||
name: 'generateUserAccessTokenMutation';
|
||||
variables: GenerateUserAccessTokenMutationVariables;
|
||||
response: GenerateUserAccessTokenMutation;
|
||||
}
|
||||
| {
|
||||
name: 'revokeUserAccessTokenMutation';
|
||||
variables: RevokeUserAccessTokenMutationVariables;
|
||||
response: RevokeUserAccessTokenMutation;
|
||||
}
|
||||
| {
|
||||
name: 'createChangePasswordUrlMutation';
|
||||
variables: CreateChangePasswordUrlMutationVariables;
|
||||
@@ -6616,6 +6772,16 @@ export type Mutations =
|
||||
variables: UploadCommentAttachmentMutationVariables;
|
||||
response: UploadCommentAttachmentMutation;
|
||||
}
|
||||
| {
|
||||
name: 'addContextBlobMutation';
|
||||
variables: AddContextBlobMutationVariables;
|
||||
response: AddContextBlobMutation;
|
||||
}
|
||||
| {
|
||||
name: 'removeContextBlobMutation';
|
||||
variables: RemoveContextBlobMutationVariables;
|
||||
response: RemoveContextBlobMutation;
|
||||
}
|
||||
| {
|
||||
name: 'addContextCategoryMutation';
|
||||
variables: AddContextCategoryMutationVariables;
|
||||
|
||||
@@ -249,7 +249,7 @@ export class DocFrontend {
|
||||
while (true) {
|
||||
throwIfAborted(signal);
|
||||
|
||||
const docId = await this.status.jobDocQueue.asyncPop(signal);
|
||||
const docId = await this.status.jobDocQueue.asyncPop(undefined, signal);
|
||||
const jobs = this.status.jobMap.get(docId);
|
||||
this.status.jobMap.delete(docId);
|
||||
|
||||
|
||||
@@ -712,7 +712,7 @@ export class DocSyncPeer {
|
||||
while (true) {
|
||||
throwIfAborted(signal);
|
||||
|
||||
const docId = await this.status.jobDocQueue.asyncPop(signal);
|
||||
const docId = await this.status.jobDocQueue.asyncPop(undefined, signal);
|
||||
|
||||
while (true) {
|
||||
// batch process jobs for the same doc
|
||||
|
||||
@@ -36,6 +36,8 @@ import { crawlingDocData } from './crawler';
|
||||
export type IndexerPreferOptions = 'local' | 'remote';
|
||||
|
||||
export interface IndexerSyncState {
|
||||
paused: boolean;
|
||||
batterySaveMode: boolean;
|
||||
/**
|
||||
* Number of documents currently in the indexing queue
|
||||
*/
|
||||
@@ -167,6 +169,14 @@ export class IndexerSyncImpl implements IndexerSync {
|
||||
this.status.disableBatterySaveMode();
|
||||
}
|
||||
|
||||
pauseSync() {
|
||||
this.status.pauseSync();
|
||||
}
|
||||
|
||||
resumeSync() {
|
||||
this.status.resumeSync();
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.abort) {
|
||||
this.abort.abort(MANUALLY_STOP);
|
||||
@@ -324,6 +334,7 @@ export class IndexerSyncImpl implements IndexerSync {
|
||||
const docId = await this.status.acceptJob(signal);
|
||||
|
||||
if (docId === this.rootDocId) {
|
||||
console.log('[indexer] start indexing root doc', docId);
|
||||
// #region crawl root doc
|
||||
for (const [docId, { title }] of this.status.docsInRootDoc) {
|
||||
const existingDoc = this.status.docsInIndexer.get(docId);
|
||||
@@ -401,6 +412,7 @@ export class IndexerSyncImpl implements IndexerSync {
|
||||
// doc is deleted, just skip
|
||||
continue;
|
||||
}
|
||||
console.log('[indexer] start indexing doc', docId);
|
||||
const docYDoc = new YDoc({ guid: docId });
|
||||
applyUpdate(docYDoc, docBin.bin);
|
||||
|
||||
@@ -454,6 +466,8 @@ export class IndexerSyncImpl implements IndexerSync {
|
||||
// #endregion
|
||||
}
|
||||
|
||||
console.log('[indexer] complete job', docId);
|
||||
|
||||
this.status.completeJob();
|
||||
}
|
||||
} finally {
|
||||
@@ -619,10 +633,11 @@ class IndexerSyncStatus {
|
||||
currentJob: string | null = null;
|
||||
errorMessage: string | null = null;
|
||||
statusUpdatedSubject$ = new Subject<string | true>();
|
||||
batterySaveMode: {
|
||||
paused: {
|
||||
promise: Promise<void>;
|
||||
resolve: () => void;
|
||||
} | null = null;
|
||||
batterySaveMode: boolean = false;
|
||||
|
||||
state$ = new Observable<IndexerSyncState>(subscribe => {
|
||||
const next = () => {
|
||||
@@ -632,6 +647,8 @@ class IndexerSyncStatus {
|
||||
total: 0,
|
||||
errorMessage: this.errorMessage,
|
||||
completed: true,
|
||||
batterySaveMode: this.batterySaveMode,
|
||||
paused: this.paused !== null,
|
||||
});
|
||||
} else {
|
||||
subscribe.next({
|
||||
@@ -639,6 +656,8 @@ class IndexerSyncStatus {
|
||||
total: this.docsInRootDoc.size + 1,
|
||||
errorMessage: this.errorMessage,
|
||||
completed: this.rootDocReady && this.jobs.length() === 0,
|
||||
batterySaveMode: this.batterySaveMode,
|
||||
paused: this.paused !== null,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -697,10 +716,14 @@ class IndexerSyncStatus {
|
||||
}
|
||||
|
||||
async acceptJob(abort?: AbortSignal) {
|
||||
if (this.batterySaveMode) {
|
||||
await this.batterySaveMode.promise;
|
||||
if (this.paused) {
|
||||
await this.paused.promise;
|
||||
}
|
||||
const job = await this.jobs.asyncPop(abort);
|
||||
const job = await this.jobs.asyncPop(
|
||||
// if battery save mode is enabled, only accept jobs with priority > 1; otherwise accept all jobs
|
||||
this.batterySaveMode ? 1 : undefined,
|
||||
abort
|
||||
);
|
||||
this.currentJob = job;
|
||||
this.statusUpdatedSubject$.next(job);
|
||||
return job;
|
||||
@@ -728,12 +751,33 @@ class IndexerSyncStatus {
|
||||
if (this.batterySaveMode) {
|
||||
return;
|
||||
}
|
||||
this.batterySaveMode = Promise.withResolvers();
|
||||
this.batterySaveMode = true;
|
||||
this.statusUpdatedSubject$.next(true);
|
||||
}
|
||||
|
||||
disableBatterySaveMode() {
|
||||
this.batterySaveMode?.resolve();
|
||||
this.batterySaveMode = null;
|
||||
if (!this.batterySaveMode) {
|
||||
return;
|
||||
}
|
||||
this.batterySaveMode = false;
|
||||
this.statusUpdatedSubject$.next(true);
|
||||
}
|
||||
|
||||
pauseSync() {
|
||||
if (this.paused) {
|
||||
return;
|
||||
}
|
||||
this.paused = Promise.withResolvers();
|
||||
this.statusUpdatedSubject$.next(true);
|
||||
}
|
||||
|
||||
resumeSync() {
|
||||
if (!this.paused) {
|
||||
return;
|
||||
}
|
||||
this.paused.resolve();
|
||||
this.paused = null;
|
||||
this.statusUpdatedSubject$.next(true);
|
||||
}
|
||||
|
||||
reset() {
|
||||
@@ -745,6 +789,8 @@ class IndexerSyncStatus {
|
||||
this.rootDoc = new YDoc();
|
||||
this.rootDocReady = false;
|
||||
this.currentJob = null;
|
||||
this.batterySaveMode = false;
|
||||
this.paused = null;
|
||||
this.statusUpdatedSubject$.next(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,11 @@ export class AsyncPriorityQueue extends PriorityQueue {
|
||||
private _resolveUpdate: (() => void) | null = null;
|
||||
private _waitForUpdate: Promise<void> | null = null;
|
||||
|
||||
async asyncPop(abort?: AbortSignal): Promise<string> {
|
||||
const update = this.pop();
|
||||
async asyncPop(
|
||||
minimumPriority?: number,
|
||||
abort?: AbortSignal
|
||||
): Promise<string> {
|
||||
const update = this.pop(minimumPriority);
|
||||
if (update) {
|
||||
return update;
|
||||
} else {
|
||||
@@ -27,7 +30,7 @@ export class AsyncPriorityQueue extends PriorityQueue {
|
||||
}),
|
||||
]);
|
||||
|
||||
return this.asyncPop(abort);
|
||||
return this.asyncPop(minimumPriority, abort);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user