mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-08 18:43:46 +00:00
Compare commits
16 Commits
v0.25.0-be
...
v0.25.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a35332634a | ||
|
|
0063f039a7 | ||
|
|
d80ca57e94 | ||
|
|
c63e3e7fe6 | ||
|
|
05d373081a | ||
|
|
26fbde6b62 | ||
|
|
072b5b22df | ||
|
|
3c7461a5ce | ||
|
|
1b859a37c5 | ||
|
|
bf72833f05 | ||
|
|
96b3de8ce7 | ||
|
|
26a59db540 | ||
|
|
7d0b8aaa81 | ||
|
|
856b69e1f6 | ||
|
|
5fdae9161a | ||
|
|
03ef4625bc |
7
Cargo.lock
generated
7
Cargo.lock
generated
@@ -161,6 +161,7 @@ dependencies = [
|
||||
"affine_common",
|
||||
"chrono",
|
||||
"file-format",
|
||||
"infer",
|
||||
"mimalloc",
|
||||
"napi",
|
||||
"napi-build",
|
||||
@@ -1504,9 +1505,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "file-format"
|
||||
version = "0.26.0"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7ef3d5e8ae27277c8285ac43ed153158178ef0f79567f32024ca8140a0c7cd8"
|
||||
checksum = "0eab8aa2fba5f39f494000a22f44bf3c755b7d7f8ffad3f36c6d507893074159"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
@@ -1913,7 +1914,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.57.0",
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -39,7 +39,7 @@ crossbeam-channel = "0.5"
|
||||
dispatch2 = "0.3"
|
||||
docx-parser = { git = "https://github.com/toeverything/docx-parser" }
|
||||
dotenvy = "0.15"
|
||||
file-format = { version = "0.26", features = ["reader"] }
|
||||
file-format = { version = "0.28", features = ["reader"] }
|
||||
homedir = "0.3"
|
||||
infer = { version = "0.19.0" }
|
||||
lasso = { version = "0.7", features = ["multi-threaded"] }
|
||||
|
||||
10
SECURITY.md
10
SECURITY.md
@@ -6,12 +6,12 @@ We recommend users to always use the latest major version. Security updates will
|
||||
|
||||
| Version | Supported |
|
||||
| --------------- | ------------------ |
|
||||
| 0.17.x (stable) | :white_check_mark: |
|
||||
| < 0.17.x | :x: |
|
||||
| 0.24.x (stable) | :white_check_mark: |
|
||||
| < 0.24.x | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
We welcome you to provide us with bug reports via and email at [security@toeverything.info](mailto:security@toeverything.info). We expect your report to contain at least the following for us to evaluate and reproduce:
|
||||
We welcome you to provide us with bug reports via and email at [security@toeverything.info](mailto:security@toeverything.info) or submit directly on [GitHub](https://github.com/toeverything/AFFiNE/security), **we encourage you to submit the relevant information directly via GitHub**. We expect your report to contain at least the following for us to evaluate and reproduce:
|
||||
|
||||
1. Using platform and version, for example:
|
||||
|
||||
@@ -22,8 +22,6 @@ We welcome you to provide us with bug reports via and email at [security@toevery
|
||||
|
||||
3. Your classification or analysis of the vulnerability (optional)
|
||||
|
||||
Since we are an open source project, we also welcome you to provide corresponding fix PRs.
|
||||
|
||||
We will provide bounties for vulnerabilities involving user information leakage, permission leakage, and unauthorized code execution. For other types of vulnerabilities, we will determine specific rewards based on the evaluation results.
|
||||
Since we are an open source project, we also welcome you to provide corresponding fix PRs, we will determine specific rewards based on the evaluation results.
|
||||
|
||||
If the vulnerability is caused by a library we depend on, we encourage you to submit a security report to the corresponding dependent library at the same time to benefit more users.
|
||||
|
||||
@@ -20,7 +20,7 @@ export const calloutEmojiContainerStyles = css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: '10px',
|
||||
// marginTop is dynamically set by JavaScript based on first child's height
|
||||
marginBottom: '10px',
|
||||
flexShrink: 0,
|
||||
position: 'relative',
|
||||
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
popupTargetFromElement,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import { DefaultInlineManagerExtension } from '@blocksuite/affine-inline-preset';
|
||||
import { type CalloutBlockModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
type CalloutBlockModel,
|
||||
ParagraphBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { focusTextModel } from '@blocksuite/affine-rich-text';
|
||||
import { EDGELESS_TOP_CONTENTEDITABLE_SELECTOR } from '@blocksuite/affine-shared/consts';
|
||||
import {
|
||||
@@ -69,6 +72,35 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
|
||||
this.classList.add(calloutHostStyles);
|
||||
}
|
||||
|
||||
private _getEmojiMarginTop(): string {
|
||||
if (this.model.children.length === 0) {
|
||||
return '10px';
|
||||
}
|
||||
|
||||
const firstChild = this.model.children[0];
|
||||
const flavour = firstChild.flavour;
|
||||
|
||||
const marginTopMap: Record<string, string> = {
|
||||
'affine:paragraph:h1': '23px',
|
||||
'affine:paragraph:h2': '20px',
|
||||
'affine:paragraph:h3': '16px',
|
||||
'affine:paragraph:h4': '15px',
|
||||
'affine:paragraph:h5': '14px',
|
||||
'affine:paragraph:h6': '13px',
|
||||
};
|
||||
|
||||
// For heading blocks, use the type to determine margin
|
||||
if (flavour === 'affine:paragraph') {
|
||||
const paragraph = firstChild as ParagraphBlockModel;
|
||||
const type = paragraph.props.type$.value;
|
||||
const key = `${flavour}:${type}`;
|
||||
return marginTopMap[key] || '10px';
|
||||
}
|
||||
|
||||
// Default for all other block types
|
||||
return '10px';
|
||||
}
|
||||
|
||||
private _closeIconPicker() {
|
||||
if (this._popupCloseHandler) {
|
||||
this._popupCloseHandler();
|
||||
@@ -204,6 +236,9 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
|
||||
@click=${this._toggleIconPicker}
|
||||
contenteditable="false"
|
||||
class="${calloutEmojiContainerStyles}"
|
||||
style=${styleMap({
|
||||
marginTop: this._getEmojiMarginTop(),
|
||||
})}
|
||||
>
|
||||
<span class="${calloutEmojiStyles}" data-testid="callout-emoji"
|
||||
>${iconContent}</span
|
||||
|
||||
@@ -19,16 +19,16 @@ const DOC_BLOCK_CHILD_PADDING = 24;
|
||||
|
||||
export class DocTitle extends WithDisposable(ShadowlessElement) {
|
||||
static override styles = css`
|
||||
.doc-title-container {
|
||||
font-size: 40px;
|
||||
line-height: 50px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.doc-icon-container,
|
||||
.doc-title-container {
|
||||
box-sizing: border-box;
|
||||
font-family: var(--affine-font-family);
|
||||
font-size: var(--affine-font-base);
|
||||
line-height: var(--affine-line-height);
|
||||
color: var(--affine-text-primary-color);
|
||||
font-size: 40px;
|
||||
line-height: 50px;
|
||||
font-weight: 700;
|
||||
outline: none;
|
||||
resize: none;
|
||||
border: 0;
|
||||
@@ -47,6 +47,10 @@ export class DocTitle extends WithDisposable(ShadowlessElement) {
|
||||
${DOC_BLOCK_CHILD_PADDING}px
|
||||
);
|
||||
}
|
||||
.doc-icon-container + * .doc-title-container {
|
||||
/* when doc icon exists, remove the top padding */
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
/* Extra small devices (phones, 640px and down) */
|
||||
@container viewport (width <= 640px) {
|
||||
|
||||
@@ -76,10 +76,16 @@ export const linkedDocPopoverStyles = css`
|
||||
border-top: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
|
||||
}
|
||||
|
||||
.group icon-button svg {
|
||||
.group icon-button svg,
|
||||
.group icon-button .icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.group icon-button .icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.linked-doc-popover .group {
|
||||
display: flex;
|
||||
|
||||
@@ -10,6 +10,7 @@ crate-type = ["cdylib"]
|
||||
affine_common = { workspace = true, features = ["doc-loader"] }
|
||||
chrono = { workspace = true }
|
||||
file-format = { workspace = true }
|
||||
infer = { workspace = true }
|
||||
napi = { workspace = true, features = ["async"] }
|
||||
napi-derive = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
|
||||
@@ -2,7 +2,11 @@ use napi_derive::napi;
|
||||
|
||||
#[napi]
|
||||
pub fn get_mime(input: &[u8]) -> String {
|
||||
file_format::FileFormat::from_bytes(input)
|
||||
.media_type()
|
||||
.to_string()
|
||||
if let Some(kind) = infer::get(&input[..4096.min(input.len())]) {
|
||||
kind.mime_type().to_string()
|
||||
} else {
|
||||
file_format::FileFormat::from_bytes(input)
|
||||
.media_type()
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
createTestingApp,
|
||||
createWorkspace,
|
||||
inviteUser,
|
||||
smallestPng,
|
||||
TestingApp,
|
||||
TestUser,
|
||||
} from './utils';
|
||||
@@ -453,8 +454,6 @@ test('should create message correctly', async t => {
|
||||
randomUUID(),
|
||||
textPromptName
|
||||
);
|
||||
const smallestPng =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII';
|
||||
const pngData = await fetch(smallestPng).then(res => res.arrayBuffer());
|
||||
const messageId = await createCopilotMessage(
|
||||
app,
|
||||
@@ -475,8 +474,6 @@ test('should create message correctly', async t => {
|
||||
randomUUID(),
|
||||
textPromptName
|
||||
);
|
||||
const smallestPng =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII';
|
||||
const pngData = await fetch(smallestPng).then(res => res.arrayBuffer());
|
||||
const messageId = await createCopilotMessage(
|
||||
app,
|
||||
|
||||
@@ -6,6 +6,8 @@ import ava from 'ava';
|
||||
import {
|
||||
createTestingApp,
|
||||
getPublicUserById,
|
||||
smallestGif,
|
||||
smallestPng,
|
||||
TestingApp,
|
||||
updateAvatar,
|
||||
} from '../utils';
|
||||
@@ -27,7 +29,9 @@ test('should be able to upload user avatar', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
await app.signup();
|
||||
const avatar = Buffer.from('test');
|
||||
const avatar = await fetch(smallestPng)
|
||||
.then(res => res.arrayBuffer())
|
||||
.then(b => Buffer.from(b));
|
||||
const res = await updateAvatar(app, avatar);
|
||||
|
||||
t.is(res.status, 200);
|
||||
@@ -36,19 +40,23 @@ test('should be able to upload user avatar', async t => {
|
||||
|
||||
const avatarRes = await app.GET(new URL(avatarUrl).pathname);
|
||||
|
||||
t.deepEqual(avatarRes.body, Buffer.from('test'));
|
||||
t.deepEqual(avatarRes.body, avatar);
|
||||
});
|
||||
|
||||
test('should be able to update user avatar, and invalidate old avatar url', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
await app.signup();
|
||||
const avatar = Buffer.from('test');
|
||||
const avatar = await fetch(smallestPng)
|
||||
.then(res => res.arrayBuffer())
|
||||
.then(b => Buffer.from(b));
|
||||
let res = await updateAvatar(app, avatar);
|
||||
|
||||
const oldAvatarUrl = res.body.data.uploadAvatar.avatarUrl;
|
||||
|
||||
const newAvatar = Buffer.from('new');
|
||||
const newAvatar = await fetch(smallestGif)
|
||||
.then(res => res.arrayBuffer())
|
||||
.then(b => Buffer.from(b));
|
||||
res = await updateAvatar(app, newAvatar);
|
||||
const newAvatarUrl = res.body.data.uploadAvatar.avatarUrl;
|
||||
|
||||
@@ -58,14 +66,16 @@ test('should be able to update user avatar, and invalidate old avatar url', asyn
|
||||
t.is(avatarRes.status, 404);
|
||||
|
||||
const newAvatarRes = await app.GET(new URL(newAvatarUrl).pathname);
|
||||
t.deepEqual(newAvatarRes.body, Buffer.from('new'));
|
||||
t.deepEqual(newAvatarRes.body, newAvatar);
|
||||
});
|
||||
|
||||
test('should be able to get public user by id', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const u1 = await app.signup();
|
||||
const avatar = Buffer.from('test');
|
||||
const avatar = await fetch(smallestPng)
|
||||
.then(res => res.arrayBuffer())
|
||||
.then(b => Buffer.from(b));
|
||||
await updateAvatar(app, avatar);
|
||||
const u2 = await app.signup();
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@ import { type Blob } from '@prisma/client';
|
||||
import { TestingApp } from './testing-app';
|
||||
import { TEST_LOG_LEVEL } from './utils';
|
||||
|
||||
export const smallestPng =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII';
|
||||
export const smallestGif = 'data:image/gif;base64,R0lGODlhAQABAAAAACw=';
|
||||
|
||||
export async function listBlobs(
|
||||
app: TestingApp,
|
||||
workspaceId: string
|
||||
|
||||
@@ -135,4 +135,4 @@ export const StorageJSONSchema: JSONSchema = {
|
||||
};
|
||||
|
||||
export type * from './provider';
|
||||
export { autoMetadata, toBuffer } from './utils';
|
||||
export { applyAttachHeaders, autoMetadata, sniffMime, toBuffer } from './utils';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { crc32 } from '@node-rs/crc32';
|
||||
import type { Response } from 'express';
|
||||
import { getStreamAsBuffer } from 'get-stream';
|
||||
|
||||
import { getMime } from '../../../native';
|
||||
@@ -43,4 +44,53 @@ export function autoMetadata(
|
||||
return metadata;
|
||||
}
|
||||
|
||||
const DANGEROUS_INLINE_MIME_PREFIXES = [
|
||||
'text/html',
|
||||
'application/xhtml+xml',
|
||||
'image/svg+xml',
|
||||
'application/xml',
|
||||
'text/xml',
|
||||
'text/javascript',
|
||||
];
|
||||
|
||||
export function isDangerousInlineMime(mime: string | undefined) {
|
||||
if (!mime) return false;
|
||||
const lower = mime.toLowerCase();
|
||||
return DANGEROUS_INLINE_MIME_PREFIXES.some(p => lower.startsWith(p));
|
||||
}
|
||||
|
||||
export function applyAttachHeaders(
|
||||
res: Response,
|
||||
options: { filename?: string; buffer?: Buffer; contentType?: string }
|
||||
) {
|
||||
let { filename, buffer, contentType } = options;
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
if (!contentType && buffer) contentType = sniffMime(buffer);
|
||||
if (contentType && isDangerousInlineMime(contentType)) {
|
||||
const safeName = (filename || 'download')
|
||||
.replace(/[\r\n]/g, '')
|
||||
.replace(/[^\w\s.-]/g, '_');
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${encodeURIComponent(safeName)}"; filename*=UTF-8''${encodeURIComponent(
|
||||
safeName
|
||||
)}`
|
||||
);
|
||||
}
|
||||
if (!res.getHeader('Content-Type')) {
|
||||
res.setHeader('Content-Type', contentType || 'application/octet-stream');
|
||||
}
|
||||
}
|
||||
|
||||
export function sniffMime(
|
||||
buffer: Buffer,
|
||||
declared?: string
|
||||
): string | undefined {
|
||||
try {
|
||||
const detected = getMime(buffer);
|
||||
if (detected) return detected;
|
||||
} catch {}
|
||||
return declared;
|
||||
}
|
||||
|
||||
export const SIGNED_URL_EXPIRED = 60 * 60; // 1 hour
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { BlobQuotaExceeded, StorageQuotaExceeded } from '../error';
|
||||
import { OneKB } from './unit';
|
||||
|
||||
export type CheckExceededResult =
|
||||
| {
|
||||
@@ -52,7 +53,7 @@ export async function readBuffer(
|
||||
|
||||
export async function readBufferWithLimit(
|
||||
readable: Readable,
|
||||
limit: number
|
||||
limit: number = 500 * OneKB
|
||||
): Promise<Buffer> {
|
||||
return readBuffer(readable, size =>
|
||||
size > limit
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Controller, Get, Param, Res } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { ActionForbidden, UserAvatarNotFound } from '../../base';
|
||||
import {
|
||||
ActionForbidden,
|
||||
applyAttachHeaders,
|
||||
UserAvatarNotFound,
|
||||
} from '../../base';
|
||||
import { Public } from '../auth/guard';
|
||||
import { AvatarStorage } from '../storage';
|
||||
|
||||
@@ -30,6 +34,10 @@ export class UserAvatarController {
|
||||
res.setHeader('last-modified', metadata.lastModified.toISOString());
|
||||
res.setHeader('content-length', metadata.contentLength);
|
||||
}
|
||||
applyAttachHeaders(res, {
|
||||
contentType: metadata?.contentType,
|
||||
filename: `${id}`,
|
||||
});
|
||||
|
||||
body.pipe(res);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import { isNil, omitBy } from 'lodash-es';
|
||||
import {
|
||||
CannotDeleteOwnAccount,
|
||||
type FileUpload,
|
||||
readBufferWithLimit,
|
||||
sniffMime,
|
||||
Throttle,
|
||||
UserNotFound,
|
||||
} from '../../base';
|
||||
@@ -98,20 +100,20 @@ export class UserResolver {
|
||||
@Args({ name: 'avatar', type: () => GraphQLUpload })
|
||||
avatar: FileUpload
|
||||
) {
|
||||
if (!avatar.mimetype.startsWith('image/')) {
|
||||
throw new Error('Invalid file type');
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new UserNotFound();
|
||||
}
|
||||
|
||||
const avatarBuffer = await readBufferWithLimit(avatar.createReadStream());
|
||||
const contentType = sniffMime(avatarBuffer, avatar.mimetype);
|
||||
if (!contentType || !contentType.startsWith('image/')) {
|
||||
throw new Error(`Invalid file type: ${contentType || 'unknown'}`);
|
||||
}
|
||||
|
||||
const avatarUrl = await this.storage.put(
|
||||
`${user.id}-avatar-${Date.now()}`,
|
||||
avatar.createReadStream(),
|
||||
{
|
||||
contentType: avatar.mimetype,
|
||||
}
|
||||
avatarBuffer,
|
||||
{ contentType }
|
||||
);
|
||||
|
||||
if (user.avatarUrl) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Controller, Get, Logger, Param, Query, Res } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import {
|
||||
applyAttachHeaders,
|
||||
BlobNotFound,
|
||||
CallMetric,
|
||||
CommentAttachmentNotFound,
|
||||
@@ -83,6 +84,10 @@ export class WorkspacesController {
|
||||
} else {
|
||||
this.logger.warn(`Blob ${workspaceId}/${name} has no metadata`);
|
||||
}
|
||||
applyAttachHeaders(res, {
|
||||
contentType: metadata?.contentType,
|
||||
filename: name,
|
||||
});
|
||||
|
||||
res.setHeader('cache-control', 'public, max-age=2592000, immutable');
|
||||
body.pipe(res);
|
||||
@@ -215,6 +220,10 @@ export class WorkspacesController {
|
||||
`Comment attachment ${workspaceId}/${docId}/${key} has no metadata`
|
||||
);
|
||||
}
|
||||
applyAttachHeaders(res, {
|
||||
contentType: metadata?.contentType,
|
||||
filename: key,
|
||||
});
|
||||
|
||||
res.setHeader('cache-control', 'private, max-age=2592000, immutable');
|
||||
body.pipe(res);
|
||||
|
||||
@@ -396,7 +396,10 @@ export class CopilotSessionModel extends BaseModel {
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async update(options: UpdateChatSessionOptions): Promise<string> {
|
||||
async update(
|
||||
options: UpdateChatSessionOptions,
|
||||
internalCall = false
|
||||
): Promise<string> {
|
||||
const { userId, sessionId, docId, promptName, pinned, title } = options;
|
||||
const session = await this.getExists(
|
||||
sessionId,
|
||||
@@ -415,14 +418,16 @@ export class CopilotSessionModel extends BaseModel {
|
||||
}
|
||||
|
||||
// not allow to update action session
|
||||
if (session.prompt.action) {
|
||||
throw new CopilotSessionInvalidInput(
|
||||
`Cannot update action: ${session.id}`
|
||||
);
|
||||
} else if (docId && session.parentSessionId) {
|
||||
throw new CopilotSessionInvalidInput(
|
||||
`Cannot update docId for forked session: ${session.id}`
|
||||
);
|
||||
if (!internalCall) {
|
||||
if (session.prompt.action) {
|
||||
throw new CopilotSessionInvalidInput(
|
||||
`Cannot update action: ${session.id}`
|
||||
);
|
||||
} else if (docId && session.parentSessionId) {
|
||||
throw new CopilotSessionInvalidInput(
|
||||
`Cannot update docId for forked session: ${session.id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (promptName) {
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
EventBus,
|
||||
type FileUpload,
|
||||
RequestMutex,
|
||||
sniffMime,
|
||||
Throttle,
|
||||
TooManyRequest,
|
||||
UserFriendlyError,
|
||||
@@ -671,7 +672,11 @@ export class CopilotContextResolver {
|
||||
const { filename, mimetype } = content;
|
||||
|
||||
await this.storage.put(user.id, session.workspaceId, blobId, buffer);
|
||||
const file = await session.addFile(blobId, filename, mimetype);
|
||||
const file = await session.addFile(
|
||||
blobId,
|
||||
filename,
|
||||
sniffMime(buffer, mimetype) || mimetype
|
||||
);
|
||||
|
||||
await this.jobs.addFileEmbeddingQueue({
|
||||
userId: user.id,
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from 'rxjs';
|
||||
|
||||
import {
|
||||
applyAttachHeaders,
|
||||
BlobNotFound,
|
||||
CallMetric,
|
||||
Config,
|
||||
@@ -795,6 +796,10 @@ export class CopilotController implements BeforeApplicationShutdown {
|
||||
} else {
|
||||
this.logger.warn(`Blob ${workspaceId}/${key} has no metadata`);
|
||||
}
|
||||
applyAttachHeaders(res, {
|
||||
contentType: metadata?.contentType,
|
||||
filename: key,
|
||||
});
|
||||
|
||||
res.setHeader('cache-control', 'public, max-age=2592000, immutable');
|
||||
body.pipe(res);
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
Paginated,
|
||||
PaginationInput,
|
||||
RequestMutex,
|
||||
sniffMime,
|
||||
Throttle,
|
||||
TooManyRequest,
|
||||
UserFriendlyError,
|
||||
@@ -806,7 +807,10 @@ export class CopilotResolver {
|
||||
filename,
|
||||
uploaded.buffer
|
||||
);
|
||||
attachments.push({ attachment, mimeType: blob.mimetype });
|
||||
attachments.push({
|
||||
attachment,
|
||||
mimeType: sniffMime(uploaded.buffer, blob.mimetype) || blob.mimetype,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -636,11 +636,10 @@ export class ChatSessionService {
|
||||
})
|
||||
.then(s => s.map(s => [s.userId, s.id]));
|
||||
for (const [userId, sessionId] of sessionIds) {
|
||||
await this.models.copilotSession.update({
|
||||
userId,
|
||||
sessionId,
|
||||
docId: null,
|
||||
});
|
||||
await this.models.copilotSession.update(
|
||||
{ userId, sessionId, docId: null },
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
NoCopilotProviderAvailable,
|
||||
OnEvent,
|
||||
OnJob,
|
||||
sniffMime,
|
||||
} from '../../../base';
|
||||
import { Models } from '../../../models';
|
||||
import { PromptService } from '../prompt';
|
||||
@@ -85,7 +86,10 @@ export class CopilotTranscriptionService {
|
||||
`${blobId}-${idx}`,
|
||||
buffer
|
||||
);
|
||||
infos.push({ url, mimeType: blob.mimetype });
|
||||
infos.push({
|
||||
url,
|
||||
mimeType: sniffMime(buffer, blob.mimetype) || blob.mimetype,
|
||||
});
|
||||
}
|
||||
|
||||
const model = await this.getModel(userId);
|
||||
|
||||
@@ -2,7 +2,12 @@ import { createHash } from 'node:crypto';
|
||||
|
||||
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
|
||||
|
||||
import { FileUpload, JobQueue, PaginationInput } from '../../../base';
|
||||
import {
|
||||
FileUpload,
|
||||
JobQueue,
|
||||
PaginationInput,
|
||||
sniffMime,
|
||||
} from '../../../base';
|
||||
import { ServerFeature, ServerService } from '../../../core';
|
||||
import { Models } from '../../../models';
|
||||
import { CopilotStorage } from '../storage';
|
||||
@@ -64,7 +69,7 @@ export class CopilotWorkspaceService implements OnApplicationBootstrap {
|
||||
const file = await this.models.copilotWorkspace.addFile(workspaceId, {
|
||||
fileName,
|
||||
blobId,
|
||||
mimeType: content.mimetype,
|
||||
mimeType: sniffMime(buffer, content.mimetype) || content.mimetype,
|
||||
size: buffer.length,
|
||||
});
|
||||
return { blobId, file };
|
||||
|
||||
@@ -221,6 +221,15 @@ export class OAuthController {
|
||||
if (connectedAccount) {
|
||||
// already connected
|
||||
await this.updateConnectedAccount(connectedAccount, tokens);
|
||||
|
||||
if (
|
||||
!connectedAccount.user.emailVerifiedAt &&
|
||||
// external email may change, check if it matches exists email
|
||||
externalAccount.email.toLowerCase() ===
|
||||
connectedAccount.user.email.toLowerCase()
|
||||
) {
|
||||
await this.auth.setEmailVerified(connectedAccount.userId);
|
||||
}
|
||||
return connectedAccount.user;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,9 +50,6 @@
|
||||
ReferencedContainer = "container:App.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<StoreKitConfigurationFileReference
|
||||
identifier = "../App/Products.storekit">
|
||||
</StoreKitConfigurationFileReference>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
|
||||
@@ -74,29 +74,4 @@ class AFFiNEViewController: CAPBridgeViewController {
|
||||
super.viewDidDisappear(animated)
|
||||
intelligentsButtonTimer?.invalidate()
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
|
||||
if motion == .motionShake {
|
||||
showDebugMenu()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
import AffinePaywall
|
||||
extension AFFiNEViewController {
|
||||
@objc private func showDebugMenu() {
|
||||
let alert = UIAlertController(title: "Debug Menu", message: nil, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "Show Paywall - Pro", style: .default) { _ in
|
||||
Paywall.presentWall(toController: self, type: "Pro")
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "Show Paywall - AI", style: .default) { _ in
|
||||
Paywall.presentWall(toController: self, type: "AI")
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||
present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -6,7 +6,9 @@ import UIKit
|
||||
|
||||
@objc(PayWallPlugin)
|
||||
public class PayWallPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
init(associatedController: UIViewController? = nil) {
|
||||
init(
|
||||
associatedController: UIViewController?
|
||||
) {
|
||||
controller = associatedController
|
||||
super.init()
|
||||
}
|
||||
@@ -27,7 +29,11 @@ public class PayWallPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
// TODO: GET TO KNOW THE PAYWALL TYPE
|
||||
print("[*] showing paywall of type: \(type)")
|
||||
DispatchQueue.main.async {
|
||||
Paywall.presentWall(toController: controller, type: type)
|
||||
Paywall.presentWall(
|
||||
toController: controller,
|
||||
bindWebContext: self.webView,
|
||||
type: type
|
||||
)
|
||||
}
|
||||
|
||||
call.resolve(["success": true, "type": type])
|
||||
|
||||
@@ -44,7 +44,7 @@ struct PackageOptionView: View {
|
||||
if !badge.isEmpty {
|
||||
Text(badge)
|
||||
.contentTransition(.numericText())
|
||||
.font(.system(size: 12))
|
||||
.font(.system(size: 10))
|
||||
.bold()
|
||||
.lineLimit(1)
|
||||
.foregroundColor(AffineColors.layerPureWhite.color)
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
enum SKUnitCategory: Int, CaseIterable, Equatable, Identifiable {
|
||||
var id: Int { rawValue }
|
||||
public enum SKUnitCategory: Int, CaseIterable, Equatable, Identifiable, Sendable {
|
||||
public var id: Int { rawValue }
|
||||
|
||||
case pro
|
||||
case ai
|
||||
}
|
||||
|
||||
extension SKUnitCategory {
|
||||
public extension SKUnitCategory {
|
||||
var title: String {
|
||||
switch self {
|
||||
case .pro: "AFFINE.Pro"
|
||||
|
||||
@@ -75,6 +75,21 @@ extension ViewModel {
|
||||
|
||||
func dismiss() {
|
||||
print(#function)
|
||||
|
||||
if let context = associatedWebContext {
|
||||
Task.detached {
|
||||
do {
|
||||
_ = try await context.callAsyncJavaScript(
|
||||
"return await window.updateSubscriptionState();",
|
||||
contentWorld: .page
|
||||
)
|
||||
print("updateSubscriptionState success")
|
||||
} catch {
|
||||
print("updateSubscriptionState error:", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
associatedController?.dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
@@ -96,12 +111,30 @@ nonisolated extension ViewModel {
|
||||
// fetch purchased items if signed in
|
||||
do {
|
||||
let purchase = try await store.fetchEntitlements()
|
||||
await MainActor.run { self.purchasedItems = purchase }
|
||||
await MainActor.run { self.storePurchasedItems = purchase }
|
||||
} catch {
|
||||
print("fetchEntitlements error:", error)
|
||||
if !initial { throw error }
|
||||
}
|
||||
|
||||
// fetch external items by executing on webview's JS context
|
||||
do {
|
||||
guard let webView = await associatedWebContext else {
|
||||
throw NSError(domain: "Paywall", code: -1, userInfo: [
|
||||
NSLocalizedDescriptionKey: String(localized: "Missing required information"),
|
||||
])
|
||||
}
|
||||
let result = try await webView.callAsyncJavaScript(
|
||||
"return await window.getSubscriptionState();",
|
||||
contentWorld: .page
|
||||
)
|
||||
let purchased = decodeWebContextSubscriptionInformation(result)
|
||||
print("fetched external purchased items:", purchased)
|
||||
await MainActor.run { self.externalPurchasedItems = purchased }
|
||||
} catch {
|
||||
print("fetchExternalEntitlements error:", error.localizedDescription)
|
||||
}
|
||||
|
||||
// select the package under purchased items if any
|
||||
let availablePackages = await availablePackageOptions
|
||||
let purchase = await purchasedItems
|
||||
@@ -133,4 +166,45 @@ nonisolated extension ViewModel {
|
||||
|
||||
await MainActor.run { self.updating = false }
|
||||
}
|
||||
|
||||
nonisolated func decodeWebContextSubscriptionInformation(_ input: Any?) -> Set<String> {
|
||||
var ans: Set<String> = []
|
||||
|
||||
guard let dict = input as? [String: Any] else {
|
||||
assertionFailure()
|
||||
return ans
|
||||
}
|
||||
|
||||
let pro = dict["pro"] as? [String: Any]
|
||||
let ai = dict["ai"] as? [String: Any]
|
||||
|
||||
if let proPlan = pro?["recurring"] as? String {
|
||||
switch proPlan.lowercased() {
|
||||
case "lifetime":
|
||||
// user actually purchased believer plan
|
||||
// but we map it to yearly plan just for easier handling
|
||||
// do not purchase any of this plan if already purchased
|
||||
ans.insert(PricingConfiguration.proAnnual.productIdentifier)
|
||||
case "monthly":
|
||||
ans.insert(PricingConfiguration.proMonthly.productIdentifier)
|
||||
case "yearly":
|
||||
ans.insert(PricingConfiguration.proAnnual.productIdentifier)
|
||||
default:
|
||||
ans.insert(PricingConfiguration.proAnnual.productIdentifier) // block payment
|
||||
assertionFailure()
|
||||
}
|
||||
}
|
||||
if let aiPlan = ai?["recurring"] as? String {
|
||||
switch aiPlan.lowercased() {
|
||||
case "yearly":
|
||||
ans.insert(PricingConfiguration.aiAnnual.productIdentifier)
|
||||
default:
|
||||
// ai plan can only be purchased as yearly plan
|
||||
ans.insert(PricingConfiguration.aiAnnual.productIdentifier) // block payment
|
||||
assertionFailure()
|
||||
}
|
||||
}
|
||||
|
||||
return ans
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import StoreKit
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
@MainActor
|
||||
class ViewModel: ObservableObject {
|
||||
@@ -23,10 +24,18 @@ class ViewModel: ObservableObject {
|
||||
|
||||
@Published var updating = false
|
||||
@Published var products: [Product] = []
|
||||
@Published var purchasedItems: Set<String> = []
|
||||
@Published var storePurchasedItems: Set<String> = []
|
||||
@Published var externalPurchasedItems: Set<String> = []
|
||||
@Published var packageOptions: [SKUnitPackageOption] = SKUnit.allUnits.flatMap(\.package)
|
||||
|
||||
var purchasedItems: Set<String> {
|
||||
Set<String>()
|
||||
.union(storePurchasedItems)
|
||||
.union(externalPurchasedItems)
|
||||
}
|
||||
|
||||
private(set) weak var associatedController: UIViewController?
|
||||
private(set) weak var associatedWebContext: WKWebView?
|
||||
|
||||
init() {
|
||||
updateAppStoreStatus(initial: true)
|
||||
@@ -42,6 +51,10 @@ class ViewModel: ObservableObject {
|
||||
associatedController = controller
|
||||
}
|
||||
|
||||
func bind(context: WKWebView) {
|
||||
associatedWebContext = context
|
||||
}
|
||||
|
||||
func select(category: SKUnitCategory) {
|
||||
self.category = category
|
||||
let units = SKUnit.units(for: category)
|
||||
|
||||
@@ -7,14 +7,17 @@
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
public enum Paywall {
|
||||
@MainActor
|
||||
public static func presentWall(
|
||||
toController controller: UIViewController,
|
||||
bindWebContext context: WKWebView?,
|
||||
type: String
|
||||
) {
|
||||
let viewModel = ViewModel()
|
||||
if let context { viewModel.bind(context: context) }
|
||||
switch type.lowercased() {
|
||||
case "pro":
|
||||
viewModel.select(category: .pro)
|
||||
|
||||
@@ -19,7 +19,7 @@ let package = Package(
|
||||
.package(url: "https://github.com/apollographql/apollo-ios.git", from: "1.23.0"),
|
||||
.package(url: "https://github.com/apple/swift-collections.git", from: "1.2.1"),
|
||||
.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/SwifterSwift/SwifterSwift.git", from: "6.2.0"),
|
||||
.package(url: "https://github.com/Recouse/EventSource.git", from: "0.1.5"),
|
||||
.package(url: "https://github.com/Lakr233/ListViewKit.git", from: "1.1.6"),
|
||||
.package(url: "https://github.com/Lakr233/MarkdownView.git", from: "3.4.2"),
|
||||
|
||||
@@ -137,6 +137,9 @@ export class ChatPanel extends SignalWatcher(
|
||||
@property({ attribute: false })
|
||||
accessor aiModelService!: AIModelService;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onAISubscribe!: () => Promise<void>;
|
||||
|
||||
@state()
|
||||
accessor session: CopilotChatHistoryFragment | null | undefined;
|
||||
|
||||
@@ -462,6 +465,7 @@ export class ChatPanel extends SignalWatcher(
|
||||
.peekViewService=${this.peekViewService}
|
||||
.subscriptionService=${this.subscriptionService}
|
||||
.aiModelService=${this.aiModelService}
|
||||
.onAISubscribe=${this.onAISubscribe}
|
||||
.onEmbeddingProgressChange=${this.onEmbeddingProgressChange}
|
||||
.onContextChange=${this.onContextChange}
|
||||
.width=${this.sidebarWidth}
|
||||
|
||||
@@ -149,6 +149,9 @@ export class AIChatComposer extends SignalWatcher(
|
||||
@property({ attribute: false })
|
||||
accessor aiModelService!: AIModelService;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onAISubscribe!: () => Promise<void>;
|
||||
|
||||
@state()
|
||||
accessor chips: ChatChip[] = [];
|
||||
|
||||
@@ -200,6 +203,7 @@ export class AIChatComposer extends SignalWatcher(
|
||||
.notificationService=${this.notificationService}
|
||||
.subscriptionService=${this.subscriptionService}
|
||||
.aiModelService=${this.aiModelService}
|
||||
.onAISubscribe=${this.onAISubscribe}
|
||||
.portalContainer=${this.portalContainer}
|
||||
.onChatSuccess=${this.onChatSuccess}
|
||||
.trackOptions=${this.trackOptions}
|
||||
|
||||
@@ -192,6 +192,9 @@ export class AIChatContent extends SignalWatcher(
|
||||
@property({ attribute: false })
|
||||
accessor subscriptionService!: SubscriptionService;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onAISubscribe!: () => Promise<void>;
|
||||
|
||||
@state()
|
||||
accessor chatContextValue: ChatContextValue = DEFAULT_CHAT_CONTEXT_VALUE;
|
||||
|
||||
@@ -381,6 +384,9 @@ export class AIChatContent extends SignalWatcher(
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
// revalidate subscription to get the latest status
|
||||
this.subscriptionService.subscription.revalidate();
|
||||
|
||||
this._disposables.add(
|
||||
AIProvider.slots.actions.subscribe(({ event }) => {
|
||||
const { status } = this.chatContextValue;
|
||||
@@ -472,6 +478,7 @@ export class AIChatContent extends SignalWatcher(
|
||||
.aiToolsConfigService=${this.aiToolsConfigService}
|
||||
.subscriptionService=${this.subscriptionService}
|
||||
.aiModelService=${this.aiModelService}
|
||||
.onAISubscribe=${this.onAISubscribe}
|
||||
.trackOptions=${{
|
||||
where: 'chat-panel',
|
||||
control: 'chat-send',
|
||||
|
||||
@@ -377,6 +377,9 @@ export class AIChatInput extends SignalWatcher(
|
||||
@property({ attribute: false })
|
||||
accessor aiModelService!: AIModelService;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onAISubscribe!: () => Promise<void>;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor isRootSession: boolean = true;
|
||||
|
||||
@@ -534,6 +537,7 @@ export class AIChatInput extends SignalWatcher(
|
||||
.notificationService=${this.notificationService}
|
||||
.subscriptionService=${this.subscriptionService}
|
||||
.aiModelService=${this.aiModelService}
|
||||
.onAISubscribe=${this.onAISubscribe}
|
||||
></chat-input-preference>
|
||||
${status === 'transmitting' || status === 'loading'
|
||||
? html`<button
|
||||
|
||||
@@ -72,6 +72,9 @@ export class ChatInputPreference extends SignalWatcher(
|
||||
.ai-model-prefix svg {
|
||||
color: ${unsafeCSSVarV2('icon/activated')};
|
||||
}
|
||||
.ai-model-postfix svg:hover {
|
||||
color: ${unsafeCSSVarV2('icon/activated')};
|
||||
}
|
||||
.ai-model-version {
|
||||
font-size: 12px;
|
||||
color: ${unsafeCSSVarV2('text/tertiary')};
|
||||
@@ -119,6 +122,9 @@ export class ChatInputPreference extends SignalWatcher(
|
||||
@property({ attribute: false })
|
||||
accessor aiModelService!: AIModelService;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onAISubscribe!: () => Promise<void>;
|
||||
|
||||
model = computed(() => {
|
||||
const modelId = this.aiModelService.modelId.value;
|
||||
const activeModel = this.aiModelService.models.value.find(
|
||||
@@ -161,7 +167,7 @@ export class ChatInputPreference extends SignalWatcher(
|
||||
</div>
|
||||
`,
|
||||
postfix: html`
|
||||
<div>
|
||||
<div class="ai-model-postfix" @click=${this.onAISubscribe}>
|
||||
${model.isPro && !isSubscribed ? LockIcon() : undefined}
|
||||
</div>
|
||||
`,
|
||||
|
||||
@@ -182,6 +182,9 @@ export class PlaygroundChat extends SignalWatcher(
|
||||
@property({ attribute: false })
|
||||
accessor aiToolsConfigService!: AIToolsConfigService;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onAISubscribe: (() => Promise<void>) | undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor addChat!: () => Promise<void>;
|
||||
|
||||
@@ -374,6 +377,7 @@ export class PlaygroundChat extends SignalWatcher(
|
||||
.aiToolsConfigService=${this.aiToolsConfigService}
|
||||
.affineWorkspaceDialogService=${this.affineWorkspaceDialogService}
|
||||
.affineFeatureFlagService=${this.affineFeatureFlagService}
|
||||
.onAISubscribe=${this.onAISubscribe}
|
||||
></ai-chat-composer>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import type {
|
||||
AIDraftService,
|
||||
AIToolsConfigService,
|
||||
} from '@affine/core/modules/ai-button';
|
||||
import type { AIModelService } from '@affine/core/modules/ai-button/services/models';
|
||||
import type { SubscriptionService } from '@affine/core/modules/cloud';
|
||||
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import type {
|
||||
@@ -622,6 +624,9 @@ export class AIChatBlockPeekView extends LitElement {
|
||||
}}
|
||||
.portalContainer=${this.parentElement}
|
||||
.reasoningConfig=${this.reasoningConfig}
|
||||
.subscriptionService=${this.subscriptionService}
|
||||
.aiModelService=${this.aiModelService}
|
||||
.onAISubscribe=${this.onAISubscribe}
|
||||
></ai-chat-composer>
|
||||
</div> `;
|
||||
}
|
||||
@@ -659,6 +664,15 @@ export class AIChatBlockPeekView extends LitElement {
|
||||
@property({ attribute: false })
|
||||
accessor aiToolsConfigService!: AIToolsConfigService;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor aiModelService!: AIModelService;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor subscriptionService!: SubscriptionService;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onAISubscribe!: () => Promise<void>;
|
||||
|
||||
@state()
|
||||
accessor _historyMessages: ChatMessage[] = [];
|
||||
|
||||
@@ -697,7 +711,10 @@ export const AIChatBlockPeekViewTemplate = (
|
||||
affineFeatureFlagService: FeatureFlagService,
|
||||
affineWorkspaceDialogService: WorkspaceDialogService,
|
||||
aiDraftService: AIDraftService,
|
||||
aiToolsConfigService: AIToolsConfigService
|
||||
aiToolsConfigService: AIToolsConfigService,
|
||||
subscriptionService: SubscriptionService,
|
||||
aiModelService: AIModelService,
|
||||
onAISubscribe: (() => Promise<void>) | undefined
|
||||
) => {
|
||||
return html`<ai-chat-block-peek-view
|
||||
.blockModel=${blockModel}
|
||||
@@ -710,5 +727,8 @@ export const AIChatBlockPeekViewTemplate = (
|
||||
.affineWorkspaceDialogService=${affineWorkspaceDialogService}
|
||||
.aiDraftService=${aiDraftService}
|
||||
.aiToolsConfigService=${aiToolsConfigService}
|
||||
.subscriptionService=${subscriptionService}
|
||||
.aiModelService=${aiModelService}
|
||||
.onAISubscribe=${onAISubscribe}
|
||||
></ai-chat-block-peek-view>`;
|
||||
};
|
||||
|
||||
@@ -11,7 +11,10 @@ export const docIconPickerTrigger = style({
|
||||
lineHeight: 1,
|
||||
},
|
||||
'&[data-icon-type="emoji"]': {
|
||||
fontFamily: 'emoji',
|
||||
fontFamily: 'Inter',
|
||||
},
|
||||
'&::after': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,21 +6,12 @@ import { useLiveData, useService } from '@toeverything/infra';
|
||||
|
||||
import * as styles from './doc-icon-picker.css';
|
||||
|
||||
const TitleContainer = ({
|
||||
children,
|
||||
isPlaceholder,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
isPlaceholder: boolean;
|
||||
}) => {
|
||||
const TitleContainer = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<div
|
||||
className="doc-icon-container"
|
||||
style={{
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
// title container has `padding-top`
|
||||
transform: isPlaceholder ? 'translateY(80%)' : 'translateY(50%)',
|
||||
paddingBottom: 8,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@@ -54,7 +45,7 @@ export const DocIconPicker = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<TitleContainer isPlaceholder={isPlaceholder}>
|
||||
<TitleContainer>
|
||||
<IconEditor
|
||||
icon={icon?.icon}
|
||||
onIconChange={data => {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { generateSubscriptionCallbackLink } from '@affine/core/components/hooks/affine/use-subscription-notify';
|
||||
import { AuthService, SubscriptionService } from '@affine/core/modules/cloud';
|
||||
import { UrlService } from '@affine/core/modules/url';
|
||||
import { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql';
|
||||
import { useFramework } from '@toeverything/infra';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* Hook to handle AI subscription checkout
|
||||
* @returns A function that initiates the AI subscription checkout process
|
||||
*/
|
||||
export const useAISubscribe = () => {
|
||||
const framework = useFramework();
|
||||
|
||||
const handleAISubscribe = useCallback(async () => {
|
||||
try {
|
||||
const authService = framework.get(AuthService);
|
||||
const subscriptionService = framework.get(SubscriptionService);
|
||||
const urlService = framework.get(UrlService);
|
||||
|
||||
const account = authService.session.account$.value;
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
const idempotencyKey = nanoid();
|
||||
const checkoutOptions = {
|
||||
recurring: SubscriptionRecurring.Yearly,
|
||||
plan: SubscriptionPlan.AI,
|
||||
variant: null,
|
||||
coupon: null,
|
||||
successCallbackLink: generateSubscriptionCallbackLink(
|
||||
account,
|
||||
SubscriptionPlan.AI,
|
||||
SubscriptionRecurring.Yearly
|
||||
),
|
||||
};
|
||||
|
||||
const session = await subscriptionService.createCheckoutSession({
|
||||
idempotencyKey,
|
||||
...checkoutOptions,
|
||||
});
|
||||
|
||||
urlService.openExternal(session);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, [framework]);
|
||||
|
||||
return handleAISubscribe;
|
||||
};
|
||||
@@ -190,6 +190,7 @@ const SettingModalInner = ({
|
||||
}
|
||||
});
|
||||
}
|
||||
modalContentWrapperRef.current?.scrollTo({ top: 0 });
|
||||
}, [settingState]);
|
||||
return (
|
||||
<FrameworkScope scope={currentServer.scope}>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getViewManager } from '@affine/core/blocksuite/manager/view';
|
||||
import { NotificationServiceImpl } from '@affine/core/blocksuite/view-extensions/editor-view/notification-service';
|
||||
import { useAIChatConfig } from '@affine/core/components/hooks/affine/use-ai-chat-config';
|
||||
import { useAISpecs } from '@affine/core/components/hooks/affine/use-ai-specs';
|
||||
import { useAISubscribe } from '@affine/core/components/hooks/affine/use-ai-subscribe';
|
||||
import {
|
||||
AIDraftService,
|
||||
AIToolsConfigService,
|
||||
@@ -197,6 +198,7 @@ export const Component = () => {
|
||||
const confirmModal = useConfirmModal();
|
||||
const specs = useAISpecs();
|
||||
const mockStd = useMockStd();
|
||||
const handleAISubscribe = useAISubscribe();
|
||||
|
||||
// init or update ai-chat-content
|
||||
useEffect(() => {
|
||||
@@ -233,6 +235,8 @@ export const Component = () => {
|
||||
content.aiToolsConfigService = framework.get(AIToolsConfigService);
|
||||
content.subscriptionService = framework.get(SubscriptionService);
|
||||
content.aiModelService = framework.get(AIModelService);
|
||||
content.onAISubscribe = handleAISubscribe;
|
||||
|
||||
content.createSession = createSession;
|
||||
content.onOpenDoc = onOpenDoc;
|
||||
|
||||
@@ -260,6 +264,7 @@ export const Component = () => {
|
||||
onContextChange,
|
||||
specs,
|
||||
onOpenDoc,
|
||||
handleAISubscribe,
|
||||
]);
|
||||
|
||||
// init or update header ai-chat-toolbar
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { AffineEditorContainer } from '@affine/core/blocksuite/block-suite-
|
||||
import { NotificationServiceImpl } from '@affine/core/blocksuite/view-extensions/editor-view/notification-service';
|
||||
import { useAIChatConfig } from '@affine/core/components/hooks/affine/use-ai-chat-config';
|
||||
import { useAISpecs } from '@affine/core/components/hooks/affine/use-ai-specs';
|
||||
import { useAISubscribe } from '@affine/core/components/hooks/affine/use-ai-subscribe';
|
||||
import {
|
||||
AIDraftService,
|
||||
AIToolsConfigService,
|
||||
@@ -63,6 +64,7 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel(
|
||||
} = useAIChatConfig();
|
||||
const confirmModal = useConfirmModal();
|
||||
const specs = useAISpecs();
|
||||
const handleAISubscribe = useAISubscribe();
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || !editor.host) return;
|
||||
@@ -109,6 +111,7 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel(
|
||||
chatPanelRef.current.subscriptionService =
|
||||
framework.get(SubscriptionService);
|
||||
chatPanelRef.current.aiModelService = framework.get(AIModelService);
|
||||
chatPanelRef.current.onAISubscribe = handleAISubscribe;
|
||||
|
||||
containerRef.current?.append(chatPanelRef.current);
|
||||
} else {
|
||||
@@ -141,6 +144,7 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel(
|
||||
playgroundConfig,
|
||||
confirmModal,
|
||||
specs,
|
||||
handleAISubscribe,
|
||||
]);
|
||||
|
||||
const [autoResized, setAutoResized] = useState(false);
|
||||
|
||||
@@ -29,7 +29,7 @@ import type { DocRecord, DocsService } from '../../doc';
|
||||
import type { ExplorerIconService } from '../../explorer-icon/services/explorer-icon';
|
||||
import type { I18nService } from '../../i18n';
|
||||
import type { JournalService } from '../../journal';
|
||||
import { getDocIconComponent } from './icon';
|
||||
import { getDocIconComponent, getDocIconComponentLit } from './icon';
|
||||
|
||||
type IconType = 'rc' | 'lit';
|
||||
interface DocDisplayIconOptions<T extends IconType> {
|
||||
@@ -152,7 +152,9 @@ export class DocDisplayMetaService extends Service {
|
||||
// if (emoji) return () => emoji;
|
||||
const icon = get(this.explorerIconService.icon$('doc', docId))?.icon;
|
||||
if (icon) {
|
||||
return getDocIconComponent(icon);
|
||||
return options?.type === 'lit'
|
||||
? getDocIconComponentLit(icon)
|
||||
: getDocIconComponent(icon);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
import { type IconData, IconRenderer } from '@affine/component';
|
||||
import { type IconData, IconRenderer, IconType } from '@affine/component';
|
||||
import * as litIcons from '@blocksuite/icons/lit';
|
||||
import { html } from 'lit';
|
||||
|
||||
export const getDocIconComponent = (icon: IconData) => {
|
||||
const Icon = () => <IconRenderer data={icon} />;
|
||||
Icon.displayName = 'DocIcon';
|
||||
return Icon;
|
||||
};
|
||||
|
||||
export const getDocIconComponentLit = (icon: IconData) => {
|
||||
return () => {
|
||||
if (icon.type === IconType.Emoji) {
|
||||
return html`<div class="icon">${icon.unicode}</div>`;
|
||||
}
|
||||
if (icon.type === IconType.AffineIcon) {
|
||||
return html`<div
|
||||
style="color: ${icon.color}; display: flex; align-items: center; justify-content: center;"
|
||||
>
|
||||
${litIcons[`${icon.name}Icon` as keyof typeof litIcons]()}
|
||||
</div>`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,10 +2,13 @@ import { toReactNode } from '@affine/component';
|
||||
import { AIChatBlockPeekViewTemplate } from '@affine/core/blocksuite/ai';
|
||||
import type { AIChatBlockModel } from '@affine/core/blocksuite/ai/blocks/ai-chat-block/model/ai-chat-model';
|
||||
import { useAIChatConfig } from '@affine/core/components/hooks/affine/use-ai-chat-config';
|
||||
import { useAISubscribe } from '@affine/core/components/hooks/affine/use-ai-subscribe';
|
||||
import {
|
||||
AIDraftService,
|
||||
AIToolsConfigService,
|
||||
} from '@affine/core/modules/ai-button';
|
||||
import { AIModelService } from '@affine/core/modules/ai-button/services/models';
|
||||
import { SubscriptionService } from '@affine/core/modules/cloud';
|
||||
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import type { EditorHost } from '@blocksuite/affine/std';
|
||||
@@ -33,6 +36,9 @@ export const AIChatBlockPeekView = ({
|
||||
const affineWorkspaceDialogService = framework.get(WorkspaceDialogService);
|
||||
const aiDraftService = framework.get(AIDraftService);
|
||||
const aiToolsConfigService = framework.get(AIToolsConfigService);
|
||||
const subscriptionService = framework.get(SubscriptionService);
|
||||
const aiModelService = framework.get(AIModelService);
|
||||
const handleAISubscribe = useAISubscribe();
|
||||
|
||||
return useMemo(() => {
|
||||
const template = AIChatBlockPeekViewTemplate(
|
||||
@@ -45,7 +51,10 @@ export const AIChatBlockPeekView = ({
|
||||
affineFeatureFlagService,
|
||||
affineWorkspaceDialogService,
|
||||
aiDraftService,
|
||||
aiToolsConfigService
|
||||
aiToolsConfigService,
|
||||
subscriptionService,
|
||||
aiModelService,
|
||||
handleAISubscribe
|
||||
);
|
||||
return toReactNode(template);
|
||||
}, [
|
||||
@@ -59,5 +68,8 @@ export const AIChatBlockPeekView = ({
|
||||
affineWorkspaceDialogService,
|
||||
aiDraftService,
|
||||
aiToolsConfigService,
|
||||
subscriptionService,
|
||||
aiModelService,
|
||||
handleAISubscribe,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -62,4 +62,9 @@ export const modalContent = style({
|
||||
animationFillMode: 'forwards',
|
||||
},
|
||||
},
|
||||
'@media': {
|
||||
'screen and (max-width: 520px)': {
|
||||
minWidth: 'auto',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -28414,9 +28414,9 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"nodemailer@npm:^7.0.0":
|
||||
version: 7.0.3
|
||||
resolution: "nodemailer@npm:7.0.3"
|
||||
checksum: 10/d51e9b30753c982c35cf77839f3b7c1f138eb3fb5607c34f724ddc32360e56c3d631ff5b4eba5491f1f8805b428b945850441b4bd893bd2283c55be615f020c5
|
||||
version: 7.0.9
|
||||
resolution: "nodemailer@npm:7.0.9"
|
||||
checksum: 10/88883c58afe356d2b4c24b1e976c04857e8a7a7145e1752dab69072900b0cc2e3daa0964a08c653e692fb64382453e1cfcee3d863828844c8d6f6239727b9023
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user