feat: 添加音效功能 (v0.42.0)

- 添加工具调用音效 (tool-call.mp3)
- 添加打字机音效 (typing.mp3)
- 在设置页面添加音效开关
- 创建 useSound hook 和 SoundProvider
This commit is contained in:
coso
2026-01-11 14:11:29 +08:00
parent 7d7f156234
commit 6e2033615c
15 changed files with 236 additions and 27 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
"version": "0.41.0",
"version": "0.42.0",
"type": "module",
"repository": {
"type": "git",
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -3917,7 +3917,7 @@ dependencies = [
[[package]]
name = "proxycast"
version = "0.41.0"
version = "0.42.0"
dependencies = [
"anyhow",
"arboard",
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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",
+25 -22
View File
@@ -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 (
<ComponentDebugProvider>
<AppContainer>
<AppSidebar currentPage={currentPage} onNavigate={setCurrentPage} />
<MainContent>{renderAllPages()}</MainContent>
{/* ProxyCast Connect 确认弹窗 */}
{/* _Requirements: 5.2_ */}
<ConnectConfirmDialog
open={isDialogOpen}
relay={relayInfo}
relayId={connectPayload?.relay ?? ""}
apiKey={connectPayload?.key ?? ""}
keyName={connectPayload?.name}
isVerified={isVerified}
isSaving={isSaving}
error={error}
onConfirm={handleConfirm}
onCancel={handleCancel}
/>
{/* 组件视图调试覆盖层 */}
<ComponentDebugOverlay />
</AppContainer>
</ComponentDebugProvider>
<SoundProvider>
<ComponentDebugProvider>
<AppContainer>
<AppSidebar currentPage={currentPage} onNavigate={setCurrentPage} />
<MainContent>{renderAllPages()}</MainContent>
{/* ProxyCast Connect 确认弹窗 */}
{/* _Requirements: 5.2_ */}
<ConnectConfirmDialog
open={isDialogOpen}
relay={relayInfo}
relayId={connectPayload?.relay ?? ""}
apiKey={connectPayload?.key ?? ""}
keyName={connectPayload?.name}
isVerified={isVerified}
isSaving={isSaving}
error={error}
onConfirm={handleConfirm}
onCancel={handleCancel}
/>
{/* 组件视图调试覆盖层 */}
<ComponentDebugOverlay />
</AppContainer>
</ComponentDebugProvider>
</SoundProvider>
);
}
@@ -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 = <T>(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,
+38 -1
View File
@@ -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<Language>("zh");
const { resetOnboarding } = useOnboardingState();
const { setLanguage: setI18nLanguage } = useI18nPatch();
const { soundEnabled, setSoundEnabled, playToolcallSound } =
useSoundContext();
// 重新运行引导
const handleResetOnboarding = useCallback(() => {
@@ -217,6 +228,32 @@ export function GeneralSettings() {
</div>
</div>
{/* 音效 */}
<div className="rounded-lg border p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Volume2 className="h-4 w-4 text-muted-foreground" />
<div>
<h3 className="text-sm font-medium">音效</h3>
<p className="text-xs text-muted-foreground">
工具调用和打字时播放提示音
</p>
</div>
</div>
<input
type="checkbox"
checked={soundEnabled}
onChange={(e) => {
setSoundEnabled(e.target.checked);
if (e.target.checked) {
playToolcallSound();
}
}}
className="w-4 h-4 rounded border-gray-300"
/>
</div>
</div>
{/* 启动行为 */}
<div className="rounded-lg border p-3 space-y-2">
<h3 className="text-sm font-medium">启动行为</h3>
+16
View File
@@ -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 (
<SoundContext.Provider value={sound}>{children}</SoundContext.Provider>
);
}
+10
View File
@@ -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<UseSoundReturn | null>(null);
+17
View File
@@ -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;
}
+1
View File
@@ -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
+2
View File
@@ -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,
+71
View File
@@ -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<boolean>(() => {
const stored = localStorage.getItem(STORAGE_KEY);
return stored === "true";
});
const toolcallAudioRef = useRef<HTMLAudioElement | null>(null);
const typewriterAudioRef = useRef<HTMLAudioElement | null>(null);
const lastSoundTimeRef = useRef<number>(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,
};
}