diff --git a/web/bun.lock b/web/bun.lock index f3893ff..b0eb24d 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -5,8 +5,10 @@ "": { "name": "web", "dependencies": { + "partysocket": "^1.1.16", "react": "^19.2.4", "react-dom": "^19.2.4", + "sonner": "^2.0.7", "zustand": "^5.0.11", }, "devDependencies": { @@ -262,6 +264,8 @@ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "event-target-polyfill": ["event-target-polyfill@0.0.4", "", {}, "sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -312,6 +316,8 @@ "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + "partysocket": ["partysocket@1.1.16", "", { "dependencies": { "event-target-polyfill": "^0.0.4" }, "peerDependencies": { "react": ">=17" }, "optionalPeers": ["react"] }, "sha512-d7xFv+ZC7x0p/DAHWJ5FhxQhimIx+ucyZY+kxL0cKddLBmK9c4p2tEA/L+dOOrWm6EYrRwrBjKQV0uSzOY9x1w=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], @@ -330,6 +336,8 @@ "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], diff --git a/web/package.json b/web/package.json index b27a3fa..9d926d0 100644 --- a/web/package.json +++ b/web/package.json @@ -20,8 +20,10 @@ "vite": "^7.3.1" }, "dependencies": { + "partysocket": "^1.1.16", "react": "^19.2.4", "react-dom": "^19.2.4", + "sonner": "^2.0.7", "zustand": "^5.0.11" } } diff --git a/web/src/App.tsx b/web/src/App.tsx index 09d2813..8aeacf0 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,8 +1,8 @@ +import { Toaster } from "sonner"; import { HistoryList } from "./components/HistoryList"; import { MicButton } from "./components/MicButton"; import { PreviewBox } from "./components/PreviewBox"; import { StatusBadge } from "./components/StatusBadge"; -import { Toast } from "./components/Toast"; import { useRecorder } from "./hooks/useRecorder"; import { useWebSocket } from "./hooks/useWebSocket"; @@ -27,7 +27,7 @@ export function App() { - + ); } diff --git a/web/src/components/HistoryList.tsx b/web/src/components/HistoryList.tsx index d5b6342..a036c56 100644 --- a/web/src/components/HistoryList.tsx +++ b/web/src/components/HistoryList.tsx @@ -1,4 +1,5 @@ import { useCallback } from "react"; +import { toast } from "sonner"; import { useAppStore } from "../stores/app-store"; function formatTime(ts: number): string { @@ -13,14 +14,13 @@ interface HistoryListProps { export function HistoryList({ sendJSON }: HistoryListProps) { const history = useAppStore((s) => s.history); const clearHistory = useAppStore((s) => s.clearHistory); - const showToast = useAppStore((s) => s.showToast); const handleItemClick = useCallback( (text: string) => { sendJSON({ type: "paste", text }); - showToast("发送粘贴…"); + toast.info("发送粘贴…"); }, - [sendJSON, showToast], + [sendJSON], ); return ( @@ -39,9 +39,7 @@ export function HistoryList({ sendJSON }: HistoryListProps) { {history.length === 0 ? ( -

- {"暂无记录"} -

+

{"暂无记录"}

) : (
{history.map((item, i) => ( diff --git a/web/src/components/MicButton.tsx b/web/src/components/MicButton.tsx index a1dcaac..0edb2a8 100644 --- a/web/src/components/MicButton.tsx +++ b/web/src/components/MicButton.tsx @@ -98,9 +98,7 @@ export function MicButton({ onStart, onStop }: MicButtonProps) { ))}
-

- {"按住说话"} -

+

{"按住说话"}

); } diff --git a/web/src/components/Toast.tsx b/web/src/components/Toast.tsx deleted file mode 100644 index 9f692d7..0000000 --- a/web/src/components/Toast.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useEffect, useRef } from "react"; -import { useAppStore } from "../stores/app-store"; - -export function Toast() { - const toast = useAppStore((s) => s.toast); - const dismissToast = useAppStore((s) => s.dismissToast); - const timerRef = useRef | undefined>(undefined); - - useEffect(() => { - if (!toast) return; - if (timerRef.current) clearTimeout(timerRef.current); - timerRef.current = setTimeout(dismissToast, 2000); - return () => { - if (timerRef.current) clearTimeout(timerRef.current); - }; - }, [toast, dismissToast]); - - return ( -
- {toast} -
- ); -} diff --git a/web/src/hooks/useRecorder.ts b/web/src/hooks/useRecorder.ts index 3c8c02c..fd7cf8d 100644 --- a/web/src/hooks/useRecorder.ts +++ b/web/src/hooks/useRecorder.ts @@ -1,4 +1,5 @@ import { useCallback, useRef } from "react"; +import { toast } from "sonner"; import { resampleTo16kInt16 } from "../lib/resample"; import { useAppStore } from "../stores/app-store"; import audioProcessorUrl from "../workers/audio-processor.ts?worker&url"; @@ -89,9 +90,7 @@ export function useRecorder({ sendJSON, sendBinary }: UseRecorderOptions) { } catch (err) { useAppStore.getState().setPendingStart(false); abortRef.current = null; - useAppStore - .getState() - .showToast(`麦克风错误: ${(err as Error).message}`); + toast.error(`麦克风错误: ${(err as Error).message}`); } }, [initAudio]); diff --git a/web/src/hooks/useWebSocket.ts b/web/src/hooks/useWebSocket.ts index 617b8e7..7754292 100644 --- a/web/src/hooks/useWebSocket.ts +++ b/web/src/hooks/useWebSocket.ts @@ -1,4 +1,6 @@ +import { WebSocket as ReconnectingWebSocket } from "partysocket"; import { useCallback, useEffect, useRef } from "react"; +import { toast } from "sonner"; import { useAppStore } from "../stores/app-store"; function getWsUrl(): string { @@ -9,13 +11,8 @@ function getWsUrl(): string { return `${proto}//${location.host}/ws${q}`; } -const WS_RECONNECT_BASE = 1000; -const WS_RECONNECT_MAX = 16000; - export function useWebSocket() { - const wsRef = useRef(null); - const reconnectTimerRef = useRef | null>(null); - const reconnectDelayRef = useRef(WS_RECONNECT_BASE); + const wsRef = useRef(null); const sendJSON = useCallback((obj: Record) => { const ws = wsRef.current; @@ -27,78 +24,59 @@ export function useWebSocket() { const sendBinary = useCallback((data: Int16Array) => { const ws = wsRef.current; if (ws?.readyState === WebSocket.OPEN) { - ws.send(data.buffer); + ws.send(data.buffer as ArrayBuffer); } }, []); useEffect(() => { - function connect() { - if (wsRef.current) return; + useAppStore.getState().setConnectionStatus("connecting"); - useAppStore.getState().setConnectionStatus("connecting"); - const ws = new WebSocket(getWsUrl()); - ws.binaryType = "arraybuffer"; + const ws = new ReconnectingWebSocket(getWsUrl(), undefined, { + minReconnectionDelay: 1000, + maxReconnectionDelay: 16000, + }); + ws.binaryType = "arraybuffer"; - ws.onopen = () => { - reconnectDelayRef.current = WS_RECONNECT_BASE; - useAppStore.getState().setConnectionStatus("connected"); - }; + ws.onopen = () => { + useAppStore.getState().setConnectionStatus("connected"); + }; - ws.onmessage = (e: MessageEvent) => { - if (typeof e.data !== "string") return; - try { - const msg = JSON.parse(e.data); - const store = useAppStore.getState(); - switch (msg.type) { - case "partial": - store.setPreview(msg.text || "", false); - break; - case "final": - store.setPreview(msg.text || "", true); - if (msg.text) store.addHistory(msg.text); - break; - case "pasted": - store.showToast("✅ 已粘贴"); - break; - case "error": - store.showToast(`❌ ${msg.message || "错误"}`); - break; - } - } catch { - // Ignore malformed messages - } - }; - - ws.onclose = () => { - wsRef.current = null; + ws.onmessage = (e: MessageEvent) => { + if (typeof e.data !== "string") return; + try { + const msg = JSON.parse(e.data); const store = useAppStore.getState(); - store.setConnectionStatus("disconnected"); - if (store.recording) store.setRecording(false); - if (store.pendingStart) store.setPendingStart(false); - scheduleReconnect(); - }; + switch (msg.type) { + case "partial": + store.setPreview(msg.text || "", false); + break; + case "final": + store.setPreview(msg.text || "", true); + if (msg.text) store.addHistory(msg.text); + break; + case "pasted": + toast.success("已粘贴"); + break; + case "error": + toast.error(msg.message || "错误"); + break; + } + } catch { + // Ignore malformed messages + } + }; - ws.onerror = () => ws.close(); - wsRef.current = ws; - } + ws.onclose = () => { + const store = useAppStore.getState(); + store.setConnectionStatus("disconnected"); + if (store.recording) store.setRecording(false); + if (store.pendingStart) store.setPendingStart(false); + }; - function scheduleReconnect() { - if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current); - reconnectTimerRef.current = setTimeout( - connect, - reconnectDelayRef.current, - ); - reconnectDelayRef.current = Math.min( - reconnectDelayRef.current * 2, - WS_RECONNECT_MAX, - ); - } - - connect(); + wsRef.current = ws; return () => { - if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current); - wsRef.current?.close(); + ws.close(); wsRef.current = null; }; }, []); diff --git a/web/src/stores/app-store.ts b/web/src/stores/app-store.ts index 862fafd..b381520 100644 --- a/web/src/stores/app-store.ts +++ b/web/src/stores/app-store.ts @@ -1,11 +1,11 @@ import { create } from "zustand"; +import { persist } from "zustand/middleware"; export interface HistoryItem { text: string; ts: number; } -const HISTORY_KEY = "voicepaste_history"; const HISTORY_MAX = 50; type ConnectionStatus = "connected" | "disconnected" | "connecting"; @@ -21,8 +21,6 @@ interface AppState { previewActive: boolean; // History history: HistoryItem[]; - // Toast - toast: string | null; // Actions setConnectionStatus: (status: ConnectionStatus) => void; @@ -32,55 +30,43 @@ interface AppState { clearPreview: () => void; addHistory: (text: string) => void; clearHistory: () => void; - showToast: (message: string) => void; - dismissToast: () => void; } -function loadHistoryFromStorage(): HistoryItem[] { - try { - return JSON.parse( - localStorage.getItem(HISTORY_KEY) || "[]", - ) as HistoryItem[]; - } catch { - return []; - } -} +export const useAppStore = create()( + persist( + (set, get) => ({ + connectionStatus: "connecting", + recording: false, + pendingStart: false, + previewText: "", + previewActive: false, + history: [], -export const useAppStore = create((set, get) => ({ - connectionStatus: "connecting", - recording: false, - pendingStart: false, - previewText: "", - previewActive: false, - history: loadHistoryFromStorage(), - toast: null, + setConnectionStatus: (connectionStatus) => set({ connectionStatus }), + setRecording: (recording) => set({ recording }), + setPendingStart: (pendingStart) => set({ pendingStart }), - setConnectionStatus: (connectionStatus) => set({ connectionStatus }), - setRecording: (recording) => set({ recording }), - setPendingStart: (pendingStart) => set({ pendingStart }), + setPreview: (text, isFinal) => + set({ + previewText: text, + previewActive: !isFinal && text.length > 0, + }), - setPreview: (text, isFinal) => - set({ - previewText: text, - previewActive: !isFinal && text.length > 0, + clearPreview: () => set({ previewText: "", previewActive: false }), + + addHistory: (text) => { + const items = [{ text, ts: Date.now() }, ...get().history].slice( + 0, + HISTORY_MAX, + ); + set({ history: items }); + }, + + clearHistory: () => set({ history: [] }), }), - - clearPreview: () => set({ previewText: "", previewActive: false }), - - addHistory: (text) => { - const items = [{ text, ts: Date.now() }, ...get().history].slice( - 0, - HISTORY_MAX, - ); - localStorage.setItem(HISTORY_KEY, JSON.stringify(items)); - set({ history: items }); - }, - - clearHistory: () => { - localStorage.removeItem(HISTORY_KEY); - set({ history: [] }); - }, - - showToast: (message) => set({ toast: message }), - dismissToast: () => set({ toast: null }), -})); + { + name: "voicepaste_history", + partialize: (state) => ({ history: state.history }), + }, + ), +);