Skip to content

Commit bb74212

Browse files
committed
feat: update website with new feature showcase and real screenshots
- Add FeatureShowcase component with real desktop screenshots - Update Hero component with hero screenshot - Update navigation to include Docs and Community links - Add i18n support for all new features - Add screenshot images for all features
1 parent eecd876 commit bb74212

37 files changed

Lines changed: 1564 additions & 314 deletions

packages/app-expo/babel.config.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
module.exports = (api) => {
22
api.cache(true);
33
return {
4-
presets: ["babel-preset-expo"],
4+
presets: [
5+
[
6+
"babel-preset-expo",
7+
{
8+
unstable_transformImportMeta: true,
9+
},
10+
],
11+
],
512
plugins: [
613
[
714
"module-resolver",

packages/app-expo/metro.config.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ config.resolver.nodeModulesPaths = [
1919
config.resolver.sourceExts = [...config.resolver.sourceExts, "ts", "tsx"];
2020

2121
// 4. Add .html to asset extensions so WebView can load local HTML files
22-
config.resolver.assetExts = [...config.resolver.assetExts, "html"];
22+
// Add .bin, .ort, .wasm for ONNX models
23+
config.resolver.assetExts = [...config.resolver.assetExts, "html", "bin", "ort", "wasm"];
2324

2425
// 5. Configure SVG transformer
2526
const { transformerPath } = config.transformer;
@@ -62,6 +63,11 @@ const coreRedirects = {
6263

6364
const originalResolveRequest = config.resolver.resolveRequest;
6465
config.resolver.resolveRequest = (context, moduleName, platform) => {
66+
// Alias Transformers.js ONNX runtimes to react-native native module
67+
if (moduleName.startsWith("onnxruntime-node") || moduleName.startsWith("onnxruntime-web")) {
68+
return context.resolveRequest(context, "onnxruntime-react-native", platform);
69+
}
70+
6571
// Redirect Node built-in polyfills
6672
if (moduleRedirects[moduleName]) {
6773
return { type: "sourceFile", filePath: moduleRedirects[moduleName] };

packages/app-expo/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"expo-font": "~14.0.11",
2727
"expo-linear-gradient": "~15.0.8",
2828
"expo-linking": "~8.0.11",
29+
"expo-network": "~8.0.8",
2930
"expo-secure-store": "~15.0.8",
3031
"expo-sharing": "~14.0.8",
3132
"expo-speech": "~14.0.8",
@@ -37,6 +38,7 @@
3738
"markmap-lib": "^0.18.12",
3839
"markmap-view": "^0.18.12",
3940
"mermaid": "^11.13.0",
41+
"onnxruntime-react-native": "^1.24.3",
4042
"pako": "^2.1.0",
4143
"punycode": "^2.3.1",
4244
"react": "^19.1.0",
@@ -45,10 +47,12 @@
4547
"react-native-gesture-handler": "~2.28.0",
4648
"react-native-get-random-values": "~1.11.0",
4749
"react-native-markdown-display": "^7.0.2",
50+
"react-native-qrcode-svg": "^6.3.21",
4851
"react-native-reanimated": "4.1.1",
4952
"react-native-safe-area-context": "~5.6.2",
5053
"react-native-screens": "~4.16.0",
5154
"react-native-svg": "~15.12.1",
55+
"react-native-tcp-socket": "^6.4.1",
5256
"react-native-webview": "~13.15.0",
5357
"react-native-worklets": "0.5.1",
5458
"whatwg-fetch": "^3.6.20",

packages/app-expo/src/App.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { I18nextProvider } from "react-i18next";
3333

3434
import { ExpoPlatformService } from "@/lib/platform/expo-platform-service";
3535
import { MobileSyncAdapter } from "@/lib/sync/sync-adapter-mobile";
36+
import { RNEmbeddingEngine } from "@/lib/ai/rn-embedding-engine";
3637
import { RootNavigator } from "@/navigation/RootNavigator";
3738
import { ThemeProvider, useTheme } from "@/styles/ThemeContext";
3839

@@ -70,6 +71,10 @@ export default function App() {
7071
const { fetch: expoFetch } = await import("expo/fetch");
7172
setStreamingFetch(expoFetch as typeof globalThis.fetch);
7273

74+
// 8. Inject React Native local embedding engine
75+
const { setLocalEmbeddingEngine } = await import("@readany/core/ai/local-embedding-service");
76+
setLocalEmbeddingEngine(new RNEmbeddingEngine());
77+
7378
setReady(true);
7479
}
7580
bootstrap();

packages/app-expo/src/components/onboarding/steps/EmbeddingPage.tsx

Lines changed: 40 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useNavigation } from "@react-navigation/native";
55
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
66
import { BUILTIN_EMBEDDING_MODELS } from "@readany/core/ai/builtin-embedding-models";
77
import { loadEmbeddingPipeline } from "@readany/core/ai/local-embedding-service";
8+
import { useSettingsStore } from "@readany/core/stores/settings-store";
89
import type { VectorModelConfig } from "@readany/core/types";
910
import { Check, Cloud, Download, HardDrive, Plus, Trash2, X } from "lucide-react-native";
1011
import { useCallback, useState } from "react";
@@ -32,39 +33,21 @@ export function EmbeddingPage() {
3233
const insets = useSafeAreaInsets();
3334

3435
const {
36+
vectorModels,
3537
vectorModelMode,
3638
setVectorModelMode,
37-
vectorModels,
39+
addVectorModel,
40+
deleteVectorModel,
3841
builtinModelStates,
42+
updateBuiltinModelState,
3943
setSelectedBuiltinModelId,
4044
setSelectedVectorModelId,
41-
updateBuiltinModelState,
42-
addVectorModel,
43-
deleteVectorModel,
4445
} = useVectorModelStore();
4546

4647
const [showAddForm, setShowAddForm] = useState(false);
4748
const [formData, setFormData] = useState({ name: "", url: "", modelId: "", apiKey: "" });
4849
const [testingId, setTestingId] = useState<string | null>(null);
4950

50-
const handleLoadModel = useCallback(
51-
async (modelId: string) => {
52-
updateBuiltinModelState(modelId, { status: "downloading", progress: 0, error: undefined });
53-
setSelectedBuiltinModelId(modelId);
54-
try {
55-
await loadEmbeddingPipeline(modelId, (progress) => {
56-
updateBuiltinModelState(modelId, { progress });
57-
});
58-
updateBuiltinModelState(modelId, { status: "ready", progress: 100 });
59-
} catch (err) {
60-
const message = err instanceof Error ? err.message : String(err);
61-
updateBuiltinModelState(modelId, { status: "error", error: message });
62-
setSelectedBuiltinModelId(null);
63-
}
64-
},
65-
[updateBuiltinModelState, setSelectedBuiltinModelId],
66-
);
67-
6851
const handleAddModel = () => {
6952
if (!formData.name.trim() || !formData.url.trim() || !formData.modelId.trim()) return;
7053
const newModel: VectorModelConfig = {
@@ -103,11 +86,30 @@ export function EmbeddingPage() {
10386
}
10487
};
10588

89+
// Default built-in model
10690
const model = BUILTIN_EMBEDDING_MODELS[0];
107-
const state = builtinModelStates[model.id];
108-
const isReady = state?.status === "ready";
109-
const isDownloading = state?.status === "downloading";
110-
const hasError = state?.status === "error";
91+
const modelState = builtinModelStates[model.id];
92+
const isReady = modelState?.status === "ready";
93+
const isDownloading = modelState?.status === "downloading";
94+
const downloadProgress = modelState?.progress ?? 0;
95+
96+
const handleDownloadBuiltin = () => {
97+
updateBuiltinModelState(model.id, { status: "downloading", progress: 0 });
98+
loadEmbeddingPipeline(model.id, (progress) => {
99+
updateBuiltinModelState(model.id, {
100+
status: "downloading",
101+
progress: Math.round(progress),
102+
});
103+
})
104+
.then(() => {
105+
updateBuiltinModelState(model.id, { status: "ready" });
106+
setSelectedBuiltinModelId(model.id);
107+
})
108+
.catch((err) => {
109+
console.warn("Failed to load model:", err);
110+
updateBuiltinModelState(model.id, { status: "error", error: String(err) });
111+
});
112+
};
111113

112114
const handleNext = () => {
113115
navigation.navigate("Translation");
@@ -380,30 +382,37 @@ export function EmbeddingPage() {
380382
<Text style={[styles.modelMeta, { color: colors.mutedForeground }]}>
381383
{model.size}
382384
</Text>
385+
<Text style={[styles.modelMeta, { color: colors.mutedForeground, marginTop: 4 }]}>
386+
{t(model.descriptionKey)}
387+
</Text>
383388
</View>
384389
{isReady ? (
385390
<View style={styles.readyBadge}>
386-
<Check size={14} color="#10b981" />
391+
<Check size={16} color="#10b981" />
387392
<Text style={styles.readyText}>{t("settings.vm_loaded", "Loaded")}</Text>
388393
</View>
389394
) : isDownloading ? (
390395
<View style={styles.progressWrap}>
391396
<ActivityIndicator size="small" color={colors.primary} />
392397
<Text style={[styles.progressText, { color: colors.primary }]}>
393-
{state.progress ?? 0}%
398+
{downloadProgress}%
394399
</Text>
395400
</View>
396401
) : (
397402
<Pressable
398403
style={[styles.downloadBtn, { backgroundColor: colors.primary }]}
399-
onPress={() => handleLoadModel(model.id)}
404+
onPress={handleDownloadBuiltin}
400405
>
401-
<Download size={14} color="#fff" />
406+
<Download size={16} color="#fff" />
402407
<Text style={styles.downloadText}>{t("settings.vm_download", "Download")}</Text>
403408
</Pressable>
404409
)}
405410
</View>
406-
{hasError && <Text style={styles.errorText}>{state.error}</Text>}
411+
{modelState?.status === "error" && (
412+
<Text style={styles.errorText}>
413+
{t("onboarding.embedding.downloadError", "Failed to download model.")} {modelState.error}
414+
</Text>
415+
)}
407416
</View>
408417
)}
409418
</ScrollView>
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import type { ILocalEmbeddingEngine } from "@readany/core/ai/local-embedding-service";
2+
import Constants from "expo-constants";
3+
import * as FileSystem from "expo-file-system";
4+
5+
export class RNEmbeddingEngine implements ILocalEmbeddingEngine {
6+
private generator: any = null;
7+
private transformers: any = null;
8+
9+
private async ensureTransformers(): Promise<any> {
10+
if (this.transformers) return this.transformers;
11+
12+
const isExpoGo = Constants.executionEnvironment === "storeClient" || Constants.appOwnership === "expo";
13+
if (isExpoGo) {
14+
throw new Error("本地向量模型推理依赖 ONNX C++ 原生引擎库。Expo Go 沙盒均不提供。请编译自定义原生客户端体验本地大模型!");
15+
}
16+
17+
try {
18+
this.transformers = await import("@huggingface/transformers");
19+
} catch (e) {
20+
console.warn("[RNEmbeddingEngine] Transformers/ONNX not available natively", e);
21+
throw e;
22+
}
23+
24+
const { env } = this.transformers;
25+
env.allowLocalModels = false;
26+
27+
// Disable WASM threads to prevent issues in strict environments
28+
if (env.backends?.onnx?.wasm) {
29+
env.backends.onnx.wasm.numThreads = 1;
30+
}
31+
32+
// Intercept fetch to cache model files on disk
33+
const originalFetch = fetch;
34+
const cacheDir = `${(FileSystem as any).documentDirectory}models/`;
35+
36+
await FileSystem.makeDirectoryAsync(cacheDir, { intermediates: true }).catch(() => {});
37+
38+
// @ts-ignore - transformers.js v3 allows overriding fetch on the env object
39+
env.fetch = async (url: RequestInfo | URL, init?: RequestInit) => {
40+
const urlStr = url.toString();
41+
42+
// Only cache huggingface model files
43+
if (!urlStr.includes("huggingface.co")) {
44+
return originalFetch(url, init);
45+
}
46+
47+
const filename = urlStr.split("/").pop() || "unknown";
48+
// Generate a unique cache key based on URL path to avoid collisions
49+
const urlPath = new URL(urlStr).pathname.replace(/[^a-zA-Z0-9]/g, "_");
50+
const localUri = `${cacheDir}${urlPath}_${filename}`;
51+
52+
try {
53+
const fileInfo = await FileSystem.getInfoAsync(localUri);
54+
if (fileInfo.exists) {
55+
console.log(`[RNEmbeddingEngine] Cache HIT for ${filename}`);
56+
// Read as binary string, then convert to ArrayBuffer
57+
const base64 = await FileSystem.readAsStringAsync(localUri, { encoding: "base64" });
58+
const binaryStr = atob(base64);
59+
const len = binaryStr.length;
60+
const bytes = new Uint8Array(len);
61+
for (let i = 0; i < len; i++) {
62+
bytes[i] = binaryStr.charCodeAt(i);
63+
}
64+
65+
return new Response(bytes.buffer, {
66+
status: 200,
67+
headers: new Headers({ "Content-Type": "application/octet-stream" })
68+
});
69+
}
70+
} catch (e) {
71+
console.warn(`[RNEmbeddingEngine] Cache read error for ${filename}:`, e);
72+
}
73+
74+
console.log(`[RNEmbeddingEngine] Cache MISS for ${filename}. Downloading...`);
75+
const response = await originalFetch(url, init);
76+
77+
if (response.ok) {
78+
try {
79+
// Clone the response so we can both save it and return it
80+
const resClone = response.clone();
81+
const buffer = await resClone.arrayBuffer();
82+
const bytes = new Uint8Array(buffer);
83+
84+
// Convert to base64 for writing via Expo FileSystem
85+
// This is a bit expensive for large models but works reliably
86+
let binaryStr = "";
87+
for (let i = 0; i < bytes.length; i++) {
88+
binaryStr += String.fromCharCode(bytes[i]);
89+
}
90+
const base64 = btoa(binaryStr);
91+
92+
await FileSystem.writeAsStringAsync(localUri, base64, { encoding: "base64" });
93+
console.log(`[RNEmbeddingEngine] Saved ${filename} to cache`);
94+
} catch (e) {
95+
console.warn(`[RNEmbeddingEngine] Failed to cache ${filename}:`, e);
96+
}
97+
}
98+
99+
return response;
100+
};
101+
102+
return this.transformers;
103+
}
104+
105+
async init(): Promise<void> {
106+
// No-op for Expo initialization to prevent crashing on standard App startup.
107+
// Transformers and its native modules will be lazily loaded in `load()`.
108+
}
109+
110+
async load(modelId: string, hfModelId: string, onProgress?: (p: number) => void): Promise<void> {
111+
const transformers = await this.ensureTransformers().catch(() => null);
112+
if (!transformers) {
113+
console.warn("[RNEmbeddingEngine] Transformers engine not loaded. Cannot load model.");
114+
return;
115+
}
116+
try {
117+
console.log(`[RNEmbeddingEngine] Loading model ${hfModelId}...`);
118+
119+
const { pipeline } = transformers;
120+
// Initialize pipeline
121+
this.generator = await pipeline("feature-extraction", hfModelId, {
122+
progress_callback: (info: any) => {
123+
if (info.status === "progress" && onProgress) {
124+
onProgress(info.progress);
125+
}
126+
},
127+
dtype: "q8",
128+
});
129+
130+
console.log(`[RNEmbeddingEngine] Model ${hfModelId} ready!`);
131+
} catch (e) {
132+
console.error(`[RNEmbeddingEngine] Failed to load model:`, e);
133+
throw e;
134+
}
135+
}
136+
137+
async generate(
138+
modelId: string,
139+
texts: string[],
140+
onItemProgress?: (done: number, total: number) => void,
141+
): Promise<number[][]> {
142+
if (!this.generator) {
143+
throw new Error("RNEmbeddingEngine pipeline not loaded.");
144+
}
145+
146+
const embeddings: number[][] = [];
147+
for (let i = 0; i < texts.length; i++) {
148+
const text = texts[i];
149+
// Generate embedding for one text
150+
const output = await this.generator(text, { pooling: "mean", normalize: true });
151+
152+
// Extract Float32Array to standard JS Array
153+
embeddings.push(Array.from(output.data));
154+
155+
onItemProgress?.(i + 1, texts.length);
156+
}
157+
return embeddings;
158+
}
159+
160+
async dispose(): Promise<void> {
161+
if (this.generator) {
162+
try {
163+
await this.generator.dispose();
164+
} catch (e) {
165+
console.warn("[RNEmbeddingEngine] Error disposing pipeline:", e);
166+
}
167+
this.generator = null;
168+
}
169+
}
170+
171+
async clearCache(hfModelId: string): Promise<void> {
172+
// Currently relying on React Native's fetch cache or custom polyfills.
173+
// If transformers.js uses Cache API, we might need a custom clear mechanism.
174+
this.generator = null;
175+
console.log(`[RNEmbeddingEngine] Cleared cache flag for ${hfModelId}`);
176+
}
177+
}

0 commit comments

Comments
 (0)