mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-05 17:13:43 +00:00
Compare commits
34 Commits
v0.22.5
...
0.23.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f9c1554b7 | ||
|
|
eb9652ed4c | ||
|
|
ee8c7616bc | ||
|
|
1452f77c85 | ||
|
|
2f9a96f1c5 | ||
|
|
c882a8c5da | ||
|
|
5da56b5b04 | ||
|
|
831da01432 | ||
|
|
eb56adea46 | ||
|
|
a485ad5c45 | ||
|
|
296089efc9 | ||
|
|
882d06b359 | ||
|
|
b9c4d7230e | ||
|
|
d0beab9638 | ||
|
|
24f1181069 | ||
|
|
eb73c90b2e | ||
|
|
f961d9986f | ||
|
|
5a49d5cd24 | ||
|
|
1b9ed2fb6d | ||
|
|
ed6adcf4d9 | ||
|
|
2b0b20cdd4 | ||
|
|
fe8cb6bb44 | ||
|
|
d0d94066f7 | ||
|
|
64fb3a7243 | ||
|
|
e6b456330c | ||
|
|
2b7a8dcd8a | ||
|
|
8ed7dea823 | ||
|
|
53968f6f8c | ||
|
|
cfc108613c | ||
|
|
558279da29 | ||
|
|
c5b442225f | ||
|
|
134e62a0fa | ||
|
|
92cd2a3d0e | ||
|
|
41524425bc |
@@ -15,13 +15,7 @@ yarn affine cert --install
|
||||
|
||||
```bash
|
||||
# certificates will be located at `./.docker/dev/certs/${domain}`
|
||||
yarn affine cert --domain dev.affine.fail
|
||||
yarn affine cert --domain affine.localhost
|
||||
```
|
||||
|
||||
### 3. Enable dns and nginx service in compose.yml
|
||||
|
||||
### 4. Add custom dns server
|
||||
|
||||
```bash
|
||||
echo "nameserver 127.0.0.1" | sudo tee /etc/resolver/dev.affine.fail
|
||||
```
|
||||
### 3. Enable nginx service in compose.yml
|
||||
|
||||
@@ -73,17 +73,6 @@ services:
|
||||
# timeout: 10s
|
||||
# retries: 120
|
||||
|
||||
# dns:
|
||||
# image: strm/dnsmasq
|
||||
# volumes:
|
||||
# - ./dnsmasq.conf:/etc/dnsmasq.d/local.conf
|
||||
# ports:
|
||||
# - "53:53/udp"
|
||||
# cap_add:
|
||||
# - NET_ADMIN
|
||||
# depends_on:
|
||||
# - nginx
|
||||
|
||||
# nginx:
|
||||
# image: nginx:alpine
|
||||
# volumes:
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
log-queries
|
||||
address=/dev.affine.fail/127.0.0.1
|
||||
5
.github/deployment/node/Dockerfile
vendored
5
.github/deployment/node/Dockerfile
vendored
@@ -7,7 +7,10 @@ COPY ./packages/frontend/apps/mobile/dist /app/static/mobile
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends openssl && \
|
||||
apt-get install -y --no-install-recommends openssl libjemalloc2 && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Enable jemalloc by preloading the library
|
||||
ENV LD_PRELOAD=libjemalloc.so.2
|
||||
|
||||
CMD ["node", "./dist/main.js"]
|
||||
|
||||
1
.github/workflows/release-mobile.yml
vendored
1
.github/workflows/release-mobile.yml
vendored
@@ -124,7 +124,6 @@ jobs:
|
||||
package: 'affine_mobile_native'
|
||||
no-build: 'true'
|
||||
- name: Testflight
|
||||
if: ${{ env.BUILD_TYPE != 'stable' }}
|
||||
working-directory: packages/frontend/apps/ios/App
|
||||
run: |
|
||||
echo -n "${{ env.BUILD_PROVISION_PROFILE }}" | base64 --decode -o $PP_PATH
|
||||
|
||||
@@ -2,7 +2,9 @@ export * from './adapters';
|
||||
export * from './clipboard';
|
||||
export * from './code-block';
|
||||
export * from './code-block-config';
|
||||
export * from './code-block-service';
|
||||
export * from './code-preview-extension';
|
||||
export * from './code-toolbar';
|
||||
export * from './highlight/const';
|
||||
export * from './turbo/code-layout-handler';
|
||||
export * from './turbo/code-painter.worker';
|
||||
|
||||
@@ -5,3 +5,4 @@ export * from './edgeless-clipboard-config';
|
||||
export * from './embed-edgeless-linked-doc-block';
|
||||
export * from './embed-linked-doc-block';
|
||||
export * from './embed-linked-doc-spec';
|
||||
export { getEmbedLinkedDocIcons } from './utils';
|
||||
|
||||
@@ -168,6 +168,7 @@ export const styles = css`
|
||||
.affine-embed-linked-doc-banner {
|
||||
margin: 12px 12px 0px 0px;
|
||||
width: 204px;
|
||||
min-width: 204px;
|
||||
max-width: 100%;
|
||||
height: 102px;
|
||||
pointer-events: none;
|
||||
|
||||
@@ -190,7 +190,10 @@ export class Tooltip extends LitElement {
|
||||
middleware: [
|
||||
this.autoFlip && flip({ padding: AUTO_FLIP_PADDING }),
|
||||
this.autoShift && shift({ padding: AUTO_SHIFT_PADDING }),
|
||||
offset((this.arrow ? TRIANGLE_HEIGHT : 0) + this.offset),
|
||||
offset({
|
||||
mainAxis: (this.arrow ? TRIANGLE_HEIGHT : 0) + this.offsetY,
|
||||
crossAxis: this.offsetX,
|
||||
}),
|
||||
arrow({
|
||||
element: portalRoot.shadowRoot!.querySelector('.arrow')!,
|
||||
}),
|
||||
@@ -264,7 +267,7 @@ export class Tooltip extends LitElement {
|
||||
* Show a triangle arrow pointing to the reference element.
|
||||
*/
|
||||
@property({ attribute: false })
|
||||
accessor arrow = true;
|
||||
accessor arrow = false;
|
||||
|
||||
/**
|
||||
* changes the placement of the floating element in order to keep it in view,
|
||||
@@ -303,7 +306,10 @@ export class Tooltip extends LitElement {
|
||||
* See https://floating-ui.com/docs/offset
|
||||
*/
|
||||
@property({ attribute: false })
|
||||
accessor offset = 4;
|
||||
accessor offsetY = 6;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor offsetX = 0;
|
||||
|
||||
@property({ attribute: 'tip-position' })
|
||||
accessor placement: Placement = 'top';
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { getInlineEditorByModel } from '@blocksuite/affine-rich-text';
|
||||
import { getSelectedBlocksCommand } from '@blocksuite/affine-shared/commands';
|
||||
import {
|
||||
BlockCommentManager,
|
||||
type CommentId,
|
||||
CommentProviderIdentifier,
|
||||
findAllCommentedBlocks,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import type { AffineInlineEditor } from '@blocksuite/affine-shared/types';
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
@@ -13,8 +15,13 @@ import {
|
||||
} from '@blocksuite/std';
|
||||
import type { BaseSelection, BlockModel } from '@blocksuite/store';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import difference from 'lodash-es/difference';
|
||||
|
||||
import { extractCommentIdFromDelta, findCommentedTexts } from './utils';
|
||||
import {
|
||||
extractCommentIdFromDelta,
|
||||
findAllCommentedTexts,
|
||||
findCommentedTexts,
|
||||
} from './utils';
|
||||
|
||||
export class InlineCommentManager extends LifeCycleWatcher {
|
||||
static override key = 'inline-comment-manager';
|
||||
@@ -31,6 +38,8 @@ export class InlineCommentManager extends LifeCycleWatcher {
|
||||
const provider = this._provider;
|
||||
if (!provider) return;
|
||||
|
||||
this._init().catch(console.error);
|
||||
|
||||
this._disposables.add(provider.onCommentAdded(this._handleAddComment));
|
||||
this._disposables.add(
|
||||
provider.onCommentDeleted(this._handleDeleteAndResolve)
|
||||
@@ -50,6 +59,35 @@ export class InlineCommentManager extends LifeCycleWatcher {
|
||||
this._disposables.dispose();
|
||||
}
|
||||
|
||||
private async _init() {
|
||||
const provider = this._provider;
|
||||
if (!provider) return;
|
||||
|
||||
const commentsInProvider = await provider.getComments('unresolved');
|
||||
const inlineComments = [...findAllCommentedTexts(this.std.store).values()];
|
||||
|
||||
const blockComments = findAllCommentedBlocks(this.std.store).flatMap(
|
||||
block => Object.keys(block.props.comments)
|
||||
);
|
||||
|
||||
const commentsInEditor = [
|
||||
...new Set([...inlineComments, ...blockComments]),
|
||||
];
|
||||
|
||||
// resolve comments that are in provider but not in editor
|
||||
// which means the commented content may be deleted
|
||||
difference(commentsInProvider, commentsInEditor).forEach(comment => {
|
||||
provider.resolveComment(comment);
|
||||
});
|
||||
|
||||
// remove comments that are in editor but not in provider
|
||||
// which means the comment may be removed or resolved in provider side
|
||||
difference(commentsInEditor, commentsInProvider).forEach(comment => {
|
||||
this._handleDeleteAndResolve(comment);
|
||||
this.std.get(BlockCommentManager).handleDeleteAndResolve(comment);
|
||||
});
|
||||
}
|
||||
|
||||
private readonly _handleAddComment = (
|
||||
id: CommentId,
|
||||
selections: BaseSelection[]
|
||||
@@ -119,12 +157,17 @@ export class InlineCommentManager extends LifeCycleWatcher {
|
||||
};
|
||||
|
||||
private readonly _handleDeleteAndResolve = (id: CommentId) => {
|
||||
const commentedTexts = findCommentedTexts(this.std, id);
|
||||
const commentedTexts = findCommentedTexts(this.std.store, id);
|
||||
if (commentedTexts.length === 0) return;
|
||||
|
||||
this.std.store.withoutTransact(() => {
|
||||
commentedTexts.forEach(([selection, inlineEditor]) => {
|
||||
inlineEditor.formatText(
|
||||
commentedTexts.forEach(selection => {
|
||||
const inlineEditor = getInlineEditorByModel(
|
||||
this.std,
|
||||
selection.from.blockId
|
||||
);
|
||||
|
||||
inlineEditor?.formatText(
|
||||
selection.from,
|
||||
{
|
||||
[`comment-${id}`]: null,
|
||||
|
||||
@@ -41,6 +41,8 @@ export class InlineComment extends WithDisposable(ShadowlessElement) {
|
||||
})
|
||||
accessor commentIds!: string[];
|
||||
|
||||
private _index: number = 0;
|
||||
|
||||
@consume({ context: stdContext })
|
||||
private accessor _std!: BlockStdScope;
|
||||
|
||||
@@ -52,8 +54,8 @@ export class InlineComment extends WithDisposable(ShadowlessElement) {
|
||||
}
|
||||
|
||||
private readonly _handleClick = () => {
|
||||
const provider = this._provider;
|
||||
provider && this.commentIds.forEach(id => provider.highlightComment(id));
|
||||
this._provider?.highlightComment(this.commentIds[this._index]);
|
||||
this._index = (this._index + 1) % this.commentIds.length;
|
||||
};
|
||||
|
||||
private readonly _handleHighlight = (id: CommentId | null) => {
|
||||
|
||||
@@ -1,45 +1,56 @@
|
||||
import { getInlineEditorByModel } from '@blocksuite/affine-rich-text';
|
||||
import type { CommentId } from '@blocksuite/affine-shared/services';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import { type BlockStdScope, TextSelection } from '@blocksuite/std';
|
||||
import type { InlineEditor } from '@blocksuite/std/inline';
|
||||
import type { DeltaInsert } from '@blocksuite/store';
|
||||
import { TextSelection } from '@blocksuite/std';
|
||||
import type { DeltaInsert, Store } from '@blocksuite/store';
|
||||
|
||||
export function findCommentedTexts(std: BlockStdScope, commentId: CommentId) {
|
||||
const selections: [TextSelection, InlineEditor][] = [];
|
||||
std.store.getAllModels().forEach(model => {
|
||||
const inlineEditor = getInlineEditorByModel(std, model);
|
||||
if (!inlineEditor) return;
|
||||
export function findAllCommentedTexts(
|
||||
store: Store
|
||||
): Map<TextSelection, CommentId> {
|
||||
const result = new Map<TextSelection, CommentId>();
|
||||
|
||||
inlineEditor.mapDeltasInInlineRange(
|
||||
{
|
||||
index: 0,
|
||||
length: inlineEditor.yTextLength,
|
||||
},
|
||||
(delta, rangeIndex) => {
|
||||
if (
|
||||
delta.attributes &&
|
||||
Object.keys(delta.attributes).some(
|
||||
key => key === `comment-${commentId}`
|
||||
)
|
||||
) {
|
||||
selections.push([
|
||||
new TextSelection({
|
||||
from: {
|
||||
blockId: model.id,
|
||||
index: rangeIndex,
|
||||
length: delta.insert.length,
|
||||
},
|
||||
to: null,
|
||||
}),
|
||||
inlineEditor,
|
||||
]);
|
||||
}
|
||||
store.getAllModels().forEach(model => {
|
||||
if (!model.text) return;
|
||||
|
||||
let index = 0;
|
||||
model.text.toDelta().forEach(delta => {
|
||||
if (!delta.insert) return;
|
||||
|
||||
const length = delta.insert.length;
|
||||
|
||||
if (!delta.attributes) {
|
||||
index += length;
|
||||
return;
|
||||
}
|
||||
);
|
||||
|
||||
Object.keys(delta.attributes)
|
||||
.filter(key => key.startsWith('comment-'))
|
||||
.forEach(key => {
|
||||
const commentId = key.replace('comment-', '');
|
||||
const selection = new TextSelection({
|
||||
from: {
|
||||
blockId: model.id,
|
||||
index,
|
||||
length,
|
||||
},
|
||||
to: null,
|
||||
});
|
||||
result.set(selection, commentId);
|
||||
});
|
||||
|
||||
index += length;
|
||||
});
|
||||
});
|
||||
|
||||
return selections;
|
||||
return result;
|
||||
}
|
||||
|
||||
export function findCommentedTexts(
|
||||
store: Store,
|
||||
commentId: CommentId
|
||||
): TextSelection[] {
|
||||
return [...findAllCommentedTexts(store).entries()]
|
||||
.filter(([_, id]) => id === commentId)
|
||||
.map(([selection]) => selection);
|
||||
}
|
||||
|
||||
export function extractCommentIdFromDelta(
|
||||
|
||||
@@ -63,7 +63,8 @@
|
||||
"./theme": "./src/theme/index.ts",
|
||||
"./styles": "./src/styles/index.ts",
|
||||
"./services": "./src/services/index.ts",
|
||||
"./adapters": "./src/adapters/index.ts"
|
||||
"./adapters": "./src/adapters/index.ts",
|
||||
"./test-utils": "./src/test-utils/index.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getFirstBlockCommand } from '../../../commands/block-crud/get-first-content-block';
|
||||
import { affine } from '../../helpers/affine-template';
|
||||
import { affine } from '../../../test-utils';
|
||||
|
||||
describe('commands/block-crud', () => {
|
||||
describe('getFirstBlockCommand', () => {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getLastBlockCommand } from '../../../commands/block-crud/get-last-content-block';
|
||||
import { affine } from '../../helpers/affine-template';
|
||||
import { affine } from '../../../test-utils';
|
||||
|
||||
describe('commands/block-crud', () => {
|
||||
describe('getLastBlockCommand', () => {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import '../../helpers/affine-test-utils';
|
||||
|
||||
import type { TextSelection } from '@blocksuite/std';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { replaceSelectedTextWithBlocksCommand } from '../../../commands/model-crud/replace-selected-text-with-blocks';
|
||||
import { affine, block } from '../../helpers/affine-template';
|
||||
import { affine, block } from '../../../test-utils';
|
||||
|
||||
describe('commands/model-crud', () => {
|
||||
describe('replaceSelectedTextWithBlocksCommand', () => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { isNothingSelectedCommand } from '../../../commands/selection/is-nothing-selected';
|
||||
import { ImageSelection } from '../../../selection';
|
||||
import { affine } from '../../helpers/affine-template';
|
||||
import { affine } from '../../../test-utils';
|
||||
|
||||
describe('commands/selection', () => {
|
||||
describe('isNothingSelectedCommand', () => {
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
import {
|
||||
CodeBlockSchemaExtension,
|
||||
DatabaseBlockSchemaExtension,
|
||||
ImageBlockSchemaExtension,
|
||||
ListBlockSchemaExtension,
|
||||
NoteBlockSchemaExtension,
|
||||
ParagraphBlockSchemaExtension,
|
||||
RootBlockSchemaExtension,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { TextSelection } from '@blocksuite/std';
|
||||
import { type Block, type Store } from '@blocksuite/store';
|
||||
import { Text } from '@blocksuite/store';
|
||||
import { TestWorkspace } from '@blocksuite/store/test';
|
||||
|
||||
import { createTestHost } from './create-test-host';
|
||||
|
||||
// Extensions array
|
||||
const extensions = [
|
||||
RootBlockSchemaExtension,
|
||||
NoteBlockSchemaExtension,
|
||||
ParagraphBlockSchemaExtension,
|
||||
ListBlockSchemaExtension,
|
||||
ImageBlockSchemaExtension,
|
||||
DatabaseBlockSchemaExtension,
|
||||
CodeBlockSchemaExtension,
|
||||
];
|
||||
|
||||
// Mapping from tag names to flavours
|
||||
const tagToFlavour: Record<string, string> = {
|
||||
'affine-page': 'affine:page',
|
||||
'affine-note': 'affine:note',
|
||||
'affine-paragraph': 'affine:paragraph',
|
||||
'affine-list': 'affine:list',
|
||||
'affine-image': 'affine:image',
|
||||
'affine-database': 'affine:database',
|
||||
'affine-code': 'affine:code',
|
||||
};
|
||||
|
||||
interface SelectionInfo {
|
||||
anchorBlockId?: string;
|
||||
anchorOffset?: number;
|
||||
focusBlockId?: string;
|
||||
focusOffset?: number;
|
||||
cursorBlockId?: string;
|
||||
cursorOffset?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse template strings and build BlockSuite document structure,
|
||||
* then create a host object with the document
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* const host = affine`
|
||||
* <affine-page id="page">
|
||||
* <affine-note id="note">
|
||||
* <affine-paragraph id="paragraph-1">Hello, world<anchor /></affine-paragraph>
|
||||
* <affine-paragraph id="paragraph-2">Hello, world<focus /></affine-paragraph>
|
||||
* </affine-note>
|
||||
* </affine-page>
|
||||
* `;
|
||||
* ```
|
||||
*/
|
||||
export function affine(strings: TemplateStringsArray, ...values: any[]) {
|
||||
// Merge template strings and values
|
||||
let htmlString = '';
|
||||
strings.forEach((str, i) => {
|
||||
htmlString += str;
|
||||
if (i < values.length) {
|
||||
htmlString += values[i];
|
||||
}
|
||||
});
|
||||
|
||||
// Create a new doc
|
||||
const workspace = new TestWorkspace({});
|
||||
workspace.meta.initialize();
|
||||
const doc = workspace.createDoc('test-doc');
|
||||
const store = doc.getStore({ extensions });
|
||||
|
||||
let selectionInfo: SelectionInfo = {};
|
||||
|
||||
// Use DOMParser to parse HTML string
|
||||
doc.load(() => {
|
||||
const parser = new DOMParser();
|
||||
const dom = parser.parseFromString(htmlString.trim(), 'text/html');
|
||||
const root = dom.body.firstElementChild;
|
||||
|
||||
if (!root) {
|
||||
throw new Error('Template must contain a root element');
|
||||
}
|
||||
|
||||
buildDocFromElement(store, root, null, selectionInfo);
|
||||
});
|
||||
|
||||
// Create host object
|
||||
const host = createTestHost(store);
|
||||
|
||||
// Set selection if needed
|
||||
if (selectionInfo.anchorBlockId && selectionInfo.focusBlockId) {
|
||||
const anchorBlock = store.getBlock(selectionInfo.anchorBlockId);
|
||||
const anchorTextLength = anchorBlock?.model?.text?.length ?? 0;
|
||||
const focusOffset = selectionInfo.focusOffset ?? 0;
|
||||
const anchorOffset = selectionInfo.anchorOffset ?? 0;
|
||||
|
||||
if (selectionInfo.anchorBlockId === selectionInfo.focusBlockId) {
|
||||
const selection = host.selection.create(TextSelection, {
|
||||
from: {
|
||||
blockId: selectionInfo.anchorBlockId,
|
||||
index: anchorOffset,
|
||||
length: focusOffset,
|
||||
},
|
||||
to: null,
|
||||
});
|
||||
host.selection.setGroup('note', [selection]);
|
||||
} else {
|
||||
const selection = host.selection.create(TextSelection, {
|
||||
from: {
|
||||
blockId: selectionInfo.anchorBlockId,
|
||||
index: anchorOffset,
|
||||
length: anchorTextLength - anchorOffset,
|
||||
},
|
||||
to: {
|
||||
blockId: selectionInfo.focusBlockId,
|
||||
index: 0,
|
||||
length: focusOffset,
|
||||
},
|
||||
});
|
||||
host.selection.setGroup('note', [selection]);
|
||||
}
|
||||
} else if (selectionInfo.cursorBlockId) {
|
||||
const selection = host.selection.create(TextSelection, {
|
||||
from: {
|
||||
blockId: selectionInfo.cursorBlockId,
|
||||
index: selectionInfo.cursorOffset ?? 0,
|
||||
length: 0,
|
||||
},
|
||||
to: null,
|
||||
});
|
||||
host.selection.setGroup('note', [selection]);
|
||||
}
|
||||
|
||||
return host;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single block from template string
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* const block = block`<affine-note />`
|
||||
* ```
|
||||
*/
|
||||
export function block(
|
||||
strings: TemplateStringsArray,
|
||||
...values: any[]
|
||||
): Block | null {
|
||||
// Merge template strings and values
|
||||
let htmlString = '';
|
||||
strings.forEach((str, i) => {
|
||||
htmlString += str;
|
||||
if (i < values.length) {
|
||||
htmlString += values[i];
|
||||
}
|
||||
});
|
||||
|
||||
// Create a temporary doc to hold the block
|
||||
const workspace = new TestWorkspace({});
|
||||
workspace.meta.initialize();
|
||||
const doc = workspace.createDoc('temp-doc');
|
||||
const store = doc.getStore({ extensions });
|
||||
|
||||
let blockId: string | null = null;
|
||||
const selectionInfo: SelectionInfo = {};
|
||||
|
||||
// Use DOMParser to parse HTML string
|
||||
doc.load(() => {
|
||||
const parser = new DOMParser();
|
||||
const dom = parser.parseFromString(htmlString.trim(), 'text/html');
|
||||
const root = dom.body.firstElementChild;
|
||||
|
||||
if (!root) {
|
||||
throw new Error('Template must contain a root element');
|
||||
}
|
||||
|
||||
// Create a root block if needed
|
||||
const flavour = tagToFlavour[root.tagName.toLowerCase()];
|
||||
if (
|
||||
flavour === 'affine:paragraph' ||
|
||||
flavour === 'affine:list' ||
|
||||
flavour === 'affine:code'
|
||||
) {
|
||||
const pageId = store.addBlock('affine:page', {});
|
||||
const noteId = store.addBlock('affine:note', {}, pageId);
|
||||
blockId = buildDocFromElement(store, root, noteId, selectionInfo);
|
||||
} else {
|
||||
blockId = buildDocFromElement(store, root, null, selectionInfo);
|
||||
}
|
||||
});
|
||||
|
||||
// Return the created block
|
||||
return blockId ? (store.getBlock(blockId) ?? null) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively build document structure
|
||||
* @param doc
|
||||
* @param element
|
||||
* @param parentId
|
||||
* @param selectionInfo
|
||||
* @returns
|
||||
*/
|
||||
function buildDocFromElement(
|
||||
doc: Store,
|
||||
element: Element,
|
||||
parentId: string | null,
|
||||
selectionInfo: SelectionInfo
|
||||
): string {
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
|
||||
// Handle selection tags
|
||||
if (tagName === 'anchor') {
|
||||
if (!parentId) return '';
|
||||
const parentBlock = doc.getBlock(parentId);
|
||||
if (parentBlock) {
|
||||
const textBeforeCursor = element.previousSibling?.textContent ?? '';
|
||||
selectionInfo.anchorBlockId = parentId;
|
||||
selectionInfo.anchorOffset = textBeforeCursor.length;
|
||||
}
|
||||
return parentId;
|
||||
} else if (tagName === 'focus') {
|
||||
if (!parentId) return '';
|
||||
const parentBlock = doc.getBlock(parentId);
|
||||
if (parentBlock) {
|
||||
const textBeforeCursor = element.previousSibling?.textContent ?? '';
|
||||
selectionInfo.focusBlockId = parentId;
|
||||
selectionInfo.focusOffset = textBeforeCursor.length;
|
||||
}
|
||||
return parentId;
|
||||
} else if (tagName === 'cursor') {
|
||||
if (!parentId) return '';
|
||||
const parentBlock = doc.getBlock(parentId);
|
||||
if (parentBlock) {
|
||||
const textBeforeCursor = element.previousSibling?.textContent ?? '';
|
||||
selectionInfo.cursorBlockId = parentId;
|
||||
selectionInfo.cursorOffset = textBeforeCursor.length;
|
||||
}
|
||||
return parentId;
|
||||
}
|
||||
|
||||
const flavour = tagToFlavour[tagName];
|
||||
|
||||
if (!flavour) {
|
||||
throw new Error(`Unknown tag name: ${tagName}`);
|
||||
}
|
||||
|
||||
const props: Record<string, any> = {};
|
||||
|
||||
const customId = element.getAttribute('id');
|
||||
|
||||
// If ID is specified, add it to props
|
||||
if (customId) {
|
||||
props.id = customId;
|
||||
}
|
||||
|
||||
// Process element attributes
|
||||
Array.from(element.attributes).forEach(attr => {
|
||||
if (attr.name !== 'id') {
|
||||
// Skip id attribute, we already handled it
|
||||
props[attr.name] = attr.value;
|
||||
}
|
||||
});
|
||||
|
||||
// Special handling for different block types based on their flavours
|
||||
switch (flavour) {
|
||||
case 'affine:paragraph':
|
||||
case 'affine:list':
|
||||
if (element.textContent) {
|
||||
props.text = new Text(element.textContent);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Create block
|
||||
const blockId = doc.addBlock(flavour, props, parentId);
|
||||
|
||||
// Process all child nodes, including text nodes
|
||||
Array.from(element.children).forEach(child => {
|
||||
if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
// Handle element nodes
|
||||
buildDocFromElement(doc, child as Element, blockId, selectionInfo);
|
||||
} else if (child.nodeType === Node.TEXT_NODE) {
|
||||
// Handle text nodes
|
||||
console.log('buildDocFromElement text node:', child.textContent);
|
||||
}
|
||||
});
|
||||
|
||||
return blockId;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TextSelection } from '@blocksuite/std';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { affine } from './affine-template';
|
||||
import { affine } from '../../test-utils';
|
||||
|
||||
describe('helpers/affine-template', () => {
|
||||
it('should create a basic document structure from template', () => {
|
||||
@@ -1,8 +1,12 @@
|
||||
import {
|
||||
type ReferenceParams,
|
||||
ReferenceParamsSchema,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { ReferenceParamsSchema } from '@blocksuite/affine-model';
|
||||
import { BaseSelection, SelectionExtension } from '@blocksuite/store';
|
||||
import z from 'zod';
|
||||
|
||||
const HighlightSelectionParamsSchema = ReferenceParamsSchema.extend({
|
||||
highlight: z.boolean().optional(),
|
||||
});
|
||||
|
||||
type HighlightSelectionParams = z.infer<typeof HighlightSelectionParamsSchema>;
|
||||
|
||||
export class HighlightSelection extends BaseSelection {
|
||||
static override group = 'scene';
|
||||
@@ -15,16 +19,24 @@ export class HighlightSelection extends BaseSelection {
|
||||
|
||||
readonly mode: 'page' | 'edgeless' = 'page';
|
||||
|
||||
constructor({ mode, blockIds, elementIds }: ReferenceParams) {
|
||||
readonly highlight: boolean = true;
|
||||
|
||||
constructor({
|
||||
mode,
|
||||
blockIds,
|
||||
elementIds,
|
||||
highlight = true,
|
||||
}: HighlightSelectionParams) {
|
||||
super({ blockId: '[scene-highlight]' });
|
||||
|
||||
this.mode = mode ?? 'page';
|
||||
this.blockIds = blockIds ?? [];
|
||||
this.elementIds = elementIds ?? [];
|
||||
this.highlight = highlight;
|
||||
}
|
||||
|
||||
static override fromJSON(json: Record<string, unknown>): HighlightSelection {
|
||||
const result = ReferenceParamsSchema.parse(json);
|
||||
const result = HighlightSelectionParamsSchema.parse(json);
|
||||
return new HighlightSelection(result);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,10 +42,10 @@ export class BlockCommentManager extends LifeCycleWatcher {
|
||||
|
||||
this._disposables.add(provider.onCommentAdded(this._handleAddComment));
|
||||
this._disposables.add(
|
||||
provider.onCommentDeleted(this._handleDeleteAndResolve)
|
||||
provider.onCommentDeleted(this.handleDeleteAndResolve)
|
||||
);
|
||||
this._disposables.add(
|
||||
provider.onCommentResolved(this._handleDeleteAndResolve)
|
||||
provider.onCommentResolved(this.handleDeleteAndResolve)
|
||||
);
|
||||
this._disposables.add(
|
||||
provider.onCommentHighlighted(this._handleHighlightComment)
|
||||
@@ -103,7 +103,7 @@ export class BlockCommentManager extends LifeCycleWatcher {
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _handleDeleteAndResolve = (id: CommentId) => {
|
||||
readonly handleDeleteAndResolve = (id: CommentId) => {
|
||||
const commentedBlocks = findCommentedBlocks(this.std.store, id);
|
||||
this.std.store.withoutTransact(() => {
|
||||
commentedBlocks.forEach(block => {
|
||||
|
||||
@@ -15,7 +15,10 @@ export interface CommentProvider {
|
||||
addComment: (selections: BaseSelection[]) => void;
|
||||
resolveComment: (id: CommentId) => void;
|
||||
highlightComment: (id: CommentId | null) => void;
|
||||
getComments: () => CommentId[];
|
||||
|
||||
getComments: (
|
||||
type: 'resolved' | 'unresolved' | 'all'
|
||||
) => Promise<CommentId[]> | CommentId[];
|
||||
|
||||
onCommentAdded: (
|
||||
callback: (id: CommentId, selections: BaseSelection[]) => void
|
||||
|
||||
@@ -5,18 +5,23 @@ import type { BlockModel, Store } from '@blocksuite/store';
|
||||
import type { ToolbarAction } from '../toolbar-service';
|
||||
import { type CommentId, CommentProviderIdentifier } from './comment-provider';
|
||||
|
||||
export function findCommentedBlocks(store: Store, commentId: CommentId) {
|
||||
export function findAllCommentedBlocks(store: Store) {
|
||||
type CommentedBlock = BlockModel<{ comments: Record<CommentId, boolean> }>;
|
||||
return store.getAllModels().filter((block): block is CommentedBlock => {
|
||||
return (
|
||||
'comments' in block.props &&
|
||||
typeof block.props.comments === 'object' &&
|
||||
block.props.comments !== null &&
|
||||
commentId in block.props.comments
|
||||
block.props.comments !== null
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function findCommentedBlocks(store: Store, commentId: CommentId) {
|
||||
return findAllCommentedBlocks(store).filter(block => {
|
||||
return block.props.comments[commentId];
|
||||
});
|
||||
}
|
||||
|
||||
export const blockCommentToolbarButton: Omit<ToolbarAction, 'id'> = {
|
||||
tooltip: 'Comment',
|
||||
when: ({ std }) => !!std.getOptional(CommentProviderIdentifier),
|
||||
|
||||
@@ -21,7 +21,6 @@ export interface BlockSuiteFlags {
|
||||
enable_table_virtual_scroll: boolean;
|
||||
enable_turbo_renderer: boolean;
|
||||
enable_dom_renderer: boolean;
|
||||
enable_comment: boolean;
|
||||
}
|
||||
|
||||
export class FeatureFlagService extends StoreExtension {
|
||||
@@ -47,7 +46,6 @@ export class FeatureFlagService extends StoreExtension {
|
||||
enable_table_virtual_scroll: false,
|
||||
enable_turbo_renderer: false,
|
||||
enable_dom_renderer: false,
|
||||
enable_comment: false,
|
||||
});
|
||||
|
||||
setFlag(key: keyof BlockSuiteFlags, value: boolean) {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
### Basic Usage
|
||||
|
||||
```typescript
|
||||
import { affine } from '../__tests__/utils/affine-template';
|
||||
import { affine } from '@blocksuite/affine-shared/test-utils';
|
||||
|
||||
// Create a simple document
|
||||
const doc = affine`
|
||||
316
blocksuite/affine/shared/src/test-utils/affine-template.ts
Normal file
316
blocksuite/affine/shared/src/test-utils/affine-template.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import {
|
||||
CodeBlockSchemaExtension,
|
||||
DatabaseBlockSchemaExtension,
|
||||
ImageBlockSchemaExtension,
|
||||
ListBlockSchemaExtension,
|
||||
NoteBlockSchemaExtension,
|
||||
ParagraphBlockSchemaExtension,
|
||||
RootBlockSchemaExtension,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { Container } from '@blocksuite/global/di';
|
||||
import { TextSelection } from '@blocksuite/std';
|
||||
import {
|
||||
type Block,
|
||||
type ExtensionType,
|
||||
type Store,
|
||||
Text,
|
||||
} from '@blocksuite/store';
|
||||
import { TestWorkspace } from '@blocksuite/store/test';
|
||||
|
||||
import { createTestHost } from './create-test-host';
|
||||
|
||||
const DEFAULT_EXTENSIONS = [
|
||||
RootBlockSchemaExtension,
|
||||
NoteBlockSchemaExtension,
|
||||
ParagraphBlockSchemaExtension,
|
||||
ListBlockSchemaExtension,
|
||||
ImageBlockSchemaExtension,
|
||||
DatabaseBlockSchemaExtension,
|
||||
CodeBlockSchemaExtension,
|
||||
];
|
||||
|
||||
// Mapping from tag names to flavours
|
||||
const tagToFlavour: Record<string, string> = {
|
||||
'affine-page': 'affine:page',
|
||||
'affine-note': 'affine:note',
|
||||
'affine-paragraph': 'affine:paragraph',
|
||||
'affine-list': 'affine:list',
|
||||
'affine-image': 'affine:image',
|
||||
'affine-database': 'affine:database',
|
||||
'affine-code': 'affine:code',
|
||||
};
|
||||
|
||||
interface SelectionInfo {
|
||||
anchorBlockId?: string;
|
||||
anchorOffset?: number;
|
||||
focusBlockId?: string;
|
||||
focusOffset?: number;
|
||||
cursorBlockId?: string;
|
||||
cursorOffset?: number;
|
||||
}
|
||||
|
||||
export function createAffineTemplate(
|
||||
extensions: ExtensionType[] = DEFAULT_EXTENSIONS
|
||||
) {
|
||||
/**
|
||||
* Parse template strings and build BlockSuite document structure,
|
||||
* then create a host object with the document
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* const host = affine`
|
||||
* <affine-page id="page">
|
||||
* <affine-note id="note">
|
||||
* <affine-paragraph id="paragraph-1">Hello, world<anchor /></affine-paragraph>
|
||||
* <affine-paragraph id="paragraph-2">Hello, world<focus /></affine-paragraph>
|
||||
* </affine-note>
|
||||
* </affine-page>
|
||||
* `;
|
||||
* ```
|
||||
*/
|
||||
function affine(strings: TemplateStringsArray, ...values: any[]) {
|
||||
// Merge template strings and values
|
||||
let htmlString = '';
|
||||
strings.forEach((str, i) => {
|
||||
htmlString += str;
|
||||
if (i < values.length) {
|
||||
htmlString += values[i];
|
||||
}
|
||||
});
|
||||
|
||||
// Create a new doc
|
||||
const workspace = new TestWorkspace({});
|
||||
workspace.meta.initialize();
|
||||
const doc = workspace.createDoc('test-doc');
|
||||
const container = new Container();
|
||||
extensions.forEach(extension => {
|
||||
extension.setup(container);
|
||||
});
|
||||
const store = doc.getStore({ extensions, provider: container.provider() });
|
||||
let selectionInfo: SelectionInfo = {};
|
||||
|
||||
// Use DOMParser to parse HTML string
|
||||
doc.load(() => {
|
||||
const parser = new DOMParser();
|
||||
const dom = parser.parseFromString(htmlString.trim(), 'text/html');
|
||||
const root = dom.body.firstElementChild;
|
||||
|
||||
if (!root) {
|
||||
throw new Error('Template must contain a root element');
|
||||
}
|
||||
|
||||
buildDocFromElement(store, root, null, selectionInfo);
|
||||
});
|
||||
|
||||
// Create host object
|
||||
const host = createTestHost(store);
|
||||
|
||||
// Set selection if needed
|
||||
if (selectionInfo.anchorBlockId && selectionInfo.focusBlockId) {
|
||||
const anchorBlock = store.getBlock(selectionInfo.anchorBlockId);
|
||||
const anchorTextLength = anchorBlock?.model?.text?.length ?? 0;
|
||||
const focusOffset = selectionInfo.focusOffset ?? 0;
|
||||
const anchorOffset = selectionInfo.anchorOffset ?? 0;
|
||||
|
||||
if (selectionInfo.anchorBlockId === selectionInfo.focusBlockId) {
|
||||
const selection = host.selection.create(TextSelection, {
|
||||
from: {
|
||||
blockId: selectionInfo.anchorBlockId,
|
||||
index: anchorOffset,
|
||||
length: focusOffset,
|
||||
},
|
||||
to: null,
|
||||
});
|
||||
host.selection.setGroup('note', [selection]);
|
||||
} else {
|
||||
const selection = host.selection.create(TextSelection, {
|
||||
from: {
|
||||
blockId: selectionInfo.anchorBlockId,
|
||||
index: anchorOffset,
|
||||
length: anchorTextLength - anchorOffset,
|
||||
},
|
||||
to: {
|
||||
blockId: selectionInfo.focusBlockId,
|
||||
index: 0,
|
||||
length: focusOffset,
|
||||
},
|
||||
});
|
||||
host.selection.setGroup('note', [selection]);
|
||||
}
|
||||
} else if (selectionInfo.cursorBlockId) {
|
||||
const selection = host.selection.create(TextSelection, {
|
||||
from: {
|
||||
blockId: selectionInfo.cursorBlockId,
|
||||
index: selectionInfo.cursorOffset ?? 0,
|
||||
length: 0,
|
||||
},
|
||||
to: null,
|
||||
});
|
||||
host.selection.setGroup('note', [selection]);
|
||||
}
|
||||
|
||||
return host;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single block from template string
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* const block = block`<affine-note />`
|
||||
* ```
|
||||
*/
|
||||
function block(
|
||||
strings: TemplateStringsArray,
|
||||
...values: any[]
|
||||
): Block | null {
|
||||
// Merge template strings and values
|
||||
let htmlString = '';
|
||||
strings.forEach((str, i) => {
|
||||
htmlString += str;
|
||||
if (i < values.length) {
|
||||
htmlString += values[i];
|
||||
}
|
||||
});
|
||||
|
||||
// Create a temporary doc to hold the block
|
||||
const workspace = new TestWorkspace({});
|
||||
workspace.meta.initialize();
|
||||
const doc = workspace.createDoc('temp-doc');
|
||||
const store = doc.getStore({ extensions });
|
||||
|
||||
let blockId: string | null = null;
|
||||
const selectionInfo: SelectionInfo = {};
|
||||
|
||||
// Use DOMParser to parse HTML string
|
||||
doc.load(() => {
|
||||
const parser = new DOMParser();
|
||||
const dom = parser.parseFromString(htmlString.trim(), 'text/html');
|
||||
const root = dom.body.firstElementChild;
|
||||
|
||||
if (!root) {
|
||||
throw new Error('Template must contain a root element');
|
||||
}
|
||||
|
||||
// Create a root block if needed
|
||||
const flavour = tagToFlavour[root.tagName.toLowerCase()];
|
||||
if (
|
||||
flavour === 'affine:paragraph' ||
|
||||
flavour === 'affine:list' ||
|
||||
flavour === 'affine:code'
|
||||
) {
|
||||
const pageId = store.addBlock('affine:page', {});
|
||||
const noteId = store.addBlock('affine:note', {}, pageId);
|
||||
blockId = buildDocFromElement(store, root, noteId, selectionInfo);
|
||||
} else {
|
||||
blockId = buildDocFromElement(store, root, null, selectionInfo);
|
||||
}
|
||||
});
|
||||
|
||||
// Return the created block
|
||||
return blockId ? (store.getBlock(blockId) ?? null) : null;
|
||||
}
|
||||
|
||||
return {
|
||||
affine,
|
||||
block,
|
||||
};
|
||||
}
|
||||
|
||||
export const { affine, block } = createAffineTemplate();
|
||||
|
||||
/**
|
||||
* Recursively build document structure
|
||||
* @param doc
|
||||
* @param element
|
||||
* @param parentId
|
||||
* @param selectionInfo
|
||||
* @returns
|
||||
*/
|
||||
function buildDocFromElement(
|
||||
doc: Store,
|
||||
element: Element,
|
||||
parentId: string | null,
|
||||
selectionInfo: SelectionInfo
|
||||
): string {
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
|
||||
// Handle selection tags
|
||||
if (tagName === 'anchor') {
|
||||
if (!parentId) return '';
|
||||
const parentBlock = doc.getBlock(parentId);
|
||||
if (parentBlock) {
|
||||
const textBeforeCursor = element.previousSibling?.textContent ?? '';
|
||||
selectionInfo.anchorBlockId = parentId;
|
||||
selectionInfo.anchorOffset = textBeforeCursor.length;
|
||||
}
|
||||
return parentId;
|
||||
} else if (tagName === 'focus') {
|
||||
if (!parentId) return '';
|
||||
const parentBlock = doc.getBlock(parentId);
|
||||
if (parentBlock) {
|
||||
const textBeforeCursor = element.previousSibling?.textContent ?? '';
|
||||
selectionInfo.focusBlockId = parentId;
|
||||
selectionInfo.focusOffset = textBeforeCursor.length;
|
||||
}
|
||||
return parentId;
|
||||
} else if (tagName === 'cursor') {
|
||||
if (!parentId) return '';
|
||||
const parentBlock = doc.getBlock(parentId);
|
||||
if (parentBlock) {
|
||||
const textBeforeCursor = element.previousSibling?.textContent ?? '';
|
||||
selectionInfo.cursorBlockId = parentId;
|
||||
selectionInfo.cursorOffset = textBeforeCursor.length;
|
||||
}
|
||||
return parentId;
|
||||
}
|
||||
|
||||
const flavour = tagToFlavour[tagName];
|
||||
|
||||
if (!flavour) {
|
||||
throw new Error(`Unknown tag name: ${tagName}`);
|
||||
}
|
||||
|
||||
const props: Record<string, any> = {};
|
||||
|
||||
const customId = element.getAttribute('id');
|
||||
|
||||
// If ID is specified, add it to props
|
||||
if (customId) {
|
||||
props.id = customId;
|
||||
}
|
||||
|
||||
// Process element attributes
|
||||
Array.from(element.attributes).forEach(attr => {
|
||||
if (attr.name !== 'id') {
|
||||
// Skip id attribute, we already handled it
|
||||
props[attr.name] = attr.value;
|
||||
}
|
||||
});
|
||||
|
||||
// Special handling for different block types based on their flavours
|
||||
switch (flavour) {
|
||||
case 'affine:paragraph':
|
||||
case 'affine:list':
|
||||
if (element.textContent) {
|
||||
props.text = new Text(element.textContent);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Create block
|
||||
const blockId = doc.addBlock(flavour, props, parentId);
|
||||
|
||||
// Process all child nodes, including text nodes
|
||||
Array.from(element.children).forEach(child => {
|
||||
if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
// Handle element nodes
|
||||
buildDocFromElement(doc, child as Element, blockId, selectionInfo);
|
||||
} else if (child.nodeType === Node.TEXT_NODE) {
|
||||
// Handle text nodes
|
||||
console.log('buildDocFromElement text node:', child.textContent);
|
||||
}
|
||||
});
|
||||
|
||||
return blockId;
|
||||
}
|
||||
@@ -63,10 +63,8 @@ function compareBlocks(
|
||||
if (JSON.stringify(actualProps) !== JSON.stringify(expectedProps))
|
||||
return false;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-for-of
|
||||
for (let i = 0; i < actual.children.length; i++) {
|
||||
if (!compareBlocks(actual.children[i], expected.children[i], compareId))
|
||||
return false;
|
||||
for (const [i, child] of actual.children.entries()) {
|
||||
if (!compareBlocks(child, expected.children[i], compareId)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -240,7 +240,7 @@ export function createTestHost(doc: Store): EditorHost {
|
||||
std.selection = new MockSelectionStore();
|
||||
|
||||
std.command = new CommandManager(std as any);
|
||||
// @ts-expect-error
|
||||
// @ts-expect-error dev-only
|
||||
host.command = std.command;
|
||||
host.selection = std.selection;
|
||||
|
||||
3
blocksuite/affine/shared/src/test-utils/index.ts
Normal file
3
blocksuite/affine/shared/src/test-utils/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './affine-template';
|
||||
export * from './affine-test-utils';
|
||||
export * from './create-test-host';
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { EditorHost } from '@blocksuite/std';
|
||||
|
||||
export function isInsidePageEditor(host: EditorHost) {
|
||||
export function isInsidePageEditor(host?: EditorHost) {
|
||||
if (!host) return false;
|
||||
return Array.from(host.children).some(
|
||||
v => v.tagName.toLowerCase() === 'affine-page-root'
|
||||
);
|
||||
}
|
||||
|
||||
export function isInsideEdgelessEditor(host: EditorHost) {
|
||||
export function isInsideEdgelessEditor(host?: EditorHost) {
|
||||
if (!host) return false;
|
||||
|
||||
return Array.from(host.children).some(
|
||||
|
||||
@@ -42,6 +42,7 @@ export class AffineKeyboardToolbarWidget extends WidgetComponent<RootBlockModel>
|
||||
|
||||
return {
|
||||
// fallback keyboard actions
|
||||
fallback: true,
|
||||
show: () => {
|
||||
const rootComponent = this.block?.rootComponent;
|
||||
if (rootComponent && rootComponent === document.activeElement) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { styleMap } from 'lit/directives/style-map.js';
|
||||
type Anchor = {
|
||||
id: string;
|
||||
mode: DocMode;
|
||||
highlight: boolean;
|
||||
};
|
||||
|
||||
export const AFFINE_SCROLL_ANCHORING_WIDGET = 'affine-scroll-anchoring-widget';
|
||||
@@ -221,6 +222,7 @@ export class AffineScrollAnchoringWidget extends WidgetComponent {
|
||||
mode,
|
||||
blockIds: [bid],
|
||||
elementIds: [eid],
|
||||
highlight,
|
||||
} = highlighted;
|
||||
const id = mode === 'page' ? bid : eid || bid;
|
||||
if (!id) return;
|
||||
@@ -228,7 +230,7 @@ export class AffineScrollAnchoringWidget extends WidgetComponent {
|
||||
// Consumes highlight selection
|
||||
this.std.selection.clear(['highlight']);
|
||||
|
||||
this.anchor$.value = { mode, id };
|
||||
this.anchor$.value = { mode, id, highlight };
|
||||
this.#listened = true;
|
||||
})
|
||||
);
|
||||
@@ -241,7 +243,7 @@ export class AffineScrollAnchoringWidget extends WidgetComponent {
|
||||
|
||||
override render() {
|
||||
const anchor = this.anchor$.value;
|
||||
if (!anchor) return nothing;
|
||||
if (!anchor || !anchor.highlight) return nothing;
|
||||
|
||||
const { mode, id } = anchor;
|
||||
|
||||
|
||||
@@ -244,8 +244,14 @@ export function mockCommentProvider() {
|
||||
this.commentHighlightSubject.next(id);
|
||||
}
|
||||
|
||||
getComments() {
|
||||
return Array.from(this.comments.keys());
|
||||
getComments(type: 'resolved' | 'unresolved' | 'all' = 'all') {
|
||||
return Array.from(this.comments.entries())
|
||||
.filter(([_, comment]) => {
|
||||
if (type === 'all') return true;
|
||||
if (type === 'resolved') return comment.resolved;
|
||||
return !comment.resolved;
|
||||
})
|
||||
.map(([id]) => id);
|
||||
}
|
||||
|
||||
onCommentAdded(
|
||||
|
||||
@@ -134,14 +134,16 @@ test.before(async t => {
|
||||
t.context.jobs = jobs;
|
||||
});
|
||||
|
||||
const textPromptName = 'prompt';
|
||||
const imagePromptName = 'prompt-image';
|
||||
let textPromptName = 'prompt';
|
||||
let imagePromptName = 'prompt-image';
|
||||
|
||||
test.beforeEach(async t => {
|
||||
Sinon.restore();
|
||||
const { app, prompt } = t.context;
|
||||
await app.initTestingDB();
|
||||
await prompt.onApplicationBootstrap();
|
||||
t.context.u1 = await app.signupV1('u1@affine.pro');
|
||||
t.context.u1 = await app.signupV1();
|
||||
textPromptName = randomUUID().replaceAll('-', '');
|
||||
imagePromptName = randomUUID().replaceAll('-', '');
|
||||
|
||||
await prompt.set(textPromptName, 'test', [
|
||||
{ role: 'system', content: 'hello {{word}}' },
|
||||
@@ -189,7 +191,7 @@ test('should create session correctly', async t => {
|
||||
}
|
||||
|
||||
{
|
||||
const u2 = await app.createUser('u2@affine.pro');
|
||||
const u2 = await app.createUser();
|
||||
const { id } = await createWorkspace(app);
|
||||
await app.login(u2);
|
||||
await assertCreateSession(id, '', async x => {
|
||||
@@ -253,8 +255,8 @@ test('should update session correctly', async t => {
|
||||
}
|
||||
|
||||
{
|
||||
await app.signupV1('test@affine.pro');
|
||||
const u2 = await app.createUser('u2@affine.pro');
|
||||
await app.signupV1();
|
||||
const u2 = await app.createUser();
|
||||
const { id: workspaceId } = await createWorkspace(app);
|
||||
const inviteId = await inviteUser(app, workspaceId, u2.email);
|
||||
await app.login(u2);
|
||||
@@ -356,7 +358,7 @@ test('should fork session correctly', async t => {
|
||||
}
|
||||
|
||||
{
|
||||
const u2 = await app.signupV1('u2@affine.pro');
|
||||
const u2 = await app.signupV1();
|
||||
await assertForkSession(id, sessionId, randomUUID(), '', async x => {
|
||||
await t.throwsAsync(
|
||||
x,
|
||||
@@ -712,7 +714,7 @@ test('should reject message from different session', async t => {
|
||||
test('should reject request from different user', async t => {
|
||||
const { app, u1 } = t.context;
|
||||
|
||||
const u2 = await app.createUser('u2@affine.pro');
|
||||
const u2 = await app.createUser();
|
||||
const { id } = await createWorkspace(app);
|
||||
const sessionId = await createCopilotSession(
|
||||
app,
|
||||
@@ -789,7 +791,7 @@ test('should be able to list history', async t => {
|
||||
test('should reject request that user have not permission', async t => {
|
||||
const { app, u1 } = t.context;
|
||||
|
||||
const u2 = await app.createUser('u2@affine.pro');
|
||||
const u2 = await app.createUser();
|
||||
const { id: workspaceId } = await createWorkspace(app);
|
||||
|
||||
// should reject request that user have not permission
|
||||
|
||||
@@ -687,6 +687,119 @@ e2e(
|
||||
}
|
||||
);
|
||||
|
||||
e2e(
|
||||
'should create reply and send comment mention notification to comment author',
|
||||
async t => {
|
||||
const docId = randomUUID();
|
||||
await app.create(Mockers.DocUser, {
|
||||
workspaceId: teamWorkspace.id,
|
||||
docId,
|
||||
userId: owner.id,
|
||||
type: DocRole.Owner,
|
||||
});
|
||||
|
||||
await app.login(member);
|
||||
const createResult = await app.gql({
|
||||
query: createCommentMutation,
|
||||
variables: {
|
||||
input: {
|
||||
workspaceId: teamWorkspace.id,
|
||||
docId,
|
||||
docMode: DocMode.page,
|
||||
docTitle: 'test',
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// owner login to create reply and send notification to comment author: member
|
||||
await app.login(owner);
|
||||
const count = app.queue.count('notification.sendComment');
|
||||
const result = await app.gql({
|
||||
query: createReplyMutation,
|
||||
variables: {
|
||||
input: {
|
||||
commentId: createResult.createComment.id,
|
||||
docMode: DocMode.page,
|
||||
docTitle: 'test',
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(result.createReply.id);
|
||||
t.is(result.createReply.commentId, createResult.createComment.id);
|
||||
t.is(app.queue.count('notification.sendComment'), count + 1);
|
||||
const notification = app.queue.last('notification.sendComment');
|
||||
t.is(notification.name, 'notification.sendComment');
|
||||
t.is(notification.payload.userId, member.id);
|
||||
t.is(notification.payload.body.replyId, result.createReply.id);
|
||||
t.is(notification.payload.isMention, true);
|
||||
}
|
||||
);
|
||||
|
||||
e2e(
|
||||
'should create reply and send comment mention notification to comment author only when author is doc owner',
|
||||
async t => {
|
||||
const docId = randomUUID();
|
||||
await app.create(Mockers.DocUser, {
|
||||
workspaceId: teamWorkspace.id,
|
||||
docId,
|
||||
userId: member.id,
|
||||
type: DocRole.Owner,
|
||||
});
|
||||
|
||||
await app.login(member);
|
||||
const createResult = await app.gql({
|
||||
query: createCommentMutation,
|
||||
variables: {
|
||||
input: {
|
||||
workspaceId: teamWorkspace.id,
|
||||
docId,
|
||||
docMode: DocMode.page,
|
||||
docTitle: 'test',
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await app.login(owner);
|
||||
const count = app.queue.count('notification.sendComment');
|
||||
const result = await app.gql({
|
||||
query: createReplyMutation,
|
||||
variables: {
|
||||
input: {
|
||||
commentId: createResult.createComment.id,
|
||||
docMode: DocMode.page,
|
||||
docTitle: 'test',
|
||||
content: {
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'test' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
t.truthy(result.createReply.id);
|
||||
t.is(result.createReply.commentId, createResult.createComment.id);
|
||||
t.is(app.queue.count('notification.sendComment'), count + 1);
|
||||
const notification = app.queue.last('notification.sendComment');
|
||||
t.is(notification.name, 'notification.sendComment');
|
||||
t.is(notification.payload.userId, member.id);
|
||||
t.is(notification.payload.body.replyId, result.createReply.id);
|
||||
t.is(notification.payload.isMention, true);
|
||||
}
|
||||
);
|
||||
|
||||
e2e('should create reply work when user is Commenter', async t => {
|
||||
const docId = randomUUID();
|
||||
await app.create(Mockers.DocUser, {
|
||||
|
||||
@@ -368,7 +368,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
],
|
||||
},
|
||||
non_action_sessions: {
|
||||
count: 5,
|
||||
count: 4,
|
||||
sessionTypes: [
|
||||
{
|
||||
hasMessages: false,
|
||||
@@ -391,13 +391,6 @@ Generated by [AVA](https://avajs.dev).
|
||||
messageCount: 0,
|
||||
type: 'doc',
|
||||
},
|
||||
{
|
||||
hasMessages: false,
|
||||
isAction: true,
|
||||
isFork: false,
|
||||
messageCount: 0,
|
||||
type: 'doc',
|
||||
},
|
||||
{
|
||||
hasMessages: false,
|
||||
isAction: false,
|
||||
@@ -408,7 +401,7 @@ Generated by [AVA](https://avajs.dev).
|
||||
],
|
||||
},
|
||||
non_fork_sessions: {
|
||||
count: 3,
|
||||
count: 4,
|
||||
sessionTypes: [
|
||||
{
|
||||
hasMessages: false,
|
||||
@@ -424,6 +417,13 @@ Generated by [AVA](https://avajs.dev).
|
||||
messageCount: 0,
|
||||
type: 'doc',
|
||||
},
|
||||
{
|
||||
hasMessages: false,
|
||||
isAction: true,
|
||||
isFork: false,
|
||||
messageCount: 0,
|
||||
type: 'doc',
|
||||
},
|
||||
{
|
||||
hasMessages: false,
|
||||
isAction: false,
|
||||
|
||||
Binary file not shown.
@@ -14,6 +14,7 @@ export async function getUserSettings(
|
||||
settings {
|
||||
receiveInvitationEmail
|
||||
receiveMentionEmail
|
||||
receiveCommentEmail
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,14 +374,33 @@ export class CommentResolver {
|
||||
mentions?: string[],
|
||||
reply?: Reply
|
||||
) {
|
||||
// send comment notification to doc owners
|
||||
const owner = await this.models.docUser.getOwner(
|
||||
comment.workspaceId,
|
||||
comment.docId
|
||||
);
|
||||
if (owner && owner.userId !== sender.id) {
|
||||
const mentionUserIds = new Set(mentions);
|
||||
// send comment mention notification to comment author on reply
|
||||
if (reply) {
|
||||
mentionUserIds.add(comment.userId);
|
||||
}
|
||||
|
||||
// send comment mention notification to mentioned users
|
||||
for (const mentionUserId of mentionUserIds) {
|
||||
// skip if the mention user is the sender
|
||||
if (mentionUserId === sender.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// check if the mention user has Doc.Comments.Read permission
|
||||
const hasPermission = await this.ac
|
||||
.user(mentionUserId)
|
||||
.workspace(comment.workspaceId)
|
||||
.doc(comment.docId)
|
||||
.can('Doc.Comments.Read');
|
||||
|
||||
if (!hasPermission) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.queue.add('notification.sendComment', {
|
||||
userId: owner.userId,
|
||||
isMention: true,
|
||||
userId: mentionUserId,
|
||||
body: {
|
||||
workspaceId: comment.workspaceId,
|
||||
createdByUserId: sender.id,
|
||||
@@ -396,41 +415,31 @@ export class CommentResolver {
|
||||
});
|
||||
}
|
||||
|
||||
// send comment mention notification to mentioned users
|
||||
if (mentions) {
|
||||
for (const mentionUserId of mentions) {
|
||||
// skip if the mention user is the doc owner
|
||||
if (mentionUserId === owner?.userId || mentionUserId === sender.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// check if the mention user has Doc.Comments.Read permission
|
||||
const hasPermission = await this.ac
|
||||
.user(mentionUserId)
|
||||
.workspace(comment.workspaceId)
|
||||
.doc(comment.docId)
|
||||
.can('Doc.Comments.Read');
|
||||
|
||||
if (!hasPermission) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.queue.add('notification.sendComment', {
|
||||
isMention: true,
|
||||
userId: mentionUserId,
|
||||
body: {
|
||||
workspaceId: comment.workspaceId,
|
||||
createdByUserId: sender.id,
|
||||
commentId: comment.id,
|
||||
replyId: reply?.id,
|
||||
doc: {
|
||||
id: comment.docId,
|
||||
title: docTitle,
|
||||
mode: docMode,
|
||||
},
|
||||
// send comment notification to doc owners
|
||||
const owner = await this.models.docUser.getOwner(
|
||||
comment.workspaceId,
|
||||
comment.docId
|
||||
);
|
||||
// if the owner is not in the mention user ids, send comment notification to the owner
|
||||
if (
|
||||
owner &&
|
||||
owner.userId !== sender.id &&
|
||||
!mentionUserIds.has(owner.userId)
|
||||
) {
|
||||
await this.queue.add('notification.sendComment', {
|
||||
userId: owner.userId,
|
||||
body: {
|
||||
workspaceId: comment.workspaceId,
|
||||
createdByUserId: sender.id,
|
||||
commentId: comment.id,
|
||||
replyId: reply?.id,
|
||||
doc: {
|
||||
id: comment.docId,
|
||||
title: docTitle,
|
||||
mode: docMode,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,9 +47,14 @@ export class DocRpcController {
|
||||
@Get('/workspaces/:workspaceId/docs/:docId/markdown')
|
||||
async getDocMarkdown(
|
||||
@Param('workspaceId') workspaceId: string,
|
||||
@Param('docId') docId: string
|
||||
@Param('docId') docId: string,
|
||||
@Query('aiEditable') aiEditable?: string
|
||||
) {
|
||||
const result = await this.docReader.getDocMarkdown(workspaceId, docId);
|
||||
const result = await this.docReader.getDocMarkdown(
|
||||
workspaceId,
|
||||
docId,
|
||||
aiEditable === 'true'
|
||||
);
|
||||
if (!result) {
|
||||
throw new NotFound('Doc not found');
|
||||
}
|
||||
|
||||
@@ -269,7 +269,11 @@ test('should return doc markdown success', async t => {
|
||||
user,
|
||||
});
|
||||
|
||||
const result = await docReader.getDocMarkdown(workspace.id, docSnapshot.id);
|
||||
const result = await docReader.getDocMarkdown(
|
||||
workspace.id,
|
||||
docSnapshot.id,
|
||||
false
|
||||
);
|
||||
t.snapshot(result);
|
||||
});
|
||||
|
||||
@@ -279,6 +283,10 @@ test('should read markdown return null when doc not exists', async t => {
|
||||
name: '',
|
||||
});
|
||||
|
||||
const result = await docReader.getDocMarkdown(workspace.id, randomUUID());
|
||||
const result = await docReader.getDocMarkdown(
|
||||
workspace.id,
|
||||
randomUUID(),
|
||||
false
|
||||
);
|
||||
t.is(result, null);
|
||||
});
|
||||
|
||||
@@ -389,7 +389,11 @@ test('should return doc markdown success', async t => {
|
||||
user,
|
||||
});
|
||||
|
||||
const result = await docReader.getDocMarkdown(workspace.id, docSnapshot.id);
|
||||
const result = await docReader.getDocMarkdown(
|
||||
workspace.id,
|
||||
docSnapshot.id,
|
||||
false
|
||||
);
|
||||
t.snapshot(result);
|
||||
});
|
||||
|
||||
@@ -401,6 +405,10 @@ test('should read markdown return null when doc not exists', async t => {
|
||||
name: '',
|
||||
});
|
||||
|
||||
const result = await docReader.getDocMarkdown(workspace.id, randomUUID());
|
||||
const result = await docReader.getDocMarkdown(
|
||||
workspace.id,
|
||||
randomUUID(),
|
||||
false
|
||||
);
|
||||
t.is(result, null);
|
||||
});
|
||||
|
||||
@@ -67,7 +67,8 @@ export abstract class DocReader {
|
||||
|
||||
abstract getDocMarkdown(
|
||||
workspaceId: string,
|
||||
docId: string
|
||||
docId: string,
|
||||
aiEditable: boolean
|
||||
): Promise<DocMarkdown | null>;
|
||||
|
||||
abstract getDocDiff(
|
||||
@@ -184,13 +185,19 @@ export class DatabaseDocReader extends DocReader {
|
||||
|
||||
async getDocMarkdown(
|
||||
workspaceId: string,
|
||||
docId: string
|
||||
docId: string,
|
||||
aiEditable: boolean
|
||||
): Promise<DocMarkdown | null> {
|
||||
const doc = await this.workspace.getDoc(workspaceId, docId);
|
||||
if (!doc) {
|
||||
return null;
|
||||
}
|
||||
return parseDocToMarkdownFromDocSnapshot(workspaceId, docId, doc.bin);
|
||||
return parseDocToMarkdownFromDocSnapshot(
|
||||
workspaceId,
|
||||
docId,
|
||||
doc.bin,
|
||||
aiEditable
|
||||
);
|
||||
}
|
||||
|
||||
async getDocDiff(
|
||||
@@ -328,9 +335,10 @@ export class RpcDocReader extends DatabaseDocReader {
|
||||
|
||||
override async getDocMarkdown(
|
||||
workspaceId: string,
|
||||
docId: string
|
||||
docId: string,
|
||||
aiEditable: boolean
|
||||
): Promise<DocMarkdown | null> {
|
||||
const url = `${this.config.docService.endpoint}/rpc/workspaces/${workspaceId}/docs/${docId}/markdown`;
|
||||
const url = `${this.config.docService.endpoint}/rpc/workspaces/${workspaceId}/docs/${docId}/markdown?aiEditable=${aiEditable}`;
|
||||
const accessToken = this.crypto.sign(docId);
|
||||
try {
|
||||
const res = await this.fetch(accessToken, url, 'GET');
|
||||
@@ -349,7 +357,7 @@ export class RpcDocReader extends DatabaseDocReader {
|
||||
err
|
||||
);
|
||||
// fallback to database doc reader if the error is not user friendly, like network error
|
||||
return await super.getDocMarkdown(workspaceId, docId);
|
||||
return await super.getDocMarkdown(workspaceId, docId, aiEditable);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ test('should get user settings', async t => {
|
||||
t.deepEqual(settings, {
|
||||
receiveInvitationEmail: true,
|
||||
receiveMentionEmail: true,
|
||||
receiveCommentEmail: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,11 +32,13 @@ test('should update user settings', async t => {
|
||||
await updateUserSettings(app, {
|
||||
receiveInvitationEmail: false,
|
||||
receiveMentionEmail: false,
|
||||
receiveCommentEmail: false,
|
||||
});
|
||||
const settings = await getUserSettings(app);
|
||||
t.deepEqual(settings, {
|
||||
receiveInvitationEmail: false,
|
||||
receiveMentionEmail: false,
|
||||
receiveCommentEmail: false,
|
||||
});
|
||||
|
||||
await updateUserSettings(app, {
|
||||
@@ -45,6 +48,7 @@ test('should update user settings', async t => {
|
||||
t.deepEqual(settings2, {
|
||||
receiveInvitationEmail: false,
|
||||
receiveMentionEmail: true,
|
||||
receiveCommentEmail: false,
|
||||
});
|
||||
|
||||
await updateUserSettings(app, {
|
||||
@@ -55,6 +59,33 @@ test('should update user settings', async t => {
|
||||
t.deepEqual(settings3, {
|
||||
receiveInvitationEmail: false,
|
||||
receiveMentionEmail: true,
|
||||
receiveCommentEmail: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('should update user settings with comment email', async t => {
|
||||
await app.signup();
|
||||
|
||||
await updateUserSettings(app, {
|
||||
receiveCommentEmail: true,
|
||||
});
|
||||
|
||||
const settings = await getUserSettings(app);
|
||||
t.deepEqual(settings, {
|
||||
receiveCommentEmail: true,
|
||||
receiveInvitationEmail: true,
|
||||
receiveMentionEmail: true,
|
||||
});
|
||||
|
||||
await updateUserSettings(app, {
|
||||
receiveCommentEmail: false,
|
||||
});
|
||||
|
||||
const settings2 = await getUserSettings(app);
|
||||
t.deepEqual(settings2, {
|
||||
receiveCommentEmail: false,
|
||||
receiveInvitationEmail: true,
|
||||
receiveMentionEmail: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ export type ListSessionOptions = Pick<
|
||||
Partial<ChatSession>,
|
||||
'sessionId' | 'workspaceId' | 'docId' | 'pinned'
|
||||
> & {
|
||||
userId: string;
|
||||
userId: string | undefined;
|
||||
action?: boolean;
|
||||
fork?: boolean;
|
||||
limit?: number;
|
||||
@@ -310,7 +310,7 @@ export class CopilotSessionModel extends BaseModel {
|
||||
id: getEqCond(sessionId),
|
||||
deletedAt: null,
|
||||
pinned: getEqCond(options.pinned),
|
||||
prompt: getNullCond(fork, ret => ({ action: ret })),
|
||||
prompt: getNullCond(action, ret => ({ action: ret })),
|
||||
parentSessionId: getNullCond(fork),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -51,7 +51,7 @@ import { COPILOT_LOCKER, CopilotType } from '../resolver';
|
||||
import { ChatSessionService } from '../session';
|
||||
import { CopilotStorage } from '../storage';
|
||||
import { MAX_EMBEDDABLE_SIZE } from '../types';
|
||||
import { readStream } from '../utils';
|
||||
import { getSignal, readStream } from '../utils';
|
||||
import { CopilotContextService } from './service';
|
||||
|
||||
@InputType()
|
||||
@@ -394,16 +394,6 @@ export class CopilotContextResolver {
|
||||
private readonly storage: CopilotStorage
|
||||
) {}
|
||||
|
||||
private getSignal(req: Request) {
|
||||
const controller = new AbortController();
|
||||
req.socket.on('close', hasError => {
|
||||
if (hasError) {
|
||||
controller.abort();
|
||||
}
|
||||
});
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
@ResolveField(() => [CopilotContextCategory], {
|
||||
description: 'list collections in context',
|
||||
})
|
||||
@@ -710,7 +700,7 @@ export class CopilotContextResolver {
|
||||
context.workspaceId,
|
||||
content,
|
||||
limit,
|
||||
this.getSignal(ctx.req),
|
||||
getSignal(ctx.req).signal,
|
||||
threshold
|
||||
);
|
||||
}
|
||||
@@ -719,7 +709,7 @@ export class CopilotContextResolver {
|
||||
return await session.matchFiles(
|
||||
content,
|
||||
limit,
|
||||
this.getSignal(ctx.req),
|
||||
getSignal(ctx.req).signal,
|
||||
scopedThreshold,
|
||||
threshold
|
||||
);
|
||||
@@ -785,7 +775,7 @@ export class CopilotContextResolver {
|
||||
context.workspaceId,
|
||||
content,
|
||||
limit,
|
||||
this.getSignal(ctx.req),
|
||||
getSignal(ctx.req).signal,
|
||||
threshold
|
||||
);
|
||||
}
|
||||
@@ -802,7 +792,7 @@ export class CopilotContextResolver {
|
||||
const chunks = await session.matchWorkspaceDocs(
|
||||
content,
|
||||
limit,
|
||||
this.getSignal(ctx.req),
|
||||
getSignal(ctx.req).signal,
|
||||
scopedThreshold,
|
||||
threshold
|
||||
);
|
||||
|
||||
@@ -13,22 +13,22 @@ import type { Request, Response } from 'express';
|
||||
import {
|
||||
BehaviorSubject,
|
||||
catchError,
|
||||
concatMap,
|
||||
connect,
|
||||
EMPTY,
|
||||
filter,
|
||||
finalize,
|
||||
from,
|
||||
ignoreElements,
|
||||
interval,
|
||||
lastValueFrom,
|
||||
map,
|
||||
merge,
|
||||
mergeMap,
|
||||
Observable,
|
||||
reduce,
|
||||
Subject,
|
||||
take,
|
||||
takeUntil,
|
||||
toArray,
|
||||
tap,
|
||||
} from 'rxjs';
|
||||
|
||||
import {
|
||||
@@ -50,11 +50,13 @@ import {
|
||||
CopilotProviderFactory,
|
||||
ModelInputType,
|
||||
ModelOutputType,
|
||||
StreamObject,
|
||||
} from './providers';
|
||||
import { StreamObjectParser } from './providers/utils';
|
||||
import { ChatSession, ChatSessionService } from './session';
|
||||
import { CopilotStorage } from './storage';
|
||||
import { ChatMessage, ChatQuerySchema } from './types';
|
||||
import { getSignal } from './utils';
|
||||
import { CopilotWorkflowService, GraphExecutorState } from './workflow';
|
||||
|
||||
export interface ChatEvent {
|
||||
@@ -156,16 +158,6 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
return [latestMessage, session];
|
||||
}
|
||||
|
||||
private getSignal(req: Request) {
|
||||
const controller = new AbortController();
|
||||
req.socket.on('close', hasError => {
|
||||
if (hasError) {
|
||||
controller.abort();
|
||||
}
|
||||
});
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
private parseNumber(value: string | string[] | undefined) {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
@@ -255,7 +247,7 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
const { reasoning, webSearch } = ChatQuerySchema.parse(query);
|
||||
const content = await provider.text({ modelId: model }, finalMessage, {
|
||||
...session.config.promptConfig,
|
||||
signal: this.getSignal(req),
|
||||
signal: getSignal(req).signal,
|
||||
user: user.id,
|
||||
session: session.config.sessionId,
|
||||
workspace: session.config.workspaceId,
|
||||
@@ -306,11 +298,13 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
metrics.ai.counter('chat_stream_calls').add(1, { model });
|
||||
this.ongoingStreamCount$.next(this.ongoingStreamCount$.value + 1);
|
||||
|
||||
const { signal, onConnectionClosed } = getSignal(req);
|
||||
const { messageId, reasoning, webSearch } = ChatQuerySchema.parse(query);
|
||||
|
||||
const source$ = from(
|
||||
provider.streamText({ modelId: model }, finalMessage, {
|
||||
...session.config.promptConfig,
|
||||
signal: this.getSignal(req),
|
||||
signal,
|
||||
user: user.id,
|
||||
session: session.config.sessionId,
|
||||
workspace: session.config.workspaceId,
|
||||
@@ -326,16 +320,25 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
),
|
||||
// save the generated text to the session
|
||||
shared$.pipe(
|
||||
toArray(),
|
||||
concatMap(values => {
|
||||
session.push({
|
||||
role: 'assistant',
|
||||
content: values.join(''),
|
||||
createdAt: new Date(),
|
||||
reduce((acc, chunk) => acc + chunk, ''),
|
||||
tap(buffer => {
|
||||
onConnectionClosed(isAborted => {
|
||||
session.push({
|
||||
role: 'assistant',
|
||||
content: isAborted ? '> Request aborted' : buffer,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
void session
|
||||
.save()
|
||||
.catch(err =>
|
||||
this.logger.error(
|
||||
'Failed to save session in sse stream',
|
||||
err
|
||||
)
|
||||
);
|
||||
});
|
||||
return from(session.save());
|
||||
}),
|
||||
mergeMap(() => EMPTY)
|
||||
ignoreElements()
|
||||
)
|
||||
)
|
||||
),
|
||||
@@ -380,11 +383,13 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
metrics.ai.counter('chat_object_stream_calls').add(1, { model });
|
||||
this.ongoingStreamCount$.next(this.ongoingStreamCount$.value + 1);
|
||||
|
||||
const { signal, onConnectionClosed } = getSignal(req);
|
||||
const { messageId, reasoning, webSearch } = ChatQuerySchema.parse(query);
|
||||
|
||||
const source$ = from(
|
||||
provider.streamObject({ modelId: model }, finalMessage, {
|
||||
...session.config.promptConfig,
|
||||
signal: this.getSignal(req),
|
||||
signal,
|
||||
user: user.id,
|
||||
session: session.config.sessionId,
|
||||
workspace: session.config.workspaceId,
|
||||
@@ -400,20 +405,29 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
),
|
||||
// save the generated text to the session
|
||||
shared$.pipe(
|
||||
toArray(),
|
||||
concatMap(values => {
|
||||
const parser = new StreamObjectParser();
|
||||
const streamObjects = parser.mergeTextDelta(values);
|
||||
const content = parser.mergeContent(streamObjects);
|
||||
session.push({
|
||||
role: 'assistant',
|
||||
content,
|
||||
streamObjects,
|
||||
createdAt: new Date(),
|
||||
reduce((acc, chunk) => acc.concat([chunk]), [] as StreamObject[]),
|
||||
tap(result => {
|
||||
onConnectionClosed(isAborted => {
|
||||
const parser = new StreamObjectParser();
|
||||
const streamObjects = parser.mergeTextDelta(result);
|
||||
const content = parser.mergeContent(streamObjects);
|
||||
session.push({
|
||||
role: 'assistant',
|
||||
content: isAborted ? '> Request aborted' : content,
|
||||
streamObjects: isAborted ? null : streamObjects,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
void session
|
||||
.save()
|
||||
.catch(err =>
|
||||
this.logger.error(
|
||||
'Failed to save session in sse stream',
|
||||
err
|
||||
)
|
||||
);
|
||||
});
|
||||
return from(session.save());
|
||||
}),
|
||||
mergeMap(() => EMPTY)
|
||||
ignoreElements()
|
||||
)
|
||||
)
|
||||
),
|
||||
@@ -461,10 +475,12 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
});
|
||||
}
|
||||
this.ongoingStreamCount$.next(this.ongoingStreamCount$.value + 1);
|
||||
|
||||
const { signal, onConnectionClosed } = getSignal(req);
|
||||
const source$ = from(
|
||||
this.workflow.runGraph(params, session.model, {
|
||||
...session.config.promptConfig,
|
||||
signal: this.getSignal(req),
|
||||
signal,
|
||||
user: user.id,
|
||||
session: session.config.sessionId,
|
||||
workspace: session.config.workspaceId,
|
||||
@@ -503,19 +519,30 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
),
|
||||
// save the generated text to the session
|
||||
shared$.pipe(
|
||||
toArray(),
|
||||
concatMap(values => {
|
||||
session.push({
|
||||
role: 'assistant',
|
||||
content: values
|
||||
.filter(v => v.status === GraphExecutorState.EmitContent)
|
||||
.map(v => v.content)
|
||||
.join(''),
|
||||
createdAt: new Date(),
|
||||
reduce((acc, chunk) => {
|
||||
if (chunk.status === GraphExecutorState.EmitContent) {
|
||||
acc += chunk.content;
|
||||
}
|
||||
return acc;
|
||||
}, ''),
|
||||
tap(content => {
|
||||
onConnectionClosed(isAborted => {
|
||||
session.push({
|
||||
role: 'assistant',
|
||||
content: isAborted ? '> Request aborted' : content,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
void session
|
||||
.save()
|
||||
.catch(err =>
|
||||
this.logger.error(
|
||||
'Failed to save session in sse stream',
|
||||
err
|
||||
)
|
||||
);
|
||||
});
|
||||
return from(session.save());
|
||||
}),
|
||||
mergeMap(() => EMPTY)
|
||||
ignoreElements()
|
||||
)
|
||||
)
|
||||
),
|
||||
@@ -575,6 +602,8 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
sessionId
|
||||
);
|
||||
this.ongoingStreamCount$.next(this.ongoingStreamCount$.value + 1);
|
||||
|
||||
const { signal, onConnectionClosed } = getSignal(req);
|
||||
const source$ = from(
|
||||
provider.streamImages(
|
||||
{
|
||||
@@ -588,7 +617,7 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
...session.config.promptConfig,
|
||||
quality: params.quality || undefined,
|
||||
seed: this.parseNumber(params.seed),
|
||||
signal: this.getSignal(req),
|
||||
signal,
|
||||
user: user.id,
|
||||
session: session.config.sessionId,
|
||||
workspace: session.config.workspaceId,
|
||||
@@ -608,17 +637,26 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
),
|
||||
// save the generated text to the session
|
||||
shared$.pipe(
|
||||
toArray(),
|
||||
concatMap(attachments => {
|
||||
session.push({
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
attachments: attachments,
|
||||
createdAt: new Date(),
|
||||
reduce((acc, chunk) => acc.concat([chunk]), [] as string[]),
|
||||
tap(attachments => {
|
||||
onConnectionClosed(isAborted => {
|
||||
session.push({
|
||||
role: 'assistant',
|
||||
content: isAborted ? '> Request aborted' : '',
|
||||
attachments: isAborted ? [] : attachments,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
void session
|
||||
.save()
|
||||
.catch(err =>
|
||||
this.logger.error(
|
||||
'Failed to save session in sse stream',
|
||||
err
|
||||
)
|
||||
);
|
||||
});
|
||||
return from(session.save());
|
||||
}),
|
||||
mergeMap(() => EMPTY)
|
||||
ignoreElements()
|
||||
)
|
||||
)
|
||||
),
|
||||
@@ -656,7 +694,7 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
`https://api.unsplash.com/search/photos?${query}`,
|
||||
{
|
||||
headers: { Authorization: `Client-ID ${key}` },
|
||||
signal: this.getSignal(req),
|
||||
signal: getSignal(req).signal,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -1615,19 +1615,7 @@ const CHAT_PROMPT: Omit<Prompt, 'name'> = {
|
||||
{
|
||||
role: 'system',
|
||||
content: `### Your Role
|
||||
You are AFFiNE AI, a professional and humorous copilot within AFFiNE. Powered by the latest GPT model provided by OpenAI and AFFiNE, you assist users within AFFiNE — an open-source, all-in-one productivity tool. 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.
|
||||
|
||||
### Your Mission
|
||||
Your mission is to do your utmost to help users leverage AFFiNE's capabilities for writing documents, drawing diagrams, or planning. You always work step-by-step and construct your responses using markdown — including paragraphs, text, markdown lists, code blocks, and tables — so users can directly insert your output into their documents. Do not include any of your own thoughts or additional commentary.
|
||||
|
||||
### About AFFiNE
|
||||
AFFiNE is developed by Toeverything Pte. Ltd., a Singapore-registered company with a diverse international team. The company has also open-sourced BlockSuite and OctoBase to support the creation of tools similar to AFFiNE. The name "AFFiNE" is inspired by the concept of affine transformation, as blocks within AFFiNE can move freely across page, edgeless, and database modes. Currently, the AFFiNE team consists of 25 members and is an engineer-driven open-source company.
|
||||
|
||||
<response_guide>
|
||||
<tool_usage_guide>
|
||||
- When searching for information, prioritize searching the user's Workspace information.
|
||||
- Depending on the complexity of the question and the information returned by the search tools, you can call different tools multiple times to search.
|
||||
</tool_usage_guide>
|
||||
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.
|
||||
|
||||
<real_world_info>
|
||||
Today is: {{affine::date}}.
|
||||
@@ -1649,52 +1637,53 @@ User's timezone is {{affine::timezone}}.
|
||||
</content_fragments>
|
||||
|
||||
<citations>
|
||||
<citation_format>
|
||||
Always use markdown footnote format for citations:
|
||||
- Format: [^reference_index]
|
||||
- Where reference_index is an increasing positive integer (1, 2, 3...)
|
||||
- Place citations immediately after the relevant sentence or paragraph
|
||||
- NO spaces within citation brackets: [^1] is correct, [^ 1] or [ ^1] are incorrect
|
||||
- DO NOT linked together like [^1, ^6, ^7] and [^1, ^2], if you need to use multiple citations, use [^1][^2]
|
||||
</citation_format>
|
||||
|
||||
<citation_placement>
|
||||
|
||||
Citations must appear in two places:
|
||||
1. INLINE: Within your main content as [^reference_index]
|
||||
2. REFERENCE LIST: At the end of your response as properly formatted JSON
|
||||
</citation_placement>
|
||||
|
||||
<reference_format>
|
||||
The citation reference list MUST use these exact JSON formats:
|
||||
- For documents: [^reference_index]:{"type":"doc","docId":"document_id"}
|
||||
- For files: [^reference_index]:{"type":"attachment","blobId":"blob_id","fileName":"file_name","fileType":"file_type"}
|
||||
- For web url: [^reference_index]:{"type":"url","url":"url_path"}
|
||||
</reference_format>
|
||||
|
||||
<response_structure>
|
||||
Your complete response MUST follow this structure:
|
||||
1. Main content with inline citations [^reference_index]
|
||||
2. One empty line
|
||||
3. Reference list with all citations in required JSON format
|
||||
</response_structure>
|
||||
|
||||
<example>
|
||||
This sentence contains information from the first source[^1]. This sentence references data from an attachment[^2].
|
||||
|
||||
[^1]:{"type":"doc","docId":"abc123"}
|
||||
[^2]:{"type":"attachment","blobId":"xyz789","fileName":"example.txt","fileType":"text"}
|
||||
[^3]:{"type":"url","url":"https://affine.pro/"}
|
||||
</example>
|
||||
|
||||
</citations>
|
||||
|
||||
<formatting_guidelines>
|
||||
- Use proper markdown for all content (headings, lists, tables, code blocks)
|
||||
- Format code in markdown code blocks with appropriate language tags
|
||||
- Add explanatory comments to all code provided
|
||||
- Use tables for structured data comparison
|
||||
- Structure longer responses with clear headings and sections
|
||||
</formatting_guidelines>
|
||||
|
||||
<tool-calling-guidelines>
|
||||
Before starting Tool calling, you need to follow:
|
||||
- DO NOT embed a tool call mid-sentence.
|
||||
- When searching for information, searching web & searching the user's Workspace information.
|
||||
- Depending on the complexity of the question and the information returned by the search tools, you can call different tools multiple times to search.
|
||||
</tool-calling-guidelines>
|
||||
|
||||
<comparison_table>
|
||||
- Must use tables for structured data comparison
|
||||
</comparison_table>
|
||||
|
||||
<interaction_rules>
|
||||
## Interaction Guidelines
|
||||
- Ask at most ONE follow-up question per response — only if necessary
|
||||
@@ -1702,13 +1691,12 @@ This sentence contains information from the first source[^1]. This sentence refe
|
||||
- Work within your knowledge cutoff (October 2024)
|
||||
- Assume positive and legal intent when queries are ambiguous
|
||||
</interaction_rules>
|
||||
</response_guide>
|
||||
|
||||
|
||||
## Other Instructions
|
||||
- When writing code, use markdown and add comments to explain it.
|
||||
- Ask at most one follow-up question per response — and only if appropriate.
|
||||
- When counting characters, words, or letters, think step-by-step and show your working.
|
||||
- You are aware of your knowledge cutoff (October 2024) and do not claim updates beyond that.
|
||||
- If you encounter ambiguous queries, default to assuming users have legal and positive intent.`,
|
||||
},
|
||||
{
|
||||
@@ -1752,6 +1740,8 @@ Below is the user's query. Please respond in the user's preferred language witho
|
||||
'docKeywordSearch',
|
||||
'docSemanticSearch',
|
||||
'webSearch',
|
||||
'docCompose',
|
||||
'codeArtifact',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -108,6 +108,12 @@ export abstract class AnthropicProvider<T> extends CopilotProvider<T> {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!options.signal?.aborted) {
|
||||
const footnotes = parser.end();
|
||||
if (footnotes.length) {
|
||||
yield `\n\n${footnotes}`;
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
metrics.ai.counter('chat_text_stream_errors').add(1, { model: model.id });
|
||||
throw this.handleError(e);
|
||||
|
||||
@@ -166,6 +166,12 @@ export abstract class GeminiProvider<T> extends CopilotProvider<T> {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!options.signal?.aborted) {
|
||||
const footnotes = parser.end();
|
||||
if (footnotes.length) {
|
||||
yield `\n\n${footnotes}`;
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
metrics.ai.counter('chat_text_stream_errors').add(1, { model: model.id });
|
||||
throw this.handleError(e);
|
||||
|
||||
@@ -16,7 +16,7 @@ import type {
|
||||
PromptMessage,
|
||||
} from './types';
|
||||
import { CopilotProviderType, ModelInputType, ModelOutputType } from './types';
|
||||
import { chatToGPTMessage, CitationParser, TextStreamParser } from './utils';
|
||||
import { chatToGPTMessage, TextStreamParser } from './utils';
|
||||
|
||||
export const DEFAULT_DIMENSIONS = 256;
|
||||
|
||||
@@ -130,18 +130,11 @@ export class MorphProvider extends CopilotProvider<MorphConfig> {
|
||||
abortSignal: options.signal,
|
||||
});
|
||||
|
||||
const citationParser = new CitationParser();
|
||||
const textParser = new TextStreamParser();
|
||||
for await (const chunk of fullStream) {
|
||||
switch (chunk.type) {
|
||||
case 'text-delta': {
|
||||
let result = textParser.parse(chunk);
|
||||
result = citationParser.parse(result);
|
||||
yield result;
|
||||
break;
|
||||
}
|
||||
case 'finish': {
|
||||
const result = citationParser.end();
|
||||
yield result;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -347,7 +347,9 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
break;
|
||||
}
|
||||
case 'finish': {
|
||||
const result = citationParser.end();
|
||||
const footnotes = textParser.end();
|
||||
const result =
|
||||
citationParser.end() + (footnotes.length ? '\n' + footnotes : '');
|
||||
yield result;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -14,11 +14,14 @@ import { AccessController } from '../../../core/permission';
|
||||
import { Models } from '../../../models';
|
||||
import { IndexerService } from '../../indexer';
|
||||
import { CopilotContextService } from '../context';
|
||||
import { PromptService } from '../prompt';
|
||||
import {
|
||||
buildContentGetter,
|
||||
buildDocContentGetter,
|
||||
buildDocKeywordSearchGetter,
|
||||
buildDocSearchGetter,
|
||||
createCodeArtifactTool,
|
||||
createDocComposeTool,
|
||||
createDocEditTool,
|
||||
createDocKeywordSearchTool,
|
||||
createDocReadTool,
|
||||
@@ -198,6 +201,26 @@ export abstract class CopilotProvider<C = any> {
|
||||
tools.web_crawl_exa = createExaCrawlTool(this.AFFiNEConfig);
|
||||
break;
|
||||
}
|
||||
case 'docCompose': {
|
||||
const promptService = this.moduleRef.get(PromptService, {
|
||||
strict: false,
|
||||
});
|
||||
tools.doc_compose = createDocComposeTool(
|
||||
promptService,
|
||||
this.factory
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'codeArtifact': {
|
||||
const promptService = this.moduleRef.get(PromptService, {
|
||||
strict: false,
|
||||
});
|
||||
tools.code_artifact = createCodeArtifactTool(
|
||||
promptService,
|
||||
this.factory
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return tools;
|
||||
|
||||
@@ -69,6 +69,9 @@ export const PromptConfigStrictSchema = z.object({
|
||||
'docSemanticSearch',
|
||||
// work with exa/model internal tools
|
||||
'webSearch',
|
||||
// artifact tools
|
||||
'docCompose',
|
||||
'codeArtifact',
|
||||
])
|
||||
.array()
|
||||
.nullable()
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
import { ZodType } from 'zod';
|
||||
|
||||
import {
|
||||
createCodeArtifactTool,
|
||||
createDocComposeTool,
|
||||
createDocEditTool,
|
||||
createDocKeywordSearchTool,
|
||||
createDocReadTool,
|
||||
@@ -388,8 +390,10 @@ export interface CustomAITools extends ToolSet {
|
||||
doc_semantic_search: ReturnType<typeof createDocSemanticSearchTool>;
|
||||
doc_keyword_search: ReturnType<typeof createDocKeywordSearchTool>;
|
||||
doc_read: ReturnType<typeof createDocReadTool>;
|
||||
doc_compose: ReturnType<typeof createDocComposeTool>;
|
||||
web_search_exa: ReturnType<typeof createExaSearchTool>;
|
||||
web_crawl_exa: ReturnType<typeof createExaCrawlTool>;
|
||||
code_artifact: ReturnType<typeof createCodeArtifactTool>;
|
||||
}
|
||||
|
||||
type ChunkType = TextStreamPart<CustomAITools>['type'];
|
||||
@@ -410,6 +414,10 @@ export function toError(error: unknown): Error {
|
||||
}
|
||||
}
|
||||
|
||||
type DocEditFootnote = {
|
||||
intent: string;
|
||||
result: string;
|
||||
};
|
||||
export class TextStreamParser {
|
||||
private readonly logger = new Logger(TextStreamParser.name);
|
||||
private readonly CALLOUT_PREFIX = '\n[!]\n';
|
||||
@@ -418,6 +426,8 @@ export class TextStreamParser {
|
||||
|
||||
private prefix: string | null = this.CALLOUT_PREFIX;
|
||||
|
||||
private readonly docEditFootnotes: DocEditFootnote[] = [];
|
||||
|
||||
public parse(chunk: TextStreamPart<CustomAITools>) {
|
||||
let result = '';
|
||||
switch (chunk.type) {
|
||||
@@ -457,6 +467,17 @@ export class TextStreamParser {
|
||||
result += `\nReading the doc "${chunk.args.doc_id}"\n`;
|
||||
break;
|
||||
}
|
||||
case 'doc_compose': {
|
||||
result += `\nWriting document "${chunk.args.title}"\n`;
|
||||
break;
|
||||
}
|
||||
case 'doc_edit': {
|
||||
this.docEditFootnotes.push({
|
||||
intent: chunk.args.instructions,
|
||||
result: '',
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
result = this.markAsCallout(result);
|
||||
break;
|
||||
@@ -470,6 +491,10 @@ export class TextStreamParser {
|
||||
case 'doc_edit': {
|
||||
if (chunk.result && typeof chunk.result === 'object') {
|
||||
result += `\n${chunk.result.result}\n`;
|
||||
this.docEditFootnotes[this.docEditFootnotes.length - 1].result =
|
||||
chunk.result.result;
|
||||
} else {
|
||||
this.docEditFootnotes.pop();
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -486,6 +511,16 @@ export class TextStreamParser {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'doc_compose': {
|
||||
if (
|
||||
chunk.result &&
|
||||
typeof chunk.result === 'object' &&
|
||||
'title' in chunk.result
|
||||
) {
|
||||
result += `\nDocument "${chunk.result.title}" created successfully with ${chunk.result.wordCount} words.\n`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'web_search_exa': {
|
||||
if (Array.isArray(chunk.result)) {
|
||||
result += `\n${this.getWebSearchLinks(chunk.result)}\n`;
|
||||
@@ -504,6 +539,13 @@ export class TextStreamParser {
|
||||
return result;
|
||||
}
|
||||
|
||||
public end() {
|
||||
const footnotes = this.docEditFootnotes.map((footnote, index) => {
|
||||
return `[^edit${index + 1}]: ${JSON.stringify({ type: 'doc-edit', ...footnote })}`;
|
||||
});
|
||||
return footnotes.join('\n');
|
||||
}
|
||||
|
||||
private addPrefix(text: string) {
|
||||
if (this.prefix) {
|
||||
const result = this.prefix + text;
|
||||
|
||||
@@ -47,6 +47,10 @@ declare global {
|
||||
'copilot.session.generateTitle': {
|
||||
sessionId: string;
|
||||
};
|
||||
'copilot.session.deleteDoc': {
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -580,6 +584,24 @@ export class ChatSessionService {
|
||||
return provider.text(cond, [...prompt.finish({}), msg], config);
|
||||
}
|
||||
|
||||
@OnJob('copilot.session.deleteDoc')
|
||||
async deleteDocSessions(doc: Jobs['copilot.session.deleteDoc']) {
|
||||
const sessionIds = await this.models.copilotSession
|
||||
.list({
|
||||
userId: undefined,
|
||||
workspaceId: doc.workspaceId,
|
||||
docId: doc.docId,
|
||||
})
|
||||
.then(s => s.map(s => [s.userId, s.id]));
|
||||
for (const [userId, sessionId] of sessionIds) {
|
||||
await this.models.copilotSession.update({
|
||||
userId,
|
||||
sessionId,
|
||||
docId: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@OnJob('copilot.session.generateTitle')
|
||||
async generateSessionTitle(job: Jobs['copilot.session.generateTitle']) {
|
||||
const { sessionId } = job;
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
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('CodeArtifactTool');
|
||||
|
||||
/**
|
||||
* A copilot tool that produces a completely self-contained HTML artifact.
|
||||
* The returned HTML must include <style> and <script> tags directly so that
|
||||
* it can be saved as a single .html file and opened in any browser with no
|
||||
* external dependencies.
|
||||
*/
|
||||
export const createCodeArtifactTool = (
|
||||
promptService: PromptService,
|
||||
factory: CopilotProviderFactory
|
||||
) => {
|
||||
return tool({
|
||||
description:
|
||||
'Generate a single-file HTML snippet (with inline <style> and <script>) that accomplishes the requested functionality. The final HTML should be runnable when saved as an .html file and opened in a browser. Do NOT reference external resources (CSS, JS, images) except through data URIs.',
|
||||
parameters: z.object({
|
||||
/**
|
||||
* The <title> text that will appear in the browser tab.
|
||||
*/
|
||||
title: z.string().describe('The title of the HTML page'),
|
||||
/**
|
||||
* The optimized user prompt
|
||||
*/
|
||||
userPrompt: z
|
||||
.string()
|
||||
.describe(
|
||||
'The user description of the code artifact, will be used to generate the code artifact'
|
||||
),
|
||||
}),
|
||||
execute: async ({ title, userPrompt }) => {
|
||||
try {
|
||||
const prompt = await promptService.get('Make it real with text');
|
||||
if (!prompt) {
|
||||
throw new Error('Prompt not found');
|
||||
}
|
||||
|
||||
const provider = await factory.getProviderByModel(prompt.model);
|
||||
if (!provider) {
|
||||
throw new Error('Provider not found');
|
||||
}
|
||||
|
||||
const content = await provider.text(
|
||||
{
|
||||
modelId: prompt.model,
|
||||
},
|
||||
[...prompt.finish({}), { role: 'user', content: userPrompt }]
|
||||
);
|
||||
|
||||
// Remove surrounding ``` or ```html fences if present
|
||||
let stripped = content.trim();
|
||||
if (stripped.startsWith('```')) {
|
||||
const firstNewline = stripped.indexOf('\n');
|
||||
if (firstNewline !== -1) {
|
||||
stripped = stripped.slice(firstNewline + 1);
|
||||
}
|
||||
if (stripped.endsWith('```')) {
|
||||
stripped = stripped.slice(0, -3);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
html: stripped,
|
||||
size: stripped.length,
|
||||
};
|
||||
} catch (err: any) {
|
||||
logger.error(`Failed to compose code artifact (${title})`, err);
|
||||
return toolError('Code Artifact Failed', err.message ?? String(err));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
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('DocComposeTool');
|
||||
|
||||
export const createDocComposeTool = (
|
||||
promptService: PromptService,
|
||||
factory: CopilotProviderFactory
|
||||
) => {
|
||||
return tool({
|
||||
description:
|
||||
'Write a new document with markdown content. This tool creates structured markdown content for documents including titles, sections, and formatting.',
|
||||
parameters: z.object({
|
||||
title: z.string().describe('The title of the document'),
|
||||
userPrompt: z
|
||||
.string()
|
||||
.describe(
|
||||
'The user description of the document, will be used to generate the document'
|
||||
),
|
||||
}),
|
||||
execute: async ({ title, userPrompt }) => {
|
||||
try {
|
||||
const prompt = await promptService.get('Write an article about this');
|
||||
if (!prompt) {
|
||||
throw new Error('Prompt not found');
|
||||
}
|
||||
|
||||
const provider = await factory.getProviderByModel(prompt.model);
|
||||
|
||||
if (!provider) {
|
||||
throw new Error('Provider not found');
|
||||
}
|
||||
|
||||
const content = await provider.text(
|
||||
{
|
||||
modelId: prompt.model,
|
||||
},
|
||||
[...prompt.finish({}), { role: 'user', content: userPrompt }]
|
||||
);
|
||||
|
||||
return {
|
||||
title,
|
||||
markdown: content,
|
||||
wordCount: content.split(/\s+/).length,
|
||||
};
|
||||
} catch (err: any) {
|
||||
logger.error(`Failed to write document: ${title}`, err);
|
||||
return toolError('Doc Write Failed', err.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -16,8 +16,8 @@ export const buildContentGetter = (ac: AccessController, doc: DocReader) => {
|
||||
.doc(docId)
|
||||
.can('Doc.Read');
|
||||
if (!canAccess) return undefined;
|
||||
const content = await doc.getFullDocContent(options.workspace, docId);
|
||||
return content?.summary.trim() || undefined;
|
||||
const content = await doc.getDocMarkdown(options.workspace, docId, true);
|
||||
return content?.markdown.trim() || undefined;
|
||||
};
|
||||
return getDocContent;
|
||||
};
|
||||
|
||||
@@ -39,7 +39,7 @@ export const createDocKeywordSearchTool = (
|
||||
) => {
|
||||
return tool({
|
||||
description:
|
||||
'Full-text search for relevant documents in the current workspace',
|
||||
'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 lookup is sufficient.',
|
||||
parameters: z.object({
|
||||
query: z.string().describe('The query to search for'),
|
||||
}),
|
||||
|
||||
@@ -47,7 +47,11 @@ export const buildDocContentGetter = (
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await docReader.getDocMarkdown(options.workspace, docId);
|
||||
const content = await docReader.getDocMarkdown(
|
||||
options.workspace,
|
||||
docId,
|
||||
true
|
||||
);
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
@@ -68,7 +72,8 @@ export const createDocReadTool = (
|
||||
getDoc: (targetId?: string) => Promise<object | undefined>
|
||||
) => {
|
||||
return tool({
|
||||
description: 'Read the content of a doc in the current workspace',
|
||||
description:
|
||||
'Return the complete text and basic metadata of a single document identified by docId; use this when the user needs the full content of a specific file rather than a search result.',
|
||||
parameters: z.object({
|
||||
doc_id: z.string().describe('The target doc to read'),
|
||||
}),
|
||||
|
||||
@@ -56,7 +56,7 @@ export const createDocSemanticSearchTool = (
|
||||
) => {
|
||||
return tool({
|
||||
description:
|
||||
'Semantic search for relevant documents in the current workspace',
|
||||
'Retrieve conceptually related passages by performing vector-based semantic similarity search across embedded documents; call this tool only when exact keyword search fails or the user explicitly needs meaning-level matches (e.g., paraphrases, synonyms, broader concepts).',
|
||||
parameters: z.object({
|
||||
query: z
|
||||
.string()
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from './code-artifact';
|
||||
export * from './doc-compose';
|
||||
export * from './doc-edit';
|
||||
export * from './doc-keyword-search';
|
||||
export * from './doc-read';
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { readBufferWithLimit } from '../../base';
|
||||
import { MAX_EMBEDDABLE_SIZE } from './types';
|
||||
|
||||
@@ -9,3 +11,38 @@ export function readStream(
|
||||
): Promise<Buffer> {
|
||||
return readBufferWithLimit(readable, maxSize);
|
||||
}
|
||||
|
||||
type RequestClosedCallback = (isAborted: boolean) => void;
|
||||
type SignalReturnType = {
|
||||
signal: AbortSignal;
|
||||
onConnectionClosed: (cb: RequestClosedCallback) => void;
|
||||
};
|
||||
|
||||
export function getSignal(req: Request): SignalReturnType {
|
||||
const controller = new AbortController();
|
||||
|
||||
let isAborted = true;
|
||||
let callback: ((isAborted: boolean) => void) | undefined = undefined;
|
||||
|
||||
const onSocketEnd = () => {
|
||||
isAborted = false;
|
||||
};
|
||||
const onSocketClose = (hadError: boolean) => {
|
||||
req.socket.off('end', onSocketEnd);
|
||||
req.socket.off('close', onSocketClose);
|
||||
const aborted = hadError || isAborted;
|
||||
if (aborted) {
|
||||
controller.abort();
|
||||
}
|
||||
|
||||
callback?.(aborted);
|
||||
};
|
||||
|
||||
req.socket.on('end', onSocketEnd);
|
||||
req.socket.on('close', onSocketClose);
|
||||
|
||||
return {
|
||||
signal: controller.signal,
|
||||
onConnectionClosed: cb => (callback = cb),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -322,6 +322,10 @@ export class IndexerService {
|
||||
);
|
||||
|
||||
await this.deleteBlocksByDocId(workspaceId, docId, options);
|
||||
await this.queue.add('copilot.session.deleteDoc', {
|
||||
workspaceId,
|
||||
docId,
|
||||
});
|
||||
await this.queue.add('copilot.embedding.deleteDoc', {
|
||||
workspaceId,
|
||||
docId,
|
||||
|
||||
@@ -21,7 +21,7 @@ const OIDCTokenSchema = z.object({
|
||||
const OIDCUserInfoSchema = z
|
||||
.object({
|
||||
sub: z.string(),
|
||||
preferred_username: z.string(),
|
||||
preferred_username: z.string().optional(),
|
||||
email: z.string().email(),
|
||||
name: z.string(),
|
||||
groups: z.array(z.string()).optional(),
|
||||
|
||||
@@ -3,6 +3,7 @@ query getUserSettings {
|
||||
settings {
|
||||
receiveInvitationEmail
|
||||
receiveMentionEmail
|
||||
receiveCommentEmail
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1558,6 +1558,7 @@ export const getUserSettingsQuery = {
|
||||
settings {
|
||||
receiveInvitationEmail
|
||||
receiveMentionEmail
|
||||
receiveCommentEmail
|
||||
}
|
||||
}
|
||||
}`,
|
||||
|
||||
@@ -4714,6 +4714,7 @@ export type GetUserSettingsQuery = {
|
||||
__typename?: 'UserSettingsType';
|
||||
receiveInvitationEmail: boolean;
|
||||
receiveMentionEmail: boolean;
|
||||
receiveCommentEmail: boolean;
|
||||
};
|
||||
} | null;
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ export const workbenchViewIconNameSchema = z.enum([
|
||||
'journal',
|
||||
'attachment',
|
||||
'pdf',
|
||||
'ai',
|
||||
]);
|
||||
|
||||
export const workbenchViewMetaSchema = z.object({
|
||||
|
||||
@@ -12,10 +12,46 @@
|
||||
{
|
||||
"identity" : "eventsource",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/loopwork-ai/eventsource.git",
|
||||
"location" : "https://github.com/Recouse/EventSource",
|
||||
"state" : {
|
||||
"revision" : "07957602bb99a5355c810187e66e6ce378a1057d",
|
||||
"version" : "1.1.1"
|
||||
"revision" : "d783b1cf60599dbcec6396c55a6bab33a1c92dc3",
|
||||
"version" : "0.1.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "listviewkit",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Lakr233/ListViewKit",
|
||||
"state" : {
|
||||
"revision" : "a4372d7f90c846d834c1f1575d1af0050d70fa0f",
|
||||
"version" : "1.1.6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "litext",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Lakr233/Litext",
|
||||
"state" : {
|
||||
"revision" : "c37f3ab5826659854311e20d6c3942d4905b00b6",
|
||||
"version" : "0.5.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "lrucache",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/nicklockwood/LRUCache",
|
||||
"state" : {
|
||||
"revision" : "542f0449556327415409ededc9c43a4bd0a397dc",
|
||||
"version" : "1.0.7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "markdownview",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Lakr233/MarkdownView",
|
||||
"state" : {
|
||||
"revision" : "29a9da19d6dc21af4e629c423961b0f453ffe192",
|
||||
"version" : "2.3.8"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -27,6 +63,33 @@
|
||||
"version" : "5.7.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "splash",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Lakr233/Splash",
|
||||
"state" : {
|
||||
"revision" : "4d997712fe07f75695aacdf287aeb3b1f2c6ab88",
|
||||
"version" : "0.17.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "springinterpolation",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Lakr233/SpringInterpolation",
|
||||
"state" : {
|
||||
"revision" : "f9ae95ece5d6b7cdceafd4381f1d5f0f9494e5d2",
|
||||
"version" : "1.3.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-cmark",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/swiftlang/swift-cmark",
|
||||
"state" : {
|
||||
"revision" : "b022b08312decdc46585e0b3440d97f6f22ef703",
|
||||
"version" : "0.6.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-collections",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -45,6 +108,15 @@
|
||||
"version" : "6.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftmath",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Lakr233/SwiftMath",
|
||||
"state" : {
|
||||
"revision" : "cfd646dcac0c5553e21ebf1ee05f9078277518bc",
|
||||
"version" : "1.7.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "then",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
@@ -19,7 +19,9 @@ let package = Package(
|
||||
.package(url: "https://github.com/devxoul/Then", from: "3.0.0"),
|
||||
.package(url: "https://github.com/SnapKit/SnapKit.git", from: "5.7.1"),
|
||||
.package(url: "https://github.com/SwifterSwift/SwifterSwift.git", from: "6.0.0"),
|
||||
.package(url: "https://github.com/loopwork-ai/eventsource.git", from: "1.1.1"),
|
||||
.package(url: "https://github.com/Recouse/EventSource", from: "0.1.4"),
|
||||
.package(url: "https://github.com/Lakr233/ListViewKit", from: "1.1.6"),
|
||||
.package(url: "https://github.com/Lakr233/MarkdownView", from: "2.3.8"),
|
||||
],
|
||||
targets: [
|
||||
.target(name: "Intelligents", dependencies: [
|
||||
@@ -29,7 +31,10 @@ let package = Package(
|
||||
"SwifterSwift",
|
||||
.product(name: "Apollo", package: "apollo-ios"),
|
||||
.product(name: "OrderedCollections", package: "swift-collections"),
|
||||
.product(name: "EventSource", package: "eventsource"),
|
||||
|
||||
"ListViewKit",
|
||||
"MarkdownView",
|
||||
"EventSource",
|
||||
], resources: [
|
||||
.process("Interface/View/InputBox/InputBox.xcassets"),
|
||||
.process("Interface/Controller/AttachmentManagementController/AttachmentIcon.xcassets"),
|
||||
|
||||
@@ -5,9 +5,17 @@
|
||||
// Created by 秋星桥 on 6/30/25.
|
||||
//
|
||||
|
||||
import EventSource
|
||||
import Foundation
|
||||
|
||||
protocol Closable { func close() }
|
||||
|
||||
extension EventSource: @preconcurrency Closable {}
|
||||
class ClosableTask: Closable {
|
||||
let detachedTask: Task<Void, Never>
|
||||
init(detachedTask: Task<Void, Never>) {
|
||||
self.detachedTask = detachedTask
|
||||
}
|
||||
|
||||
func close() {
|
||||
detachedTask.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,19 @@ import Apollo
|
||||
import ApolloAPI
|
||||
import EventSource
|
||||
import Foundation
|
||||
import MarkdownParser
|
||||
import MarkdownView
|
||||
|
||||
private let loadingIndicator = " ●"
|
||||
|
||||
private extension InputBoxData {
|
||||
var hasAttachment: Bool {
|
||||
if !imageAttachments.isEmpty { return false }
|
||||
if !fileAttachments.isEmpty { return false }
|
||||
if !documentAttachments.isEmpty { return false }
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
extension ChatManager {
|
||||
public func startUserRequest(
|
||||
@@ -21,7 +34,13 @@ extension ChatManager {
|
||||
id: .init(),
|
||||
content: inputBoxData.text,
|
||||
timestamp: .init(),
|
||||
attachments: []
|
||||
))
|
||||
append(sessionId: sessionId, UserHintCellViewModel(
|
||||
id: .init(),
|
||||
timestamp: .init(),
|
||||
imageAttachments: inputBoxData.imageAttachments,
|
||||
fileAttachments: inputBoxData.fileAttachments,
|
||||
docAttachments: inputBoxData.documentAttachments
|
||||
))
|
||||
|
||||
let messageParameters: [String: AnyHashable] = [
|
||||
@@ -102,37 +121,67 @@ extension ChatManager {
|
||||
report(sessionId, ChatError.invalidStreamURL)
|
||||
return
|
||||
}
|
||||
let eventSource = EventSource(
|
||||
request: .init(
|
||||
url: finalUrl,
|
||||
cachePolicy: .reloadIgnoringLocalAndRemoteCacheData,
|
||||
timeoutInterval: 10
|
||||
),
|
||||
configuration: .default
|
||||
var request = URLRequest(
|
||||
url: finalUrl,
|
||||
cachePolicy: .reloadIgnoringLocalAndRemoteCacheData,
|
||||
timeoutInterval: 10
|
||||
)
|
||||
eventSource.onOpen = {
|
||||
print("[*] \(messageId): connection established")
|
||||
}
|
||||
eventSource.onError = {
|
||||
self.report(sessionId, $0 ?? ChatError.unknownError)
|
||||
}
|
||||
request.setValue("close", forHTTPHeaderField: "Connection")
|
||||
|
||||
var document = ""
|
||||
let queue = DispatchQueue(label: "com.affine.chat.stream.\(sessionId)")
|
||||
eventSource.onMessage = { event in
|
||||
queue.async {
|
||||
print("[*] \(messageId): \(event.event ?? "?") received message: \(event.data)")
|
||||
switch event.event {
|
||||
case "message":
|
||||
document += event.data
|
||||
self.with(sessionId: sessionId, vmId: vmId) { (viewModel: inout AssistantMessageCellViewModel) in
|
||||
viewModel.content = document
|
||||
}
|
||||
default:
|
||||
break
|
||||
let closable = ClosableTask(detachedTask: .detached(operation: {
|
||||
let eventSource = EventSource()
|
||||
let dataTask = await eventSource.dataTask(for: request)
|
||||
var document = ""
|
||||
self.writeMarkdownContent(document + loadingIndicator, sessionId: sessionId, vmId: vmId)
|
||||
for await event in await dataTask.events() {
|
||||
switch event {
|
||||
case .open:
|
||||
print("[*] connection opened")
|
||||
case let .error(error):
|
||||
print("[!] error occurred", error)
|
||||
case let .event(event):
|
||||
guard let data = event.data else { continue }
|
||||
document += data
|
||||
self.writeMarkdownContent(
|
||||
document + loadingIndicator,
|
||||
sessionId: sessionId,
|
||||
vmId: vmId
|
||||
)
|
||||
self.scrollToBottomPublisher.send(sessionId)
|
||||
case .closed:
|
||||
print("[*] connection closed")
|
||||
}
|
||||
}
|
||||
self.writeMarkdownContent(document, sessionId: sessionId, vmId: vmId)
|
||||
self.closeAll()
|
||||
}))
|
||||
self.closable.append(closable)
|
||||
}
|
||||
|
||||
private func writeMarkdownContent(
|
||||
_ document: String,
|
||||
sessionId: SessionID,
|
||||
vmId: UUID
|
||||
) {
|
||||
let result = MarkdownParser().parse(document)
|
||||
var renderedContexts: [String: RenderedItem] = [:]
|
||||
for (key, value) in result.mathContext {
|
||||
let image = MathRenderer.renderToImage(
|
||||
latex: value,
|
||||
fontSize: MarkdownTheme.default.fonts.body.pointSize,
|
||||
textColor: MarkdownTheme.default.colors.body
|
||||
)?.withRenderingMode(.alwaysTemplate)
|
||||
let renderedContext = RenderedItem(
|
||||
image: image,
|
||||
text: value
|
||||
)
|
||||
renderedContexts["math://\(key)"] = renderedContext
|
||||
}
|
||||
|
||||
with(sessionId: sessionId, vmId: vmId) { (viewModel: inout AssistantMessageCellViewModel) in
|
||||
viewModel.content = document
|
||||
viewModel.documentBlocks = result.document
|
||||
viewModel.documentRenderedContent = renderedContexts
|
||||
}
|
||||
closable.append(eventSource)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import AffineGraphQL
|
||||
import Apollo
|
||||
import ApolloAPI
|
||||
import Combine
|
||||
import EventSource
|
||||
import Foundation
|
||||
import OrderedCollections
|
||||
|
||||
@@ -22,12 +21,14 @@ public class ChatManager: ObservableObject, @unchecked Sendable {
|
||||
SessionID,
|
||||
OrderedDictionary<MessageID, any ChatCellViewModel>
|
||||
> = [:]
|
||||
public let scrollToBottomPublisher = PassthroughSubject<SessionID, Never>()
|
||||
|
||||
var closable: [Closable] = []
|
||||
|
||||
private init() {}
|
||||
|
||||
public func closeAll() {
|
||||
print("[+] terminating all closables...")
|
||||
closable.forEach { $0.close() }
|
||||
closable.removeAll()
|
||||
}
|
||||
@@ -74,7 +75,8 @@ public class ChatManager: ObservableObject, @unchecked Sendable {
|
||||
public func report(_ sessionID: String, _ error: Error) -> UUID {
|
||||
let model = ErrorCellViewModel(
|
||||
id: .init(),
|
||||
errorMessage: error.localizedDescription
|
||||
errorMessage: error.localizedDescription,
|
||||
timestamp: .init()
|
||||
)
|
||||
append(sessionId: sessionID, model)
|
||||
return model.id
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// IntelligentContext+Markdown.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 7/4/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MarkdownView
|
||||
|
||||
extension IntelligentContext {
|
||||
func prepareMarkdownViewThemes() {
|
||||
MarkdownTheme.default.colors.body = .affineTextPrimary
|
||||
MarkdownTheme.default.colors.highlight = .affineTextLink
|
||||
}
|
||||
}
|
||||
@@ -42,8 +42,8 @@ public class IntelligentContext {
|
||||
case currentI18nLocale
|
||||
}
|
||||
|
||||
public private(set) var currentSession: ChatSessionObject?
|
||||
public private(set) var currentWorkspaceId: String?
|
||||
@Published public private(set) var currentSession: ChatSessionObject?
|
||||
@Published public private(set) var currentWorkspaceId: String?
|
||||
|
||||
public lazy var temporaryDirectory: URL = {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
@@ -70,6 +70,7 @@ public class IntelligentContext {
|
||||
assert(webView != nil)
|
||||
DispatchQueue.global(qos: .userInitiated).async { [self] in
|
||||
prepareTemporaryDirectory()
|
||||
prepareMarkdownViewThemes()
|
||||
|
||||
let webViewGroup = DispatchGroup()
|
||||
var webViewMetadataResult: [WebViewMetadataKey: Any] = [:]
|
||||
|
||||
@@ -10,9 +10,7 @@ class MainViewController: UIViewController {
|
||||
$0.delegate = self
|
||||
}
|
||||
|
||||
lazy var chatTableView = ChatTableView().then {
|
||||
$0.delegate = self
|
||||
}
|
||||
lazy var listView = ChatListView()
|
||||
|
||||
lazy var inputBox = InputBox().then {
|
||||
$0.delegate = self
|
||||
@@ -54,7 +52,7 @@ class MainViewController: UIViewController {
|
||||
|
||||
private func setupUI() {
|
||||
view.addSubview(headerView)
|
||||
view.addSubview(chatTableView)
|
||||
view.addSubview(listView)
|
||||
view.addSubview(inputBox)
|
||||
view.addSubview(documentPickerHideDetector)
|
||||
view.addSubview(documentPickerView)
|
||||
@@ -64,7 +62,7 @@ class MainViewController: UIViewController {
|
||||
make.leading.trailing.equalToSuperview()
|
||||
}
|
||||
|
||||
chatTableView.snp.makeConstraints { make in
|
||||
listView.snp.makeConstraints { make in
|
||||
make.top.equalTo(headerView.snp.bottom)
|
||||
make.left.right.equalToSuperview()
|
||||
make.bottom.equalToSuperview()
|
||||
@@ -100,17 +98,17 @@ class MainViewController: UIViewController {
|
||||
navigationController!.setNavigationBarHidden(false, animated: animated)
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
|
||||
let bottomAnchor = inputBox.frame.minY
|
||||
let bottomInset = view.bounds.height - bottomAnchor + 64
|
||||
if listView.listView.bottomInset != bottomInset {
|
||||
listView.listView.bottomInset = bottomInset
|
||||
}
|
||||
}
|
||||
|
||||
@objc func terminateEditing() {
|
||||
view.endEditing(true)
|
||||
}
|
||||
|
||||
// MARK: - Chat Methods
|
||||
}
|
||||
|
||||
// MARK: - ChatTableViewDelegate
|
||||
|
||||
extension MainViewController: ChatTableViewDelegate {
|
||||
func chatTableView(_: ChatTableView, didSelectRowAt _: IndexPath) {
|
||||
// Handle cell selection if needed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,135 +5,59 @@
|
||||
// Created by 秋星桥 on 6/27/25.
|
||||
//
|
||||
|
||||
import Litext
|
||||
import MarkdownView
|
||||
import SnapKit
|
||||
import Then
|
||||
import UIKit
|
||||
|
||||
private let markdownViewForSizeCalculation: MarkdownTextView = .init()
|
||||
|
||||
class AssistantMessageCell: ChatBaseCell {
|
||||
// MARK: - UI Components
|
||||
let markdownView = MarkdownTextView()
|
||||
|
||||
private lazy var messageLabel = UILabel().then {
|
||||
$0.numberOfLines = 0
|
||||
$0.font = .systemFont(ofSize: 16)
|
||||
$0.textColor = .label
|
||||
override func prepareContentView(inside contentView: UIView) {
|
||||
super.prepareContentView(inside: contentView)
|
||||
contentView.addSubview(markdownView)
|
||||
}
|
||||
|
||||
private lazy var metadataStackView = UIStackView().then {
|
||||
$0.axis = .horizontal
|
||||
$0.spacing = 8
|
||||
$0.alignment = .center
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
markdownView.prepareForReuse()
|
||||
}
|
||||
|
||||
private lazy var modelLabel = UILabel().then {
|
||||
$0.font = .systemFont(ofSize: 12, weight: .medium)
|
||||
$0.textColor = .secondaryLabel
|
||||
}
|
||||
|
||||
private lazy var tokensLabel = UILabel().then {
|
||||
$0.font = .systemFont(ofSize: 12)
|
||||
$0.textColor = .secondaryLabel
|
||||
}
|
||||
|
||||
private lazy var timestampLabel = UILabel().then {
|
||||
$0.font = .systemFont(ofSize: 12)
|
||||
$0.textColor = .secondaryLabel
|
||||
}
|
||||
|
||||
private lazy var streamingIndicator = UIActivityIndicatorView().then {
|
||||
$0.style = .medium
|
||||
$0.hidesWhenStopped = true
|
||||
}
|
||||
|
||||
private lazy var retryButton = UIButton(type: .system).then {
|
||||
$0.setTitle("重试", for: .normal)
|
||||
$0.titleLabel?.font = .systemFont(ofSize: 12)
|
||||
$0.setTitleColor(.systemBlue, for: .normal)
|
||||
}
|
||||
|
||||
private lazy var mainStackView = UIStackView().then {
|
||||
$0.axis = .vertical
|
||||
$0.spacing = 8
|
||||
$0.alignment = .fill
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private var viewModel: AssistantMessageCellViewModel?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
override func setupContentView() {
|
||||
containerView.addSubview(mainStackView)
|
||||
|
||||
mainStackView.addArrangedSubview(messageLabel)
|
||||
mainStackView.addArrangedSubview(metadataStackView)
|
||||
|
||||
metadataStackView.addArrangedSubview(modelLabel)
|
||||
metadataStackView.addArrangedSubview(tokensLabel)
|
||||
metadataStackView.addArrangedSubview(UIView()) // Spacer
|
||||
metadataStackView.addArrangedSubview(streamingIndicator)
|
||||
metadataStackView.addArrangedSubview(retryButton)
|
||||
metadataStackView.addArrangedSubview(timestampLabel)
|
||||
|
||||
mainStackView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(contentInsets)
|
||||
}
|
||||
|
||||
retryButton.addTarget(self, action: #selector(retryButtonTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
override func configure(with viewModel: any ChatCellViewModel) {
|
||||
guard let assistantViewModel = viewModel as? AssistantMessageCellViewModel else { return }
|
||||
self.viewModel = assistantViewModel
|
||||
super.configure(with: viewModel)
|
||||
|
||||
messageLabel.text = assistantViewModel.content
|
||||
configureContainer(backgroundColor: backgroundColor(for: assistantViewModel.cellType))
|
||||
|
||||
// 配置模型信息
|
||||
if let model = assistantViewModel.model {
|
||||
modelLabel.text = model
|
||||
modelLabel.isHidden = false
|
||||
} else {
|
||||
modelLabel.isHidden = true
|
||||
guard let vm = viewModel as? AssistantMessageCellViewModel else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
|
||||
// 配置 tokens 信息
|
||||
if let tokens = assistantViewModel.tokens {
|
||||
tokensLabel.text = "\(tokens) tokens"
|
||||
tokensLabel.isHidden = false
|
||||
} else {
|
||||
tokensLabel.isHidden = true
|
||||
}
|
||||
|
||||
// 配置时间戳
|
||||
let timestamp = assistantViewModel.timestamp
|
||||
timestampLabel.text = formatTimestamp(timestamp)
|
||||
timestampLabel.isHidden = false
|
||||
|
||||
// 配置流式状态
|
||||
if assistantViewModel.isStreaming {
|
||||
streamingIndicator.startAnimating()
|
||||
} else {
|
||||
streamingIndicator.stopAnimating()
|
||||
}
|
||||
|
||||
// 配置重试按钮
|
||||
retryButton.isHidden = !assistantViewModel.canRetry
|
||||
markdownView.setMarkdown(
|
||||
vm.documentBlocks,
|
||||
renderedContent: vm.documentRenderedContent
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
@objc private func retryButtonTapped() {
|
||||
// TODO: 实现重试逻辑
|
||||
override func layoutContentView(bounds: CGRect) {
|
||||
super.layoutContentView(bounds: bounds)
|
||||
markdownView.frame = bounds
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func formatTimestamp(_ timestamp: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.timeStyle = .short
|
||||
return formatter.string(from: timestamp)
|
||||
override class func heightForContent(
|
||||
for viewModel: any ChatCellViewModel,
|
||||
width: CGFloat
|
||||
) -> CGFloat {
|
||||
let vm = viewModel as! AssistantMessageCellViewModel
|
||||
markdownViewForSizeCalculation.theme = .default
|
||||
markdownViewForSizeCalculation.frame = .init(
|
||||
x: 0, y: 0, width: width, height: .greatestFiniteMagnitude
|
||||
)
|
||||
markdownViewForSizeCalculation.setMarkdown(
|
||||
vm.documentBlocks,
|
||||
renderedContent: vm.documentRenderedContent,
|
||||
)
|
||||
let boundingSize = markdownViewForSizeCalculation.boundingSize(for: width)
|
||||
return ceil(boundingSize.height)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,37 +5,24 @@
|
||||
// Created by 秋星桥 on 6/27/25.
|
||||
//
|
||||
|
||||
import ListViewKit
|
||||
import Litext
|
||||
import MarkdownView
|
||||
import SnapKit
|
||||
import Then
|
||||
import UIKit
|
||||
|
||||
class ChatBaseCell: UITableViewCell {
|
||||
// MARK: - UI Components
|
||||
|
||||
/// 主容器视图,负责管理内边距和统一行为
|
||||
lazy var containerView = UIView().then {
|
||||
$0.layer.cornerRadius = 8
|
||||
$0.layer.cornerCurve = .continuous
|
||||
class ChatBaseCell: ListRowView {
|
||||
static var contentInsets: UIEdgeInsets {
|
||||
.init(top: 0, left: 16, bottom: 16, right: 16)
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
private let contentView = UIView()
|
||||
|
||||
/// 容器视图的内边距,子类可以重写
|
||||
var containerInsets: UIEdgeInsets {
|
||||
UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16)
|
||||
}
|
||||
|
||||
/// 容器视图内部的内边距,子类可以重写
|
||||
var contentInsets: UIEdgeInsets {
|
||||
UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
setupBaseUI()
|
||||
setupContentView()
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
addSubview(contentView)
|
||||
prepareContentView(inside: contentView)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
@@ -43,68 +30,49 @@ class ChatBaseCell: UITableViewCell {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
private func setupBaseUI() {
|
||||
backgroundColor = .clear
|
||||
selectionStyle = .none
|
||||
|
||||
contentView.addSubview(containerView)
|
||||
containerView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(containerInsets)
|
||||
}
|
||||
func prepareContentView(inside contentView: UIView) {
|
||||
_ = contentView
|
||||
}
|
||||
|
||||
/// 子类重写此方法来设置具体的内容视图
|
||||
func setupContentView() {
|
||||
// 子类实现
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
let contentInsets = Self.contentInsets
|
||||
contentView.frame = .init(
|
||||
x: contentInsets.left,
|
||||
y: contentInsets.top,
|
||||
width: bounds.width - contentInsets.left - contentInsets.right,
|
||||
height: bounds.height - contentInsets.top - contentInsets.bottom
|
||||
)
|
||||
layoutContentView(bounds: contentView.bounds)
|
||||
}
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
/// 配置容器视图的外观
|
||||
func configureContainer(backgroundColor: UIColor?, borderColor: UIColor? = nil, borderWidth: CGFloat = 0) {
|
||||
containerView.backgroundColor = backgroundColor
|
||||
|
||||
if let borderColor {
|
||||
containerView.layer.borderColor = borderColor.cgColor
|
||||
containerView.layer.borderWidth = borderWidth
|
||||
} else {
|
||||
containerView.layer.borderColor = nil
|
||||
containerView.layer.borderWidth = 0
|
||||
}
|
||||
override func addSubview(_ view: UIView) {
|
||||
assert(view == contentView)
|
||||
super.addSubview(view)
|
||||
}
|
||||
|
||||
/// 配置 ViewModel,子类需要重写
|
||||
func configure(with _: any ChatCellViewModel) {
|
||||
// 子类实现
|
||||
func layoutContentView(bounds: CGRect) {
|
||||
_ = bounds // override pass
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// 获取适当的文本颜色
|
||||
func textColor(for cellType: CellType) -> UIColor {
|
||||
switch cellType {
|
||||
case .userMessage, .assistantMessage, .systemMessage:
|
||||
.label
|
||||
case .error:
|
||||
.systemRed
|
||||
case .loading:
|
||||
.secondaryLabel
|
||||
}
|
||||
class func heightForContent(
|
||||
for viewModel: any ChatCellViewModel,
|
||||
width: CGFloat
|
||||
) -> CGFloat {
|
||||
_ = viewModel
|
||||
_ = width
|
||||
return 0 // override pass
|
||||
}
|
||||
|
||||
/// 获取适当的背景颜色
|
||||
func backgroundColor(for cellType: CellType) -> UIColor? {
|
||||
switch cellType {
|
||||
case .userMessage, .assistantMessage:
|
||||
.clear
|
||||
case .systemMessage:
|
||||
.systemYellow.withAlphaComponent(0.2)
|
||||
case .error:
|
||||
.systemRed.withAlphaComponent(0.1)
|
||||
case .loading:
|
||||
.systemGray6
|
||||
}
|
||||
static func heightForCell(for viewModel: any ChatCellViewModel, width: CGFloat) -> CGFloat {
|
||||
let contentWidth = width - contentInsets.left - contentInsets.right
|
||||
return heightForContent(
|
||||
for: viewModel,
|
||||
width: contentWidth
|
||||
) + contentInsets.top + contentInsets.bottom
|
||||
}
|
||||
|
||||
func configure(with viewModel: any ChatCellViewModel) {
|
||||
_ = viewModel
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
//
|
||||
// ChatCellFactory.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/27/25.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class ChatCellFactory {
|
||||
// MARK: - Cell Registration
|
||||
|
||||
static func registerCells(for tableView: UITableView) {
|
||||
tableView.register(UserMessageCell.self, forCellReuseIdentifier: CellType.userMessage.rawValue)
|
||||
tableView.register(AssistantMessageCell.self, forCellReuseIdentifier: CellType.assistantMessage.rawValue)
|
||||
tableView.register(SystemMessageCell.self, forCellReuseIdentifier: CellType.systemMessage.rawValue)
|
||||
tableView.register(LoadingCell.self, forCellReuseIdentifier: CellType.loading.rawValue)
|
||||
tableView.register(ErrorCell.self, forCellReuseIdentifier: CellType.error.rawValue)
|
||||
}
|
||||
|
||||
// MARK: - Cell Creation
|
||||
|
||||
static func dequeueCell(
|
||||
for tableView: UITableView,
|
||||
at indexPath: IndexPath,
|
||||
with viewModel: any ChatCellViewModel
|
||||
) -> ChatBaseCell {
|
||||
let identifier = viewModel.cellType.rawValue
|
||||
|
||||
guard let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: identifier,
|
||||
for: indexPath
|
||||
) as? ChatBaseCell else {
|
||||
// 如果无法获取指定类型的cell,使用系统消息cell作为fallback
|
||||
let fallbackCell = tableView.dequeueReusableCell(
|
||||
withIdentifier: CellType.systemMessage.rawValue,
|
||||
for: indexPath
|
||||
) as! SystemMessageCell
|
||||
|
||||
// 创建一个fallback的ViewModel
|
||||
let fallbackViewModel = SystemMessageCellViewModel(
|
||||
id: viewModel.id,
|
||||
content: "不支持的消息类型: \\(viewModel.cellType.rawValue)",
|
||||
timestamp: Date()
|
||||
)
|
||||
fallbackCell.configure(with: fallbackViewModel)
|
||||
return fallbackCell
|
||||
}
|
||||
|
||||
cell.configure(with: viewModel)
|
||||
return cell
|
||||
}
|
||||
|
||||
// MARK: - Height Estimation
|
||||
|
||||
static func estimatedHeight(for viewModel: any ChatCellViewModel) -> CGFloat {
|
||||
switch viewModel.cellType {
|
||||
case .userMessage,
|
||||
.assistantMessage:
|
||||
80
|
||||
case .systemMessage:
|
||||
60
|
||||
case .loading:
|
||||
100
|
||||
case .error:
|
||||
120
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,93 +5,30 @@
|
||||
// Created by 秋星桥 on 6/27/25.
|
||||
//
|
||||
|
||||
import Litext
|
||||
import SnapKit
|
||||
import Then
|
||||
import UIKit
|
||||
|
||||
class ErrorCell: ChatBaseCell {
|
||||
// MARK: - UI Components
|
||||
|
||||
private lazy var iconView = UIImageView().then {
|
||||
$0.image = UIImage(systemName: "exclamationmark.triangle.fill")
|
||||
$0.tintColor = .systemRed
|
||||
$0.contentMode = .scaleAspectFit
|
||||
override func prepareContentView(inside contentView: UIView) {
|
||||
super.prepareContentView(inside: contentView)
|
||||
}
|
||||
|
||||
private lazy var errorLabel = UILabel().then {
|
||||
$0.numberOfLines = 0
|
||||
$0.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
$0.textColor = .systemRed
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
}
|
||||
|
||||
private lazy var retryButton = UIButton(type: .system).then {
|
||||
$0.setTitle("Retry", for: .normal)
|
||||
$0.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
$0.setTitleColor(.systemBlue, for: .normal)
|
||||
$0.backgroundColor = .systemBlue.withAlphaComponent(0.1)
|
||||
$0.layer.cornerRadius = 8
|
||||
$0.layer.cornerCurve = .continuous
|
||||
$0.contentEdgeInsets = UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16)
|
||||
override func layoutContentView(bounds: CGRect) {
|
||||
super.layoutContentView(bounds: bounds)
|
||||
}
|
||||
|
||||
private lazy var contentStackView = UIStackView().then {
|
||||
$0.axis = .horizontal
|
||||
$0.spacing = 12
|
||||
$0.alignment = .top
|
||||
}
|
||||
|
||||
private lazy var textStackView = UIStackView().then {
|
||||
$0.axis = .vertical
|
||||
$0.spacing = 12
|
||||
$0.alignment = .fill
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private var viewModel: ErrorCellViewModel?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
override func setupContentView() {
|
||||
containerView.addSubview(contentStackView)
|
||||
|
||||
contentStackView.addArrangedSubview(iconView)
|
||||
contentStackView.addArrangedSubview(textStackView)
|
||||
|
||||
textStackView.addArrangedSubview(errorLabel)
|
||||
textStackView.addArrangedSubview(retryButton)
|
||||
|
||||
contentStackView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(contentInsets)
|
||||
}
|
||||
|
||||
iconView.snp.makeConstraints { make in
|
||||
make.width.height.equalTo(24)
|
||||
}
|
||||
|
||||
retryButton.addTarget(self, action: #selector(retryButtonTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
override func configure(with viewModel: any ChatCellViewModel) {
|
||||
guard let errorViewModel = viewModel as? ErrorCellViewModel else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
self.viewModel = errorViewModel
|
||||
|
||||
errorLabel.text = errorViewModel.errorMessage
|
||||
configureContainer(
|
||||
backgroundColor: backgroundColor(for: errorViewModel.cellType),
|
||||
borderColor: .systemRed.withAlphaComponent(0.3),
|
||||
borderWidth: 1
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
@objc private func retryButtonTapped() {
|
||||
// TODO: 实现重试逻辑
|
||||
override class func heightForContent(
|
||||
for viewModel: any ChatCellViewModel,
|
||||
width: CGFloat
|
||||
) -> CGFloat {
|
||||
_ = viewModel
|
||||
_ = width
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,85 +5,30 @@
|
||||
// Created by 秋星桥 on 6/27/25.
|
||||
//
|
||||
|
||||
import Litext
|
||||
import SnapKit
|
||||
import Then
|
||||
import UIKit
|
||||
|
||||
class LoadingCell: ChatBaseCell {
|
||||
// MARK: - UI Components
|
||||
|
||||
private lazy var activityIndicator = UIActivityIndicatorView().then {
|
||||
$0.style = .medium
|
||||
$0.hidesWhenStopped = false
|
||||
override func prepareContentView(inside contentView: UIView) {
|
||||
super.prepareContentView(inside: contentView)
|
||||
}
|
||||
|
||||
private lazy var messageLabel = UILabel().then {
|
||||
$0.numberOfLines = 0
|
||||
$0.font = .systemFont(ofSize: 14)
|
||||
$0.textColor = .secondaryLabel
|
||||
$0.textAlignment = .center
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
}
|
||||
|
||||
private lazy var progressView = UIProgressView().then {
|
||||
$0.progressViewStyle = .default
|
||||
$0.trackTintColor = .systemGray5
|
||||
$0.progressTintColor = .systemBlue
|
||||
override func layoutContentView(bounds: CGRect) {
|
||||
super.layoutContentView(bounds: bounds)
|
||||
}
|
||||
|
||||
private lazy var stackView = UIStackView().then {
|
||||
$0.axis = .vertical
|
||||
$0.spacing = 12
|
||||
$0.alignment = .center
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private var viewModel: LoadingCellViewModel?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
override func setupContentView() {
|
||||
containerView.addSubview(stackView)
|
||||
|
||||
stackView.addArrangedSubview(activityIndicator)
|
||||
stackView.addArrangedSubview(messageLabel)
|
||||
stackView.addArrangedSubview(progressView)
|
||||
|
||||
stackView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(contentInsets)
|
||||
}
|
||||
|
||||
progressView.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.height.equalTo(4)
|
||||
}
|
||||
|
||||
activityIndicator.startAnimating()
|
||||
}
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
override func configure(with viewModel: any ChatCellViewModel) {
|
||||
guard let loadingViewModel = viewModel as? LoadingCellViewModel else { return }
|
||||
self.viewModel = loadingViewModel
|
||||
|
||||
configureContainer(backgroundColor: backgroundColor(for: loadingViewModel.cellType))
|
||||
|
||||
// 配置消息
|
||||
if let message = loadingViewModel.message {
|
||||
messageLabel.text = message
|
||||
messageLabel.isHidden = false
|
||||
} else {
|
||||
messageLabel.text = "Processing..."
|
||||
messageLabel.isHidden = false
|
||||
}
|
||||
|
||||
// 配置进度
|
||||
if let progress = loadingViewModel.progress {
|
||||
progressView.progress = Float(progress)
|
||||
progressView.isHidden = false
|
||||
} else {
|
||||
progressView.isHidden = true
|
||||
}
|
||||
override class func heightForContent(
|
||||
for viewModel: any ChatCellViewModel,
|
||||
width: CGFloat
|
||||
) -> CGFloat {
|
||||
_ = viewModel
|
||||
_ = width
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,90 +5,76 @@
|
||||
// Created by 秋星桥 on 6/27/25.
|
||||
//
|
||||
|
||||
import Litext
|
||||
import SnapKit
|
||||
import Then
|
||||
import UIKit
|
||||
|
||||
private let labelForSizeCalculation = LTXLabel()
|
||||
|
||||
class SystemMessageCell: ChatBaseCell {
|
||||
// MARK: - UI Components
|
||||
|
||||
private lazy var iconView = UIImageView().then {
|
||||
$0.image = UIImage(systemName: "info.circle.fill")
|
||||
$0.tintColor = .systemOrange
|
||||
$0.contentMode = .scaleAspectFit
|
||||
let contentLabel = LTXLabel().then {
|
||||
$0.isSelectable = false
|
||||
}
|
||||
|
||||
private lazy var messageLabel = UILabel().then {
|
||||
$0.numberOfLines = 0
|
||||
$0.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
$0.textColor = .label
|
||||
override func prepareContentView(inside contentView: UIView) {
|
||||
super.prepareContentView(inside: contentView)
|
||||
contentView.addSubview(contentLabel)
|
||||
}
|
||||
|
||||
private lazy var timestampLabel = UILabel().then {
|
||||
$0.font = .systemFont(ofSize: 12)
|
||||
$0.textColor = .secondaryLabel
|
||||
$0.textAlignment = .right
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
contentLabel.attributedText = .init()
|
||||
}
|
||||
|
||||
private lazy var contentStackView = UIStackView().then {
|
||||
$0.axis = .horizontal
|
||||
$0.spacing = 12
|
||||
$0.alignment = .top
|
||||
}
|
||||
|
||||
private lazy var textStackView = UIStackView().then {
|
||||
$0.axis = .vertical
|
||||
$0.spacing = 4
|
||||
$0.alignment = .fill
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private var viewModel: SystemMessageCellViewModel?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
override func setupContentView() {
|
||||
containerView.addSubview(contentStackView)
|
||||
|
||||
contentStackView.addArrangedSubview(iconView)
|
||||
contentStackView.addArrangedSubview(textStackView)
|
||||
|
||||
textStackView.addArrangedSubview(messageLabel)
|
||||
textStackView.addArrangedSubview(timestampLabel)
|
||||
|
||||
contentStackView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(contentInsets)
|
||||
}
|
||||
|
||||
iconView.snp.makeConstraints { make in
|
||||
make.width.height.equalTo(20)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
override func configure(with viewModel: any ChatCellViewModel) {
|
||||
guard let systemViewModel = viewModel as? SystemMessageCellViewModel else { return }
|
||||
self.viewModel = systemViewModel
|
||||
|
||||
messageLabel.text = systemViewModel.content
|
||||
configureContainer(backgroundColor: backgroundColor(for: systemViewModel.cellType))
|
||||
|
||||
// 配置时间戳
|
||||
if let timestamp = systemViewModel.timestamp {
|
||||
timestampLabel.text = formatTimestamp(timestamp)
|
||||
timestampLabel.isHidden = false
|
||||
} else {
|
||||
timestampLabel.isHidden = true
|
||||
super.configure(with: viewModel)
|
||||
guard let vm = viewModel as? SystemMessageCellViewModel else {
|
||||
assertionFailure("")
|
||||
return
|
||||
}
|
||||
contentLabel.attributedText = Self.prepareAttributeText(vm.content)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
override func layoutContentView(bounds: CGRect) {
|
||||
super.layoutContentView(bounds: bounds)
|
||||
let textMaxWidth = bounds.width * 0.8
|
||||
contentLabel.preferredMaxLayoutWidth = textMaxWidth
|
||||
let textSize = contentLabel.intrinsicContentSize
|
||||
let labelWidth = textSize.width
|
||||
let labelHeight = textSize.height
|
||||
contentLabel.frame = .init(
|
||||
x: (bounds.width - labelWidth) / 2,
|
||||
y: 0,
|
||||
width: labelWidth,
|
||||
height: labelHeight
|
||||
)
|
||||
}
|
||||
|
||||
private func formatTimestamp(_ timestamp: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.timeStyle = .short
|
||||
return formatter.string(from: timestamp)
|
||||
class func prepareAttributeText(_ text: String) -> NSAttributedString {
|
||||
.init(string: text, attributes: [
|
||||
.font: UIFont.preferredFont(forTextStyle: .footnote),
|
||||
.foregroundColor: UIColor.affineTextSecondary,
|
||||
.paragraphStyle: NSMutableParagraphStyle().then {
|
||||
$0.lineBreakMode = .byWordWrapping
|
||||
$0.alignment = .center
|
||||
$0.lineSpacing = 2
|
||||
$0.paragraphSpacing = 4
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
override class func heightForContent(
|
||||
for viewModel: any ChatCellViewModel,
|
||||
width: CGFloat
|
||||
) -> CGFloat {
|
||||
guard let vm = viewModel as? SystemMessageCellViewModel else {
|
||||
assertionFailure()
|
||||
return 0
|
||||
}
|
||||
labelForSizeCalculation.attributedText = prepareAttributeText(vm.content)
|
||||
labelForSizeCalculation.preferredMaxLayoutWidth = width * 0.8
|
||||
let textSize = labelForSizeCalculation.intrinsicContentSize
|
||||
return textSize.height
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//
|
||||
// UserHintCell.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 7/4/25.
|
||||
//
|
||||
|
||||
import Litext
|
||||
import UIKit
|
||||
|
||||
private let labelForSizeCalculation = LTXLabel()
|
||||
private let formatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .none
|
||||
formatter.timeStyle = .short
|
||||
formatter.locale = .current
|
||||
return formatter
|
||||
}()
|
||||
|
||||
class UserHintCell: ChatBaseCell {
|
||||
let contentLabel = LTXLabel().then {
|
||||
$0.isSelectable = true
|
||||
}
|
||||
|
||||
override func prepareContentView(inside contentView: UIView) {
|
||||
super.prepareContentView(inside: contentView)
|
||||
|
||||
contentView.addSubview(contentLabel)
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
contentLabel.attributedText = .init()
|
||||
}
|
||||
|
||||
override func configure(with viewModel: any ChatCellViewModel) {
|
||||
super.configure(with: viewModel)
|
||||
guard let vm = viewModel as? UserHintCellViewModel else {
|
||||
assertionFailure("")
|
||||
return
|
||||
}
|
||||
contentLabel.attributedText = Self.prepareAttributeText(Self.prepareText(vm))
|
||||
}
|
||||
|
||||
override func layoutContentView(bounds: CGRect) {
|
||||
super.layoutContentView(bounds: bounds)
|
||||
|
||||
contentLabel.preferredMaxLayoutWidth = bounds.width
|
||||
let textSize = contentLabel.intrinsicContentSize
|
||||
contentLabel.frame = CGRect(
|
||||
x: bounds.width - textSize.width,
|
||||
y: 0,
|
||||
width: textSize.width,
|
||||
height: bounds.height
|
||||
)
|
||||
}
|
||||
|
||||
class func prepareText(_ vm: UserHintCellViewModel) -> String {
|
||||
let attachmentsCount = [
|
||||
vm.docAttachments.count,
|
||||
vm.imageAttachments.count,
|
||||
vm.fileAttachments.count,
|
||||
].reduce(0, +)
|
||||
let text: [String] = [
|
||||
formatter.string(from: vm.timestamp),
|
||||
{
|
||||
if attachmentsCount > 0 {
|
||||
String(localized: "\(attachmentsCount) attachments")
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}(),
|
||||
].filter { !$0.isEmpty }
|
||||
return text.joined(separator: " ")
|
||||
}
|
||||
|
||||
class func prepareAttributeText(_ text: String) -> NSAttributedString {
|
||||
.init(string: text, attributes: [
|
||||
.font: UIFont.preferredFont(forTextStyle: .footnote),
|
||||
.foregroundColor: UIColor.affineTextSecondary,
|
||||
.paragraphStyle: NSMutableParagraphStyle().then {
|
||||
$0.lineBreakMode = .byWordWrapping
|
||||
$0.alignment = .natural
|
||||
$0.lineSpacing = 2
|
||||
$0.paragraphSpacing = 4
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
override class func heightForContent(
|
||||
for viewModel: any ChatCellViewModel,
|
||||
width: CGFloat
|
||||
) -> CGFloat {
|
||||
guard let vm = viewModel as? UserHintCellViewModel else {
|
||||
assertionFailure()
|
||||
return 0
|
||||
}
|
||||
labelForSizeCalculation.attributedText = prepareAttributeText(prepareText(vm))
|
||||
labelForSizeCalculation.preferredMaxLayoutWidth = width
|
||||
return labelForSizeCalculation.intrinsicContentSize.height
|
||||
}
|
||||
}
|
||||
@@ -5,90 +5,88 @@
|
||||
// Created by 秋星桥 on 6/27/25.
|
||||
//
|
||||
|
||||
import Litext
|
||||
import SnapKit
|
||||
import Then
|
||||
import UIKit
|
||||
|
||||
private let labelForSizeCalculation = LTXLabel()
|
||||
|
||||
class UserMessageCell: ChatBaseCell {
|
||||
// MARK: - UI Components
|
||||
|
||||
private lazy var messageLabel = UILabel().then {
|
||||
$0.numberOfLines = 0
|
||||
$0.font = .systemFont(ofSize: 16)
|
||||
$0.textColor = .label
|
||||
let backgroundView = UIView().then {
|
||||
$0.backgroundColor = .gray.withAlphaComponent(0.05)
|
||||
$0.layer.cornerRadius = 8
|
||||
}
|
||||
|
||||
private lazy var timestampLabel = UILabel().then {
|
||||
$0.font = .systemFont(ofSize: 12)
|
||||
$0.textColor = .secondaryLabel
|
||||
$0.textAlignment = .right
|
||||
let contentLabel = LTXLabel().then {
|
||||
$0.isSelectable = true
|
||||
}
|
||||
|
||||
private lazy var retryIndicator = UIActivityIndicatorView().then {
|
||||
$0.style = .medium
|
||||
$0.hidesWhenStopped = true
|
||||
override func prepareContentView(inside contentView: UIView) {
|
||||
super.prepareContentView(inside: contentView)
|
||||
|
||||
contentView.addSubview(backgroundView)
|
||||
backgroundView.addSubview(contentLabel)
|
||||
}
|
||||
|
||||
private lazy var stackView = UIStackView().then {
|
||||
$0.axis = .vertical
|
||||
$0.spacing = 8
|
||||
$0.alignment = .fill
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
contentLabel.attributedText = .init()
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private var viewModel: UserMessageCellViewModel?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
override func setupContentView() {
|
||||
containerView.addSubview(stackView)
|
||||
stackView.addArrangedSubview(messageLabel)
|
||||
|
||||
let bottomContainer = UIView()
|
||||
stackView.addArrangedSubview(bottomContainer)
|
||||
|
||||
bottomContainer.addSubview(retryIndicator)
|
||||
bottomContainer.addSubview(timestampLabel)
|
||||
|
||||
stackView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(contentInsets)
|
||||
}
|
||||
|
||||
retryIndicator.snp.makeConstraints { make in
|
||||
make.leading.centerY.equalToSuperview()
|
||||
make.width.height.equalTo(16)
|
||||
}
|
||||
|
||||
timestampLabel.snp.makeConstraints { make in
|
||||
make.trailing.top.bottom.equalToSuperview()
|
||||
make.leading.greaterThanOrEqualTo(retryIndicator.snp.trailing).offset(8)
|
||||
}
|
||||
|
||||
bottomContainer.snp.makeConstraints { make in
|
||||
make.height.equalTo(16)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
override func configure(with viewModel: any ChatCellViewModel) {
|
||||
guard let userViewModel = viewModel as? UserMessageCellViewModel else { return }
|
||||
self.viewModel = userViewModel
|
||||
|
||||
messageLabel.text = userViewModel.content
|
||||
configureContainer(backgroundColor: backgroundColor(for: userViewModel.cellType))
|
||||
|
||||
let timestamp = userViewModel.timestamp
|
||||
timestampLabel.text = formatTimestamp(timestamp)
|
||||
timestampLabel.isHidden = false
|
||||
super.configure(with: viewModel)
|
||||
guard let vm = viewModel as? UserMessageCellViewModel else {
|
||||
assertionFailure("")
|
||||
return
|
||||
}
|
||||
contentLabel.attributedText = Self.prepareAttributeText(vm.content)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
override func layoutContentView(bounds: CGRect) {
|
||||
super.layoutContentView(bounds: bounds)
|
||||
|
||||
private func formatTimestamp(_ timestamp: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.timeStyle = .short
|
||||
return formatter.string(from: timestamp)
|
||||
let inset: CGFloat = 8
|
||||
let textMaxWidth = bounds.width * 0.8 - inset * 2
|
||||
contentLabel.preferredMaxLayoutWidth = textMaxWidth
|
||||
let textSize = contentLabel.intrinsicContentSize
|
||||
let backgroundWidth = textSize.width + inset * 2
|
||||
|
||||
backgroundView.frame = .init(
|
||||
x: bounds.width - backgroundWidth, // right aligned
|
||||
y: 0,
|
||||
width: backgroundWidth,
|
||||
height: bounds.height
|
||||
)
|
||||
contentLabel.frame = backgroundView.bounds.insetBy(dx: inset, dy: inset)
|
||||
}
|
||||
|
||||
class func prepareAttributeText(_ text: String) -> NSAttributedString {
|
||||
.init(string: text, attributes: [
|
||||
.font: UIFont.preferredFont(forTextStyle: .body),
|
||||
.foregroundColor: UIColor.affineTextPrimary,
|
||||
.paragraphStyle: NSMutableParagraphStyle().then {
|
||||
$0.lineBreakMode = .byWordWrapping
|
||||
$0.alignment = .natural
|
||||
$0.lineSpacing = 2
|
||||
$0.paragraphSpacing = 4
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
override class func heightForContent(
|
||||
for viewModel: any ChatCellViewModel,
|
||||
width: CGFloat
|
||||
) -> CGFloat {
|
||||
guard let vm = viewModel as? UserMessageCellViewModel else {
|
||||
assertionFailure()
|
||||
return 0
|
||||
}
|
||||
labelForSizeCalculation.attributedText = prepareAttributeText(vm.content)
|
||||
|
||||
let inset: CGFloat = 8
|
||||
labelForSizeCalculation.preferredMaxLayoutWidth = width * 0.8 - inset * 2
|
||||
let textSize = labelForSizeCalculation.intrinsicContentSize
|
||||
return textSize.height + inset * 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
//
|
||||
// AssistantMessageCellViewModel.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/27/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct AssistantMessageCellViewModel: ChatCellViewModel {
|
||||
var cellType: CellType = .assistantMessage
|
||||
var id: UUID
|
||||
var content: String
|
||||
var timestamp: Date
|
||||
var isStreaming: Bool = false
|
||||
var model: String?
|
||||
var tokens: Int?
|
||||
var canRetry: Bool = false
|
||||
var citations: [CitationViewModel]?
|
||||
var actions: [MessageActionViewModel]?
|
||||
}
|
||||
|
||||
struct CitationViewModel: Codable, Identifiable, Equatable, Hashable {
|
||||
var id: String
|
||||
var title: String
|
||||
var url: String?
|
||||
var snippet: String?
|
||||
}
|
||||
|
||||
struct MessageActionViewModel: Codable, Identifiable, Equatable, Hashable {
|
||||
var id: String
|
||||
var title: String
|
||||
var actionType: ActionType
|
||||
var data: [String: String]?
|
||||
|
||||
enum ActionType: String, Codable {
|
||||
case copy
|
||||
case regenerate
|
||||
case like
|
||||
case dislike
|
||||
case share
|
||||
case edit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//
|
||||
// CCVM+Assistant.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/27/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MarkdownParser
|
||||
import MarkdownView
|
||||
|
||||
struct AssistantMessageCellViewModel: ChatCellViewModel {
|
||||
static func == (lhs: AssistantMessageCellViewModel, rhs: AssistantMessageCellViewModel) -> Bool {
|
||||
lhs.hashValue == rhs.hashValue
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(cellType)
|
||||
hasher.combine(id)
|
||||
hasher.combine(content)
|
||||
hasher.combine(timestamp)
|
||||
hasher.combine(isStreaming)
|
||||
hasher.combine(model)
|
||||
hasher.combine(tokens)
|
||||
hasher.combine(canRetry)
|
||||
hasher.combine(citations)
|
||||
hasher.combine(actions)
|
||||
}
|
||||
|
||||
var cellType: ChatCellType = .assistantMessage
|
||||
var id: UUID
|
||||
var content: String
|
||||
var timestamp: Date
|
||||
var isStreaming: Bool = false
|
||||
var model: String?
|
||||
var tokens: Int?
|
||||
var canRetry: Bool = false
|
||||
var citations: [CitationViewModel]?
|
||||
var actions: [MessageActionViewModel]?
|
||||
|
||||
var documentBlocks: [MarkdownBlockNode]
|
||||
var documentRenderedContent: RenderContext
|
||||
|
||||
init(
|
||||
id: UUID,
|
||||
content: String,
|
||||
timestamp: Date,
|
||||
isStreaming: Bool = false,
|
||||
model: String? = nil,
|
||||
tokens: Int? = nil,
|
||||
canRetry: Bool = false,
|
||||
citations: [CitationViewModel]? = nil,
|
||||
actions: [MessageActionViewModel]? = nil
|
||||
) {
|
||||
// time expensive rendering should not happen here
|
||||
assert(!Thread.isMainThread || content.isEmpty)
|
||||
|
||||
self.id = id
|
||||
self.content = content
|
||||
self.timestamp = timestamp
|
||||
self.isStreaming = isStreaming
|
||||
self.model = model
|
||||
self.tokens = tokens
|
||||
self.canRetry = canRetry
|
||||
self.citations = citations
|
||||
self.actions = actions
|
||||
|
||||
let parser = MarkdownParser()
|
||||
let parserResult = parser.parse(content)
|
||||
documentBlocks = parserResult.document
|
||||
var renderedContexts: [String: RenderedItem] = [:]
|
||||
for (key, value) in parserResult.mathContext {
|
||||
let image = MathRenderer.renderToImage(
|
||||
latex: value,
|
||||
fontSize: MarkdownTheme.default.fonts.body.pointSize,
|
||||
textColor: MarkdownTheme.default.colors.body
|
||||
)?.withRenderingMode(.alwaysTemplate)
|
||||
let renderedContext = RenderedItem(
|
||||
image: image,
|
||||
text: value
|
||||
)
|
||||
renderedContexts["math://\(key)"] = renderedContext
|
||||
}
|
||||
documentRenderedContent = renderedContexts
|
||||
}
|
||||
}
|
||||
|
||||
struct CitationViewModel: Codable, Identifiable, Equatable, Hashable {
|
||||
var id: String
|
||||
var title: String
|
||||
var url: String?
|
||||
var snippet: String?
|
||||
}
|
||||
|
||||
struct MessageActionViewModel: Codable, Identifiable, Equatable, Hashable {
|
||||
var id: String
|
||||
var title: String
|
||||
var actionType: ActionType
|
||||
var data: [String: String]?
|
||||
|
||||
enum ActionType: String, Codable {
|
||||
case copy
|
||||
case regenerate
|
||||
case like
|
||||
case dislike
|
||||
case share
|
||||
case edit
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
//
|
||||
// ErrorCellViewModel.swift
|
||||
// CCVM+Error.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/26/25.
|
||||
@@ -8,7 +8,8 @@
|
||||
import Foundation
|
||||
|
||||
struct ErrorCellViewModel: ChatCellViewModel {
|
||||
var cellType: CellType = .error
|
||||
var cellType: ChatCellType = .error
|
||||
var id: UUID
|
||||
var errorMessage: String
|
||||
var timestamp: Date
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
//
|
||||
// LoadingCellViewModel.swift
|
||||
// CCVM+Loading.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/26/25.
|
||||
@@ -8,8 +8,9 @@
|
||||
import Foundation
|
||||
|
||||
struct LoadingCellViewModel: ChatCellViewModel {
|
||||
var cellType: CellType = .loading
|
||||
var cellType: ChatCellType = .loading
|
||||
var id: UUID
|
||||
var timestamp: Date
|
||||
var message: String?
|
||||
var progress: Double?
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
//
|
||||
// SystemMessageCellViewModel.swift
|
||||
// CCVM+System.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/27/25.
|
||||
@@ -8,8 +8,8 @@
|
||||
import Foundation
|
||||
|
||||
struct SystemMessageCellViewModel: ChatCellViewModel {
|
||||
var cellType: CellType = .systemMessage
|
||||
var cellType: ChatCellType = .systemMessage
|
||||
var id: UUID
|
||||
var content: String
|
||||
var timestamp: Date?
|
||||
var timestamp: Date
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//
|
||||
// CCVM+User.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/27/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct UserMessageCellViewModel: ChatCellViewModel {
|
||||
var cellType: ChatCellType = .userMessage
|
||||
var id: UUID
|
||||
var content: String
|
||||
var timestamp: Date
|
||||
}
|
||||
|
||||
struct UserHintCellViewModel: ChatCellViewModel {
|
||||
var cellType: ChatCellType = .userAttachmentsHint
|
||||
var id: UUID
|
||||
var timestamp: Date
|
||||
var imageAttachments: [ImageAttachment]
|
||||
var fileAttachments: [FileAttachment]
|
||||
var docAttachments: [DocumentAttachment]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
//
|
||||
// CellType.swift
|
||||
// ChatCellType.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/26/25.
|
||||
@@ -7,8 +7,9 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum CellType: String, Codable, CaseIterable {
|
||||
public enum ChatCellType: String, CaseIterable {
|
||||
case userMessage
|
||||
case userAttachmentsHint
|
||||
case assistantMessage
|
||||
case systemMessage
|
||||
case loading
|
||||
@@ -7,7 +7,8 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
public protocol ChatCellViewModel: Codable, Identifiable, Equatable, Hashable {
|
||||
public protocol ChatCellViewModel: Identifiable, Equatable, Hashable {
|
||||
var id: UUID { get }
|
||||
var cellType: CellType { get }
|
||||
var cellType: ChatCellType { get }
|
||||
var timestamp: Date { get }
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
//
|
||||
// UserMessageCellViewModel.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/27/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct UserMessageCellViewModel: ChatCellViewModel {
|
||||
var cellType: CellType = .userMessage
|
||||
var id: UUID
|
||||
var content: String
|
||||
var timestamp: Date
|
||||
var attachments: [String] = []
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//
|
||||
// ChatItemEntity.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 7/2/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
struct ChatItemEntity: Identifiable, Hashable, Equatable {
|
||||
var id: UUID
|
||||
var object: any ChatCellViewModel
|
||||
|
||||
static func == (lhs: ChatItemEntity, rhs: ChatItemEntity) -> Bool {
|
||||
lhs.id == rhs.id && lhs.object.cellType == rhs.object.cellType && lhs.object.hashValue == rhs.object.hashValue
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
hasher.combine(object.cellType)
|
||||
hasher.combine(object.hashValue)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//
|
||||
// ChatListView+Adapter.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 7/2/25.
|
||||
//
|
||||
|
||||
import ListViewKit
|
||||
import UIKit
|
||||
|
||||
private let dayDateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
extension ChatListView: ListViewAdapter {
|
||||
func fill(viewModels: [any ChatCellViewModel]) {
|
||||
assert(!Thread.isMainThread)
|
||||
var items = viewModels.map { ChatItemEntity(id: $0.id, object: $0) }
|
||||
items = preprocessItems(items)
|
||||
DispatchQueue.main.asyncAndWait { [self] in
|
||||
dataSource.applySnapshot(using: items, animatingDifferences: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func preprocessItems(_ items: [ChatItemEntity]) -> [ChatItemEntity] {
|
||||
var ans = [ChatItemEntity]()
|
||||
|
||||
// prepend a date hint for each day
|
||||
let calendar = Calendar.current
|
||||
var currentDayAnchor: Date?
|
||||
for item in items {
|
||||
defer { ans.append(item) }
|
||||
|
||||
guard item.object.cellType == .userMessage,
|
||||
let userMessage = item.object as? UserMessageCellViewModel
|
||||
else { continue }
|
||||
let messageDate = userMessage.timestamp
|
||||
let dayAnchor = calendar.startOfDay(for: messageDate)
|
||||
if currentDayAnchor == nil || dayAnchor > currentDayAnchor! {
|
||||
currentDayAnchor = dayAnchor
|
||||
let dateHint = SystemMessageCellViewModel(
|
||||
id: .init(),
|
||||
content: dayDateFormatter.string(from: dayAnchor),
|
||||
timestamp: .init()
|
||||
)
|
||||
ans.append(ChatItemEntity(id: dateHint.id, object: dateHint))
|
||||
}
|
||||
}
|
||||
|
||||
return ans
|
||||
}
|
||||
|
||||
func listView(_: ListViewKit.ListView, rowKindFor item: ItemType, at _: Int) -> RowKind {
|
||||
let item = item as! ChatItemEntity
|
||||
return item.object.cellType
|
||||
}
|
||||
|
||||
func listViewMakeRow(for kind: RowKind) -> ListViewKit.ListRowView {
|
||||
switch kind as! ChatCellType {
|
||||
case .userMessage: UserMessageCell()
|
||||
case .userAttachmentsHint: UserHintCell()
|
||||
case .assistantMessage: AssistantMessageCell()
|
||||
case .systemMessage: SystemMessageCell()
|
||||
case .loading: LoadingCell()
|
||||
case .error: ErrorCell()
|
||||
}
|
||||
}
|
||||
|
||||
func listView(_ list: ListViewKit.ListView, heightFor item: ItemType, at _: Int) -> CGFloat {
|
||||
let item = item as! ChatItemEntity
|
||||
return switch item.object.cellType {
|
||||
case .userMessage: UserMessageCell.heightForCell(for: item.object, width: list.bounds.width)
|
||||
case .userAttachmentsHint: UserHintCell.heightForCell(for: item.object, width: list.bounds.width)
|
||||
case .assistantMessage: AssistantMessageCell.heightForCell(for: item.object, width: list.bounds.width)
|
||||
case .systemMessage: SystemMessageCell.heightForCell(for: item.object, width: list.bounds.width)
|
||||
case .loading: LoadingCell.heightForCell(for: item.object, width: list.bounds.width)
|
||||
case .error: ErrorCell.heightForCell(for: item.object, width: list.bounds.width)
|
||||
}
|
||||
}
|
||||
|
||||
func listView(_: ListViewKit.ListView, configureRowView rowView: ListViewKit.ListRowView, for item: ItemType, at _: Int) {
|
||||
let base = rowView as! ChatBaseCell
|
||||
let item = item as! ChatItemEntity
|
||||
base.configure(with: item.object)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//
|
||||
// ChatListView.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 7/2/25.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import ListViewKit
|
||||
import MarkdownView
|
||||
import UIKit
|
||||
|
||||
class ChatListView: UIView {
|
||||
private(set) lazy var listView = ListView()
|
||||
private(set) lazy var dataSource = ListViewDiffableDataSource<ChatItemEntity>(listView: listView)
|
||||
|
||||
var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
|
||||
listView.topInset = 8
|
||||
listView.bottomInset = 64
|
||||
listView.adapter = self
|
||||
addSubview(listView)
|
||||
listView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
|
||||
let dataSourceQueue = DispatchQueue(label: "com.affine.intelligents.chat.list.dataSource", qos: .userInteractive)
|
||||
|
||||
Publishers.CombineLatest(
|
||||
IntelligentContext.shared.$currentSession
|
||||
.map { $0?.id ?? "default_session" }
|
||||
.removeDuplicates(),
|
||||
ChatManager.shared.$viewModels
|
||||
)
|
||||
.receive(on: dataSourceQueue)
|
||||
.map { sessionIdentifier, viewModels in
|
||||
.init(viewModels[sessionIdentifier]?.map(\.value) ?? [])
|
||||
}
|
||||
.sink { [weak self] viewModels in
|
||||
guard let self else { return }
|
||||
fill(viewModels: viewModels)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
Publishers.CombineLatest(
|
||||
IntelligentContext.shared.$currentSession
|
||||
.map { $0?.id ?? "default_session" }
|
||||
.removeDuplicates(),
|
||||
ChatManager.shared.scrollToBottomPublisher
|
||||
)
|
||||
.receive(on: dataSourceQueue)
|
||||
.filter { $0 == $1 }
|
||||
.map { _ in () }
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
guard let self else { return }
|
||||
scrollToBottom()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
deinit {
|
||||
cancellables.forEach { $0.cancel() }
|
||||
cancellables.removeAll()
|
||||
}
|
||||
|
||||
func scrollToBottom() {
|
||||
if listView.contentSize.height <= listView.bounds.height {
|
||||
// If the content size is smaller than the bounds, no need to scroll.
|
||||
return
|
||||
}
|
||||
let contentOffset = CGPoint(
|
||||
x: 0,
|
||||
y: listView.contentSize.height - listView.bounds.height
|
||||
)
|
||||
listView.scroll(to: contentOffset)
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import Combine
|
||||
import OrderedCollections
|
||||
import SnapKit
|
||||
import Then
|
||||
import UIKit
|
||||
|
||||
protocol ChatTableViewDelegate: AnyObject {
|
||||
func chatTableView(_ tableView: ChatTableView, didSelectRowAt indexPath: IndexPath)
|
||||
}
|
||||
|
||||
class ChatTableView: UIView {
|
||||
// MARK: - UI Components
|
||||
|
||||
lazy var tableView = UITableView().then {
|
||||
$0.backgroundColor = .clear
|
||||
$0.separatorStyle = .none
|
||||
$0.delegate = self
|
||||
$0.dataSource = self
|
||||
$0.keyboardDismissMode = .interactive
|
||||
$0.contentInsetAdjustmentBehavior = .never
|
||||
$0.tableFooterView = UIView(frame: .init(x: 0, y: 0, width: 100, height: 500))
|
||||
}
|
||||
|
||||
lazy var emptyStateView = UIView().then {
|
||||
$0.isHidden = true
|
||||
}
|
||||
|
||||
lazy var emptyStateLabel = UILabel().then {
|
||||
$0.text = "Start a conversation..."
|
||||
$0.font = .systemFont(ofSize: 18, weight: .medium)
|
||||
$0.textColor = .systemGray
|
||||
$0.textAlignment = .center
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
weak var delegate: ChatTableViewDelegate?
|
||||
var sessionId: String? {
|
||||
didSet {
|
||||
if let sessionId {
|
||||
bindToSession(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
var cellViewModels: OrderedDictionary<UUID, any ChatCellViewModel> = [:] {
|
||||
didSet {
|
||||
updateEmptyState()
|
||||
tableView.reloadData()
|
||||
|
||||
if !cellViewModels.isEmpty {
|
||||
let indexPath = IndexPath(row: cellViewModels.count - 1, section: 0)
|
||||
tableView.scrollToRow(at: indexPath, at: .bottom, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
setupUI()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
setupUI()
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
private func setupUI() {
|
||||
// 注册所有 cell 类型
|
||||
ChatCellFactory.registerCells(for: tableView)
|
||||
|
||||
addSubview(tableView)
|
||||
addSubview(emptyStateView)
|
||||
|
||||
emptyStateView.addSubview(emptyStateLabel)
|
||||
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
|
||||
emptyStateView.snp.makeConstraints { make in
|
||||
make.center.equalTo(tableView)
|
||||
make.width.lessThanOrEqualTo(tableView).inset(32)
|
||||
}
|
||||
|
||||
emptyStateLabel.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
func scrollToBottom(animated: Bool = true) {
|
||||
guard !cellViewModels.isEmpty else { return }
|
||||
let indexPath = IndexPath(row: cellViewModels.count - 1, section: 0)
|
||||
tableView.scrollToRow(at: indexPath, at: .bottom, animated: animated)
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func bindToSession(_ sessionId: String) {
|
||||
cancellables.removeAll()
|
||||
|
||||
ChatManager.shared.$viewModels
|
||||
.map { $0[sessionId] ?? [:] }
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] viewModels in
|
||||
self?.cellViewModels = viewModels
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func updateEmptyState() {
|
||||
emptyStateView.isHidden = !cellViewModels.isEmpty
|
||||
tableView.isHidden = cellViewModels.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDataSource
|
||||
|
||||
extension ChatTableView: UITableViewDataSource {
|
||||
func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
|
||||
cellViewModels.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let viewModel = cellViewModels.elements[indexPath.row].value
|
||||
return ChatCellFactory.dequeueCell(for: tableView, at: indexPath, with: viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDelegate
|
||||
|
||||
extension ChatTableView: UITableViewDelegate {
|
||||
func tableView(_: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
let viewModel = cellViewModels.elements[indexPath.row].value
|
||||
return ChatCellFactory.estimatedHeight(for: viewModel)
|
||||
}
|
||||
|
||||
func tableView(_: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
let viewModel = cellViewModels.elements[indexPath.row].value
|
||||
return ChatCellFactory.estimatedHeight(for: viewModel)
|
||||
}
|
||||
|
||||
func tableView(_: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
delegate?.chatTableView(self, didSelectRowAt: indexPath)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user