forked from akai/readest
5bbc5ceccc
* 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>
351 lines
11 KiB
TypeScript
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);
|
|
}
|
|
}
|