diff --git a/package.json b/package.json index e989ab45b..d99997afa 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.41.0", + "version": "0.42.0", "type": "module", "repository": { "type": "git", diff --git a/public/sounds/tool-call.mp3 b/public/sounds/tool-call.mp3 new file mode 100644 index 000000000..63fe8002f Binary files /dev/null and b/public/sounds/tool-call.mp3 differ diff --git a/public/sounds/typing.mp3 b/public/sounds/typing.mp3 new file mode 100644 index 000000000..bfe3833a4 Binary files /dev/null and b/public/sounds/typing.mp3 differ diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7b4206e28..ee84f40e3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3917,7 +3917,7 @@ dependencies = [ [[package]] name = "proxycast" -version = "0.41.0" +version = "0.42.0" dependencies = [ "anyhow", "arboard", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index adae68c11..330437d06 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "proxycast" -version = "0.41.0" +version = "0.42.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 495e9206e..355829b02 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ProxyCast", - "version": "0.41.0", + "version": "0.42.0", "identifier": "com.proxycast.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index 2071477e1..1d1fbe229 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,6 +33,7 @@ import { showRegistryLoadError } from "./lib/utils/connectError"; import { useDeepLink } from "./hooks/useDeepLink"; import { useRelayRegistry } from "./hooks/useRelayRegistry"; import { ComponentDebugProvider } from "./contexts/ComponentDebugContext"; +import { SoundProvider } from "./contexts/SoundProvider"; import { ComponentDebugOverlay } from "./components/dev"; import { Page } from "./types/page"; @@ -252,28 +253,30 @@ function AppContent() { // 4. 正常主界面 return ( - - - - {renderAllPages()} - {/* ProxyCast Connect 确认弹窗 */} - {/* _Requirements: 5.2_ */} - - {/* 组件视图调试覆盖层 */} - - - + + + + + {renderAllPages()} + {/* ProxyCast Connect 确认弹窗 */} + {/* _Requirements: 5.2_ */} + + {/* 组件视图调试覆盖层 */} + + + + ); } diff --git a/src/components/agent/chat/hooks/useAgentChat.ts b/src/components/agent/chat/hooks/useAgentChat.ts index a7e28281c..9a51ef681 100644 --- a/src/components/agent/chat/hooks/useAgentChat.ts +++ b/src/components/agent/chat/hooks/useAgentChat.ts @@ -32,6 +32,50 @@ export interface Topic { messagesCount: number; } +// 音效播放器(模块级别单例) +let toolcallAudio: HTMLAudioElement | null = null; +let typewriterAudio: HTMLAudioElement | null = null; +let lastTypewriterTime = 0; +const TYPEWRITER_INTERVAL = 120; + +const initAudio = () => { + if (!toolcallAudio) { + toolcallAudio = new Audio("/sounds/tool-call.mp3"); + toolcallAudio.volume = 1; + toolcallAudio.load(); + } + if (!typewriterAudio) { + typewriterAudio = new Audio("/sounds/typing.mp3"); + typewriterAudio.volume = 0.6; + typewriterAudio.load(); + } +}; + +const getSoundEnabled = (): boolean => { + return localStorage.getItem("proxycast_sound_enabled") === "true"; +}; + +const playToolcallSound = () => { + if (!getSoundEnabled()) return; + initAudio(); + if (toolcallAudio) { + toolcallAudio.currentTime = 0; + toolcallAudio.play().catch(console.error); + } +}; + +const playTypewriterSound = () => { + if (!getSoundEnabled()) return; + const now = Date.now(); + if (now - lastTypewriterTime < TYPEWRITER_INTERVAL) return; + initAudio(); + if (typewriterAudio) { + typewriterAudio.currentTime = 0; + typewriterAudio.play().catch(console.error); + lastTypewriterTime = now; + } +}; + // Helper for localStorage (Persistent across reloads) const loadPersisted = (key: string, defaultValue: T): T => { try { @@ -416,6 +460,10 @@ export function useAgentChat(options: UseAgentChatOptions = {}) { case "text_delta": // 累积文本并实时更新 UI(同时更新 content 和 contentParts) accumulatedContent += data.text; + + // 播放打字机音效 + playTypewriterSound(); + setMessages((prev) => prev.map((msg) => msg.id === assistantMsgId @@ -508,6 +556,10 @@ export function useAgentChat(options: UseAgentChatOptions = {}) { case "tool_start": { // 工具开始执行 - 添加到工具调用列表和 contentParts console.log(`[Tool Start] ${data.tool_name} (${data.tool_id})`); + + // 播放工具调用音效 + playToolcallSound(); + const newToolCall = { id: data.tool_id, name: data.tool_name, diff --git a/src/components/settings/GeneralSettings.tsx b/src/components/settings/GeneralSettings.tsx index fcb988402..d084d5e8d 100644 --- a/src/components/settings/GeneralSettings.tsx +++ b/src/components/settings/GeneralSettings.tsx @@ -3,12 +3,21 @@ * @description 通用设置页面 - 主题、代理、启动行为配置 */ import { useState, useEffect, useCallback } from "react"; -import { Moon, Sun, Monitor, RefreshCw, Info, RotateCcw } from "lucide-react"; +import { + Moon, + Sun, + Monitor, + RefreshCw, + Info, + RotateCcw, + Volume2, +} from "lucide-react"; import { cn, validateProxyUrl } from "@/lib/utils"; import { getConfig, saveConfig, Config } from "@/hooks/useTauri"; import { useOnboardingState } from "@/components/onboarding"; import { LanguageSelector, Language } from "./LanguageSelector"; import { useI18nPatch } from "@/i18n/I18nPatchProvider"; +import { useSoundContext } from "@/contexts/useSoundContext"; type Theme = "light" | "dark" | "system"; @@ -19,6 +28,8 @@ export function GeneralSettings() { const [language, setLanguageState] = useState("zh"); const { resetOnboarding } = useOnboardingState(); const { setLanguage: setI18nLanguage } = useI18nPatch(); + const { soundEnabled, setSoundEnabled, playToolcallSound } = + useSoundContext(); // 重新运行引导 const handleResetOnboarding = useCallback(() => { @@ -217,6 +228,32 @@ export function GeneralSettings() { + {/* 音效 */} +
+
+
+ +
+

音效

+

+ 工具调用和打字时播放提示音 +

+
+
+ { + setSoundEnabled(e.target.checked); + if (e.target.checked) { + playToolcallSound(); + } + }} + className="w-4 h-4 rounded border-gray-300" + /> +
+
+ {/* 启动行为 */}

