Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
393 changes: 227 additions & 166 deletions .omc/project-memory.json

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"iconAsTemplate": true,
"tooltip": "EchoType"
},
"withGlobalTauri": true,
"security": {
"csp": null
}
Expand All @@ -41,7 +42,11 @@
"binaries/": "binaries/"
},
"macOS": {
"minimumSystemVersion": "10.15"
"minimumSystemVersion": "10.15",
"entitlements": "./Entitlements.plist",
"files": {
"Info.plist": "./Info.plist"
}
}
},
"plugins": {
Expand Down
57 changes: 57 additions & 0 deletions src/app/(app)/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@ import {
Library,
Mic,
PenTool,
Settings,
Sparkles,
Target,
TrendingUp,
Upload,
Zap,
} from 'lucide-react';
import Link from 'next/link';
import { useEffect, useState } from 'react';
Expand All @@ -22,6 +25,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { db } from '@/lib/db';
import { CEFR_LABELS, type CEFRLevel, useAssessmentStore } from '@/stores/assessment-store';
import { useProviderStore } from '@/stores/provider-store';
import type { TypingSession } from '@/types/content';

// ─── Types ────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -75,6 +79,10 @@ export default function DashboardPage() {
const [recent, setRecent] = useState<RecentItem[]>([]);
const isNewUser = stats.totalContent === 0;

const hasProvider = useProviderStore((s) => s.hasAnyProviderConfigured());
const activeProviderId = useProviderStore((s) => s.activeProviderId);
const activeProviderConnected = useProviderStore((s) => s.isConnected(s.activeProviderId));

const { currentLevel, shouldShowReminder, dismissReminder } = useAssessmentStore();
const showReminder = shouldShowReminder(stats.totalSessions);

Expand Down Expand Up @@ -186,6 +194,55 @@ export default function DashboardPage() {
))}
</div>

{/* AI Provider setup prompt */}
{!hasProvider && (
<Card className="bg-linear-to-br from-violet-50 via-indigo-50 to-white border-violet-200 shadow-md">
<CardContent className="p-6 flex flex-col sm:flex-row items-start sm:items-center gap-5">
<div className="w-12 h-12 rounded-xl bg-linear-to-br from-violet-500 to-indigo-600 flex items-center justify-center shrink-0 shadow-lg shadow-violet-200">
<Sparkles className="w-6 h-6 text-white" />
</div>
<div className="flex-1">
<p className="font-semibold text-indigo-900">配置 AI 模型以解锁全部功能</p>
<p className="text-sm text-indigo-500 mt-0.5">
EchoType 的智能对话、内容生成、翻译和发音评估等功能需要连接 AI 服务。前往设置页面配置你的 API
密钥和模型。
</p>
</div>
<Link href="/settings">
<Button size="sm" className="bg-violet-600 hover:bg-violet-700 text-white cursor-pointer shadow-sm">
<Settings className="w-4 h-4 mr-1.5" /> 前往设置
</Button>
</Link>
</CardContent>
</Card>
)}

{/* Active provider not connected warning */}
{hasProvider && !activeProviderConnected && (
<Card className="bg-linear-to-br from-amber-50 to-white border-amber-200">
<CardContent className="p-5 flex flex-col sm:flex-row items-start sm:items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-amber-500 flex items-center justify-center shrink-0">
<Zap className="w-5 h-5 text-white" />
</div>
<div className="flex-1">
<p className="font-semibold text-indigo-900 text-sm">当前 AI 服务未连接</p>
<p className="text-xs text-indigo-500 mt-0.5">
你选择的服务商({activeProviderId})尚未配置 API 密钥,AI 功能可能无法正常使用。
</p>
</div>
<Link href="/settings">
<Button
size="sm"
variant="outline"
className="border-amber-200 text-amber-700 hover:bg-amber-50 cursor-pointer"
>
<Settings className="w-4 h-4 mr-1.5" /> 配置
</Button>
</Link>
</CardContent>
</Card>
)}

