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 }),
+ },
+ ),
+);