Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions .github/workflows/build-windows.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: Build Windows Native

on:
push:
branches:
- feature/voice-input
workflow_dispatch:

env:
RUST_BACKTRACE: 1
HUSKY: 0

jobs:
build:
name: Build Windows App
runs-on: windows-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 11.9.0

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"

- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc

- name: Rust Cache
uses: swatinem/rust-cache@v2
with:
workspaces: "./src-tauri -> target"

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build codeg-server
run: pnpm server:build

- name: Build Tauri App
run: pnpm tauri build --bundles nsis
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Upload Build Artifacts
uses: actions/upload-artifact@v4
with:
name: codeg-windows-voice-build
path: |
src-tauri/target/release/codeg.exe
src-tauri/target/release/codeg-server.exe
src-tauri/target/release/bundle/nsis/*.exe
if-no-files-found: error
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
},
"bundle": {
"active": true,
"createUpdaterArtifacts": true,
"createUpdaterArtifacts": false,
"targets": "all",
"icon": [
"icons/32x32.png",
Expand Down
77 changes: 77 additions & 0 deletions src/components/chat/composer/composer-voice-button.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, it, expect, vi } from "vitest"
import { render, screen, fireEvent } from "@testing-library/react"
import { ComposerVoiceButton } from "./composer-voice-button"
import type { UseVoiceInputReturn } from "./use-voice-input"

function createMockVoice(overrides: Partial<UseVoiceInputReturn> = {}): UseVoiceInputReturn {
return {
status: "idle",
mode: "web-speech",
isSupported: true,
volume: 0,
interimText: "",
errorMessage: null,
startListening: vi.fn(),
stopListening: vi.fn(),
cancel: vi.fn(),
...overrides,
}
}

describe("ComposerVoiceButton", () => {
it("renders idle button with microphone icon", () => {
const voice = createMockVoice()
render(<ComposerVoiceButton voice={voice} />)

const button = screen.getByTestId("composer-voice-button")
expect(button).toBeInTheDocument()
expect(button).not.toBeDisabled()
})

it("calls startListening on click when idle", () => {
const voice = createMockVoice()
render(<ComposerVoiceButton voice={voice} />)

fireEvent.click(screen.getByTestId("composer-voice-button"))
expect(voice.startListening).toHaveBeenCalled()
})

it("renders active state and calls stopListening on click when listening", () => {
const voice = createMockVoice({ status: "listening" })
render(<ComposerVoiceButton voice={voice} />)

const button = screen.getByTestId("composer-voice-button")
expect(button.className).toContain("text-red-500")

fireEvent.click(button)
expect(voice.stopListening).toHaveBeenCalled()
})

it("shows interim text bubble when speaking", () => {
const voice = createMockVoice({
status: "listening",
interimText: "测试语音内容",
})
render(<ComposerVoiceButton voice={voice} />)

const bubble = screen.getByTestId("voice-interim-bubble")
expect(bubble).toBeInTheDocument()
expect(bubble).toHaveTextContent("测试语音内容")
})

it("cancels on Escape key while listening", () => {
const voice = createMockVoice({ status: "listening" })
render(<ComposerVoiceButton voice={voice} />)

fireEvent.keyDown(window, { key: "Escape" })
expect(voice.cancel).toHaveBeenCalled()
})

it("disables button when transcribing", () => {
const voice = createMockVoice({ status: "transcribing" })
render(<ComposerVoiceButton voice={voice} />)

const button = screen.getByTestId("composer-voice-button")
expect(button).toBeDisabled()
})
})
118 changes: 118 additions & 0 deletions src/components/chat/composer/composer-voice-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Mic, Loader2 } from "lucide-react"
import { cn } from "@/lib/utils"
import type { UseVoiceInputReturn } from "./use-voice-input"
import { toast } from "sonner"

export interface ComposerVoiceButtonProps {
voice: UseVoiceInputReturn
disabled?: boolean
className?: string
/** Localized string for default tooltip */
label?: string
/** Localized string for listening state tooltip */
listeningLabel?: string
/** Localized string for transcribing state tooltip */
transcribingLabel?: string
/** Localized string for permission denied */
permissionDeniedLabel?: string
}

export function ComposerVoiceButton({
voice,
disabled = false,
className,
label = "语音输入 (点击开始)",
listeningLabel = "正在录音... 点击完成 (Esc 取消)",
transcribingLabel = "正在转写...",
permissionDeniedLabel = "麦克风权限被拒绝,请在设置中允许访问",
}: ComposerVoiceButtonProps) {
const isListening = voice.status === "listening"
const isTranscribing = voice.status === "transcribing"

// Cancel on Escape key while listening
useEffect(() => {
if (!isListening) return

const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault()
voice.cancel()
}
}

window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [isListening, voice])

// Toast on permission error
useEffect(() => {
if (voice.status === "error" && voice.errorMessage === "micPermissionDenied") {
toast.error(permissionDeniedLabel)
}
}, [voice.status, voice.errorMessage, permissionDeniedLabel])

const handleClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (disabled || isTranscribing) return

if (isListening) {
voice.stopListening()
} else {
void voice.startListening()
}
}

const title = isTranscribing ? transcribingLabel : isListening ? listeningLabel : label

return (
<div className="relative inline-flex items-center">
{/* Live speech interim preview bubble while user is speaking */}
{isListening && voice.interimText && (
<div
data-testid="voice-interim-bubble"
className="absolute bottom-full right-0 mb-2 z-50 max-w-xs truncate rounded-md bg-popover/95 px-2.5 py-1 text-xs text-popover-foreground shadow-md border border-border/80 backdrop-blur-sm animate-in fade-in zoom-in-95 duration-150"
>
<span className="inline-block size-1.5 rounded-full bg-red-500 mr-1.5 animate-pulse" />
{voice.interimText}
</div>
)}

<Button
type="button"
variant={isListening ? "outline" : "ghost"}
size="icon"
onClick={handleClick}
disabled={disabled || isTranscribing || !voice.isSupported}
title={title}
aria-label={title}
data-testid="composer-voice-button"
className={cn(
"relative h-8 w-8 transition-colors",
isListening && [
"border-red-500/40 bg-red-500/10 text-red-500 hover:bg-red-500/20 hover:text-red-600",
"dark:border-red-500/50 dark:bg-red-950/30 dark:text-red-400",
],
className
)}
>
{isListening && (
<span
className="absolute inset-0 rounded-md bg-red-500/20 animate-ping opacity-75 pointer-events-none"
style={{
animationDuration: "1.5s",
transform: `scale(${1 + voice.volume * 0.3})`,
}}
/>
)}
{isTranscribing ? (
<Loader2 className="size-4 animate-spin text-primary" />
) : (
<Mic className={cn("size-4", isListening ? "animate-pulse text-red-500" : "text-muted-foreground")} />
)}
</Button>
</div>
)
}
Loading