{/* New-user onboarding */}
{isNewUser && (
<Card className="bg-linear-to-br from-indigo-50 to-white border-indigo-200">
Expand Down
85 changes: 67 additions & 18 deletions src/app/(app)/read/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import { AnimatePresence, motion } from 'framer-motion';
import { ArrowLeft, Mic, MicOff, RotateCcw, Volume2 } from 'lucide-react';
import { ArrowLeft, Loader2, Mic, MicOff, RotateCcw, Volume2 } from 'lucide-react';
import { nanoid } from 'nanoid';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
Expand All @@ -16,6 +16,7 @@ import { TranslationBar } from '@/components/translation/translation-bar';
import { TranslationDisplay } from '@/components/translation/translation-display';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useFallbackSTT } from '@/hooks/use-fallback-stt';
import type { Recommendation } from '@/hooks/use-recommendations';
import { useShortcuts } from '@/hooks/use-shortcuts';
import { useTranslation } from '@/hooks/use-translation';
Expand All @@ -25,6 +26,7 @@ import { savePracticeSession } from '@/lib/daily-plan-progress';
import { db } from '@/lib/db';
import { compareWords, type WordResult } from '@/lib/levenshtein';
import { matchesShortcutEvent } from '@/lib/shortcut-utils';
import { IS_TAURI } from '@/lib/tauri';
import { useContentStore } from '@/stores/content-store';
import { useShortcutStore } from '@/stores/shortcut-store';
import { useTTSStore } from '@/stores/tts-store';
Expand All @@ -44,6 +46,9 @@ export default function ReadDetailPage() {
const transcriptRef = useRef('');
const interimTranscriptRef = useRef('');
const hasPersistedResultRef = useRef(false);
const useNative = useRef(
typeof window !== 'undefined' && !IS_TAURI && !!(window.SpeechRecognition || window.webkitSpeechRecognition),
);
const [sessionCompleted, setSessionCompleted] = useState(false);
const showTranslation = useTTSStore((s) => s.showTranslation);
const targetLang = useTTSStore((s) => s.targetLang);
Expand Down Expand Up @@ -139,9 +144,32 @@ export default function ReadDetailPage() {
}, [params.id, shadowReadingEnabled, setActiveContentId]);

const [speechError, setSpeechError] = useState<string | null>(null);
const [isFallbackTranscribing, setIsFallbackTranscribing] = useState(false);

// Fallback STT for Tauri / browsers without SpeechRecognition
const fallbackSTT = useFallbackSTT({
lang: 'en',
onTranscript: useCallback((text: string) => {
transcriptRef.current = text;
setTranscript(text);
setIsListening(false);
setIsFallbackTranscribing(false);
finalizePracticeRef.current();
}, []),
onInterimTranscript: useCallback((text: string) => {
interimTranscriptRef.current = text;
setInterimTranscript(text);
}, []),
onError: useCallback((error: string) => {
setSpeechError(error);
setIsListening(false);
setIsFallbackTranscribing(false);
}, []),
});

useEffect(() => {
if (typeof window === 'undefined') return;
if (IS_TAURI) return;
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
setSpeechError('Speech recognition is not supported in this browser.');
Expand Down Expand Up @@ -220,33 +248,40 @@ export default function ReadDetailPage() {
}, [finalizePractice]);

const startListening = useCallback(() => {
if (!recognitionRef.current) {
setSpeechError('Speech recognition is not available. Please try reloading the page.');
return;
}
setSpeechError(null);
transcriptRef.current = '';
interimTranscriptRef.current = '';
hasPersistedResultRef.current = false;
setTranscript('');
setInterimTranscript('');
setResults(null);
setSessionCompleted(false);
speakStartRef.current = Date.now();
try {
recognitionRef.current.start();

if (useNative.current && recognitionRef.current) {
try {
recognitionRef.current.start();
setIsListening(true);
} catch (err) {
console.error('Speech recognition start failed:', err);
setSpeechError(`Failed to start recording: ${err instanceof Error ? err.message : String(err)}`);
}
} else {
void fallbackSTT.startRecording();
setIsListening(true);
} catch (err) {
console.error('Speech recognition start failed:', err);
setSpeechError(`Failed to start recording: ${err instanceof Error ? err.message : String(err)}`);
}
}, []);
}, [fallbackSTT]);

const stopListening = useCallback(() => {
if (!recognitionRef.current) return;
recognitionRef.current.stop();
setIsListening(false);
finalizePractice();
}, [finalizePractice]);
if (useNative.current && recognitionRef.current) {
recognitionRef.current.stop();
setIsListening(false);
finalizePractice();
} else {
fallbackSTT.stopRecording();
setIsFallbackTranscribing(true);
}
}, [finalizePractice, fallbackSTT]);

