mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-04 19:15:33 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ba78a6bd45 |
@@ -540,11 +540,6 @@
|
||||
"description": "Where the server get deployed(FQDN).\n@default \"localhost\"\n@environment `AFFINE_SERVER_HOST`",
|
||||
"default": "localhost"
|
||||
},
|
||||
"hosts": {
|
||||
"type": "array",
|
||||
"description": "Multiple hosts the server will accept requests from.\n@default []",
|
||||
"default": []
|
||||
},
|
||||
"port": {
|
||||
"type": "number",
|
||||
"description": "Which port the server will listen on.\n@default 3010\n@environment `AFFINE_SERVER_PORT`",
|
||||
|
||||
@@ -126,10 +126,7 @@ const createHelmCommand = ({ isDryRun }) => {
|
||||
? 'internal'
|
||||
: 'dev';
|
||||
|
||||
const hosts = (DEPLOY_HOST || CANARY_DEPLOY_HOST)
|
||||
.split(',')
|
||||
.map(host => host.trim())
|
||||
.filter(host => host);
|
||||
const host = DEPLOY_HOST || CANARY_DEPLOY_HOST;
|
||||
const deployCommand = [
|
||||
`helm upgrade --install affine .github/helm/affine`,
|
||||
`--namespace ${namespace}`,
|
||||
@@ -138,9 +135,7 @@ const createHelmCommand = ({ isDryRun }) => {
|
||||
`--set-string global.app.buildType="${buildType}"`,
|
||||
`--set global.ingress.enabled=true`,
|
||||
`--set-json global.ingress.annotations="{ \\"kubernetes.io/ingress.class\\": \\"gce\\", \\"kubernetes.io/ingress.allow-http\\": \\"true\\", \\"kubernetes.io/ingress.global-static-ip-name\\": \\"${STATIC_IP_NAME}\\" }"`,
|
||||
...hosts.map(
|
||||
(host, index) => `--set global.ingress.hosts[${index}]=${host}`
|
||||
),
|
||||
`--set-string global.ingress.host="${host}"`,
|
||||
`--set-string global.version="${APP_VERSION}"`,
|
||||
...redisAndPostgres,
|
||||
...indexerOptions,
|
||||
@@ -148,14 +143,14 @@ const createHelmCommand = ({ isDryRun }) => {
|
||||
`--set-string web.image.tag="${imageTag}"`,
|
||||
`--set graphql.replicaCount=${replica.graphql}`,
|
||||
`--set-string graphql.image.tag="${imageTag}"`,
|
||||
`--set graphql.app.host=${hosts[0]}`,
|
||||
`--set graphql.app.host=${host}`,
|
||||
`--set sync.replicaCount=${replica.sync}`,
|
||||
`--set-string sync.image.tag="${imageTag}"`,
|
||||
`--set-string renderer.image.tag="${imageTag}"`,
|
||||
`--set renderer.app.host=${hosts[0]}`,
|
||||
`--set renderer.app.host=${host}`,
|
||||
`--set renderer.replicaCount=${replica.renderer}`,
|
||||
`--set-string doc.image.tag="${imageTag}"`,
|
||||
`--set doc.app.host=${hosts[0]}`,
|
||||
`--set doc.app.host=${host}`,
|
||||
`--set doc.replicaCount=${replica.doc}`,
|
||||
...serviceAnnotations,
|
||||
...resources,
|
||||
|
||||
@@ -36,8 +36,7 @@ spec:
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range .Values.global.ingress.hosts }}
|
||||
- host: {{ . | quote }}
|
||||
- host: "{{ .Values.global.ingress.host }}"
|
||||
http:
|
||||
paths:
|
||||
- path: /socket.io
|
||||
@@ -46,34 +45,33 @@ spec:
|
||||
service:
|
||||
name: affine-sync
|
||||
port:
|
||||
number: {{ $.Values.sync.service.port }}
|
||||
number: {{ .Values.sync.service.port }}
|
||||
- path: /graphql
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: affine-graphql
|
||||
port:
|
||||
number: {{ $.Values.graphql.service.port }}
|
||||
number: {{ .Values.graphql.service.port }}
|
||||
- path: /api
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: affine-graphql
|
||||
port:
|
||||
number: {{ $.Values.graphql.service.port }}
|
||||
number: {{ .Values.graphql.service.port }}
|
||||
- path: /workspace
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: affine-renderer
|
||||
port:
|
||||
number: {{ $.Values.renderer.service.port }}
|
||||
number: {{ .Values.renderer.service.port }}
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: affine-web
|
||||
port:
|
||||
number: {{ $.Values.web.service.port }}
|
||||
{{- end }}
|
||||
number: {{ .Values.web.service.port }}
|
||||
{{- end }}
|
||||
|
||||
@@ -4,13 +4,7 @@ global:
|
||||
ingress:
|
||||
enabled: false
|
||||
className: ''
|
||||
# hosts for ingress rules
|
||||
# e.g.
|
||||
# hosts:
|
||||
# - affine.pro
|
||||
# - www.affine.pro
|
||||
hosts:
|
||||
- affine.pro
|
||||
host: affine.pro
|
||||
tls: []
|
||||
secret:
|
||||
secretName: 'server-private-key'
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
canEmbedAsEmbedBlock,
|
||||
canEmbedAsIframe,
|
||||
EMBED_IFRAME_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
EMBED_IFRAME_DEFAULT_WIDTH_IN_SURFACE,
|
||||
@@ -150,10 +149,13 @@ const builtinToolbarConfig = {
|
||||
if (!model) return true;
|
||||
|
||||
const url = model.props.url;
|
||||
// check if the url can be embedded as iframe block or other embed blocks
|
||||
const options = ctx.std
|
||||
.get(EmbedOptionProvider)
|
||||
.getEmbedBlockOptions(url);
|
||||
|
||||
return (
|
||||
!canEmbedAsIframe(ctx.std, url) &&
|
||||
!canEmbedAsEmbedBlock(ctx.std, url)
|
||||
!canEmbedAsIframe(ctx.std, url) && options?.viewType !== 'embed'
|
||||
);
|
||||
},
|
||||
run(ctx) {
|
||||
@@ -167,8 +169,15 @@ const builtinToolbarConfig = {
|
||||
|
||||
let blockId: string | undefined;
|
||||
|
||||
// first try to embed as a custom embed block
|
||||
if (canEmbedAsEmbedBlock(ctx.std, url)) {
|
||||
// first try to embed as iframe block
|
||||
if (canEmbedAsIframe(ctx.std, url)) {
|
||||
const embedIframeService = ctx.std.get(EmbedIframeService);
|
||||
blockId = embedIframeService.addEmbedIframeBlock(
|
||||
{ url, caption, title, description },
|
||||
parent.id,
|
||||
index
|
||||
);
|
||||
} else {
|
||||
const options = ctx.std
|
||||
.get(EmbedOptionProvider)
|
||||
.getEmbedBlockOptions(url);
|
||||
@@ -193,13 +202,6 @@ const builtinToolbarConfig = {
|
||||
parent,
|
||||
index
|
||||
);
|
||||
} else if (canEmbedAsIframe(ctx.std, url)) {
|
||||
const embedIframeService = ctx.std.get(EmbedIframeService);
|
||||
blockId = embedIframeService.addEmbedIframeBlock(
|
||||
{ url, caption, title, description },
|
||||
parent.id,
|
||||
index
|
||||
);
|
||||
}
|
||||
|
||||
if (!blockId) return;
|
||||
@@ -377,8 +379,27 @@ const builtinSurfaceToolbarConfig = {
|
||||
|
||||
let newId: string | undefined;
|
||||
|
||||
// first try to embed as a custom embed block
|
||||
if (canEmbedAsEmbedBlock(ctx.std, url)) {
|
||||
// first try to embed as iframe block
|
||||
if (canEmbedAsIframe(ctx.std, url)) {
|
||||
const embedIframeService = ctx.std.get(EmbedIframeService);
|
||||
const config = embedIframeService.getConfig(url);
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bound = Bound.deserialize(xywh);
|
||||
const options = config.options;
|
||||
const { widthInSurface, heightInSurface } = options ?? {};
|
||||
bound.w = widthInSurface ?? EMBED_IFRAME_DEFAULT_WIDTH_IN_SURFACE;
|
||||
bound.h =
|
||||
heightInSurface ?? EMBED_IFRAME_DEFAULT_HEIGHT_IN_SURFACE;
|
||||
|
||||
newId = ctx.store.addBlock(
|
||||
'affine:embed-iframe',
|
||||
{ url, caption, title, description, xywh: bound.serialize() },
|
||||
parent
|
||||
);
|
||||
} else {
|
||||
const options = ctx.std
|
||||
.get(EmbedOptionProvider)
|
||||
.getEmbedBlockOptions(url);
|
||||
@@ -408,29 +429,8 @@ const builtinSurfaceToolbarConfig = {
|
||||
},
|
||||
parent
|
||||
);
|
||||
} else if (canEmbedAsIframe(ctx.std, url)) {
|
||||
const embedIframeService = ctx.std.get(EmbedIframeService);
|
||||
const config = embedIframeService.getConfig(url);
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bound = Bound.deserialize(xywh);
|
||||
const options = config.options;
|
||||
const { widthInSurface, heightInSurface } = options ?? {};
|
||||
bound.w = widthInSurface ?? EMBED_IFRAME_DEFAULT_WIDTH_IN_SURFACE;
|
||||
bound.h =
|
||||
heightInSurface ?? EMBED_IFRAME_DEFAULT_HEIGHT_IN_SURFACE;
|
||||
|
||||
newId = ctx.store.addBlock(
|
||||
'affine:embed-iframe',
|
||||
{ url, caption, title, description, xywh: bound.serialize() },
|
||||
parent
|
||||
);
|
||||
}
|
||||
|
||||
if (!newId) return;
|
||||
|
||||
ctx.command.exec(reassociateConnectorsCommand, { oldId, newId });
|
||||
|
||||
ctx.store.deleteBlock(model);
|
||||
@@ -449,10 +449,13 @@ const builtinSurfaceToolbarConfig = {
|
||||
when(ctx) {
|
||||
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
|
||||
if (!model) return false;
|
||||
|
||||
const { url } = model.props;
|
||||
return (
|
||||
canEmbedAsIframe(ctx.std, url) || canEmbedAsEmbedBlock(ctx.std, url)
|
||||
);
|
||||
const options = ctx.std
|
||||
.get(EmbedOptionProvider)
|
||||
.getEmbedBlockOptions(url);
|
||||
|
||||
return canEmbedAsIframe(ctx.std, url) || options?.viewType === 'embed';
|
||||
},
|
||||
content(ctx) {
|
||||
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
|
||||
|
||||
@@ -11,8 +11,6 @@ import {
|
||||
EmbedCardLightVerticalIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import { ColorScheme } from '@blocksuite/affine-model';
|
||||
import { EmbedOptionProvider } from '@blocksuite/affine-shared/services';
|
||||
import type { BlockStdScope } from '@blocksuite/std';
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
type EmbedCardIcons = {
|
||||
@@ -42,8 +40,3 @@ export function getEmbedCardIcons(theme: ColorScheme): EmbedCardIcons {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function canEmbedAsEmbedBlock(std: BlockStdScope, url: string) {
|
||||
const options = std.get(EmbedOptionProvider).getEmbedBlockOptions(url);
|
||||
return options?.viewType === 'embed';
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { EmbedIframeConfigExtension } from '@blocksuite/affine-shared/services';
|
||||
|
||||
const GENERIC_DEFAULT_WIDTH_IN_SURFACE = 800;
|
||||
const GENERIC_DEFAULT_HEIGHT_IN_SURFACE = 600;
|
||||
const GENERIC_DEFAULT_WIDTH_PERCENT = 100;
|
||||
const GENERIC_DEFAULT_HEIGHT_IN_NOTE = 400;
|
||||
|
||||
/**
|
||||
* AFFiNE domains that should be excluded from generic embedding
|
||||
* These are based on the centralized cloud constants and known AFFiNE domains
|
||||
*/
|
||||
const AFFINE_DOMAINS = [
|
||||
'affine.pro', // Main AFFiNE domain
|
||||
'app.affine.pro', // Stable cloud domain
|
||||
'insider.affine.pro', // Beta/internal cloud domain
|
||||
'affine.fail', // Canary cloud domain
|
||||
'toeverything.app', // Safety measure for potential future use
|
||||
'apple.getaffineapp.com', // Cloud domain for Apple app
|
||||
];
|
||||
|
||||
/**
|
||||
* Validates if a URL is suitable for generic iframe embedding
|
||||
* Allows HTTPS URLs but excludes AFFiNE domains
|
||||
* @param url The URL to validate
|
||||
* @returns Boolean indicating if the URL can be generically embedded
|
||||
*/
|
||||
function isValidGenericEmbedUrl(url: string): boolean {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
// Only allow HTTPS for security
|
||||
if (parsedUrl.protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exclude AFFiNE domains
|
||||
const hostname = parsedUrl.hostname.toLowerCase();
|
||||
if (
|
||||
AFFINE_DOMAINS.some(
|
||||
domain => hostname === domain || hostname.endsWith(`.${domain}`)
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
// Invalid URL
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const genericConfig = {
|
||||
name: 'generic',
|
||||
match: (url: string) => isValidGenericEmbedUrl(url),
|
||||
buildOEmbedUrl: (url: string) => {
|
||||
if (!isValidGenericEmbedUrl(url)) {
|
||||
return undefined;
|
||||
}
|
||||
return url;
|
||||
},
|
||||
useOEmbedUrlDirectly: true,
|
||||
options: {
|
||||
widthInSurface: GENERIC_DEFAULT_WIDTH_IN_SURFACE,
|
||||
heightInSurface: GENERIC_DEFAULT_HEIGHT_IN_SURFACE,
|
||||
widthPercent: GENERIC_DEFAULT_WIDTH_PERCENT,
|
||||
heightInNote: GENERIC_DEFAULT_HEIGHT_IN_NOTE,
|
||||
allowFullscreen: true,
|
||||
style: 'border: none; border-radius: 8px;',
|
||||
allow: 'clipboard-read; clipboard-write; picture-in-picture;',
|
||||
referrerpolicy: 'no-referrer-when-downgrade',
|
||||
},
|
||||
};
|
||||
|
||||
export const GenericEmbedConfig = EmbedIframeConfigExtension(genericConfig);
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ExcalidrawEmbedConfig } from './excalidraw';
|
||||
import { GenericEmbedConfig } from './generic';
|
||||
import { GoogleDocsEmbedConfig } from './google-docs';
|
||||
import { GoogleDriveEmbedConfig } from './google-drive';
|
||||
import { MiroEmbedConfig } from './miro';
|
||||
@@ -11,5 +10,4 @@ export const EmbedIframeConfigExtensions = [
|
||||
MiroEmbedConfig,
|
||||
ExcalidrawEmbedConfig,
|
||||
GoogleDocsEmbedConfig,
|
||||
GenericEmbedConfig,
|
||||
];
|
||||
|
||||
@@ -228,20 +228,23 @@ export const builtinInlineLinkToolbarConfig = {
|
||||
const props = { url };
|
||||
let blockId: string | undefined;
|
||||
|
||||
// first try to embed as iframe block
|
||||
const embedIframeService = ctx.std.get(EmbedIframeService);
|
||||
const embedOptions = ctx.std
|
||||
.get(EmbedOptionProvider)
|
||||
.getEmbedBlockOptions(url);
|
||||
|
||||
if (embedOptions?.viewType === 'embed') {
|
||||
const flavour = embedOptions.flavour;
|
||||
blockId = ctx.store.addBlock(flavour, props, parent, index + 1);
|
||||
} else if (embedIframeService.canEmbed(url)) {
|
||||
if (embedIframeService.canEmbed(url)) {
|
||||
blockId = embedIframeService.addEmbedIframeBlock(
|
||||
props,
|
||||
parent.id,
|
||||
index + 1
|
||||
);
|
||||
} else {
|
||||
// if not, try to add as other embed link block
|
||||
const options = ctx.std
|
||||
.get(EmbedOptionProvider)
|
||||
.getEmbedBlockOptions(url);
|
||||
if (options?.viewType !== 'embed') return;
|
||||
|
||||
const flavour = options.flavour;
|
||||
blockId = ctx.store.addBlock(flavour, props, parent, index + 1);
|
||||
}
|
||||
|
||||
if (!blockId) return;
|
||||
|
||||
@@ -4,6 +4,7 @@ import ava, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
import request from 'supertest';
|
||||
|
||||
import { Due } from '../../base';
|
||||
import { AuthModule, CurrentUser, Public, Session } from '../../core/auth';
|
||||
import { AuthService } from '../../core/auth/service';
|
||||
import { Models } from '../../models';
|
||||
@@ -125,7 +126,7 @@ test('should be able to refresh session if needed', async t => {
|
||||
sessionId,
|
||||
},
|
||||
data: {
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60 /* expires in 1 hour */),
|
||||
expiresAt: Due.after('1h'),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PrismaClient } from '@prisma/client';
|
||||
import test from 'ava';
|
||||
import * as Sinon from 'sinon';
|
||||
|
||||
import { Due } from '../../base';
|
||||
import { DocStorageModule, PgWorkspaceDocStorageAdapter } from '../../core/doc';
|
||||
import { DocStorageOptions } from '../../core/doc/options';
|
||||
import { DocRecord } from '../../core/doc/storage';
|
||||
@@ -122,7 +123,7 @@ test('should create history if time diff is larger than interval config and stat
|
||||
|
||||
// @ts-expect-error private method
|
||||
Sinon.stub(adapter, 'lastDocHistory').resolves({
|
||||
timestamp: new Date(timestamp.getTime() - 1000 * 60 * 20),
|
||||
timestamp: Due.before('20m', timestamp),
|
||||
state: Buffer.from([0, 1]),
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Config } from '../../base/config';
|
||||
import { SessionModel } from '../../models/session';
|
||||
import { UserModel } from '../../models/user';
|
||||
import { createTestingModule, type TestingModule } from '../utils';
|
||||
import { Due } from '../../base/utils';
|
||||
|
||||
interface Context {
|
||||
config: Config;
|
||||
@@ -137,9 +138,7 @@ test('should not refresh userSession when expires time not hit ttr', async t =>
|
||||
let newExpiresAt =
|
||||
await t.context.session.refreshUserSessionIfNeeded(userSession);
|
||||
t.is(newExpiresAt, undefined);
|
||||
userSession.expiresAt = new Date(
|
||||
userSession.expiresAt!.getTime() - t.context.config.auth.session.ttr * 1000
|
||||
);
|
||||
userSession.expiresAt = Due.before(t.context.config.auth.session.ttr);
|
||||
newExpiresAt =
|
||||
await t.context.session.refreshUserSessionIfNeeded(userSession);
|
||||
t.is(newExpiresAt, undefined);
|
||||
@@ -154,9 +153,9 @@ test('should not refresh userSession when expires time hit ttr', async t => {
|
||||
user.id,
|
||||
session.id
|
||||
);
|
||||
const ttr = t.context.config.auth.session.ttr * 2;
|
||||
userSession.expiresAt = new Date(
|
||||
userSession.expiresAt!.getTime() - ttr * 1000
|
||||
userSession.expiresAt!.getTime() -
|
||||
Due.ms(t.context.config.auth.session.ttr) * 2
|
||||
);
|
||||
const newExpiresAt =
|
||||
await t.context.session.refreshUserSessionIfNeeded(userSession);
|
||||
|
||||
@@ -37,10 +37,6 @@ test.before(async t => {
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
hosts: ['localhost', 'test.affine.dev'],
|
||||
https: true,
|
||||
},
|
||||
}),
|
||||
AppModule,
|
||||
],
|
||||
@@ -94,38 +90,6 @@ test("should be able to redirect to oauth provider's login page", async t => {
|
||||
);
|
||||
});
|
||||
|
||||
test('should be able to redirect to oauth provider with multiple hosts', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const res = await app
|
||||
.POST('/api/oauth/preflight')
|
||||
.set('host', 'test.affine.dev')
|
||||
.send({ provider: 'Google' })
|
||||
.expect(HttpStatus.OK);
|
||||
|
||||
const { url } = res.body;
|
||||
|
||||
const redirect = new URL(url);
|
||||
t.is(redirect.origin, 'https://accounts.google.com');
|
||||
|
||||
t.is(redirect.pathname, '/o/oauth2/v2/auth');
|
||||
t.is(redirect.searchParams.get('client_id'), 'google-client-id');
|
||||
t.is(
|
||||
redirect.searchParams.get('redirect_uri'),
|
||||
'https://test.affine.dev/oauth/callback'
|
||||
);
|
||||
t.is(redirect.searchParams.get('response_type'), 'code');
|
||||
t.is(redirect.searchParams.get('prompt'), 'select_account');
|
||||
t.truthy(redirect.searchParams.get('state'));
|
||||
// state should be a json string
|
||||
const state = JSON.parse(redirect.searchParams.get('state')!);
|
||||
t.is(state.provider, 'Google');
|
||||
t.regex(
|
||||
state.state,
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||
);
|
||||
});
|
||||
|
||||
test('should be able to redirect to oauth provider with client_nonce', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import Sinon from 'sinon';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
import { AppModule } from '../../app.module';
|
||||
import { EventBus } from '../../base';
|
||||
import { Due, EventBus } from '../../base';
|
||||
import { ConfigFactory, ConfigModule } from '../../base/config';
|
||||
import { CurrentUser } from '../../core/auth';
|
||||
import { AuthService } from '../../core/auth/service';
|
||||
@@ -129,8 +129,8 @@ const sub: Stripe.Subscription = {
|
||||
object: 'subscription',
|
||||
cancel_at_period_end: false,
|
||||
canceled_at: null,
|
||||
current_period_end: unixNow() + 60 * 60 * 24 * 30,
|
||||
current_period_start: unixNow() - 60 * 60 * 24 * 1,
|
||||
current_period_end: Due.after('30d').getTime() / 1000,
|
||||
current_period_start: Due.before('1d').getTime() / 1000,
|
||||
// @ts-expect-error stub
|
||||
customer: {
|
||||
id: 'cus_1',
|
||||
@@ -914,7 +914,7 @@ const subscriptionSchedule: Stripe.SubscriptionSchedule = {
|
||||
},
|
||||
],
|
||||
start_date: unixNow(),
|
||||
end_date: unixNow() + 30 * 24 * 60 * 60,
|
||||
end_date: Due.after('30d').getTime() / 1000,
|
||||
},
|
||||
{
|
||||
items: [
|
||||
@@ -924,7 +924,7 @@ const subscriptionSchedule: Stripe.SubscriptionSchedule = {
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
start_date: unixNow() + 30 * 24 * 60 * 60,
|
||||
start_date: Due.after('30d').getTime() / 1000,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1550,10 +1550,7 @@ test('should be able to subscribe onetime payment subscription', async t => {
|
||||
t.is(subInDB?.recurring, SubscriptionRecurring.Monthly);
|
||||
t.is(subInDB?.status, SubscriptionStatus.Active);
|
||||
t.is(subInDB?.stripeSubscriptionId, null);
|
||||
t.is(
|
||||
subInDB?.end?.toDateString(),
|
||||
new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toDateString()
|
||||
);
|
||||
t.is(subInDB?.end?.toDateString(), Due.after('30d').toDateString());
|
||||
});
|
||||
|
||||
test('should be able to accumulate onetime payment subscription period', async t => {
|
||||
@@ -1574,7 +1571,7 @@ test('should be able to accumulate onetime payment subscription period', async t
|
||||
});
|
||||
|
||||
// add 365 days
|
||||
t.is(subInDB!.end!.getTime(), end.getTime() + 365 * 24 * 60 * 60 * 1000);
|
||||
t.is(subInDB!.end!.getTime(), Due.after('1y', end).getTime());
|
||||
});
|
||||
|
||||
test('should be able to recalculate onetime payment subscription period after expiration', async t => {
|
||||
@@ -1599,10 +1596,7 @@ test('should be able to recalculate onetime payment subscription period after ex
|
||||
});
|
||||
|
||||
// add 365 days from now
|
||||
t.is(
|
||||
subInDB?.end?.toDateString(),
|
||||
new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toDateString()
|
||||
);
|
||||
t.is(subInDB?.end?.toDateString(), Due.after('1y').toDateString());
|
||||
});
|
||||
|
||||
test('should not accumulate onetime payment subscription period for redeemed invoices', async t => {
|
||||
@@ -1617,10 +1611,7 @@ test('should not accumulate onetime payment subscription period for redeemed inv
|
||||
where: { targetId: u1.id },
|
||||
});
|
||||
|
||||
t.is(
|
||||
subInDB?.end?.toDateString(),
|
||||
new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toDateString()
|
||||
);
|
||||
t.is(subInDB?.end?.toDateString(), Due.after('1y').toDateString());
|
||||
});
|
||||
|
||||
// TEAM
|
||||
|
||||
@@ -8,7 +8,6 @@ import { ClsModule } from 'nestjs-cls';
|
||||
|
||||
import { AppController } from './app.controller';
|
||||
import {
|
||||
getRequestFromHost,
|
||||
getRequestIdFromHost,
|
||||
getRequestIdFromRequest,
|
||||
ScannerModule,
|
||||
@@ -67,9 +66,8 @@ export const FunctionalityModules = [
|
||||
// make every request has a unique id to tracing
|
||||
return getRequestIdFromRequest(req, 'http');
|
||||
},
|
||||
setup(cls, req: Request, res: Response) {
|
||||
setup(cls, _req, res: Response) {
|
||||
res.setHeader('X-Request-Id', cls.getId());
|
||||
cls.set(CLS_REQUEST_HOST, req.hostname);
|
||||
},
|
||||
},
|
||||
// for websocket connection
|
||||
@@ -81,10 +79,6 @@ export const FunctionalityModules = [
|
||||
// make every request has a unique id to tracing
|
||||
return getRequestIdFromHost(context);
|
||||
},
|
||||
setup(cls, context: ExecutionContext) {
|
||||
const req = getRequestFromHost(context);
|
||||
cls.set(CLS_REQUEST_HOST, req.hostname);
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
// https://papooch.github.io/nestjs-cls/plugins/available-plugins/transactional/prisma-adapter
|
||||
|
||||
+4
-3
@@ -1,10 +1,11 @@
|
||||
import Redis from 'ioredis';
|
||||
import { type Duration, Due } from '../utils';
|
||||
|
||||
export interface CacheSetOptions {
|
||||
/**
|
||||
* in milliseconds
|
||||
*/
|
||||
ttl?: number;
|
||||
ttl?: Duration;
|
||||
}
|
||||
|
||||
export class CacheProvider {
|
||||
@@ -30,7 +31,7 @@ export class CacheProvider {
|
||||
): Promise<boolean> {
|
||||
if (opts.ttl) {
|
||||
return this.redis
|
||||
.set(key, JSON.stringify(value), 'PX', opts.ttl)
|
||||
.set(key, JSON.stringify(value), 'PX', Due.ms(opts.ttl))
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
@@ -56,7 +57,7 @@ export class CacheProvider {
|
||||
): Promise<boolean> {
|
||||
if (opts.ttl) {
|
||||
return this.redis
|
||||
.set(key, JSON.stringify(value), 'PX', opts.ttl, 'NX')
|
||||
.set(key, JSON.stringify(value), 'PX', Due.ms(opts.ttl), 'NX')
|
||||
.then(v => !!v)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
@@ -103,8 +103,8 @@ test('should override correctly', t => {
|
||||
// keyed config
|
||||
// 'session.ttl', 'session.ttr'
|
||||
session: {
|
||||
ttl: 2000,
|
||||
ttr: 1000,
|
||||
ttl: '1M',
|
||||
ttr: '1d',
|
||||
},
|
||||
},
|
||||
storages: {
|
||||
@@ -131,7 +131,7 @@ test('should override correctly', t => {
|
||||
},
|
||||
allowSignup: true,
|
||||
session: {
|
||||
ttl: 3000,
|
||||
ttl: '2M',
|
||||
},
|
||||
},
|
||||
storages: {
|
||||
@@ -159,8 +159,8 @@ test('should override correctly', t => {
|
||||
|
||||
// right merged to left
|
||||
t.deepEqual(config.auth.session, {
|
||||
ttl: 3000,
|
||||
ttr: 1000,
|
||||
ttl: '2M',
|
||||
ttr: '1d',
|
||||
});
|
||||
|
||||
// right covered left
|
||||
|
||||
@@ -12,7 +12,6 @@ test.beforeEach(async t => {
|
||||
server: {
|
||||
externalUrl: '',
|
||||
host: 'app.affine.local',
|
||||
hosts: [],
|
||||
port: 3010,
|
||||
https: true,
|
||||
path: '',
|
||||
@@ -29,7 +28,6 @@ test('can factor base url correctly with specified external url', t => {
|
||||
server: {
|
||||
externalUrl: 'https://external.domain.com',
|
||||
host: 'app.affine.local',
|
||||
hosts: [],
|
||||
port: 3010,
|
||||
https: true,
|
||||
path: '/ignored',
|
||||
@@ -44,7 +42,6 @@ test('can factor base url correctly with specified external url and path', t =>
|
||||
server: {
|
||||
externalUrl: 'https://external.domain.com/anything',
|
||||
host: 'app.affine.local',
|
||||
hosts: [],
|
||||
port: 3010,
|
||||
https: true,
|
||||
path: '/ignored',
|
||||
@@ -59,7 +56,6 @@ test('can factor base url correctly with specified external url with port', t =>
|
||||
server: {
|
||||
externalUrl: 'https://external.domain.com:123',
|
||||
host: 'app.affine.local',
|
||||
hosts: [],
|
||||
port: 3010,
|
||||
https: true,
|
||||
},
|
||||
@@ -99,7 +95,7 @@ test('can safe redirect', t => {
|
||||
|
||||
function deny(to: string) {
|
||||
t.context.url.safeRedirect(res, to);
|
||||
t.true(spy.calledOnceWith(t.context.url.baseUrl));
|
||||
t.true(spy.calledOnceWith(t.context.url.home));
|
||||
spy.resetHistory();
|
||||
}
|
||||
|
||||
@@ -110,38 +106,3 @@ test('can safe redirect', t => {
|
||||
].forEach(allow);
|
||||
['https://other.domain.com', 'a://invalid.uri'].forEach(deny);
|
||||
});
|
||||
|
||||
test('can get request origin', t => {
|
||||
t.is(t.context.url.requestOrigin, 'https://app.affine.local');
|
||||
});
|
||||
|
||||
test('can get request base url', t => {
|
||||
t.is(t.context.url.requestBaseUrl, 'https://app.affine.local');
|
||||
});
|
||||
|
||||
test('can get request base url with multiple hosts', t => {
|
||||
// mock cls
|
||||
const cls = new Map<string, string>();
|
||||
const url = new URLHelper(
|
||||
{
|
||||
server: {
|
||||
externalUrl: '',
|
||||
host: 'app.affine.local1',
|
||||
hosts: ['app.affine.local1', 'app.affine.local2'],
|
||||
port: 3010,
|
||||
https: true,
|
||||
path: '',
|
||||
},
|
||||
} as any,
|
||||
cls as any
|
||||
);
|
||||
|
||||
// no cls, use default origin
|
||||
t.is(url.requestOrigin, 'https://app.affine.local1');
|
||||
t.is(url.requestBaseUrl, 'https://app.affine.local1');
|
||||
|
||||
// set cls
|
||||
cls.set(CLS_REQUEST_HOST, 'app.affine.local2');
|
||||
t.is(url.requestOrigin, 'https://app.affine.local2');
|
||||
t.is(url.requestBaseUrl, 'https://app.affine.local2');
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import { isIP } from 'node:net';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
|
||||
import { Config } from '../config';
|
||||
import { OnEvent } from '../event';
|
||||
@@ -12,13 +11,10 @@ export class URLHelper {
|
||||
redirectAllowHosts!: string[];
|
||||
|
||||
origin!: string;
|
||||
allowedOrigins!: string[];
|
||||
baseUrl!: string;
|
||||
home!: string;
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly cls?: ClsService
|
||||
) {
|
||||
constructor(private readonly config: Config) {
|
||||
this.init();
|
||||
}
|
||||
|
||||
@@ -38,40 +34,19 @@ export class URLHelper {
|
||||
this.baseUrl =
|
||||
externalUrl.origin + externalUrl.pathname.replace(/\/$/, '');
|
||||
} else {
|
||||
this.origin = this.convertHostToOrigin(this.config.server.host);
|
||||
this.origin = [
|
||||
this.config.server.https ? 'https' : 'http',
|
||||
'://',
|
||||
this.config.server.host,
|
||||
this.config.server.host === 'localhost' || isIP(this.config.server.host)
|
||||
? `:${this.config.server.port}`
|
||||
: '',
|
||||
].join('');
|
||||
this.baseUrl = this.origin + this.config.server.path;
|
||||
}
|
||||
|
||||
this.home = this.baseUrl;
|
||||
this.redirectAllowHosts = [this.baseUrl];
|
||||
|
||||
this.allowedOrigins = [this.origin];
|
||||
if (this.config.server.hosts.length > 0) {
|
||||
for (const host of this.config.server.hosts) {
|
||||
this.allowedOrigins.push(this.convertHostToOrigin(host));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get requestOrigin() {
|
||||
if (this.config.server.hosts.length === 0) {
|
||||
return this.origin;
|
||||
}
|
||||
|
||||
// support multiple hosts
|
||||
const requestHost = this.cls?.get<string | undefined>(CLS_REQUEST_HOST);
|
||||
if (!requestHost || !this.config.server.hosts.includes(requestHost)) {
|
||||
return this.origin;
|
||||
}
|
||||
|
||||
return this.convertHostToOrigin(requestHost);
|
||||
}
|
||||
|
||||
get requestBaseUrl() {
|
||||
if (this.config.server.hosts.length === 0) {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
return this.requestOrigin + this.config.server.path;
|
||||
}
|
||||
|
||||
stringify(query: Record<string, any>) {
|
||||
@@ -97,7 +72,7 @@ export class URLHelper {
|
||||
}
|
||||
|
||||
url(path: string, query: Record<string, any> = {}) {
|
||||
const url = new URL(path, this.requestOrigin);
|
||||
const url = new URL(path, this.origin);
|
||||
|
||||
for (const key in query) {
|
||||
url.searchParams.set(key, query[key]);
|
||||
@@ -112,7 +87,7 @@ export class URLHelper {
|
||||
|
||||
safeRedirect(res: Response, to: string) {
|
||||
try {
|
||||
const finalTo = new URL(decodeURIComponent(to), this.requestBaseUrl);
|
||||
const finalTo = new URL(decodeURIComponent(to), this.baseUrl);
|
||||
|
||||
for (const host of this.redirectAllowHosts) {
|
||||
const hostURL = new URL(host);
|
||||
@@ -128,7 +103,7 @@ export class URLHelper {
|
||||
}
|
||||
|
||||
// redirect to home if the url is invalid
|
||||
return res.redirect(this.baseUrl);
|
||||
return res.redirect(this.home);
|
||||
}
|
||||
|
||||
verify(url: string | URL) {
|
||||
@@ -143,13 +118,4 @@ export class URLHelper {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private convertHostToOrigin(host: string) {
|
||||
return [
|
||||
this.config.server.https ? 'https' : 'http',
|
||||
'://',
|
||||
host,
|
||||
host === 'localhost' || isIP(host) ? `:${this.config.server.port}` : '',
|
||||
].join('');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { type QueueOptions } from 'bullmq';
|
||||
|
||||
import { Config } from '../../config';
|
||||
import { QueueRedis } from '../../redis';
|
||||
import { Due } from '../../utils';
|
||||
import { Queue, QUEUES } from './def';
|
||||
import { JobExecutor } from './executor';
|
||||
import { JobQueue } from './queue';
|
||||
@@ -40,7 +41,7 @@ export class JobModule {
|
||||
...QUEUES.map(name => {
|
||||
if (name === Queue.NIGHTLY_JOB) {
|
||||
// avoid nightly jobs been run multiple times
|
||||
return { name, removeOnComplete: { age: 1000 * 60 * 60 } };
|
||||
return { name, removeOnComplete: { age: Due.ms('1m') } };
|
||||
}
|
||||
return { name };
|
||||
})
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
PutObjectMetadata,
|
||||
StorageProvider,
|
||||
} from './provider';
|
||||
import { autoMetadata, SIGNED_URL_EXPIRED, toBuffer } from './utils';
|
||||
import { autoMetadata, SIGNED_URL_EXPIRED_SEC, toBuffer } from './utils';
|
||||
|
||||
export interface S3StorageConfig extends S3ClientConfig {
|
||||
usePresignedURL?: {
|
||||
@@ -138,7 +138,7 @@ export class S3StorageProvider implements StorageProvider {
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
}),
|
||||
{ expiresIn: SIGNED_URL_EXPIRED }
|
||||
{ expiresIn: SIGNED_URL_EXPIRED_SEC }
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { crc32 } from '@node-rs/crc32';
|
||||
import { getStreamAsBuffer } from 'get-stream';
|
||||
|
||||
import { getMime } from '../../../native';
|
||||
import { Due } from '../../utils';
|
||||
import { BlobInputType, PutObjectMetadata } from './provider';
|
||||
|
||||
export async function toBuffer(input: BlobInputType): Promise<Buffer> {
|
||||
@@ -43,4 +44,4 @@ export function autoMetadata(
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export const SIGNED_URL_EXPIRED = 60 * 60; // 1 hour
|
||||
export const SIGNED_URL_EXPIRED_SEC = Due.s('1h');
|
||||
|
||||
@@ -53,26 +53,28 @@ function parse(str: string): DurationInput {
|
||||
return input;
|
||||
}
|
||||
|
||||
export type Duration = string | DurationInput;
|
||||
|
||||
export const Due = {
|
||||
ms: (dueStr: string | DurationInput) => {
|
||||
const input = typeof dueStr === 'string' ? parse(dueStr) : dueStr;
|
||||
ms: (duration: Duration) => {
|
||||
const input = typeof duration === 'string' ? parse(duration) : duration;
|
||||
return Object.entries(input).reduce((duration, [unit, val]) => {
|
||||
return duration + UnitToSecMap[unit as DurationUnit] * (val || 0) * 1000;
|
||||
}, 0);
|
||||
},
|
||||
s: (dueStr: string | DurationInput) => {
|
||||
const input = typeof dueStr === 'string' ? parse(dueStr) : dueStr;
|
||||
s: (duration: Duration) => {
|
||||
const input = typeof duration === 'string' ? parse(duration) : duration;
|
||||
return Object.entries(input).reduce((duration, [unit, val]) => {
|
||||
return duration + UnitToSecMap[unit as DurationUnit] * (val || 0);
|
||||
}, 0);
|
||||
},
|
||||
parse,
|
||||
after: (dueStr: string | number | DurationInput, date?: Date) => {
|
||||
const timestamp = typeof dueStr === 'number' ? dueStr : Due.ms(dueStr);
|
||||
after: (duration: Duration, date?: Date) => {
|
||||
const timestamp = Due.ms(duration);
|
||||
return new Date((date?.getTime() ?? Date.now()) + timestamp);
|
||||
},
|
||||
before: (dueStr: string | number | DurationInput, date?: Date) => {
|
||||
const timestamp = typeof dueStr === 'number' ? dueStr : Due.ms(dueStr);
|
||||
before: (duration: Duration, date?: Date) => {
|
||||
const timestamp = Due.ms(duration);
|
||||
return new Date((date?.getTime() ?? Date.now()) - timestamp);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export const OneKB = 1024;
|
||||
export const OneMB = OneKB * OneKB;
|
||||
export const OneGB = OneKB * OneMB;
|
||||
export const OneDay = 1000 * 60 * 60 * 24;
|
||||
|
||||
@@ -4,8 +4,8 @@ import { defineModuleConfig } from '../../base';
|
||||
|
||||
export interface AuthConfig {
|
||||
session: {
|
||||
ttl: number;
|
||||
ttr: number;
|
||||
ttl: string;
|
||||
ttr: string;
|
||||
};
|
||||
allowSignup: boolean;
|
||||
requireEmailDomainVerification: boolean;
|
||||
@@ -60,10 +60,10 @@ defineModuleConfig('auth', {
|
||||
},
|
||||
'session.ttl': {
|
||||
desc: 'Application auth expiration time in seconds.',
|
||||
default: 60 * 60 * 24 * 15, // 15 days
|
||||
default: '15d',
|
||||
},
|
||||
'session.ttr': {
|
||||
desc: 'Application auth time to refresh in seconds.',
|
||||
default: 60 * 60 * 24 * 7, // 7 days
|
||||
default: '7d',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -199,21 +199,17 @@ export class AuthController {
|
||||
throw new WrongSignInCredentials({ email });
|
||||
}
|
||||
|
||||
const ttlInSec = 30 * 60;
|
||||
const ttl = '30m';
|
||||
const token = await this.models.verificationToken.create(
|
||||
TokenType.SignIn,
|
||||
email,
|
||||
ttlInSec
|
||||
ttl
|
||||
);
|
||||
|
||||
const otp = this.crypto.otp();
|
||||
// TODO(@forehalo): this is a temporary solution, we should not rely on cache to store the otp
|
||||
const cacheKey = OTP_CACHE_KEY(otp);
|
||||
await this.cache.set(
|
||||
cacheKey,
|
||||
{ token, clientNonce },
|
||||
{ ttl: ttlInSec * 1000 }
|
||||
);
|
||||
await this.cache.set(cacheKey, { token, clientNonce }, { ttl });
|
||||
|
||||
const magicLink = this.url.link(callbackUrl, {
|
||||
token: otp,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import type { CookieOptions, Request, Response } from 'express';
|
||||
import { assign, pick } from 'lodash-es';
|
||||
|
||||
import { Config, SignUpForbidden } from '../../base';
|
||||
import { Config, type Duration, SignUpForbidden } from '../../base';
|
||||
import { Models, type User, type UserSession } from '../../models';
|
||||
import { FeatureService } from '../features';
|
||||
import { Mailer } from '../mail/mailer';
|
||||
@@ -128,7 +128,7 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
return await this.models.session.findUserSessionsBySessionId(sessionId);
|
||||
}
|
||||
|
||||
async createUserSession(userId: string, sessionId?: string, ttl?: number) {
|
||||
async createUserSession(userId: string, sessionId?: string, ttl?: Duration) {
|
||||
return await this.models.session.createOrRefreshUserSession(
|
||||
userId,
|
||||
sessionId,
|
||||
@@ -157,7 +157,7 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
async refreshUserSessionIfNeeded(
|
||||
res: Response,
|
||||
userSession: UserSession,
|
||||
ttr?: number
|
||||
ttr?: Duration
|
||||
): Promise<boolean> {
|
||||
const newExpiresAt = await this.models.session.refreshUserSessionIfNeeded(
|
||||
userSession,
|
||||
|
||||
@@ -12,7 +12,6 @@ declare global {
|
||||
externalUrl?: string;
|
||||
https: boolean;
|
||||
host: string;
|
||||
hosts: ConfigItem<string[]>;
|
||||
port: number;
|
||||
path: string;
|
||||
name?: string;
|
||||
@@ -53,11 +52,6 @@ Default to be \`[server.protocol]://[server.host][:server.port]\` if not specifi
|
||||
default: 'localhost',
|
||||
env: 'AFFINE_SERVER_HOST',
|
||||
},
|
||||
hosts: {
|
||||
desc: 'Multiple hosts the server will accept requests from.',
|
||||
default: [],
|
||||
shape: z.array(z.string()),
|
||||
},
|
||||
port: {
|
||||
desc: 'Which port the server will listen on.',
|
||||
default: 3010,
|
||||
|
||||
@@ -82,7 +82,7 @@ export class ServerConfigResolver {
|
||||
? 'AFFiNE Beta Cloud'
|
||||
: 'AFFiNE Cloud'),
|
||||
version: env.version,
|
||||
baseUrl: this.url.requestBaseUrl,
|
||||
baseUrl: this.url.home,
|
||||
type: env.DEPLOYMENT_TYPE,
|
||||
features: this.server.features,
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { chunk } from 'lodash-es';
|
||||
import {
|
||||
DocHistoryNotFound,
|
||||
DocNotFound,
|
||||
Due,
|
||||
EventBus,
|
||||
FailedToSaveUpdates,
|
||||
FailedToUpsertSnapshot,
|
||||
@@ -251,7 +252,8 @@ export class PgWorkspaceDocStorageAdapter extends DocStorageAdapter {
|
||||
force ||
|
||||
// last history created before interval in configs
|
||||
lastHistoryTimestamp <
|
||||
snapshot.timestamp - this.options.historyMinInterval(snapshot.spaceId)
|
||||
snapshot.timestamp -
|
||||
Due.ms(this.options.historyMinInterval(snapshot.spaceId))
|
||||
) {
|
||||
shouldCreateHistory = true;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ declare global {
|
||||
interface AppConfigSchema {
|
||||
doc: {
|
||||
history: {
|
||||
interval: number;
|
||||
interval: string;
|
||||
};
|
||||
experimental: {
|
||||
yocto: boolean;
|
||||
@@ -20,6 +20,6 @@ defineModuleConfig('doc', {
|
||||
},
|
||||
'history.interval': {
|
||||
desc: 'The minimum time interval in milliseconds of creating a new history snapshot when doc get updated.',
|
||||
default: 1000 * 60 * 10 /* 10 mins */,
|
||||
default: '10m',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -25,8 +25,6 @@ import {
|
||||
import { PgWorkspaceDocStorageAdapter } from './adapters/workspace';
|
||||
import { type DocDiff, type DocRecord } from './storage';
|
||||
|
||||
const DOC_CONTENT_CACHE_7_DAYS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface WorkspaceDocInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -90,7 +88,7 @@ export abstract class DocReader {
|
||||
const content = await this.getDocContentWithoutCache(workspaceId, docId);
|
||||
if (content) {
|
||||
await this.cache.set(cacheKey, content, {
|
||||
ttl: DOC_CONTENT_CACHE_7_DAYS,
|
||||
ttl: '7d',
|
||||
});
|
||||
}
|
||||
return content;
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
createTestingModule,
|
||||
type TestingModule,
|
||||
} from '../../../__tests__/utils';
|
||||
import { NotificationNotFound } from '../../../base';
|
||||
import { Due, NotificationNotFound } from '../../../base';
|
||||
import {
|
||||
DocMode,
|
||||
MentionNotificationBody,
|
||||
@@ -381,7 +381,7 @@ test('should clean expired notifications', async t => {
|
||||
// wait for 100 days
|
||||
mock.timers.enable({
|
||||
apis: ['Date'],
|
||||
now: Date.now() + 1000 * 60 * 60 * 24 * 100,
|
||||
now: Due.after('100d'),
|
||||
});
|
||||
await t.context.models.notification.cleanExpiredNotifications();
|
||||
count = await notificationService.countByUserId(member.id);
|
||||
@@ -390,7 +390,7 @@ test('should clean expired notifications', async t => {
|
||||
// wait for 1 year
|
||||
mock.timers.enable({
|
||||
apis: ['Date'],
|
||||
now: Date.now() + 1000 * 60 * 60 * 24 * 365,
|
||||
now: Due.after('1y'),
|
||||
});
|
||||
await t.context.models.notification.cleanExpiredNotifications();
|
||||
count = await notificationService.countByUserId(member.id);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { OneDay, OneKB } from '../../base';
|
||||
import { Due, OneKB } from '../../base';
|
||||
|
||||
export const ByteUnit = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
|
||||
@@ -14,6 +14,7 @@ export function formatSize(bytes: number, decimals: number = 2): string {
|
||||
);
|
||||
}
|
||||
|
||||
const ONE_DAY_IN_MS = Due.ms('1d');
|
||||
export function formatDate(ms: number): string {
|
||||
return `${(ms / OneDay).toFixed(0)} days`;
|
||||
return `${(ms / ONE_DAY_IN_MS).toFixed(0)} days`;
|
||||
}
|
||||
|
||||
@@ -403,8 +403,7 @@ export class WorkspaceDocResolver {
|
||||
const allowed = await this.cache.setnx(
|
||||
`fixingOwner:${workspaceId}:${docId}`,
|
||||
1,
|
||||
// TODO(@forehalo): we definitely need a timer helper
|
||||
{ ttl: 1000 * 60 * 60 * 24 }
|
||||
{ ttl: '1d' }
|
||||
);
|
||||
|
||||
// fixed by other instance
|
||||
|
||||
@@ -174,13 +174,11 @@ export class InviteResult {
|
||||
error?: object;
|
||||
}
|
||||
|
||||
const Day = 24 * 60 * 60 * 1000;
|
||||
|
||||
export enum WorkspaceInviteLinkExpireTime {
|
||||
OneDay = Day,
|
||||
ThreeDays = 3 * Day,
|
||||
OneWeek = 7 * Day,
|
||||
OneMonth = 30 * Day,
|
||||
OneDay = '1d',
|
||||
ThreeDays = '3d',
|
||||
OneWeek = '1w',
|
||||
OneMonth = '1M',
|
||||
}
|
||||
|
||||
registerEnumType(WorkspaceInviteLinkExpireTime, {
|
||||
|
||||
@@ -12,8 +12,6 @@ declare global {
|
||||
var readEnv: <T>(key: string, defaultValue: T, availableValues?: T[]) => T;
|
||||
// oxlint-disable-next-line no-var
|
||||
var CUSTOM_CONFIG_PATH: string;
|
||||
// oxlint-disable-next-line no-var
|
||||
var CLS_REQUEST_HOST: 'CLS_REQUEST_HOST';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +53,6 @@ export type AppEnv = {
|
||||
version: string;
|
||||
};
|
||||
|
||||
globalThis.CLS_REQUEST_HOST = 'CLS_REQUEST_HOST';
|
||||
globalThis.CUSTOM_CONFIG_PATH = join(homedir(), '.affine/config');
|
||||
globalThis.readEnv = function readEnv<T>(
|
||||
env: string,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { mock } from 'node:test';
|
||||
import ava, { TestFn } from 'ava';
|
||||
|
||||
import { createTestingModule, type TestingModule } from '../../__tests__/utils';
|
||||
import { Due } from '../../base';
|
||||
import { Config } from '../../base/config';
|
||||
import {
|
||||
DocMode,
|
||||
@@ -259,7 +260,7 @@ test('should clean expired notifications', async t => {
|
||||
// wait for 1 year
|
||||
mock.timers.enable({
|
||||
apis: ['Date'],
|
||||
now: Date.now() + 1000 * 60 * 60 * 24 * 365,
|
||||
now: Due.after('1y'),
|
||||
});
|
||||
count = await t.context.models.notification.cleanExpiredNotifications();
|
||||
t.is(count, 1);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { OneDay, OneGB, OneMB } from '../../base';
|
||||
import { Due, OneGB, OneMB } from '../../base';
|
||||
|
||||
const UserPlanQuotaConfig = z.object({
|
||||
// quota name
|
||||
@@ -104,7 +104,7 @@ export const FeatureConfigs: {
|
||||
blobLimit: 10 * OneMB,
|
||||
businessBlobLimit: 100 * OneMB,
|
||||
storageQuota: 10 * OneGB,
|
||||
historyPeriod: 7 * OneDay,
|
||||
historyPeriod: Due.ms('7d'),
|
||||
memberLimit: 3,
|
||||
copilotActionLimit: 10,
|
||||
},
|
||||
@@ -116,7 +116,7 @@ export const FeatureConfigs: {
|
||||
name: 'Pro',
|
||||
blobLimit: 100 * OneMB,
|
||||
storageQuota: 100 * OneGB,
|
||||
historyPeriod: 30 * OneDay,
|
||||
historyPeriod: Due.ms('30d'),
|
||||
memberLimit: 10,
|
||||
copilotActionLimit: 10,
|
||||
},
|
||||
@@ -128,7 +128,7 @@ export const FeatureConfigs: {
|
||||
name: 'Lifetime Pro',
|
||||
blobLimit: 100 * OneMB,
|
||||
storageQuota: 1024 * OneGB,
|
||||
historyPeriod: 30 * OneDay,
|
||||
historyPeriod: Due.ms('30d'),
|
||||
memberLimit: 10,
|
||||
copilotActionLimit: 10,
|
||||
},
|
||||
@@ -141,7 +141,7 @@ export const FeatureConfigs: {
|
||||
blobLimit: 500 * OneMB,
|
||||
storageQuota: 100 * OneGB,
|
||||
seatQuota: 20 * OneGB,
|
||||
historyPeriod: 30 * OneDay,
|
||||
historyPeriod: Due.ms('30d'),
|
||||
memberLimit: 1,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { PaginationInput } from '../base';
|
||||
import { Due, PaginationInput } from '../base';
|
||||
import { BaseModel } from './base';
|
||||
import { DocMode } from './common';
|
||||
|
||||
@@ -15,8 +15,6 @@ export { NotificationLevel, NotificationType };
|
||||
export type { Notification };
|
||||
|
||||
// #region input
|
||||
|
||||
export const ONE_YEAR = 1000 * 60 * 60 * 24 * 365;
|
||||
const IdSchema = z.string().trim().min(1).max(100);
|
||||
|
||||
export const BaseNotificationCreateSchema = z.object({
|
||||
@@ -237,7 +235,7 @@ export class NotificationModel extends BaseModel {
|
||||
async cleanExpiredNotifications() {
|
||||
const { count } = await this.db.notification.deleteMany({
|
||||
// delete notifications that are older than one year
|
||||
where: { createdAt: { lte: new Date(Date.now() - ONE_YEAR) } },
|
||||
where: { createdAt: { lte: Due.before('1y') } },
|
||||
});
|
||||
if (count > 0) {
|
||||
this.logger.log(`Deleted ${count} expired notifications`);
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type UserSession,
|
||||
} from '@prisma/client';
|
||||
|
||||
import { Config } from '../base';
|
||||
import { Config, Due, Duration } from '../base';
|
||||
import { BaseModel } from './base';
|
||||
|
||||
export type { Session, UserSession };
|
||||
@@ -46,7 +46,7 @@ export class SessionModel extends BaseModel {
|
||||
async createOrRefreshUserSession(
|
||||
userId: string,
|
||||
sessionId?: string,
|
||||
ttl = this.config.auth.session.ttl
|
||||
ttl: Duration = this.config.auth.session.ttl
|
||||
) {
|
||||
// check whether given session is valid
|
||||
if (sessionId) {
|
||||
@@ -66,7 +66,7 @@ export class SessionModel extends BaseModel {
|
||||
sessionId = session.id;
|
||||
}
|
||||
|
||||
const expiresAt = new Date(Date.now() + ttl * 1000);
|
||||
const expiresAt = Due.after(ttl);
|
||||
return await this.db.userSession.upsert({
|
||||
where: {
|
||||
sessionId_userId: {
|
||||
@@ -87,19 +87,17 @@ export class SessionModel extends BaseModel {
|
||||
|
||||
async refreshUserSessionIfNeeded(
|
||||
userSession: UserSession,
|
||||
ttr = this.config.auth.session.ttr
|
||||
ttr: Duration = this.config.auth.session.ttr
|
||||
): Promise<Date | undefined> {
|
||||
if (
|
||||
userSession.expiresAt &&
|
||||
userSession.expiresAt.getTime() - Date.now() > ttr * 1000
|
||||
Due.before(ttr, userSession.expiresAt) > new Date()
|
||||
) {
|
||||
// no need to refresh
|
||||
return;
|
||||
}
|
||||
|
||||
const newExpiresAt = new Date(
|
||||
Date.now() + this.config.auth.session.ttl * 1000
|
||||
);
|
||||
const newExpiresAt = Due.after(this.config.auth.session.ttl);
|
||||
await this.db.userSession.update({
|
||||
where: {
|
||||
id: userSession.id,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { type VerificationToken } from '@prisma/client';
|
||||
|
||||
import { Due, Duration } from '../base';
|
||||
import { CryptoHelper } from '../base/helpers';
|
||||
import { BaseModel } from './base';
|
||||
|
||||
@@ -25,18 +26,14 @@ export class VerificationTokenModel extends BaseModel {
|
||||
/**
|
||||
* create token by type and credential (optional) with ttl in seconds (default 30 minutes)
|
||||
*/
|
||||
async create(
|
||||
type: TokenType,
|
||||
credential?: string,
|
||||
ttlInSec: number = 30 * 60
|
||||
) {
|
||||
async create(type: TokenType, credential?: string, ttl: Duration = '30m') {
|
||||
const plaintextToken = randomUUID();
|
||||
const { token } = await this.db.verificationToken.create({
|
||||
data: {
|
||||
type,
|
||||
token: plaintextToken,
|
||||
credential,
|
||||
expiresAt: new Date(Date.now() + ttlInSec * 1000),
|
||||
expiresAt: Due.after(ttl),
|
||||
},
|
||||
});
|
||||
return this.crypto.encrypt(token);
|
||||
|
||||
@@ -56,26 +56,13 @@ export class CaptchaService {
|
||||
body: formData,
|
||||
method: 'POST',
|
||||
});
|
||||
const outcome = (await result.json()) as {
|
||||
success: boolean;
|
||||
hostname: string;
|
||||
};
|
||||
const outcome: any = await result.json();
|
||||
|
||||
if (!outcome.success) return false;
|
||||
|
||||
// skip hostname check in dev mode
|
||||
if (env.dev) return true;
|
||||
|
||||
// check if the hostname is in the hosts
|
||||
if (this.config.server.hosts.includes(outcome.hostname)) return true;
|
||||
|
||||
// check if the hostname is in the host
|
||||
if (this.config.server.host === outcome.hostname) return true;
|
||||
|
||||
this.logger.warn(
|
||||
`Captcha verification failed for hostname: ${outcome.hostname}`
|
||||
return (
|
||||
!!outcome.success &&
|
||||
// skip hostname check in dev mode
|
||||
(env.dev || outcome.hostname === this.config.server.host)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
private async verifyChallengeResponse(response: any, resource: string) {
|
||||
@@ -91,7 +78,7 @@ export class CaptchaService {
|
||||
const challenge = await this.models.verificationToken.create(
|
||||
TokenType.Challenge,
|
||||
resource,
|
||||
5 * 60
|
||||
'5m'
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -6,7 +6,6 @@ import { SessionCache } from '../../base';
|
||||
import { SubmittedMessage, SubmittedMessageSchema } from './types';
|
||||
|
||||
const CHAT_MESSAGE_KEY = 'chat-message';
|
||||
const CHAT_MESSAGE_TTL = 3600 * 1 * 1000; // 1 hours
|
||||
|
||||
@Injectable()
|
||||
export class ChatMessageCache {
|
||||
@@ -20,7 +19,7 @@ export class ChatMessageCache {
|
||||
const parsedMessage = SubmittedMessageSchema.parse(message);
|
||||
const id = randomUUID();
|
||||
await this.cache.set(`${CHAT_MESSAGE_KEY}:${id}`, parsedMessage, {
|
||||
ttl: CHAT_MESSAGE_TTL,
|
||||
ttl: '1h',
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import Sinon from 'sinon';
|
||||
|
||||
import { createModule } from '../../../__tests__/create-module';
|
||||
import { Mockers } from '../../../__tests__/mocks';
|
||||
import { JOB_SIGNAL } from '../../../base';
|
||||
import { Due, JOB_SIGNAL } from '../../../base';
|
||||
import { ConfigModule } from '../../../base/config';
|
||||
import { ServerConfigModule } from '../../../core/config';
|
||||
import { Models } from '../../../models';
|
||||
@@ -160,7 +160,7 @@ test('should not index workspace if it is not updated in 180 days', async t => {
|
||||
user,
|
||||
workspaceId: workspace.id,
|
||||
docId: workspace.id,
|
||||
updatedAt: new Date(Date.now() - 180 * 24 * 60 * 60 * 1000 - 1),
|
||||
updatedAt: Due.before('181d'),
|
||||
});
|
||||
|
||||
const count = module.queue.count('indexer.indexWorkspace');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { Config, JOB_SIGNAL, JobQueue, OnJob } from '../../base';
|
||||
import { Config, Due, JOB_SIGNAL, JobQueue, OnJob } from '../../base';
|
||||
import { readAllDocIdsFromWorkspaceSnapshot } from '../../core/utils/blocksuite';
|
||||
import { Models } from '../../models';
|
||||
import { IndexerService } from './service';
|
||||
@@ -182,8 +182,7 @@ export class IndexerJob {
|
||||
// ignore 180 days not updated workspaces
|
||||
if (
|
||||
!snapshotMeta?.updatedAt ||
|
||||
Date.now() - snapshotMeta.updatedAt.getTime() >
|
||||
180 * 24 * 60 * 60 * 1000
|
||||
snapshotMeta.updatedAt < Due.before('180d')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { z } from 'zod';
|
||||
|
||||
import {
|
||||
CryptoHelper,
|
||||
Due,
|
||||
EventBus,
|
||||
InternalServerError,
|
||||
InvalidLicenseToActivate,
|
||||
@@ -337,7 +338,7 @@ export class LicenseService {
|
||||
const licenses = await this.db.installedLicense.findMany({
|
||||
where: {
|
||||
validatedAt: {
|
||||
lte: new Date(Date.now() - 1000 * 60 * 60 /* 1h */),
|
||||
lte: Due.before('1h'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -142,7 +142,7 @@ export class OAuthController {
|
||||
provider: rawState.provider,
|
||||
})
|
||||
);
|
||||
clientUrl.searchParams.set('server', this.url.requestOrigin);
|
||||
clientUrl.searchParams.set('server', this.url.origin);
|
||||
|
||||
return res.redirect(
|
||||
this.url.link('/open-app/url?', {
|
||||
|
||||
@@ -29,7 +29,7 @@ export class OAuthService {
|
||||
async saveOAuthState(state: OAuthState) {
|
||||
const token = randomUUID();
|
||||
await this.cache.set(`${OAUTH_STATE_KEY}:${token}`, state, {
|
||||
ttl: 3600 * 3 * 1000 /* 3 hours */,
|
||||
ttl: '3h',
|
||||
});
|
||||
|
||||
return token;
|
||||
|
||||
@@ -26,9 +26,6 @@ import {
|
||||
} from './utils';
|
||||
import { decodeWithCharset } from './utils/encoding';
|
||||
|
||||
// cache for 30 minutes
|
||||
const CACHE_TTL = 1000 * 60 * 30;
|
||||
|
||||
@Public()
|
||||
@UseNamedGuard('selfhost')
|
||||
@Controller('/api/worker')
|
||||
@@ -56,7 +53,7 @@ export class WorkerController {
|
||||
this.logger.error('Invalid Origin', 'ERROR', { origin, referer });
|
||||
throw new BadRequest('Invalid header');
|
||||
}
|
||||
const url = new URL(req.url, this.url.requestBaseUrl);
|
||||
const url = new URL(req.url, this.url.baseUrl);
|
||||
const imageURL = url.searchParams.get('url');
|
||||
if (!imageURL) {
|
||||
throw new BadRequest('Missing "url" parameter');
|
||||
@@ -98,7 +95,7 @@ export class WorkerController {
|
||||
if (contentType?.startsWith('image/')) {
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
await this.cache.set(cachedUrl, buffer.toString('base64'), {
|
||||
ttl: CACHE_TTL,
|
||||
ttl: '30m',
|
||||
});
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
return resp
|
||||
@@ -118,7 +115,7 @@ export class WorkerController {
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
// rejected by server, cache a empty response
|
||||
await this.cache.set(cachedUrl, Buffer.from([]).toString('base64'), {
|
||||
ttl: CACHE_TTL,
|
||||
ttl: '30m',
|
||||
});
|
||||
}
|
||||
this.logger.error('Failed to fetch image', {
|
||||
@@ -302,7 +299,7 @@ export class WorkerController {
|
||||
responseSize: json.length,
|
||||
});
|
||||
|
||||
await this.cache.set(cachedUrl, res, { ttl: CACHE_TTL });
|
||||
await this.cache.set(cachedUrl, res, { ttl: '30m' });
|
||||
return resp
|
||||
.status(200)
|
||||
.header({
|
||||
|
||||
@@ -5,7 +5,7 @@ import { fixUrl, OriginRules } from './utils';
|
||||
|
||||
@Injectable()
|
||||
export class WorkerService {
|
||||
allowedOrigins: OriginRules = [...this.url.allowedOrigins];
|
||||
allowedOrigins: OriginRules = [this.url.origin];
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
@@ -18,7 +18,7 @@ export class WorkerService {
|
||||
...this.config.worker.allowedOrigin
|
||||
.map(u => fixUrl(u)?.origin as string)
|
||||
.filter(v => !!v),
|
||||
...this.url.allowedOrigins,
|
||||
this.url.origin,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -66,5 +66,5 @@ export async function run() {
|
||||
|
||||
logger.log(`AFFiNE Server is running in [${env.DEPLOYMENT_TYPE}] mode`);
|
||||
logger.log(`Listening on http://${listeningHost}:${config.server.port}`);
|
||||
logger.log(`And the public server should be recognized as ${url.baseUrl}`);
|
||||
logger.log(`And the public server should be recognized as ${url.home}`);
|
||||
}
|
||||
|
||||
@@ -171,10 +171,6 @@
|
||||
"desc": "Where the server get deployed(FQDN).",
|
||||
"env": "AFFINE_SERVER_HOST"
|
||||
},
|
||||
"hosts": {
|
||||
"type": "Array",
|
||||
"desc": "Multiple hosts the server will accept requests from."
|
||||
},
|
||||
"port": {
|
||||
"type": "Number",
|
||||
"desc": "Which port the server will listen on.",
|
||||
|
||||
@@ -48,7 +48,7 @@ export const KNOWN_CONFIG_GROUPS = [
|
||||
{
|
||||
name: 'Server',
|
||||
module: 'server',
|
||||
fields: ['externalUrl', 'name', 'hosts'],
|
||||
fields: ['externalUrl', 'name'],
|
||||
} as ConfigGroup<'server'>,
|
||||
{
|
||||
name: 'Auth',
|
||||
|
||||
@@ -30,19 +30,7 @@ if (isDev) {
|
||||
app.commandLine.appendSwitch('host-rules', 'MAP 0.0.0.0 127.0.0.1');
|
||||
}
|
||||
// https://github.com/electron/electron/issues/43556
|
||||
// // `CalculateNativeWinOcclusion` - Disable native window occlusion tracker (https://groups.google.com/a/chromium.org/g/embedder-dev/c/ZF3uHHyWLKw/m/VDN2hDXMAAAJ)
|
||||
app.commandLine.appendSwitch(
|
||||
'disable-features',
|
||||
'PlzDedicatedWorker,CalculateNativeWinOcclusion'
|
||||
);
|
||||
|
||||
// Following features are enabled from the runtime:
|
||||
// `DocumentPolicyIncludeJSCallStacksInCrashReports` - https://www.electronjs.org/docs/latest/api/web-frame-main#framecollectjavascriptcallstack-experimental
|
||||
// `EarlyEstablishGpuChannel` - Refs https://issues.chromium.org/issues/40208065
|
||||
// `EstablishGpuChannelAsync` - Refs https://issues.chromium.org/issues/40208065
|
||||
const featuresToEnable = `DocumentPolicyIncludeJSCallStacksInCrashReports,EarlyEstablishGpuChannel,EstablishGpuChannelAsync`;
|
||||
app.commandLine.appendSwitch('enable-features', featuresToEnable);
|
||||
app.commandLine.appendSwitch('force-color-profile', 'srgb');
|
||||
app.commandLine.appendSwitch('disable-features', 'PlzDedicatedWorker');
|
||||
|
||||
// use the same data for internal & beta for testing
|
||||
if (overrideSession) {
|
||||
|
||||
@@ -160,48 +160,6 @@ export function registerProtocol() {
|
||||
delete responseHeaders['Access-Control-Allow-Origin'];
|
||||
delete responseHeaders['Access-Control-Allow-Headers'];
|
||||
}
|
||||
|
||||
// to allow url embedding, remove "x-frame-options",
|
||||
// if response header contains "content-security-policy", remove "frame-ancestors/frame-src"
|
||||
delete responseHeaders['x-frame-options'];
|
||||
delete responseHeaders['X-Frame-Options'];
|
||||
|
||||
// Handle Content Security Policy headers
|
||||
const cspHeaders = [
|
||||
'content-security-policy',
|
||||
'Content-Security-Policy',
|
||||
];
|
||||
for (const cspHeader of cspHeaders) {
|
||||
const cspValues = responseHeaders[cspHeader];
|
||||
if (cspValues) {
|
||||
// Remove frame-ancestors and frame-src directives from CSP
|
||||
const modifiedCspValues = cspValues
|
||||
.map(cspValue => {
|
||||
if (typeof cspValue === 'string') {
|
||||
return cspValue
|
||||
.split(';')
|
||||
.filter(directive => {
|
||||
const trimmed = directive.trim().toLowerCase();
|
||||
return (
|
||||
!trimmed.startsWith('frame-ancestors') &&
|
||||
!trimmed.startsWith('frame-src')
|
||||
);
|
||||
})
|
||||
.join(';');
|
||||
}
|
||||
return cspValue;
|
||||
})
|
||||
.filter(
|
||||
value => value && typeof value === 'string' && value.trim()
|
||||
);
|
||||
|
||||
if (modifiedCspValues.length > 0) {
|
||||
responseHeaders[cspHeader] = modifiedCspValues;
|
||||
} else {
|
||||
delete responseHeaders[cspHeader];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})()
|
||||
.catch(err => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 56;
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@@ -92,8 +92,6 @@
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
C45499AB2D140B5000E21978 /* NBStore */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = NBStore;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -342,9 +340,13 @@
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-AFFiNE/Pods-AFFiNE-frameworks.sh\"\n";
|
||||
|
||||
@@ -12,14 +12,14 @@ extension AFFiNEViewController: IntelligentsButtonDelegate {
|
||||
func onIntelligentsButtonTapped(_ button: IntelligentsButton) {
|
||||
IntelligentContext.shared.webView = webView!
|
||||
button.beginProgress()
|
||||
|
||||
IntelligentContext.shared.preparePresent { result in
|
||||
|
||||
IntelligentContext.shared.preparePresent() { result in
|
||||
button.stopProgress()
|
||||
switch result {
|
||||
case .success:
|
||||
case .success(let success):
|
||||
let controller = IntelligentsController()
|
||||
self.present(controller, animated: true)
|
||||
case let .failure(failure):
|
||||
case .failure(let failure):
|
||||
let alert = UIAlertController(
|
||||
title: "Error",
|
||||
message: failure.localizedDescription,
|
||||
|
||||
@@ -13,15 +13,15 @@ class AFFiNEViewController: CAPBridgeViewController {
|
||||
intelligentsButton.delegate = self
|
||||
dismissIntelligentsButton()
|
||||
}
|
||||
|
||||
|
||||
override func webViewConfiguration(for instanceConfiguration: InstanceConfiguration) -> WKWebViewConfiguration {
|
||||
let configuration = super.webViewConfiguration(for: instanceConfiguration)
|
||||
return configuration
|
||||
}
|
||||
|
||||
|
||||
override func webView(with frame: CGRect, configuration: WKWebViewConfiguration) -> WKWebView {
|
||||
super.webView(with: frame, configuration: configuration)
|
||||
}
|
||||
return super.webView(with: frame, configuration: configuration)
|
||||
}
|
||||
|
||||
override func capacitorDidLoad() {
|
||||
let plugins: [CAPPlugin] = [
|
||||
@@ -43,3 +43,6 @@ class AFFiNEViewController: CAPBridgeViewController {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ final class AppConfigManager {
|
||||
struct AppConfig: Decodable {
|
||||
let affineVersion: String
|
||||
}
|
||||
|
||||
static var affineVersion: String?
|
||||
|
||||
|
||||
static var affineVersion: String? = nil
|
||||
|
||||
static func getAffineVersion() -> String {
|
||||
if affineVersion == nil {
|
||||
let file = Bundle(for: AppConfigManager.self).url(forResource: "capacitor.config", withExtension: "json")!
|
||||
@@ -14,7 +14,7 @@ final class AppConfigManager {
|
||||
let config = try! JSONDecoder().decode(AppConfig.self, from: data)
|
||||
affineVersion = config.affineVersion
|
||||
}
|
||||
|
||||
|
||||
return affineVersion!
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,14 +22,14 @@ enum ApplicationBridgedWindowScript: String {
|
||||
|
||||
var requiresAsyncContext: Bool {
|
||||
switch self {
|
||||
case .getCurrentDocContentInMarkdown, .createNewDocByMarkdownInCurrentWorkspace: true
|
||||
default: false
|
||||
case .getCurrentDocContentInMarkdown, .createNewDocByMarkdownInCurrentWorkspace: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension WKWebView {
|
||||
func evaluateScript(_ script: ApplicationBridgedWindowScript, callback: @escaping (Any?) -> Void) {
|
||||
func evaluateScript(_ script: ApplicationBridgedWindowScript, callback: @escaping (Any?) -> ()) {
|
||||
if script.requiresAsyncContext {
|
||||
callAsyncJavaScript(
|
||||
script.rawValue,
|
||||
@@ -38,7 +38,7 @@ extension WKWebView {
|
||||
in: .page
|
||||
) { result in
|
||||
switch result {
|
||||
case let .success(input):
|
||||
case .success(let input):
|
||||
callback(input)
|
||||
case .failure:
|
||||
callback(nil)
|
||||
@@ -49,3 +49,5 @@ extension WKWebView {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -10,44 +10,44 @@ enum RequestParamError: Error {
|
||||
case request(key: String)
|
||||
}
|
||||
|
||||
public extension JSValueContainer {
|
||||
func getStringEnsure(_ key: String) throws -> String {
|
||||
guard let str = getString(key) else {
|
||||
extension JSValueContainer {
|
||||
public func getStringEnsure(_ key: String) throws -> String {
|
||||
guard let str = self.getString(key) else {
|
||||
throw RequestParamError.request(key: key)
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
func getIntEnsure(_ key: String) throws -> Int {
|
||||
guard let int = getInt(key) else {
|
||||
|
||||
public func getIntEnsure(_ key: String) throws -> Int {
|
||||
guard let int = self.getInt(key) else {
|
||||
throw RequestParamError.request(key: key)
|
||||
}
|
||||
return int
|
||||
}
|
||||
|
||||
func getDoubleEnsure(_ key: String) throws -> Double {
|
||||
guard let doub = getDouble(key) else {
|
||||
|
||||
public func getDoubleEnsure(_ key: String) throws -> Double {
|
||||
guard let doub = self.getDouble(key) else {
|
||||
throw RequestParamError.request(key: key)
|
||||
}
|
||||
return doub
|
||||
}
|
||||
|
||||
func getBoolEnsure(_ key: String) throws -> Bool {
|
||||
guard let bool = getBool(key) else {
|
||||
|
||||
public func getBoolEnsure(_ key: String) throws -> Bool {
|
||||
guard let bool = self.getBool(key) else {
|
||||
throw RequestParamError.request(key: key)
|
||||
}
|
||||
return bool
|
||||
}
|
||||
|
||||
func getArrayEnsure(_ key: String) throws -> JSArray {
|
||||
guard let arr = getArray(key) else {
|
||||
|
||||
public func getArrayEnsure(_ key: String) throws -> JSArray {
|
||||
guard let arr = self.getArray(key) else {
|
||||
throw RequestParamError.request(key: key)
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
func getArrayEnsure<T>(_ key: String, _ ofType: T.Type) throws -> [T] {
|
||||
guard let arr = getArray(key, ofType) else {
|
||||
|
||||
public func getArrayEnsure<T>(_ key: String, _ ofType: T.Type) throws -> [T] {
|
||||
guard let arr = self.getArray(key, ofType) else {
|
||||
throw RequestParamError.request(key: key)
|
||||
}
|
||||
return arr
|
||||
|
||||
@@ -8,15 +8,15 @@
|
||||
import Foundation
|
||||
|
||||
final class Mutex<Wrapped>: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private let lock = NSLock.init()
|
||||
private var wrapped: Wrapped
|
||||
|
||||
|
||||
init(_ wrapped: Wrapped) {
|
||||
self.wrapped = wrapped
|
||||
}
|
||||
|
||||
|
||||
func withLock<R>(_ body: @Sendable (inout Wrapped) throws -> R) rethrows -> R {
|
||||
lock.lock()
|
||||
self.lock.lock()
|
||||
defer { self.lock.unlock() }
|
||||
return try body(&wrapped)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
CAPPluginMethod(name: "signInPassword", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "signOut", returnType: CAPPluginReturnPromise),
|
||||
]
|
||||
|
||||
|
||||
@objc public func signInMagicLink(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
@@ -18,7 +18,7 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
let email = try call.getStringEnsure("email")
|
||||
let token = try call.getStringEnsure("token")
|
||||
let clientNonce = call.getString("clientNonce")
|
||||
|
||||
|
||||
let (data, response) = try await self.fetch(endpoint, method: "POST", action: "/api/auth/magic-link", headers: [:], body: ["email": email, "token": token, "client_nonce": clientNonce])
|
||||
|
||||
if response.statusCode >= 400 {
|
||||
@@ -28,19 +28,19 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
call.reject("Failed to sign in")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
guard let token = try self.tokenFromCookie(endpoint) else {
|
||||
call.reject("token not found")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
call.resolve(["token": token])
|
||||
} catch {
|
||||
call.reject("Failed to sign in, \(error)", nil, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc public func signInOauth(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
@@ -48,9 +48,9 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
let code = try call.getStringEnsure("code")
|
||||
let state = try call.getStringEnsure("state")
|
||||
let clientNonce = call.getString("clientNonce")
|
||||
|
||||
|
||||
let (data, response) = try await self.fetch(endpoint, method: "POST", action: "/api/oauth/callback", headers: [:], body: ["code": code, "state": state, "client_nonce": clientNonce])
|
||||
|
||||
|
||||
if response.statusCode >= 400 {
|
||||
if let textBody = String(data: data, encoding: .utf8) {
|
||||
call.reject(textBody)
|
||||
@@ -58,19 +58,19 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
call.reject("Failed to sign in")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
guard let token = try self.tokenFromCookie(endpoint) else {
|
||||
call.reject("token not found")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
call.resolve(["token": token])
|
||||
} catch {
|
||||
call.reject("Failed to sign in, \(error)", nil, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc public func signInPassword(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
@@ -79,12 +79,12 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
let password = try call.getStringEnsure("password")
|
||||
let verifyToken = call.getString("verifyToken")
|
||||
let challenge = call.getString("challenge")
|
||||
|
||||
|
||||
let (data, response) = try await self.fetch(endpoint, method: "POST", action: "/api/auth/sign-in", headers: [
|
||||
"x-captcha-token": verifyToken,
|
||||
"x-captcha-challenge": challenge,
|
||||
"x-captcha-challenge": challenge
|
||||
], body: ["email": email, "password": password])
|
||||
|
||||
|
||||
if response.statusCode >= 400 {
|
||||
if let textBody = String(data: data, encoding: .utf8) {
|
||||
call.reject(textBody)
|
||||
@@ -92,24 +92,24 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
call.reject("Failed to sign in")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
guard let token = try self.tokenFromCookie(endpoint) else {
|
||||
call.reject("token not found")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
call.resolve(["token": token])
|
||||
} catch {
|
||||
call.reject("Failed to sign in, \(error)", nil, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc public func signOut(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
let endpoint = try call.getStringEnsure("endpoint")
|
||||
|
||||
|
||||
let (data, response) = try await self.fetch(endpoint, method: "GET", action: "/api/auth/sign-out", headers: [:], body: nil)
|
||||
|
||||
if response.statusCode >= 400 {
|
||||
@@ -119,19 +119,20 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
call.reject("Failed to sign in")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
call.resolve(["ok": true])
|
||||
} catch {
|
||||
call.reject("Failed to sign in, \(error)", nil, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func tokenFromCookie(_ endpoint: String) throws -> String? {
|
||||
guard let endpointUrl = URL(string: endpoint) else {
|
||||
throw AuthError.invalidEndpoint
|
||||
}
|
||||
|
||||
|
||||
if let cookie = HTTPCookieStorage.shared.cookies(for: endpointUrl)?.first(where: {
|
||||
$0.name == "affine_session"
|
||||
}) {
|
||||
@@ -140,14 +141,14 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func fetch(_ endpoint: String, method: String, action: String, headers: [String: String?], body: Encodable?) async throws -> (Data, HTTPURLResponse) {
|
||||
|
||||
private func fetch(_ endpoint: String, method: String, action: String, headers: Dictionary<String, String?>, body: Encodable?) async throws -> (Data, HTTPURLResponse) {
|
||||
guard let targetUrl = URL(string: "\(endpoint)\(action)") else {
|
||||
throw AuthError.invalidEndpoint
|
||||
}
|
||||
|
||||
var request = URLRequest(url: targetUrl)
|
||||
request.httpMethod = method
|
||||
|
||||
var request = URLRequest(url: targetUrl);
|
||||
request.httpMethod = method;
|
||||
request.httpShouldHandleCookies = true
|
||||
for (key, value) in headers {
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
@@ -158,8 +159,8 @@ public class AuthPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
}
|
||||
request.setValue(AppConfigManager.getAffineVersion(), forHTTPHeaderField: "x-affine-version")
|
||||
request.timeoutInterval = 10 // time out 10s
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request);
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw AuthError.internalError
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import Capacitor
|
||||
import Foundation
|
||||
|
||||
// @objc(IntelligentsPlugin)
|
||||
// public class IntelligentsPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
//@objc(IntelligentsPlugin)
|
||||
//public class IntelligentsPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
// public let identifier = "IntelligentsPlugin"
|
||||
// public let jsName = "Intelligents"
|
||||
// public let pluginMethods: [CAPPluginMethod] = [
|
||||
@@ -33,4 +33,4 @@ import Foundation
|
||||
// call.resolve()
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
||||
@@ -4,7 +4,7 @@ import Foundation
|
||||
@objc(NbStorePlugin)
|
||||
public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
private let docStoragePool: DocStoragePool = newDocStoragePool()
|
||||
|
||||
|
||||
public let identifier = "NbStorePlugin"
|
||||
public let jsName = "NbStoreDocStorage"
|
||||
public let pluginMethods: [CAPPluginMethod] = [
|
||||
@@ -37,7 +37,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
CAPPluginMethod(name: "getBlobUploadedAt", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "setBlobUploadedAt", returnType: CAPPluginReturnPromise),
|
||||
]
|
||||
|
||||
|
||||
@objc func connect(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
@@ -52,10 +52,10 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
let peerDir = documentDir.appending(path: "workspaces")
|
||||
.appending(path: spaceType)
|
||||
.appending(path:
|
||||
peer
|
||||
.replacing(#/[\/!@#$%^&*()+~`"':;,?<>|]/#, with: "_")
|
||||
.replacing(/_+/, with: "_")
|
||||
.replacing(/_+$/, with: ""))
|
||||
peer
|
||||
.replacing(#/[\/!@#$%^&*()+~`"':;,?<>|]/#, with: "_")
|
||||
.replacing(/_+/, with: "_")
|
||||
.replacing(/_+$/, with: ""))
|
||||
try FileManager.default.createDirectory(atPath: peerDir.path(), withIntermediateDirectories: true)
|
||||
let db = peerDir.appending(path: spaceId + ".db")
|
||||
try await docStoragePool.connect(universalId: id, path: db.path())
|
||||
@@ -65,7 +65,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func disconnect(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
@@ -77,7 +77,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func setSpaceId(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
@@ -90,7 +90,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func pushUpdate(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
@@ -104,13 +104,13 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func getDocSnapshot(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
let id = try call.getStringEnsure("id")
|
||||
let docId = try call.getStringEnsure("docId")
|
||||
|
||||
|
||||
if let record = try await docStoragePool.getDocSnapshot(universalId: id, docId: docId) {
|
||||
call.resolve([
|
||||
"docId": record.docId,
|
||||
@@ -125,7 +125,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func setDocSnapshot(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
@@ -143,7 +143,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func getDocUpdates(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
@@ -161,14 +161,14 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func markUpdatesMerged(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
let id = try call.getStringEnsure("id")
|
||||
let docId = try call.getStringEnsure("docId")
|
||||
let times = try call.getArrayEnsure("timestamps", Int64.self)
|
||||
|
||||
|
||||
let count = try await docStoragePool.markUpdatesMerged(universalId: id, docId: docId, updates: times)
|
||||
call.resolve(["count": count])
|
||||
} catch {
|
||||
@@ -176,13 +176,13 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func deleteDoc(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
let id = try call.getStringEnsure("id")
|
||||
let docId = try call.getStringEnsure("docId")
|
||||
|
||||
|
||||
try await docStoragePool.deleteDoc(universalId: id, docId: docId)
|
||||
call.resolve()
|
||||
} catch {
|
||||
@@ -190,13 +190,13 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func getDocClocks(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
let id = try call.getStringEnsure("id")
|
||||
let after = call.getInt("after")
|
||||
|
||||
|
||||
let docClocks = try await docStoragePool.getDocClocks(
|
||||
universalId: id,
|
||||
after: after != nil ? Int64(after!) : nil
|
||||
@@ -211,7 +211,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func getDocClock(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
@@ -230,7 +230,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func getBlob(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
@@ -242,7 +242,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
"data": blob.data,
|
||||
"mime": blob.mime,
|
||||
"size": blob.size,
|
||||
"createdAt": blob.createdAt,
|
||||
"createdAt": blob.createdAt
|
||||
])
|
||||
} else {
|
||||
call.resolve()
|
||||
@@ -252,7 +252,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func setBlob(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
@@ -267,7 +267,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func deleteBlob(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
@@ -281,7 +281,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func releaseBlobs(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
@@ -293,7 +293,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func listBlobs(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
@@ -311,13 +311,13 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func getPeerRemoteClocks(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
let id = try call.getStringEnsure("id")
|
||||
let peer = try call.getStringEnsure("peer")
|
||||
|
||||
|
||||
let clocks = try await docStoragePool.getPeerRemoteClocks(universalId: id, peer: peer)
|
||||
let mapped = clocks.map { [
|
||||
"docId": $0.docId,
|
||||
@@ -329,14 +329,14 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func getPeerRemoteClock(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
let id = try call.getStringEnsure("id")
|
||||
let peer = try call.getStringEnsure("peer")
|
||||
let docId = try call.getStringEnsure("docId")
|
||||
|
||||
|
||||
if let clock = try await docStoragePool.getPeerRemoteClock(universalId: id, peer: peer, docId: docId) {
|
||||
call.resolve([
|
||||
"docId": clock.docId,
|
||||
@@ -345,13 +345,13 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
} else {
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
|
||||
} catch {
|
||||
call.reject("Failed to get peer remote clock, \(error)", nil, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func setPeerRemoteClock(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
@@ -371,13 +371,13 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func getPeerPulledRemoteClocks(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
let id = try call.getStringEnsure("id")
|
||||
let peer = try call.getStringEnsure("peer")
|
||||
|
||||
|
||||
let clocks = try await docStoragePool.getPeerPulledRemoteClocks(universalId: id, peer: peer)
|
||||
let mapped = clocks.map { [
|
||||
"docId": $0.docId,
|
||||
@@ -389,14 +389,14 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func getPeerPulledRemoteClock(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
let id = try call.getStringEnsure("id")
|
||||
let peer = try call.getStringEnsure("peer")
|
||||
let docId = try call.getStringEnsure("docId")
|
||||
|
||||
|
||||
if let clock = try await docStoragePool.getPeerPulledRemoteClock(universalId: id, peer: peer, docId: docId) {
|
||||
call.resolve([
|
||||
"docId": clock.docId,
|
||||
@@ -405,13 +405,13 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
} else {
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
|
||||
} catch {
|
||||
call.reject("Failed to get peer pulled remote clock, \(error)", nil, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func setPeerPulledRemoteClock(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
@@ -419,7 +419,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
let peer = try call.getStringEnsure("peer")
|
||||
let docId = try call.getStringEnsure("docId")
|
||||
let timestamp = try call.getIntEnsure("timestamp")
|
||||
|
||||
|
||||
try await docStoragePool.setPeerPulledRemoteClock(
|
||||
universalId: id,
|
||||
peer: peer,
|
||||
@@ -432,7 +432,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func getPeerPushedClock(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
@@ -452,7 +452,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func getPeerPushedClocks(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
@@ -464,13 +464,13 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
"timestamp": $0.timestamp,
|
||||
] }
|
||||
call.resolve(["clocks": mapped])
|
||||
|
||||
|
||||
} catch {
|
||||
call.reject("Failed to get peer pushed clocks, \(error)", nil, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func setPeerPushedClock(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
@@ -478,7 +478,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
let peer = try call.getStringEnsure("peer")
|
||||
let docId = try call.getStringEnsure("docId")
|
||||
let timestamp = try call.getIntEnsure("timestamp")
|
||||
|
||||
|
||||
try await docStoragePool.setPeerPushedClock(
|
||||
universalId: id,
|
||||
peer: peer,
|
||||
@@ -491,29 +491,29 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func getBlobUploadedAt(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
let id = try call.getStringEnsure("id")
|
||||
let peer = try call.getStringEnsure("peer")
|
||||
let blobId = try call.getStringEnsure("blobId")
|
||||
|
||||
|
||||
let uploadedAt = try await docStoragePool.getBlobUploadedAt(
|
||||
universalId: id,
|
||||
peer: peer,
|
||||
blobId: blobId
|
||||
)
|
||||
|
||||
|
||||
call.resolve([
|
||||
"uploadedAt": uploadedAt as Any,
|
||||
"uploadedAt": uploadedAt as Any
|
||||
])
|
||||
} catch {
|
||||
call.reject("Failed to get blob uploaded, \(error)", nil, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func setBlobUploadedAt(_ call: CAPPluginCall) {
|
||||
Task {
|
||||
do {
|
||||
@@ -521,7 +521,7 @@ public class NbStorePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
let peer = try call.getStringEnsure("peer")
|
||||
let blobId = try call.getStringEnsure("blobId")
|
||||
let uploadedAt = call.getInt("uploadedAt")
|
||||
|
||||
|
||||
try await docStoragePool.setBlobUploadedAt(
|
||||
universalId: id,
|
||||
peer: peer,
|
||||
|
||||
@@ -11,24 +11,26 @@ class SafeWKURLSchemeTask: WKURLSchemeTask, NSObject {
|
||||
var origin: any WKURLSchemeTask
|
||||
init(origin: any WKURLSchemeTask) {
|
||||
self.origin = origin
|
||||
request = origin.request
|
||||
self.request = origin.request
|
||||
}
|
||||
|
||||
|
||||
var request: URLRequest
|
||||
|
||||
func didReceive(_: URLResponse) {
|
||||
|
||||
func didReceive(_ response: URLResponse) {
|
||||
<#code#>
|
||||
}
|
||||
|
||||
func didReceive(_: Data) {
|
||||
origin.didReceive(<#T##response: URLResponse##URLResponse#>)
|
||||
|
||||
func didReceive(_ data: Data) {
|
||||
self.origin.didReceive(<#T##response: URLResponse##URLResponse#>)
|
||||
}
|
||||
|
||||
|
||||
func didFinish() {
|
||||
origin.didFinish()
|
||||
self.origin.didFinish()
|
||||
}
|
||||
|
||||
|
||||
func didFailWithError(_ error: any Error) {
|
||||
origin.didFailWithError(error)
|
||||
self.origin.didFailWithError(error)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
+1
@@ -39,3 +39,4 @@ public enum CustomJSON: CustomScalarType, Hashable {
|
||||
hasher.combine(_jsonValue)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-125
@@ -1,125 +0,0 @@
|
||||
//
|
||||
// ChatManager+ContextModels.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/26/25.
|
||||
//
|
||||
|
||||
import AffineGraphQL
|
||||
import Foundation
|
||||
|
||||
// MARK: - ChatManager Context Models Extension
|
||||
|
||||
extension ChatManager {
|
||||
// MARK: - Context Models
|
||||
|
||||
struct ContextReference: Codable, Identifiable, Equatable, Hashable {
|
||||
var id: String
|
||||
var fileId: String?
|
||||
var docId: String?
|
||||
var chunk: Int
|
||||
var content: String
|
||||
var distance: Double
|
||||
var highlightedContent: String?
|
||||
|
||||
init(fileId: String? = nil, docId: String? = nil, chunk: Int, content: String, distance: Double, highlightedContent: String? = nil) {
|
||||
id = UUID().uuidString
|
||||
self.fileId = fileId
|
||||
self.docId = docId
|
||||
self.chunk = chunk
|
||||
self.content = content
|
||||
self.distance = distance
|
||||
self.highlightedContent = highlightedContent
|
||||
}
|
||||
}
|
||||
|
||||
struct CopilotContext: Codable, Identifiable, Equatable, Hashable {
|
||||
var id: String
|
||||
var sessionId: String
|
||||
var workspaceId: String
|
||||
var files: [ContextFile]
|
||||
var docs: [ContextDoc]
|
||||
var categories: [ContextCategory]
|
||||
|
||||
init(id: String, sessionId: String, workspaceId: String, files: [ContextFile] = [], docs: [ContextDoc] = [], categories: [ContextCategory] = []) {
|
||||
self.id = id
|
||||
self.sessionId = sessionId
|
||||
self.workspaceId = workspaceId
|
||||
self.files = files
|
||||
self.docs = docs
|
||||
self.categories = categories
|
||||
}
|
||||
}
|
||||
|
||||
struct ContextFile: Codable, Identifiable, Equatable, Hashable {
|
||||
var id: String
|
||||
var contextId: String
|
||||
var blobId: String
|
||||
var fileName: String?
|
||||
var fileSize: Int?
|
||||
var mimeType: String?
|
||||
var embeddingStatus: ContextEmbedStatus?
|
||||
var createdAt: DateTime?
|
||||
|
||||
var createdDate: Date? {
|
||||
createdAt?.decoded
|
||||
}
|
||||
}
|
||||
|
||||
struct ContextDoc: Codable, Identifiable, Equatable, Hashable {
|
||||
var id: String
|
||||
var contextId: String
|
||||
var docId: String
|
||||
var title: String?
|
||||
var embeddingStatus: ContextEmbedStatus?
|
||||
var createdAt: DateTime?
|
||||
|
||||
var createdDate: Date? {
|
||||
createdAt?.decoded
|
||||
}
|
||||
}
|
||||
|
||||
struct ContextCategory: Codable, Identifiable, Equatable, Hashable {
|
||||
var id: String
|
||||
var contextId: String
|
||||
var type: ContextCategoryType
|
||||
var docs: [String]
|
||||
var name: String?
|
||||
var createdAt: DateTime?
|
||||
|
||||
var createdDate: Date? {
|
||||
createdAt?.decoded
|
||||
}
|
||||
}
|
||||
|
||||
enum ContextEmbedStatus: String, Codable, CaseIterable {
|
||||
case pending = "Pending"
|
||||
case failed = "Failed"
|
||||
case completed = "Completed"
|
||||
}
|
||||
|
||||
enum ContextCategoryType: String, Codable, CaseIterable {
|
||||
case tag = "TAG"
|
||||
case collection = "COLLECTION"
|
||||
}
|
||||
|
||||
struct MatchContextResult: Codable, Identifiable, Equatable, Hashable {
|
||||
var id: String
|
||||
var fileId: String?
|
||||
var docId: String?
|
||||
var chunk: Int
|
||||
var content: String
|
||||
var distance: Double
|
||||
var highlightedContent: String?
|
||||
|
||||
init(fileId: String? = nil, docId: String? = nil, chunk: Int, content: String, distance: Double, highlightedContent: String? = nil) {
|
||||
id = UUID().uuidString
|
||||
self.fileId = fileId
|
||||
self.docId = docId
|
||||
self.chunk = chunk
|
||||
self.content = content
|
||||
self.distance = distance
|
||||
self.highlightedContent = highlightedContent
|
||||
}
|
||||
}
|
||||
}
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
//
|
||||
// ChatManager+InputModels.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/26/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - ChatManager Input Models Extension
|
||||
|
||||
extension ChatManager {
|
||||
// MARK: - Input Models
|
||||
|
||||
struct AddContextFileInput: Codable, Equatable, Hashable {
|
||||
var contextId: String
|
||||
var blobId: String
|
||||
}
|
||||
|
||||
struct RemoveContextFileInput: Codable, Equatable, Hashable {
|
||||
var contextId: String
|
||||
var fileId: String
|
||||
}
|
||||
|
||||
struct AddContextDocInput: Codable, Equatable, Hashable {
|
||||
var contextId: String
|
||||
var docId: String
|
||||
}
|
||||
|
||||
struct RemoveContextDocInput: Codable, Equatable, Hashable {
|
||||
var contextId: String
|
||||
var docId: String
|
||||
}
|
||||
|
||||
struct AddContextCategoryInput: Codable, Equatable, Hashable {
|
||||
var contextId: String
|
||||
var docs: [String]
|
||||
}
|
||||
|
||||
struct RemoveContextCategoryInput: Codable, Equatable, Hashable {
|
||||
var contextId: String
|
||||
var categoryId: String
|
||||
}
|
||||
}
|
||||
-74
@@ -1,74 +0,0 @@
|
||||
//
|
||||
// ChatManager+WorkflowModels.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/26/25.
|
||||
//
|
||||
|
||||
import AffineGraphQL
|
||||
import Foundation
|
||||
|
||||
// MARK: - ChatManager Workflow Models Extension
|
||||
|
||||
extension ChatManager {
|
||||
// MARK: - Workflow Models
|
||||
|
||||
struct WorkflowEventData: Codable, Identifiable, Equatable, Hashable {
|
||||
var id: String
|
||||
var status: String
|
||||
var type: String
|
||||
var progress: Double?
|
||||
var message: String?
|
||||
|
||||
init(status: String, type: String, progress: Double? = nil, message: String? = nil) {
|
||||
id = UUID().uuidString
|
||||
self.status = status
|
||||
self.type = type
|
||||
self.progress = progress
|
||||
self.message = message
|
||||
}
|
||||
}
|
||||
|
||||
struct WorkspaceEmbeddingStatus: Codable, Identifiable, Equatable, Hashable {
|
||||
var id: String
|
||||
var workspaceId: String
|
||||
var total: Int
|
||||
var embedded: Int
|
||||
|
||||
var progress: Double {
|
||||
total > 0 ? Double(embedded) / Double(total) : 0.0
|
||||
}
|
||||
|
||||
init(workspaceId: String, total: Int, embedded: Int) {
|
||||
id = workspaceId
|
||||
self.workspaceId = workspaceId
|
||||
self.total = total
|
||||
self.embedded = embedded
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatEvent: Codable, Identifiable, Equatable, Hashable {
|
||||
var id: String
|
||||
var type: ChatEventType
|
||||
var data: String
|
||||
var timestamp: DateTime?
|
||||
|
||||
var timestampDate: Date? {
|
||||
timestamp?.decoded
|
||||
}
|
||||
|
||||
init(type: ChatEventType, data: String, timestamp: DateTime? = nil) {
|
||||
id = UUID().uuidString
|
||||
self.type = type
|
||||
self.data = data
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
}
|
||||
|
||||
enum ChatEventType: String, Codable, CaseIterable {
|
||||
case message
|
||||
case attachment
|
||||
case event
|
||||
case ping
|
||||
}
|
||||
}
|
||||
-227
@@ -1,227 +0,0 @@
|
||||
//
|
||||
// ChatManager.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/26/25.
|
||||
//
|
||||
|
||||
import AffineGraphQL
|
||||
import Apollo
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
// MARK: - ChatManager
|
||||
|
||||
public class ChatManager: ObservableObject {
|
||||
public static let shared = ChatManager()
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
@Published public private(set) var sessions: [SessionViewModel] = []
|
||||
@Published public private(set) var currentSession: SessionViewModel?
|
||||
@Published public private(set) var messages: [String: [ChatMessage]] = [:]
|
||||
@Published public private(set) var isLoading = false
|
||||
@Published public private(set) var error: Error?
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private let apolloClient: ApolloClient
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
private init(apolloClient: ApolloClient = QLService.shared.client) {
|
||||
self.apolloClient = apolloClient
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
public func createSession(
|
||||
workspaceId: String,
|
||||
promptName: String = "",
|
||||
docId: String? = nil,
|
||||
pinned: Bool = false
|
||||
) async throws -> SessionViewModel {
|
||||
isLoading = true
|
||||
error = nil
|
||||
|
||||
do {
|
||||
let input = CreateChatSessionInput(
|
||||
docId: docId.map { .some($0) } ?? .null,
|
||||
pinned: .some(pinned),
|
||||
promptName: promptName,
|
||||
workspaceId: workspaceId
|
||||
)
|
||||
|
||||
let mutation = CreateCopilotSessionMutation(options: input)
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
apolloClient.perform(mutation: mutation) { result in
|
||||
switch result {
|
||||
case let .success(graphQLResult):
|
||||
guard let sessionId = graphQLResult.data?.createCopilotSession else {
|
||||
continuation.resume(throwing: ChatError.invalidResponse)
|
||||
return
|
||||
}
|
||||
|
||||
let session = SessionViewModel(
|
||||
id: sessionId,
|
||||
workspaceId: workspaceId,
|
||||
docId: docId,
|
||||
promptName: promptName,
|
||||
model: nil,
|
||||
pinned: pinned,
|
||||
tokens: 0,
|
||||
createdAt: DateTime(date: Date()),
|
||||
updatedAt: DateTime(date: Date()),
|
||||
parentSessionId: nil
|
||||
)
|
||||
|
||||
Task { @MainActor in
|
||||
self.sessions.append(session)
|
||||
self.currentSession = session
|
||||
self.messages[sessionId] = []
|
||||
self.isLoading = false
|
||||
}
|
||||
|
||||
continuation.resume(returning: session)
|
||||
|
||||
case let .failure(error):
|
||||
Task { @MainActor in
|
||||
self.error = error
|
||||
self.isLoading = false
|
||||
}
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.error = error
|
||||
self.isLoading = false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public func sendMessage(
|
||||
content: String,
|
||||
attachments: [String] = [],
|
||||
sessionId: String? = nil
|
||||
) async throws {
|
||||
guard let targetSessionId = sessionId ?? currentSession?.id else {
|
||||
throw ChatError.noActiveSession
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
error = nil
|
||||
|
||||
// Add user message immediately
|
||||
let userMessage = ChatMessage(
|
||||
id: UUID().uuidString,
|
||||
role: .user,
|
||||
content: content,
|
||||
attachments: attachments.isEmpty ? nil : attachments,
|
||||
params: nil,
|
||||
createdAt: DateTime(date: Date())
|
||||
)
|
||||
|
||||
await MainActor.run {
|
||||
var sessionMessages = self.messages[targetSessionId] ?? []
|
||||
sessionMessages.append(userMessage)
|
||||
self.messages[targetSessionId] = sessionMessages
|
||||
}
|
||||
|
||||
do {
|
||||
let input = CreateChatMessageInput(
|
||||
attachments: attachments.isEmpty ? .null : .some(attachments),
|
||||
blobs: .null,
|
||||
content: .some(content),
|
||||
params: .null,
|
||||
sessionId: targetSessionId
|
||||
)
|
||||
|
||||
let mutation = CreateCopilotMessageMutation(options: input)
|
||||
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
apolloClient.perform(mutation: mutation) { result in
|
||||
switch result {
|
||||
case let .success(graphQLResult):
|
||||
guard let messageId = graphQLResult.data?.createCopilotMessage else {
|
||||
continuation.resume(throwing: ChatError.invalidResponse)
|
||||
return
|
||||
}
|
||||
|
||||
// Add assistant message placeholder
|
||||
let assistantMessage = ChatMessage(
|
||||
id: messageId,
|
||||
role: .assistant,
|
||||
content: "Thinking...",
|
||||
attachments: nil,
|
||||
params: nil,
|
||||
createdAt: DateTime(date: Date())
|
||||
)
|
||||
|
||||
Task { @MainActor in
|
||||
var sessionMessages = self.messages[targetSessionId] ?? []
|
||||
sessionMessages.append(assistantMessage)
|
||||
self.messages[targetSessionId] = sessionMessages
|
||||
self.isLoading = false
|
||||
}
|
||||
|
||||
continuation.resume()
|
||||
|
||||
// TODO: Implement streaming response handling
|
||||
|
||||
case let .failure(error):
|
||||
Task { @MainActor in
|
||||
self.error = error
|
||||
self.isLoading = false
|
||||
}
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.error = error
|
||||
self.isLoading = false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public func switchToSession(_ session: SessionViewModel) {
|
||||
currentSession = session
|
||||
}
|
||||
|
||||
public func deleteSession(sessionId: String) {
|
||||
sessions.removeAll { $0.id == sessionId }
|
||||
messages.removeValue(forKey: sessionId)
|
||||
|
||||
if currentSession?.id == sessionId {
|
||||
currentSession = sessions.first
|
||||
}
|
||||
}
|
||||
|
||||
public func clearError() {
|
||||
error = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ChatError
|
||||
|
||||
public enum ChatError: LocalizedError {
|
||||
case noActiveSession
|
||||
case invalidResponse
|
||||
case networkError(Error)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .noActiveSession:
|
||||
"No active chat session"
|
||||
case .invalidResponse:
|
||||
"Invalid response from server"
|
||||
case let .networkError(error):
|
||||
"Network error: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
-95
@@ -1,95 +0,0 @@
|
||||
//
|
||||
// ChatMessage.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/26/25.
|
||||
//
|
||||
|
||||
import AffineGraphQL
|
||||
import Foundation
|
||||
|
||||
public struct ChatMessage: Codable, Identifiable, Equatable, Hashable {
|
||||
public var id: String?
|
||||
public var role: MessageRole
|
||||
public var content: String
|
||||
public var attachments: [String]?
|
||||
public var params: [String: String]?
|
||||
public var createdAt: DateTime?
|
||||
|
||||
public var createdDate: Date? {
|
||||
createdAt?.decoded
|
||||
}
|
||||
|
||||
public var messageId: String {
|
||||
id ?? UUID().uuidString
|
||||
}
|
||||
|
||||
public init(
|
||||
id: String? = nil,
|
||||
role: MessageRole,
|
||||
content: String,
|
||||
attachments: [String]? = nil,
|
||||
params: [String: String]? = nil,
|
||||
createdAt: DateTime? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.role = role
|
||||
self.content = content
|
||||
self.attachments = attachments
|
||||
self.params = params
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
}
|
||||
|
||||
public extension ChatMessage {
|
||||
enum MessageRole: String, Codable, CaseIterable {
|
||||
case user
|
||||
case assistant
|
||||
case system
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionViewModel: Codable, Identifiable, Equatable, Hashable {
|
||||
public var id: String
|
||||
public var workspaceId: String
|
||||
public var docId: String?
|
||||
public var promptName: String
|
||||
public var model: String?
|
||||
public var pinned: Bool
|
||||
public var tokens: Int
|
||||
public var createdAt: DateTime?
|
||||
public var updatedAt: DateTime?
|
||||
public var parentSessionId: String?
|
||||
|
||||
public var createdDate: Date? {
|
||||
createdAt?.decoded
|
||||
}
|
||||
|
||||
public var updatedDate: Date? {
|
||||
updatedAt?.decoded
|
||||
}
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
workspaceId: String,
|
||||
docId: String? = nil,
|
||||
promptName: String,
|
||||
model: String? = nil,
|
||||
pinned: Bool,
|
||||
tokens: Int,
|
||||
createdAt: DateTime? = nil,
|
||||
updatedAt: DateTime? = nil,
|
||||
parentSessionId: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.workspaceId = workspaceId
|
||||
self.docId = docId
|
||||
self.promptName = promptName
|
||||
self.model = model
|
||||
self.pinned = pinned
|
||||
self.tokens = tokens
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
self.parentSessionId = parentSessionId
|
||||
}
|
||||
}
|
||||
+3
-5
@@ -11,6 +11,7 @@ import ApolloAPI
|
||||
import Foundation
|
||||
|
||||
/// A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format.
|
||||
extension DateTime {
|
||||
extension DateTime {
|
||||
private static let formatter: DateFormatter = {
|
||||
let fmt = DateFormatter()
|
||||
@@ -19,11 +20,8 @@ extension DateTime {
|
||||
return fmt
|
||||
}()
|
||||
|
||||
init(date: Date) {
|
||||
self.init(Self.formatter.string(from: date))
|
||||
}
|
||||
|
||||
var decoded: Date? {
|
||||
Self.formatter.date(from: self)
|
||||
return Self.formatter.date(from: self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import Foundation
|
||||
public final class QLService {
|
||||
public static let shared = QLService()
|
||||
private var endpointURL: URL
|
||||
public var client: ApolloClient
|
||||
public private(set) var client: ApolloClient
|
||||
|
||||
private init() {
|
||||
let store = ApolloStore()
|
||||
|
||||
+1
-42
@@ -75,48 +75,7 @@ extension MainViewController: InputBoxDelegate {
|
||||
}
|
||||
|
||||
func inputBoxDidSend(_ inputBox: InputBox) {
|
||||
let inputData = inputBox.inputBoxData
|
||||
|
||||
Task { @MainActor in
|
||||
do {
|
||||
let chatManager = ChatManager.shared
|
||||
|
||||
if let currentSession = chatManager.currentSession {
|
||||
try await chatManager.sendMessage(
|
||||
content: inputData.text,
|
||||
attachments: [], // TODO: Handle attachments
|
||||
sessionId: currentSession.id
|
||||
)
|
||||
} else {
|
||||
guard let workspaceId = IntelligentContext.shared.webViewMetadata[.currentWorkspaceId] as? String,
|
||||
!workspaceId.isEmpty
|
||||
else {
|
||||
showAlert(title: "Error", message: "No workspace available")
|
||||
return
|
||||
}
|
||||
|
||||
let session = try await chatManager.createSession(workspaceId: workspaceId)
|
||||
|
||||
try await chatManager.sendMessage(
|
||||
content: inputData.text,
|
||||
attachments: [], // TODO: Handle attachments
|
||||
sessionId: session.id
|
||||
)
|
||||
}
|
||||
|
||||
inputBox.text = ""
|
||||
inputBox.viewModel.clearAllAttachments()
|
||||
|
||||
} catch {
|
||||
showAlert(title: "Error", message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func showAlert(title: String, message: String) {
|
||||
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "OK", style: .default))
|
||||
present(alert, animated: true)
|
||||
print(#function, inputBox, inputBox.viewModel)
|
||||
}
|
||||
|
||||
func inputBoxTextDidChange(_ text: String) {
|
||||
|
||||
+7
-131
@@ -10,27 +10,6 @@ class MainViewController: UIViewController {
|
||||
$0.delegate = self
|
||||
}
|
||||
|
||||
lazy var tableView = UITableView().then {
|
||||
$0.backgroundColor = .clear
|
||||
$0.separatorStyle = .none
|
||||
$0.delegate = self
|
||||
$0.dataSource = self
|
||||
$0.register(ChatCell.self, forCellReuseIdentifier: "ChatCell")
|
||||
$0.keyboardDismissMode = .interactive
|
||||
$0.contentInsetAdjustmentBehavior = .never
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
lazy var inputBox = InputBox().then {
|
||||
$0.delegate = self
|
||||
}
|
||||
@@ -49,10 +28,8 @@ class MainViewController: UIViewController {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private var messages: [ChatMessage] = []
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private let intelligentContext = IntelligentContext.shared
|
||||
private let chatManager = ChatManager.shared
|
||||
var terminateEditGesture: UITapGestureRecognizer!
|
||||
|
||||
// MARK: - Lifecycle
|
||||
@@ -61,46 +38,21 @@ class MainViewController: UIViewController {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .affineLayerBackgroundPrimary
|
||||
|
||||
setupUI()
|
||||
setupBindings()
|
||||
let inputBox = InputBox().then {
|
||||
$0.delegate = self
|
||||
}
|
||||
self.inputBox = inputBox
|
||||
|
||||
view.isUserInteractionEnabled = true
|
||||
terminateEditGesture = UITapGestureRecognizer(target: self, action: #selector(terminateEditing))
|
||||
view.addGestureRecognizer(terminateEditGesture)
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
private func setupUI() {
|
||||
view.addSubview(headerView)
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(emptyStateView)
|
||||
view.addSubview(inputBox)
|
||||
view.addSubview(documentPickerHideDetector)
|
||||
view.addSubview(documentPickerView)
|
||||
|
||||
emptyStateView.addSubview(emptyStateLabel)
|
||||
|
||||
headerView.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide)
|
||||
make.leading.trailing.equalToSuperview()
|
||||
}
|
||||
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.top.equalTo(headerView.snp.bottom)
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalTo(inputBox.snp.top)
|
||||
}
|
||||
|
||||
emptyStateView.snp.makeConstraints { make in
|
||||
make.center.equalTo(tableView)
|
||||
make.width.lessThanOrEqualTo(tableView).inset(32)
|
||||
}
|
||||
|
||||
emptyStateLabel.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
|
||||
inputBox.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalTo(view.keyboardLayoutGuide.snp.top)
|
||||
@@ -115,24 +67,10 @@ class MainViewController: UIViewController {
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.height.equalTo(500)
|
||||
}
|
||||
}
|
||||
|
||||
private func setupBindings() {
|
||||
chatManager.$currentSession
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] session in
|
||||
self?.updateMessages(for: session?.id)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
chatManager.$messages
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
if let sessionId = self?.chatManager.currentSession?.id {
|
||||
self?.updateMessages(for: sessionId)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
view.isUserInteractionEnabled = true
|
||||
terminateEditGesture = UITapGestureRecognizer(target: self, action: #selector(terminateEditing))
|
||||
view.addGestureRecognizer(terminateEditGesture)
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
@@ -152,66 +90,4 @@ class MainViewController: UIViewController {
|
||||
@objc func terminateEditing() {
|
||||
view.endEditing(true)
|
||||
}
|
||||
|
||||
// MARK: - Chat Methods
|
||||
|
||||
private func updateMessages(for sessionId: String?) {
|
||||
guard let sessionId else {
|
||||
messages = []
|
||||
updateEmptyState()
|
||||
tableView.reloadData()
|
||||
return
|
||||
}
|
||||
|
||||
messages = chatManager.messages[sessionId] ?? []
|
||||
updateEmptyState()
|
||||
tableView.reloadData()
|
||||
|
||||
if !messages.isEmpty {
|
||||
let indexPath = IndexPath(row: messages.count - 1, section: 0)
|
||||
tableView.scrollToRow(at: indexPath, at: .bottom, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateEmptyState() {
|
||||
emptyStateView.isHidden = !messages.isEmpty
|
||||
tableView.isHidden = messages.isEmpty
|
||||
}
|
||||
|
||||
// MARK: - Internal Methods for Preview/Testing
|
||||
|
||||
#if DEBUG
|
||||
func setMessagesForPreview(_ previewMessages: [ChatMessage]) {
|
||||
messages = previewMessages
|
||||
updateEmptyState()
|
||||
tableView.reloadData()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDataSource
|
||||
|
||||
extension MainViewController: UITableViewDataSource {
|
||||
func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
|
||||
messages.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "ChatCell", for: indexPath) as! ChatCell
|
||||
let message = messages[indexPath.row]
|
||||
cell.configure(with: message)
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDelegate
|
||||
|
||||
extension MainViewController: UITableViewDelegate {
|
||||
func tableView(_: UITableView, heightForRowAt _: IndexPath) -> CGFloat {
|
||||
UITableView.automaticDimension
|
||||
}
|
||||
|
||||
func tableView(_: UITableView, estimatedHeightForRowAt _: IndexPath) -> CGFloat {
|
||||
60
|
||||
}
|
||||
}
|
||||
|
||||
-155
@@ -1,155 +0,0 @@
|
||||
//
|
||||
// ChatCell.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/26/25.
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import Then
|
||||
import UIKit
|
||||
|
||||
class ChatCell: UITableViewCell {
|
||||
// MARK: - UI Components
|
||||
|
||||
private lazy var avatarImageView = UIImageView().then {
|
||||
$0.contentMode = .scaleAspectFit
|
||||
$0.layer.cornerRadius = 16
|
||||
$0.layer.cornerCurve = .continuous
|
||||
$0.clipsToBounds = true
|
||||
$0.backgroundColor = .systemGray5
|
||||
}
|
||||
|
||||
private lazy var messageContainerView = UIView().then {
|
||||
$0.layer.cornerRadius = 12
|
||||
$0.layer.cornerCurve = .continuous
|
||||
}
|
||||
|
||||
private lazy var messageLabel = UILabel().then {
|
||||
$0.numberOfLines = 0
|
||||
$0.font = .systemFont(ofSize: 16)
|
||||
$0.textColor = .label
|
||||
}
|
||||
|
||||
private lazy var timestampLabel = UILabel().then {
|
||||
$0.font = .systemFont(ofSize: 12)
|
||||
$0.textColor = .systemGray
|
||||
$0.textAlignment = .right
|
||||
}
|
||||
|
||||
private lazy var stackView = UIStackView().then {
|
||||
$0.axis = .horizontal
|
||||
$0.spacing = 12
|
||||
$0.alignment = .top
|
||||
}
|
||||
|
||||
private lazy var messageStackView = UIStackView().then {
|
||||
$0.axis = .vertical
|
||||
$0.spacing = 4
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private var message: ChatMessage?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
setupUI()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
private func setupUI() {
|
||||
backgroundColor = .clear
|
||||
selectionStyle = .none
|
||||
|
||||
contentView.addSubview(stackView)
|
||||
|
||||
messageStackView.addArrangedSubview(messageContainerView)
|
||||
messageStackView.addArrangedSubview(timestampLabel)
|
||||
|
||||
messageContainerView.addSubview(messageLabel)
|
||||
|
||||
stackView.addArrangedSubview(avatarImageView)
|
||||
stackView.addArrangedSubview(messageStackView)
|
||||
|
||||
stackView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(16)
|
||||
}
|
||||
|
||||
avatarImageView.snp.makeConstraints { make in
|
||||
make.size.equalTo(32)
|
||||
}
|
||||
|
||||
messageLabel.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(12)
|
||||
}
|
||||
|
||||
messageStackView.snp.makeConstraints { make in
|
||||
make.width.lessThanOrEqualTo(250)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
func configure(with message: ChatMessage) {
|
||||
self.message = message
|
||||
|
||||
messageLabel.text = message.content
|
||||
|
||||
if let createdDate = message.createdDate {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .none
|
||||
formatter.timeStyle = .short
|
||||
timestampLabel.text = formatter.string(from: createdDate)
|
||||
} else {
|
||||
timestampLabel.text = ""
|
||||
}
|
||||
|
||||
switch message.role {
|
||||
case .user:
|
||||
configureUserMessage()
|
||||
case .assistant:
|
||||
configureAssistantMessage()
|
||||
case .system:
|
||||
configureSystemMessage()
|
||||
}
|
||||
}
|
||||
|
||||
private func configureUserMessage() {
|
||||
// User message - align to right
|
||||
stackView.semanticContentAttribute = .forceRightToLeft
|
||||
messageContainerView.backgroundColor = .systemBlue
|
||||
messageLabel.textColor = .white
|
||||
avatarImageView.image = UIImage(systemName: "person.circle.fill")
|
||||
avatarImageView.tintColor = .systemBlue
|
||||
timestampLabel.textAlignment = .left
|
||||
}
|
||||
|
||||
private func configureAssistantMessage() {
|
||||
// Assistant message - align to left
|
||||
stackView.semanticContentAttribute = .forceLeftToRight
|
||||
messageContainerView.backgroundColor = .systemGray6
|
||||
messageLabel.textColor = .label
|
||||
avatarImageView.image = UIImage(systemName: "brain.head.profile")
|
||||
avatarImageView.tintColor = .systemPurple
|
||||
timestampLabel.textAlignment = .right
|
||||
}
|
||||
|
||||
private func configureSystemMessage() {
|
||||
// System message - center aligned
|
||||
stackView.semanticContentAttribute = .forceLeftToRight
|
||||
messageContainerView.backgroundColor = .systemYellow.withAlphaComponent(0.3)
|
||||
messageLabel.textColor = .label
|
||||
avatarImageView.image = UIImage(systemName: "gear")
|
||||
avatarImageView.tintColor = .systemOrange
|
||||
timestampLabel.textAlignment = .center
|
||||
}
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
//
|
||||
// AttachmentCellViewModel.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/26/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct AttachmentCellViewModel: ChatCellViewModel {
|
||||
var cellType: CellType = .attachment
|
||||
var id: String
|
||||
var attachments: [AttachmentViewModel]
|
||||
var parentMessageId: String
|
||||
}
|
||||
|
||||
struct AttachmentViewModel: Codable, Identifiable, Equatable, Hashable {
|
||||
var id: String
|
||||
var url: String
|
||||
var mimeType: String?
|
||||
var fileName: String?
|
||||
var size: Int64?
|
||||
}
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
//
|
||||
// CellType.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/26/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum CellType: String, Codable, CaseIterable {
|
||||
case userMessage
|
||||
case assistantMessage
|
||||
case systemMessage
|
||||
case attachment
|
||||
case contextReference
|
||||
case workflowStatus
|
||||
case transcription
|
||||
case loading
|
||||
case error
|
||||
}
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
//
|
||||
// ChatCellViewModels.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/26/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol ChatCellViewModel: Codable, Identifiable, Equatable, Hashable {
|
||||
var cellType: CellType { get }
|
||||
var id: String { get }
|
||||
}
|
||||
|
||||
struct UserMessageCellViewModel: ChatCellViewModel {
|
||||
var cellType: CellType = .userMessage
|
||||
var id: String
|
||||
var content: String
|
||||
var attachments: [AttachmentViewModel]
|
||||
var timestamp: Date?
|
||||
var isRetrying: Bool
|
||||
}
|
||||
|
||||
struct AssistantMessageCellViewModel: ChatCellViewModel {
|
||||
var cellType: CellType = .assistantMessage
|
||||
var id: String
|
||||
var content: String
|
||||
var attachments: [AttachmentViewModel]
|
||||
var timestamp: Date?
|
||||
var isStreaming: Bool
|
||||
var model: String?
|
||||
var tokens: Int?
|
||||
var canRetry: Bool
|
||||
}
|
||||
|
||||
struct SystemMessageCellViewModel: ChatCellViewModel {
|
||||
var cellType: CellType = .systemMessage
|
||||
var id: String
|
||||
var content: String
|
||||
var timestamp: Date?
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
//
|
||||
// ContextReferenceCellViewModel.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/26/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct ContextReferenceCellViewModel: ChatCellViewModel {
|
||||
var cellType: CellType = .contextReference
|
||||
var id: String
|
||||
var references: [ChatManager.ContextReference]
|
||||
var parentMessageId: String
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
//
|
||||
// ErrorCellViewModel.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/26/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct ErrorCellViewModel: ChatCellViewModel {
|
||||
var cellType: CellType = .error
|
||||
var id: String
|
||||
var errorMessage: String
|
||||
var canRetry: Bool
|
||||
var retryAction: String?
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
//
|
||||
// LoadingCellViewModel.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/26/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct LoadingCellViewModel: ChatCellViewModel {
|
||||
var cellType: CellType = .loading
|
||||
var id: String
|
||||
var message: String?
|
||||
var progress: Double?
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
//
|
||||
// WorkflowStatusCellViewModel.swift
|
||||
// Intelligents
|
||||
//
|
||||
// Created by 秋星桥 on 6/26/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct WorkflowStatusCellViewModel: ChatCellViewModel {
|
||||
var cellType: CellType = .workflowStatus
|
||||
var id: String
|
||||
var workflow: ChatManager.WorkflowEventData
|
||||
var parentMessageId: String
|
||||
}
|
||||
-6
@@ -66,12 +66,6 @@ public class InputBoxViewModel: ObservableObject {
|
||||
.assign(to: \.canSend, on: self)
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
public func clearAllAttachments() {
|
||||
imageAttachments.removeAll()
|
||||
fileAttachments.removeAll()
|
||||
documentAttachments.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Text Management
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const fallback = style({
|
||||
padding: '4px 8px',
|
||||
padding: '4px 16px',
|
||||
height: '100%',
|
||||
overflow: 'clip',
|
||||
});
|
||||
@@ -13,7 +13,7 @@ export const fallbackHeader = style({
|
||||
flexDirection: 'row',
|
||||
gap: '8px',
|
||||
overflow: 'hidden',
|
||||
height: '42px',
|
||||
height: '52px',
|
||||
});
|
||||
|
||||
export const spacer = style({
|
||||
|
||||
@@ -63,11 +63,7 @@ export const BUILD_IN_SERVERS: (ServerMetadata & { config: ServerConfig })[] =
|
||||
? [
|
||||
{
|
||||
id: 'affine-cloud',
|
||||
baseUrl: BUILD_CONFIG.isNative
|
||||
? BUILD_CONFIG.isIOS
|
||||
? 'https://apple.getaffineapp.com'
|
||||
: 'https://app.affine.pro'
|
||||
: location.origin,
|
||||
baseUrl: 'https://app.affine.pro',
|
||||
config: {
|
||||
serverName: 'Affine Cloud',
|
||||
features: [
|
||||
@@ -95,11 +91,7 @@ export const BUILD_IN_SERVERS: (ServerMetadata & { config: ServerConfig })[] =
|
||||
? [
|
||||
{
|
||||
id: 'affine-cloud',
|
||||
baseUrl: BUILD_CONFIG.isNative
|
||||
? BUILD_CONFIG.isIOS
|
||||
? 'https://apple.getaffineapp.com'
|
||||
: 'https://insider.affine.pro'
|
||||
: location.origin,
|
||||
baseUrl: 'https://insider.affine.pro',
|
||||
config: {
|
||||
serverName: 'Affine Cloud',
|
||||
features: [
|
||||
@@ -155,9 +147,7 @@ export const BUILD_IN_SERVERS: (ServerMetadata & { config: ServerConfig })[] =
|
||||
? [
|
||||
{
|
||||
id: 'affine-cloud',
|
||||
baseUrl: BUILD_CONFIG.isNative
|
||||
? 'https://affine.fail'
|
||||
: location.origin,
|
||||
baseUrl: 'https://affine.fail',
|
||||
config: {
|
||||
serverName: 'Affine Cloud',
|
||||
features: [
|
||||
|
||||
@@ -8,7 +8,6 @@ function maybeAffineOrigin(origin: string, baseUrl: string) {
|
||||
return (
|
||||
origin.startsWith('file://') ||
|
||||
origin.endsWith('affine.pro') || // stable/beta
|
||||
origin.endsWith('apple.getaffineapp.com') || // stable/beta
|
||||
origin.endsWith('affine.fail') || // canary
|
||||
origin === baseUrl // localhost or self-hosted
|
||||
);
|
||||
|
||||
@@ -80,7 +80,7 @@ export class DesktopStateSynchronizer extends Service {
|
||||
|
||||
this.electronApi.events.ui.onToggleRightSidebar(tabId => {
|
||||
if (tabId === appInfo?.viewId) {
|
||||
workbench.setSidebarOpen(!workbench.sidebarOpen$.value);
|
||||
workbench.sidebarOpen$.next(!workbench.sidebarOpen$.value);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -19,18 +19,13 @@ export const header = style({
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
background: cssVar('backgroundPrimaryColor'),
|
||||
padding: '0 16px',
|
||||
padding: '0 16px 0px 8px',
|
||||
contain: 'strict',
|
||||
'@media': {
|
||||
print: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
selectors: {
|
||||
'&[data-show-switch=true]': {
|
||||
paddingLeft: 8,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const viewBodyContainer = style({
|
||||
|
||||
@@ -52,15 +52,10 @@ export const RouteContainer = () => {
|
||||
workbench.toggleSidebar();
|
||||
}, [workbench]);
|
||||
|
||||
const showSwitch = !BUILD_CONFIG.isElectron && viewPosition.isFirst;
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div
|
||||
className={styles.header}
|
||||
data-show-switch={showSwitch && !leftSidebarOpen}
|
||||
>
|
||||
{showSwitch && (
|
||||
<div className={styles.header}>
|
||||
{!BUILD_CONFIG.isElectron && viewPosition.isFirst && (
|
||||
<SidebarSwitch
|
||||
show={!leftSidebarOpen}
|
||||
className={styles.leftSidebarButton}
|
||||
|
||||
Reference in New Issue
Block a user