feat(android): ai chat scaffold (#11124)

Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
Co-authored-by: eyhn <cneyhn@gmail.com>
This commit is contained in:
Aki Chang
2025-04-14 14:05:47 +08:00
committed by GitHub
parent 08dbaae19b
commit 00bd05897e
58 changed files with 3049 additions and 2168 deletions

View File

@@ -6,9 +6,13 @@ import { router } from '@affine/core/mobile/router';
import { configureCommonModules } from '@affine/core/modules';
import { AIButtonProvider } from '@affine/core/modules/ai-button';
import {
AuthProvider,
AuthService,
DefaultServerService,
ServerScope,
ServerService,
ServersService,
ValidatorProvider,
} from '@affine/core/modules/cloud';
import { DocsService } from '@affine/core/modules/doc';
import { GlobalContextService } from '@affine/core/modules/global-context';
@@ -40,16 +44,19 @@ import { EdgeToEdge } from '@capawesome/capacitor-android-edge-to-edge-support';
import { InAppBrowser } from '@capgo/inappbrowser';
import { Framework, FrameworkRoot, getCurrentStore } from '@toeverything/infra';
import { OpClient } from '@toeverything/infra/op';
import { AsyncCall } from 'async-call-rpc';
import { useTheme } from 'next-themes';
import { Suspense, useEffect } from 'react';
import { RouterProvider } from 'react-router-dom';
import { AffineTheme } from './plugins/affine-theme';
import { AIButton } from './plugins/ai-button';
import { Auth } from './plugins/auth';
import { HashCash } from './plugins/hashcash';
import { NbStoreNativeDBApis } from './plugins/nbstore';
import { writeEndpointToken } from './proxy';
const storeManagerClient = new StoreManagerClient(
new OpClient(new Worker(getWorkerUrl('nbstore')))
);
const storeManagerClient = createStoreManagerClient();
window.addEventListener('beforeunload', () => {
storeManagerClient.dispose();
});
@@ -137,6 +144,13 @@ framework.impl(VirtualKeyboardProvider, {
},
});
framework.impl(ValidatorProvider, {
async validate(_challenge, resource) {
const res = await HashCash.hash({ challenge: resource });
return res.value;
},
});
framework.impl(AIButtonProvider, {
presentAIButton: () => {
return AIButton.present();
@@ -146,6 +160,44 @@ framework.impl(AIButtonProvider, {
},
});
framework.scope(ServerScope).override(AuthProvider, resolver => {
const serverService = resolver.get(ServerService);
const endpoint = serverService.server.baseUrl;
return {
async signInMagicLink(email, linkToken, clientNonce) {
const { token } = await Auth.signInMagicLink({
endpoint,
email,
token: linkToken,
clientNonce,
});
await writeEndpointToken(endpoint, token);
},
async signInOauth(code, state, _provider, clientNonce) {
const { token } = await Auth.signInOauth({
endpoint,
code,
state,
clientNonce,
});
await writeEndpointToken(endpoint, token);
return {};
},
async signInPassword(credential) {
const { token } = await Auth.signInPassword({
endpoint,
...credential,
});
await writeEndpointToken(endpoint, token);
},
async signOut() {
await Auth.signOut({
endpoint,
});
},
};
});
// ------ some apis for native ------
(window as any).getCurrentServerBaseUrl = () => {
const globalContextService = frameworkProvider.get(GlobalContextService);
@@ -302,3 +354,35 @@ export function App() {
</Suspense>
);
}
function createStoreManagerClient() {
const worker = new Worker(getWorkerUrl('nbstore.worker.js'));
const { port1: nativeDBApiChannelServer, port2: nativeDBApiChannelClient } =
new MessageChannel();
AsyncCall<typeof NbStoreNativeDBApis>(NbStoreNativeDBApis, {
channel: {
on(listener) {
const f = (e: MessageEvent<any>) => {
listener(e.data);
};
nativeDBApiChannelServer.addEventListener('message', f);
return () => {
nativeDBApiChannelServer.removeEventListener('message', f);
};
},
send(data) {
nativeDBApiChannelServer.postMessage(data);
},
},
log: false,
});
nativeDBApiChannelServer.start();
worker.postMessage(
{
type: 'native-db-api-channel',
port: nativeDBApiChannelClient,
},
[nativeDBApiChannelClient]
);
return new StoreManagerClient(new OpClient(worker));
}

View File

@@ -1,10 +1,14 @@
import './setup';
import { Telemetry } from '@affine/core/components/telemetry';
import { bindNativeDBApis } from '@affine/nbstore/sqlite';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './app';
import { NbStoreNativeDBApis } from './plugins/nbstore';
bindNativeDBApis(NbStoreNativeDBApis);
function mountApp() {
// oxlint-disable-next-line no-non-null-assertion

View File

@@ -1,22 +1,71 @@
import '@affine/core/bootstrap/browser';
import './setup-worker';
import { broadcastChannelStorages } from '@affine/nbstore/broadcast-channel';
import { cloudStorages } from '@affine/nbstore/cloud';
import { idbStorages } from '@affine/nbstore/idb';
import {
cloudStorages,
configureSocketAuthMethod,
} from '@affine/nbstore/cloud';
import { idbStoragesIndexerOnly } from '@affine/nbstore/idb';
import {
bindNativeDBApis,
type NativeDBApis,
sqliteStorages,
} from '@affine/nbstore/sqlite';
import {
StoreManagerConsumer,
type WorkerManagerOps,
} from '@affine/nbstore/worker/consumer';
import { type MessageCommunicapable, OpConsumer } from '@toeverything/infra/op';
import { AsyncCall } from 'async-call-rpc';
const consumer = new StoreManagerConsumer([
...idbStorages,
import { readEndpointToken } from './proxy';
configureSocketAuthMethod((endpoint, cb) => {
readEndpointToken(endpoint)
.then(token => {
cb({ token });
})
.catch(e => {
console.error(e);
});
});
globalThis.addEventListener('message', e => {
if (e.data.type === 'native-db-api-channel') {
const port = e.ports[0] as MessagePort;
const rpc = AsyncCall<NativeDBApis>(
{},
{
channel: {
on(listener) {
const f = (e: MessageEvent<any>) => {
listener(e.data);
};
port.addEventListener('message', f);
return () => {
port.removeEventListener('message', f);
};
},
send(data) {
port.postMessage(data);
},
},
}
);
bindNativeDBApis(rpc);
port.start();
}
});
const consumer = new OpConsumer<WorkerManagerOps>(
globalThis as MessageCommunicapable
);
const storeManager = new StoreManagerConsumer([
...idbStoragesIndexerOnly,
...sqliteStorages,
...broadcastChannelStorages,
...cloudStorages,
]);
const opConsumer = new OpConsumer<WorkerManagerOps>(
globalThis as MessageCommunicapable
);
consumer.bindConsumer(opConsumer);
storeManager.bindConsumer(consumer);

View File

@@ -0,0 +1,22 @@
export interface AuthPlugin {
signInMagicLink(options: {
endpoint: string;
email: string;
token: string;
clientNonce?: string;
}): Promise<{ token: string }>;
signInOauth(options: {
endpoint: string;
code: string;
state: string;
clientNonce?: string;
}): Promise<{ token: string }>;
signInPassword(options: {
endpoint: string;
email: string;
password: string;
verifyToken?: string;
challenge?: string;
}): Promise<{ token: string }>;
signOut(options: { endpoint: string }): Promise<void>;
}

View File

@@ -0,0 +1,8 @@
import { registerPlugin } from '@capacitor/core';
import type { AuthPlugin } from './definitions';
const Auth = registerPlugin<AuthPlugin>('Auth');
export * from './definitions';
export { Auth };

View File

@@ -0,0 +1,6 @@
export interface HashCashPlugin {
hash(options: {
challenge: string;
bits?: number;
}): Promise<{ value: string }>;
}

View File

@@ -0,0 +1,8 @@
import { registerPlugin } from '@capacitor/core';
import type { HashCashPlugin } from './definitions';
const HashCash = registerPlugin<HashCashPlugin>('HashCash');
export * from './definitions';
export { HashCash };

View File

@@ -0,0 +1,152 @@
export interface Blob {
key: string;
// base64 encoded data
data: string;
mime: string;
size: number;
createdAt: number;
}
export interface SetBlob {
key: string;
// base64 encoded data
data: string;
mime: string;
}
export interface ListedBlob {
key: string;
mime: string;
size: number;
createdAt: number;
}
export interface DocClock {
docId: string;
timestamp: number;
}
export interface NbStorePlugin {
connect: (options: {
id: string;
spaceId: string;
spaceType: string;
peer: string;
}) => Promise<void>;
disconnect: (options: { id: string }) => Promise<void>;
setSpaceId: (options: { id: string; spaceId: string }) => Promise<void>;
pushUpdate: (options: {
id: string;
docId: string;
data: string;
}) => Promise<{ timestamp: number }>;
getDocSnapshot: (options: { id: string; docId: string }) => Promise<
| {
docId: string;
// base64 encoded data
bin: string;
timestamp: number;
}
| undefined
>;
setDocSnapshot: (options: {
id: string;
docId: string;
bin: string;
timestamp: number;
}) => Promise<{ success: boolean }>;
getDocUpdates: (options: { id: string; docId: string }) => Promise<{
updates: {
docId: string;
timestamp: number;
// base64 encoded data
bin: string;
}[];
}>;
markUpdatesMerged: (options: {
id: string;
docId: string;
timestamps: number[];
}) => Promise<{ count: number }>;
deleteDoc: (options: { id: string; docId: string }) => Promise<void>;
getDocClocks: (options: { id: string; after?: number | null }) => Promise<{
clocks: {
docId: string;
timestamp: number;
}[];
}>;
getDocClock: (options: { id: string; docId: string }) => Promise<
| {
docId: string;
timestamp: number;
}
| undefined
>;
getBlob: (options: { id: string; key: string }) => Promise<Blob | null>;
setBlob: (options: { id: string } & SetBlob) => Promise<void>;
deleteBlob: (options: {
id: string;
key: string;
permanently: boolean;
}) => Promise<void>;
releaseBlobs: (options: { id: string }) => Promise<void>;
listBlobs: (options: { id: string }) => Promise<{ blobs: Array<ListedBlob> }>;
getPeerRemoteClocks: (options: {
id: string;
peer: string;
}) => Promise<{ clocks: Array<DocClock> }>;
getPeerRemoteClock: (options: {
id: string;
peer: string;
docId: string;
}) => Promise<DocClock | null>;
setPeerRemoteClock: (options: {
id: string;
peer: string;
docId: string;
timestamp: number;
}) => Promise<void>;
getPeerPushedClocks: (options: {
id: string;
peer: string;
}) => Promise<{ clocks: Array<DocClock> }>;
getPeerPushedClock: (options: {
id: string;
peer: string;
docId: string;
}) => Promise<DocClock | null>;
setPeerPushedClock: (options: {
id: string;
peer: string;
docId: string;
timestamp: number;
}) => Promise<void>;
getPeerPulledRemoteClocks: (options: {
id: string;
peer: string;
}) => Promise<{ clocks: Array<DocClock> }>;
getPeerPulledRemoteClock: (options: {
id: string;
peer: string;
docId: string;
}) => Promise<DocClock | null>;
setPeerPulledRemoteClock: (options: {
id: string;
peer: string;
docId: string;
timestamp: number;
}) => Promise<void>;
getBlobUploadedAt: (options: {
id: string;
peer: string;
blobId: string;
}) => Promise<{ uploadedAt: number | null }>;
setBlobUploadedAt: (options: {
id: string;
peer: string;
blobId: string;
uploadedAt: number | null;
}) => Promise<void>;
clearClocks: (options: { id: string }) => Promise<void>;
}

View File

@@ -0,0 +1,339 @@
import {
base64ToUint8Array,
uint8ArrayToBase64,
} from '@affine/core/modules/workspace-engine';
import {
type BlobRecord,
type DocClock,
type DocRecord,
type ListedBlobRecord,
parseUniversalId,
} from '@affine/nbstore';
import { type NativeDBApis } from '@affine/nbstore/sqlite';
import { registerPlugin } from '@capacitor/core';
import type { NbStorePlugin } from './definitions';
export * from './definitions';
export const NbStore = registerPlugin<NbStorePlugin>('NbStoreDocStorage');
export const NbStoreNativeDBApis: NativeDBApis = {
connect: async function (id: string): Promise<void> {
const { peer, type, id: spaceId } = parseUniversalId(id);
return await NbStore.connect({ id, spaceId, spaceType: type, peer });
},
disconnect: function (id: string): Promise<void> {
return NbStore.disconnect({ id });
},
pushUpdate: async function (
id: string,
docId: string,
update: Uint8Array
): Promise<Date> {
const { timestamp } = await NbStore.pushUpdate({
id,
docId,
data: await uint8ArrayToBase64(update),
});
return new Date(timestamp);
},
getDocSnapshot: async function (
id: string,
docId: string
): Promise<DocRecord | null> {
const snapshot = await NbStore.getDocSnapshot({ id, docId });
return snapshot
? {
bin: base64ToUint8Array(snapshot.bin),
docId: snapshot.docId,
timestamp: new Date(snapshot.timestamp),
}
: null;
},
setDocSnapshot: async function (
id: string,
snapshot: DocRecord
): Promise<boolean> {
const { success } = await NbStore.setDocSnapshot({
id,
docId: snapshot.docId,
bin: await uint8ArrayToBase64(snapshot.bin),
timestamp: snapshot.timestamp.getTime(),
});
return success;
},
getDocUpdates: async function (
id: string,
docId: string
): Promise<DocRecord[]> {
const { updates } = await NbStore.getDocUpdates({ id, docId });
return updates.map(update => ({
bin: base64ToUint8Array(update.bin),
docId: update.docId,
timestamp: new Date(update.timestamp),
}));
},
markUpdatesMerged: async function (
id: string,
docId: string,
updates: Date[]
): Promise<number> {
const { count } = await NbStore.markUpdatesMerged({
id,
docId,
timestamps: updates.map(t => t.getTime()),
});
return count;
},
deleteDoc: async function (id: string, docId: string): Promise<void> {
await NbStore.deleteDoc({
id,
docId,
});
},
getDocClocks: async function (
id: string,
after?: Date | undefined | null
): Promise<DocClock[]> {
const clocks = (
await NbStore.getDocClocks({
id,
after: after?.getTime(),
})
).clocks;
return clocks.map(c => ({
docId: c.docId,
timestamp: new Date(c.timestamp),
}));
},
getDocClock: async function (
id: string,
docId: string
): Promise<DocClock | null> {
const clock = await NbStore.getDocClock({
id,
docId,
});
return clock
? {
timestamp: new Date(clock.timestamp),
docId: clock.docId,
}
: null;
},
getBlob: async function (
id: string,
key: string
): Promise<BlobRecord | null> {
const record = await NbStore.getBlob({
id,
key,
});
return record
? {
data: base64ToUint8Array(record.data),
key: record.key,
mime: record.mime,
createdAt: new Date(record.createdAt),
}
: null;
},
setBlob: async function (id: string, blob: BlobRecord): Promise<void> {
await NbStore.setBlob({
id,
data: await uint8ArrayToBase64(blob.data),
key: blob.key,
mime: blob.mime,
});
},
deleteBlob: async function (
id: string,
key: string,
permanently: boolean
): Promise<void> {
await NbStore.deleteBlob({
id,
key,
permanently,
});
},
releaseBlobs: async function (id: string): Promise<void> {
await NbStore.releaseBlobs({
id,
});
},
listBlobs: async function (id: string): Promise<ListedBlobRecord[]> {
const listed = await NbStore.listBlobs({
id,
});
return listed.blobs.map(b => ({
key: b.key,
mime: b.mime,
size: b.size,
createdAt: new Date(b.createdAt),
}));
},
getPeerRemoteClocks: async function (
id: string,
peer: string
): Promise<DocClock[]> {
const clocks = (
await NbStore.getPeerRemoteClocks({
id,
peer,
})
).clocks;
return clocks.map(c => ({
docId: c.docId,
timestamp: new Date(c.timestamp),
}));
},
getPeerRemoteClock: async function (id: string, peer: string, docId: string) {
const clock = await NbStore.getPeerRemoteClock({
id,
peer,
docId,
});
return clock
? {
docId: clock.docId,
timestamp: new Date(clock.timestamp),
}
: null;
},
setPeerRemoteClock: async function (
id: string,
peer: string,
docId: string,
clock: Date
): Promise<void> {
await NbStore.setPeerRemoteClock({
id,
peer,
docId,
timestamp: clock.getTime(),
});
},
getPeerPulledRemoteClocks: async function (
id: string,
peer: string
): Promise<DocClock[]> {
const clocks = (
await NbStore.getPeerPulledRemoteClocks({
id,
peer,
})
).clocks;
return clocks.map(c => ({
docId: c.docId,
timestamp: new Date(c.timestamp),
}));
},
getPeerPulledRemoteClock: async function (
id: string,
peer: string,
docId: string
) {
const clock = await NbStore.getPeerPulledRemoteClock({
id,
peer,
docId,
});
return clock
? {
docId: clock.docId,
timestamp: new Date(clock.timestamp),
}
: null;
},
setPeerPulledRemoteClock: async function (
id: string,
peer: string,
docId: string,
clock: Date
): Promise<void> {
await NbStore.setPeerPulledRemoteClock({
id,
peer,
docId,
timestamp: clock.getTime(),
});
},
getPeerPushedClocks: async function (
id: string,
peer: string
): Promise<DocClock[]> {
const clocks = (
await NbStore.getPeerPushedClocks({
id,
peer,
})
).clocks;
return clocks.map(c => ({
docId: c.docId,
timestamp: new Date(c.timestamp),
}));
},
getPeerPushedClock: async function (
id: string,
peer: string,
docId: string
): Promise<DocClock | null> {
const clock = await NbStore.getPeerPushedClock({
id,
peer,
docId,
});
return clock
? {
docId: clock.docId,
timestamp: new Date(clock.timestamp),
}
: null;
},
setPeerPushedClock: async function (
id: string,
peer: string,
docId: string,
clock: Date
): Promise<void> {
await NbStore.setPeerPushedClock({
id,
peer,
docId,
timestamp: clock.getTime(),
});
},
clearClocks: async function (id: string): Promise<void> {
await NbStore.clearClocks({
id,
});
},
getBlobUploadedAt: async function (
id: string,
peer: string,
blobId: string
): Promise<Date | null> {
const result = await NbStore.getBlobUploadedAt({
id,
peer,
blobId,
});
return result.uploadedAt ? new Date(result.uploadedAt) : null;
},
setBlobUploadedAt: async function (
id: string,
peer: string,
blobId: string,
uploadedAt: Date | null
): Promise<void> {
await NbStore.setBlobUploadedAt({
id,
peer,
blobId,
uploadedAt: uploadedAt ? uploadedAt.getTime() : null,
});
},
};

View File

@@ -0,0 +1,65 @@
import { openDB } from 'idb';
/**
* the below code includes the custom fetch and xmlhttprequest implementation for ios webview.
* should be included in the entry file of the app or webworker.
*/
const rawFetch = globalThis.fetch;
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(input, init);
const origin = new URL(request.url, globalThis.location.origin).origin;
const token = await readEndpointToken(origin);
if (token) {
request.headers.set('Authorization', `Bearer ${token}`);
}
return rawFetch(request);
};
const rawXMLHttpRequest = globalThis.XMLHttpRequest;
globalThis.XMLHttpRequest = class extends rawXMLHttpRequest {
override send(body?: Document | XMLHttpRequestBodyInit | null): void {
const origin = new URL(this.responseURL, globalThis.location.origin).origin;
readEndpointToken(origin).then(
token => {
if (token) {
this.setRequestHeader('Authorization', `Bearer ${token}`);
}
return super.send(body);
},
() => {
throw new Error('Failed to read token');
}
);
}
};
export async function readEndpointToken(
endpoint: string
): Promise<string | null> {
const idb = await openDB('affine-token', 1, {
upgrade(db) {
if (!db.objectStoreNames.contains('tokens')) {
db.createObjectStore('tokens', { keyPath: 'endpoint' });
}
},
});
const token = await idb.get('tokens', endpoint);
return token ? token.token : null;
}
export async function writeEndpointToken(endpoint: string, token: string) {
const db = await openDB('affine-token', 1, {
upgrade(db) {
if (!db.objectStoreNames.contains('tokens')) {
db.createObjectStore('tokens', { keyPath: 'endpoint' });
}
},
});
await db.put('tokens', { endpoint, token });
}

View File

@@ -0,0 +1,2 @@
import '@affine/core/bootstrap/browser';
import './proxy';

View File

@@ -2,3 +2,4 @@ import '@affine/core/bootstrap/browser';
import '@affine/core/bootstrap/blocksuite';
import '@affine/component/theme';
import '@affine/core/mobile/styles/mobile.css';
import './proxy';