Files
readest/apps/readest-app/src/services/webAppService.ts
T
Mohammed Efaz 5bbc5ceccc feat(ai): AI reading assistant phase 2 (#3023)
* feat(ai): add dependencies

* chore: bump zod version to default version

* feat(ai): define types and model constants

* feat(ai): ollama provider for local LLM

* feat(ai): implement openrouter provider for cloud models

* feat(settings): register ai settings panel in global dialog

* refactor(ai): expose provider factory and service layer entry point

* test(ai): add unit tests for the providers

* test(ai): add unit tests for the providers

* feat(ai): settings panel for ai configurations

* refactor(ai): rewrite aipanel with autosave and greyed out disabled state

* fix: remove unused onClose prop from aipanel

* test(ai): update mock data

* refactor(ai): remove models

* refactor: use centralised defaults in system defaults

* chore(ai): remove comments

* fix(ai): merge default ai settings on load to prevent undefined values

* refactor(ai): rewrite settings panel with autosave and model input

* feat(ai): add ai tab with simplified highlighting

* feat(sidebar): render AIAssistant for ai tab

* feat(ai): add chat UI

* feat(ai); add chat service with RAG context

* feat(ai): temp debug logger

* feat(ai): add RAG service

* feat(ai): add text chunking utility

* feat(ai): add structured method

* feat(ai): add chatstructured method

* feat(ai): add rag types nd structured output schema

* feat(ai): add aistore, indexdb, bm25

* fix: update lock file

* feat(ai): update types for AI SDK v5

* feat(ai): add placeholder gateway model constants

* refactor(ai): update OllamaProvider for AI SDK

* feat(ai): add native gateway provider

* refactor(ai): update provider exports

* refactor(ai): use streamText from AI SDK

* refactor(ai): use embed from AI sdk

* refactor(ai): update provider factory exports

* feat(ai): add AI Elements and shadcn components

* config: add shadcn component config

* deps: add AI SDK and AI Elements dependencies

* config: add ai packages to transpilePackages

* refactor(ai): remove OpenRouterProvider and old tests

* feat(ai):add assistant-ui components

* feat(ai): add TauriChatAdapter for assistant-ui runtime

* refactor(ai): remove ai-elements components

* dep(ai): install assistant-ui and update next config

* chore(ai): export adapters from service index

* feat(ui): enhance ui components for assistant integration

* feat(settings): migrate ai settings to gateway

* feat(sidebar): integrate assistant-ui

* feat: add ai settings toggle to sidebar content

* feat: conditionally show ai tab in sidebar navigation

* feat: update ai model constants for cheaper options

* feat: add gateway provider with proxied embedding

* feat: add timeouts to ollama provider health checks

* feat: add retry logic to rag service embeddings

* feat: add error recovery to ai store

* feat: add ai feature tests

* feat: add ai api endpoints

* feat: add proxied gateway embedding provider

* feat: add ai runtime utilities

* feat: add ai retry utilities

* feat: add tauri env example template

* feat: add web env example template

* chore: add env

* feat(ai): update models and pricing, remove GLM-4.7-FlashX

* feat(ai): improve system prompt with official headings and no numeric citations

* feat(ai): optimize system prompt for tauri chat

* feat(ui): refine ai chat UI and relocate sources

* feat(ui): update ai settings panel with model pricing and custom model support

* feat(ai): add custom model support to ai settings

* test(ai): update constants tests for removed model

* feat(api): implement ai chat proxy route

* feat(api): implement ai embedding proxy route

* feat(ai): implement ai gateway health check and proxy logic

* feat(ai): simplify proxied embedding provider

* feat(ui): improve markdown text rendering

* feat(ui): add input group component

* test(ai): update ai provider tests

* feat(ai): add pageNumber to text chunk schema

* feat(ai): implement page-based chunking with 1500 char formula

* feat(ai): bump db to v2 and add store reset migration

* feat(ai): transition rag pipeline to page level spoiler filtering

* feat(ai): overhaul readest persona and antijailbreak prompt

* feat(ai): update tauri adapter for page tracking and persona

* chore(ai): export aiStore and logger from core index

* feat(reader): integrate page tracking and manual index reset

* feat(ui): add re-index action and reset logic to chat

* chore: sync pnpm lockfile with ai dependencies

* feat(utils): add browser-safe file utilities for web builds

* refactor(utils): use dynamic tauri fs import to prevent web crashes

* refactor(services): defer osType call to init() for web compatibility

* refactor(services): import RemoteFile from file.web

* refactor(services): import ClosableFile from file.web

* fix(libs): cast Entry to any for getData access

* fix(annotator): cast Overlayer to any for bubble access

* refactor(ai): replace SparklesIcon with BookOpenIcon for index prompt

* test(ai): add pageNumber to TextChunk mocks

* test(ai): fix chunkSection signature in tests

* chore: update files

* fix(ai): prevent useLocalRuntime crash when adapter is null

* refactor: optimize annotator overlay drawing

* feat: stabilize AI assistant runtime and adapter

* refactor: improve document zip loader type safety

* feat: update tauri chat adapter for dynamic options

* fix: restore architecture comments and refine platform properties

* build: update lockfile with assistant-ui patch

* fix(library): patch @assistant-ui/react for runtime initialization

* build: update dependencies in readest-app

* build: update root dependencies and patch configuration

* fix(ai): patch @assistant-ui/react for thread deletion and runtime init

* fix(ai): update assistant-ui patch with dist guards and deletion fallback

* build: sync lockfile with assistant-ui patch updates

* chore(env): update .gitignore by removing .env files from it

* chore(env): update .gitignore by adding .env.local

* chore(env): update .gitignore by adding .env*.local

* fix: restore static osType import

* chore: sync submodules with upstream/main

* refactor: remove redundant file.web module and revert import

* chore: update pnpm-lock.yaml

* refactor: revert guards

* refactor; remove deprecated codes and extract prompts.ts

* refactor(ai): remove unused ragservice exports

* refactor: remove unused ollama and embedding models

* refactor: remove unused type

* test: remove test for the now deleted constants

* refactor: remove unused export

* style: fix ui component formatting

* style: fix core and style file formatting

* test: fix broken ai provider import

* fix: typescript error

* fix: add eslint disable command

* fix(deps): remove unused ai sdk provider util after v6 ai sdk migration

* fix(patch): add lookbehind regex patch

* feat(dep): upgrade vercel ai sdk to v6 and ai-sdk-ollama to v3

* chore: update lockfile for vercel ai sdk v6

* refactor(ai): remove EmbeddingModel generic for ai sdk v6

* refactor(ai): remove EmbeddingModel generic for ai sdk v6

* test(ai): update mock to use embeddingModel

* fix(patch): add lookbehind regex patch for email autolinks in markdown

* refactor(ai): use ai sdk v6 syntax

* fix: prettier formatting

* chore: revert cargo.lock

* fix(ai): update proxied embedding model to v3 spec

* feat(ai): add aiconversation types for chat persistence

* feat(ai): add conversation/message indexeddb and crud operations

* feat(ai): create aiChatStore zustand store for chat state management

* feat(notebook): add notebookactivetab state for Notes/AI

* refactor(ai): refine conversation and message types for persistence

* feat(types): add notebookActiveTab to ReadSettings type

* chore: update deps

* feat: add notebookactive tab default value

* feat: add hook for ai chat

* feat: update left side panel with history/chat icon

* feat: integrate ChatHistoryView into sidebar content

* feat: create UI for managing AI chat history

* feat: implement persistent history with assistant-ui adapter

* feat: create tab navigation component for notes and AI

* feat: add tab navigation and AI assistant view

* feat: update header to display active tab title

* fix: formatting

* feat: remove title and update new chat button

* fix: formatting

* fix: revert tooltip and styling

* feat: implement cross-platform ask dialog bridge

* feat(ai): preserve history during ui clear & use native dialogs

* fix: align notebook navigation height with sidebar tabs

* fix(ai): add missing dependency to handleDeleteConversation hook

* docs: update PROJECT.md with session highlights

* chore: delete projectmd

* chore: update package.json and lock file

* chore: update package.json

* chore: remove patch

* chore: upgrade react types to 19 and show ai features only in development mode for now

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2026-01-24 11:38:48 +01:00

351 lines
11 KiB
TypeScript

import { FileSystem, BaseDir, AppPlatform, ResolvedPath, FileItem } from '@/types/system';
import { getOSPlatform, isValidURL } from '@/utils/misc';
import { RemoteFile } from '@/utils/file';
import { isPWA } from './environment';
import { BaseAppService } from './appService';
import {
DATA_SUBDIR,
LOCAL_BOOKS_SUBDIR,
LOCAL_FONTS_SUBDIR,
LOCAL_IMAGES_SUBDIR,
} from './constants';
const basePrefix = async () => '';
const resolvePath = (path: string, base: BaseDir): ResolvedPath => {
switch (base) {
case 'Data':
return { baseDir: 0, basePrefix, fp: `${DATA_SUBDIR}/${path}`, base };
case 'Books':
return { baseDir: 0, basePrefix, fp: `${LOCAL_BOOKS_SUBDIR}/${path}`, base };
case 'Fonts':
return { baseDir: 0, basePrefix, fp: `${LOCAL_FONTS_SUBDIR}/${path}`, base };
case 'Images':
return { baseDir: 0, basePrefix, fp: `${LOCAL_IMAGES_SUBDIR}/${path}`, base };
case 'None':
return { baseDir: 0, basePrefix, fp: path, base };
default:
return { baseDir: 0, basePrefix, fp: `${base}/${path}`, base };
}
};
const dbName = 'AppFileSystem';
const dbVersion = 1;
async function openIndexedDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, dbVersion);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains('files')) {
db.createObjectStore('files', { keyPath: 'path' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
const indexedDBFileSystem: FileSystem = {
resolvePath,
async getPrefix(base: BaseDir) {
const { basePrefix, fp } = this.resolvePath('', base);
const basePath = await basePrefix();
const prefix = fp ? (basePath ? `${basePath}/${fp}` : fp) : basePath;
return prefix.replace(/\/+$/, '');
},
getURL(path: string) {
if (isValidURL(path)) {
return path;
} else {
return URL.createObjectURL(new Blob([path]));
}
},
async getBlobURL(path: string, base: BaseDir) {
try {
const content = await this.readFile(path, base, 'binary');
return URL.createObjectURL(new Blob([content]));
} catch {
return path;
}
},
async getImageURL(path: string) {
return await this.getBlobURL(path, 'None');
},
async openFile(path: string, base: BaseDir, filename?: string) {
if (isValidURL(path)) {
return await new RemoteFile(path, filename).open();
} else {
const content = await this.readFile(path, base, 'binary');
return new File([content], filename || path);
}
},
async copyFile(srcPath: string, dstPath: string, base: BaseDir) {
const { fp } = this.resolvePath(dstPath, base);
const db = await openIndexedDB();
return new Promise<void>((resolve, reject) => {
const transaction = db.transaction('files', 'readwrite');
const store = transaction.objectStore('files');
const getRequest = store.get(srcPath);
getRequest.onsuccess = () => {
const data = getRequest.result;
if (data) {
store.put({ path: fp, content: data.content });
resolve();
} else {
reject(new Error(`File not found: ${srcPath}`));
}
};
getRequest.onerror = () => reject(getRequest.error);
});
},
async readFile(path: string, base: BaseDir, mode: 'text' | 'binary') {
const { fp } = this.resolvePath(path, base);
const db = await openIndexedDB();
return new Promise<string | ArrayBuffer>((resolve, reject) => {
const transaction = db.transaction('files', 'readonly');
const store = transaction.objectStore('files');
const request = store.get(fp);
request.onsuccess = async () => {
if (request.result) {
const content = request.result.content;
if (mode === 'text') resolve(content);
else {
if (content instanceof Blob) {
const arrayBuffer = await content.arrayBuffer();
resolve(arrayBuffer);
} else if (content instanceof ArrayBuffer) {
resolve(content);
} else if (typeof content === 'string') {
resolve(new TextEncoder().encode(content).buffer as ArrayBuffer);
} else {
reject(new Error('Unsupported content type in IndexedDB'));
}
}
} else {
reject(new Error(`File not found: ${fp}`));
}
};
request.onerror = () => reject(request.error);
});
},
async writeFile(path: string, base: BaseDir, content: string | ArrayBuffer | File) {
const { fp } = this.resolvePath(path, base);
const db = await openIndexedDB();
if (content instanceof File) {
content = await content.arrayBuffer();
}
return new Promise<void>((resolve, reject) => {
const transaction = db.transaction('files', 'readwrite');
const store = transaction.objectStore('files');
store.put({ path: fp, content });
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
},
async removeFile(path: string, base: BaseDir) {
const { fp } = this.resolvePath(path, base);
const db = await openIndexedDB();
return new Promise<void>((resolve, reject) => {
const transaction = db.transaction('files', 'readwrite');
const store = transaction.objectStore('files');
store.delete(fp);
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
},
async createDir(path: string, base: BaseDir) {
return await this.writeFile(path, base, '');
},
async removeDir(path: string, base: BaseDir) {
const { fp } = this.resolvePath(path, base);
const db = await openIndexedDB();
return new Promise<void>((resolve, reject) => {
const transaction = db.transaction('files', 'readwrite');
const store = transaction.objectStore('files');
const request = store.getAll();
request.onsuccess = () => {
const files = request.result as { path: string }[];
files.forEach((file) => {
if (file.path.startsWith(fp)) {
store.delete(file.path);
}
});
};
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
},
async readDir(path: string, base: BaseDir) {
const { fp } = this.resolvePath(path, base);
const db = await openIndexedDB();
return new Promise<FileItem[]>((resolve, reject) => {
const transaction = db.transaction('files', 'readonly');
const store = transaction.objectStore('files');
const request = store.getAll();
request.onsuccess = () => {
const files = request.result as { path: string; content: string | ArrayBuffer | Blob }[];
resolve(
files
.filter((file) => file.path.startsWith(fp))
.map((file) => ({
path: file.path.slice(fp.length + 1),
size:
file.content instanceof Blob
? file.content.size
: typeof file.content === 'string'
? file.content.length
: file.content instanceof ArrayBuffer
? file.content.byteLength
: 0,
})),
);
};
request.onerror = () => reject(request.error);
});
},
async exists(path: string, base: BaseDir) {
const { fp } = this.resolvePath(path, base);
const db = await openIndexedDB();
return new Promise<boolean>((resolve, reject) => {
const transaction = db.transaction('files', 'readonly');
const store = transaction.objectStore('files');
const request = store.get(fp);
request.onsuccess = () => resolve(!!request.result);
request.onerror = () => reject(request.error);
});
},
async stats(path: string, base: BaseDir) {
const { fp } = this.resolvePath(path, base);
const db = await openIndexedDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction('files', 'readonly');
const store = transaction.objectStore('files');
const request = store.get(fp);
request.onsuccess = () => {
const result = request.result;
if (result) {
const content = result.content;
const size =
content instanceof Blob
? content.size
: typeof content === 'string'
? content.length
: content instanceof ArrayBuffer
? content.byteLength
: 0;
resolve({
isFile: true,
isDirectory: false,
size,
mtime: null,
atime: null,
birthtime: null,
});
} else {
reject(new Error(`File not found: ${fp}`));
}
};
request.onerror = () => reject(request.error);
});
},
};
export class WebAppService extends BaseAppService {
fs = indexedDBFileSystem;
override isMobile = ['android', 'ios'].includes(getOSPlatform());
override appPlatform = 'web' as AppPlatform;
override hasSafeAreaInset = isPWA();
override async init() {
await this.loadSettings();
await this.prepareBooksDir();
await this.runMigrations();
}
override async runMigrations() {
try {
const settings = await this.loadSettings();
const lastMigrationVersion = settings.migrationVersion || 0;
await super.runMigrations(lastMigrationVersion);
if (lastMigrationVersion < this.CURRENT_MIGRATION_VERSION) {
await this.saveSettings({
...settings,
migrationVersion: this.CURRENT_MIGRATION_VERSION,
});
}
} catch (error) {
console.error('Failed to run migrations:', error);
}
}
override resolvePath(fp: string, base: BaseDir): ResolvedPath {
return this.fs.resolvePath(fp, base);
}
async setCustomRootDir() {
// No-op in web environment
}
async selectDirectory(): Promise<string> {
throw new Error('selectDirectory is not supported in browser');
}
async selectFiles(): Promise<string[]> {
throw new Error('selectFiles is not supported in browser');
}
async saveFile(
filename: string,
content: string | ArrayBuffer,
mimeType?: string,
): Promise<boolean> {
try {
const blob = new Blob([content], { type: mimeType || 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
return true;
} catch (error) {
console.error('Failed to save file:', error);
return false;
}
}
async ask(message: string): Promise<boolean> {
return window.confirm(message);
}
}