const { speak: ttsSpeak } = useTTS();

Expand All @@ -264,7 +299,9 @@ export default function ReadDetailPage() {

const handleReset = () => {
recognitionRef.current?.abort();
fallbackSTT.stopRecording();
setIsListening(false);
setIsFallbackTranscribing(false);
transcriptRef.current = '';
interimTranscriptRef.current = '';
hasPersistedResultRef.current = false;
Expand Down Expand Up @@ -400,11 +437,22 @@ export default function ReadDetailPage() {
>
<Button
onClick={isListening ? stopListening : startListening}
disabled={isFallbackTranscribing}
className={`w-16 h-16 rounded-full cursor-pointer transition-colors duration-200 ${
isListening ? 'bg-red-500 hover:bg-red-600' : 'bg-green-500 hover:bg-green-600'
isFallbackTranscribing
? 'bg-amber-500 shadow-lg shadow-amber-200'
: isListening
? 'bg-red-500 hover:bg-red-600'
: 'bg-green-500 hover:bg-green-600'
}`}
>
{isListening ? <MicOff className="w-7 h-7" /> : <Mic className="w-7 h-7" />}
{isFallbackTranscribing ? (
<Loader2 className="w-7 h-7 animate-spin" />
) : isListening ? (
<MicOff className="w-7 h-7" />
) : (
<Mic className="w-7 h-7" />
)}
</Button>
</motion.div>
<Button
Expand All @@ -417,6 +465,7 @@ export default function ReadDetailPage() {
</Button>
</div>
{speechError && <p className="text-xs text-red-500 text-center max-w-md">{speechError}</p>}
{isFallbackTranscribing && <p className="text-xs text-amber-600 font-medium">Processing your speech...</p>}
</div>

<AnimatePresence>
Expand Down
1 change: 1 addition & 0 deletions src/app/(app)/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,7 @@ function AIProviderSection({
body: JSON.stringify({
models: candidateModels,
evaluatorModelId,
selectedModelId: evaluatorModelId,
}),
});

Expand Down
7 changes: 4 additions & 3 deletions src/app/api/model-recommendations/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ interface RecommendationResponse {
export async function POST(req: NextRequest) {
try {
const providerId = req.nextUrl.searchParams.get('providerId') as ProviderId | null;
const { models, evaluatorModelId } = (await req.json()) as {
const { models, evaluatorModelId, selectedModelId } = (await req.json()) as {
models?: ProviderModel[];
evaluatorModelId?: string;
selectedModelId?: string;
};

if (!providerId) {
Expand All @@ -39,7 +40,7 @@ export async function POST(req: NextRequest) {

const model = resolveModel({
providerId,
modelId: evaluatorModelId || getDefaultModelId(providerId),
modelId: evaluatorModelId || selectedModelId || getDefaultModelId(providerId),
apiKey: apiKey || 'ollama',
baseUrl,
apiPath,
Expand Down Expand Up @@ -108,7 +109,7 @@ Return ONLY valid JSON in this exact format:
{"recommendations":[{"modelId":"exact-id-from-list","rank":1,"score":96,"reason":"short concrete reason tied to EchoType workloads","label":"Recommended"}]}`;

const prompt = `Provider: ${providerId}
Evaluator model: ${evaluatorModelId || getDefaultModelId(providerId)}
Evaluator model: ${evaluatorModelId || selectedModelId || getDefaultModelId(providerId)}

Candidate models:
${JSON.stringify(candidateModels, null, 2)}
Expand Down
Loading
Loading