mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: 添加音效功能 (v0.42.0)
- 添加工具调用音效 (tool-call.mp3) - 添加打字机音效 (typing.mp3) - 在设置页面添加音效开关 - 创建 useSound hook 和 SoundProvider
This commit is contained in:
+1
-1
@@ -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.
Generated
+1
-1
@@ -3917,7 +3917,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast"
|
||||
version = "0.41.0"
|
||||
version = "0.42.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
|
||||
@@ -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,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
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user