启动行为

diff --git a/src/contexts/SoundProvider.tsx b/src/contexts/SoundProvider.tsx new file mode 100644 index 000000000..11349f75f --- /dev/null +++ b/src/contexts/SoundProvider.tsx @@ -0,0 +1,16 @@ +/** + * @file SoundProvider.tsx + * @description 音效 Provider 组件 + * @module contexts/SoundProvider + */ + +import { ReactNode } from "react"; +import { useSound } from "../hooks/useSound"; +import { SoundContext } from "./soundContext"; + +export function SoundProvider({ children }: { children: ReactNode }) { + const sound = useSound(); + return ( + {children} + ); +} diff --git a/src/contexts/soundContext.ts b/src/contexts/soundContext.ts new file mode 100644 index 000000000..7aa74b814 --- /dev/null +++ b/src/contexts/soundContext.ts @@ -0,0 +1,10 @@ +/** + * @file soundContext.ts + * @description 音效上下文定义 + * @module contexts/soundContext + */ + +import { createContext } from "react"; +import type { UseSoundReturn } from "../hooks/useSound"; + +export const SoundContext = createContext(null); diff --git a/src/contexts/useSoundContext.ts b/src/contexts/useSoundContext.ts new file mode 100644 index 000000000..4a9b062e8 --- /dev/null +++ b/src/contexts/useSoundContext.ts @@ -0,0 +1,17 @@ +/** + * @file useSoundContext.ts + * @description 音效上下文 Hook + * @module contexts/useSoundContext + */ + +import { useContext } from "react"; +import { SoundContext } from "./soundContext"; +import type { UseSoundReturn } from "../hooks/useSound"; + +export function useSoundContext(): UseSoundReturn { + const context = useContext(SoundContext); + if (!context) { + throw new Error("useSoundContext must be used within a SoundProvider"); + } + return context; +} diff --git a/src/hooks/README.md b/src/hooks/README.md index 0fe14ef73..ca1ef6a1c 100644 --- a/src/hooks/README.md +++ b/src/hooks/README.md @@ -25,6 +25,7 @@ React 自定义 Hooks,封装业务逻辑和状态管理。 - `useProviderState.ts` - Provider 状态 Hook - `useRelayRegistry.ts` - Relay Registry 管理 Hook(Requirements 2.1, 7.2, 7.3) - `useSkills.ts` - 技能管理 Hook +- `useSound.ts` - 音效管理 Hook(工具调用和打字机音效) - `useSwitch.ts` - 开关状态 Hook - `useTauri.ts` - Tauri 通用 Hook - `useWindowResize.ts` - 窗口大小 Hook diff --git a/src/hooks/index.ts b/src/hooks/index.ts index cc6289054..db8444814 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -3,6 +3,8 @@ export { useConfigEvents } from "./useConfigEvents"; export { useOAuthPlugins, useSingleOAuthPlugin } from "./useOAuthPlugins"; export { useDeepLink } from "./useDeepLink"; export { useModelRegistry } from "./useModelRegistry"; +export { useSound } from "./useSound"; +export type { UseSoundReturn } from "./useSound"; export type { ConnectPayload, RelayInfo, diff --git a/src/hooks/useSound.ts b/src/hooks/useSound.ts new file mode 100644 index 000000000..a29d7dd47 --- /dev/null +++ b/src/hooks/useSound.ts @@ -0,0 +1,71 @@ +/** + * @file useSound.ts + * @description 音效管理 Hook,提供工具调用和打字机音效播放功能 + * @module hooks/useSound + * @requires react + */ + +import { useState, useEffect, useCallback, useRef } from "react"; + +const STORAGE_KEY = "proxycast_sound_enabled"; +const SOUND_INTERVAL = 120; // 打字音效间隔 120ms + +export interface UseSoundReturn { + soundEnabled: boolean; + setSoundEnabled: (enabled: boolean) => void; + playToolcallSound: () => void; + playTypewriterSound: () => void; +} + +export function useSound(): UseSoundReturn { + const [soundEnabled, setSoundEnabledState] = useState(() => { + const stored = localStorage.getItem(STORAGE_KEY); + return stored === "true"; + }); + + const toolcallAudioRef = useRef(null); + const typewriterAudioRef = useRef(null); + const lastSoundTimeRef = useRef(0); + + // 初始化音频 + useEffect(() => { + if (!toolcallAudioRef.current) { + toolcallAudioRef.current = new Audio("/sounds/tool-call.mp3"); + toolcallAudioRef.current.volume = 1; + toolcallAudioRef.current.load(); + } + if (!typewriterAudioRef.current) { + typewriterAudioRef.current = new Audio("/sounds/typing.mp3"); + typewriterAudioRef.current.volume = 0.6; + typewriterAudioRef.current.load(); + } + }, []); + + const setSoundEnabled = useCallback((enabled: boolean) => { + setSoundEnabledState(enabled); + localStorage.setItem(STORAGE_KEY, String(enabled)); + }, []); + + const playToolcallSound = useCallback(() => { + if (!soundEnabled || !toolcallAudioRef.current) return; + toolcallAudioRef.current.currentTime = 0; + toolcallAudioRef.current.play().catch(console.error); + }, [soundEnabled]); + + const playTypewriterSound = useCallback(() => { + const now = Date.now(); + if (!soundEnabled || !typewriterAudioRef.current) return; + if (now - lastSoundTimeRef.current > SOUND_INTERVAL) { + typewriterAudioRef.current.currentTime = 0; + typewriterAudioRef.current.play().catch(console.error); + lastSoundTimeRef.current = now; + } + }, [soundEnabled]); + + return { + soundEnabled, + setSoundEnabled, + playToolcallSound, + playTypewriterSound, + }; +}