diff --git a/.env.example b/.env.example index f8839e4..fb136f9 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ # Created by `convex dev`; used by the browser and the eve persistence hook. VITE_CONVEX_URL=https://your-deployment.convex.cloud -# Used by eve's default Vercel AI Gateway provider. -AI_GATEWAY_API_KEY=your-ai-gateway-key +# Used only to mint short-lived live transcription tokens. +TRANSCRIPTION_AI_GATEWAY_API_KEY=your-ai-gateway-key diff --git a/AGENTS.md b/AGENTS.md index 598ae23..0731b9a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,12 @@ of the product. - Keep functions and components small, linear, and responsible for one thing. If a unit must understand unrelated or partially defined data, fix the boundary or data model. +- Compose sibling capabilities in their nearest common parent. A component owns only + the behavior implied by its name; do not move unrelated actions into it to hide + coordination. An optional feature must be removable by deleting its import and + composition node without breaking sibling capabilities. +- Express UI variants with focused components and early returns. Do not accumulate + JSX in mutable variables or turn one component into a dispatcher for unrelated UI. - Keep one source of truth and derive the rest. Model state with one explicit status, not overlapping booleans or synchronization effects. - Keep logic above JSX. Avoid ternaries and boolean chains in markup. Use diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6a3273d..9f97e10 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -174,6 +174,8 @@ Here `sessionId` is Eve's durable session ID, not the app's public session ID. Its header shows the selected GitHub repository when the workspace has one. The read-only workspace contains breadcrumbs, a keyboard-accessible tree, and a highlighted source viewer. File tool activity can open the corresponding file. +- **Composer** composes text input, the self-contained Chat Voice Input package, and + submit as independent controls. Audio is never recorded or persisted. - **Activity** projects Eve events into reasoning, tool calls, live Bash output, file diffs, and elapsed time. - **Session management** includes responsive sidebar navigation, rename, and delete. @@ -220,9 +222,12 @@ agent/ Eve agent and its server-side adapters skills/ optional stack recipes tools/ narrow additions to Eve's built-in tool set convex/ schema, session operations, and checkpoint persistence -lib/ lowest-level non-component modules and runtime/vendor adapters +lib/ lowest-level reusable modules and extractable feature packages + chat-voice-input/ + self-contained voice input package components/ ui/ generic visual primitives + composer/ message input, optional controls, and submit composition code/ Pierre-backed source and diff facades session/ conversation, activity, navigation, and preview control workspace/ file navigation, tree, queries, and panel @@ -235,16 +240,23 @@ Layer rules: - `agent/` imports low-level `lib/` contracts and generated Convex APIs, never UI. - `convex/` imports only pure `lib/` modules. -- `lib/` never imports feature components, `app/`, `agent/`, or `convex/`. A module - may be cross-runtime or browser-only, but its dependencies must make that boundary - obvious. +- `lib/` never imports feature components, `app/`, `agent/`, or `convex/`. An + extractable feature may own components as long as its directory remains + self-contained. Modules may be cross-runtime, browser-only, or server-only, but + filenames and dependencies must make those boundaries obvious. - External data is validated and normalized at its boundary. Internal consumers receive one stable shape instead of repeating defensive parsing. - Feature components may import `ui/`, `lib/`, generated Convex APIs, and sibling or lower feature facades. `app/` wires them together; nothing imports from `app/`. +- Parents compose sibling capabilities and own only the coordination between them. + Feature components own the behavior named by their boundary and never absorb + unrelated sibling actions. Removing an optional feature at its composition site + must leave unrelated workflows intact. - Vendor renderers stay behind `components/code/` or the workspace feature boundary. Consumers do not depend on Pierre directly. -- There are no barrel files. Modules export only what a real consumer uses. +- There are no application barrel files. An extractable package directory may expose + one public `index.ts` plus explicit runtime subpaths such as `server`; its internal + imports remain relative so the directory can move unchanged. - Split on responsibility, not line count. Extract shared code on its second real consumer, not in anticipation of one. diff --git a/README.md b/README.md index ba3f5fb..a35922c 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,8 @@ bunx vercel env pull .env.local bun run dev ``` -Set `AI_GATEWAY_API_KEY` in `.env.local` only when Vercel OIDC is unavailable. +Set `TRANSCRIPTION_AI_GATEWAY_API_KEY` in `.env.local` to enable voice input. +Eve continues to use Vercel OIDC, independently from this key. Open [http://localhost:5173](http://localhost:5173). diff --git a/agent/channels/transcription.ts b/agent/channels/transcription.ts new file mode 100644 index 0000000..7bd7ab4 --- /dev/null +++ b/agent/channels/transcription.ts @@ -0,0 +1,13 @@ +import { defineChannel, POST } from "eve/channels"; + +import { createTranscriptionTokenResponse } from "@/lib/chat-voice-input/server"; + +export default defineChannel({ + routes: [ + POST("/eve/v1/transcription", () => + createTranscriptionTokenResponse({ + apiKey: process.env.TRANSCRIPTION_AI_GATEWAY_API_KEY, + }), + ), + ], +}); diff --git a/bun.lock b/bun.lock index 1cf1bef..9c4f8da 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "eve-code", "dependencies": { + "@ai-sdk/gateway": "^4.0.0", "@convex-dev/react-query": "^0.1.0", "@fontsource-variable/geist": "^5.2.9", "@pierre/diffs": "^1.2.12", @@ -14,6 +15,7 @@ "@tanstack/react-query": "^5.101.2", "@vercel/oidc": "^3.8.0", "@vercel/sandbox": "^2.3.0", + "ai": "^7.0.0", "convex": "^1.42.2", "diff": "^9.0.0", "eve": "^0.24.6", diff --git a/components/session/composer.tsx b/components/composer/composer.tsx similarity index 50% rename from components/session/composer.tsx rename to components/composer/composer.tsx index f1183eb..b771b93 100644 --- a/components/session/composer.tsx +++ b/components/composer/composer.tsx @@ -2,6 +2,7 @@ import { ArrowUp, Square } from "lucide-react"; import { type FormEvent, type KeyboardEvent, useEffect, useRef } from "react"; import { Button } from "@/components/ui/button"; +import ChatVoiceInput from "@/lib/chat-voice-input"; import { useComposerStore } from "@/lib/composer-store"; type ComposerProps = { @@ -11,14 +12,57 @@ type ComposerProps = { readonly onStop?: () => void; }; +function SubmitButton({ + disabled, + isGenerating, + onStop, +}: { + readonly disabled: boolean; + readonly isGenerating: boolean; + readonly onStop?: () => void; +}) { + if (isGenerating) { + return ( + + ); + } + + return ( + + ); +} + +function handleKeyDown(event: KeyboardEvent): void { + if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; + + event.preventDefault(); + event.currentTarget.form?.requestSubmit(); +} + export function Composer({ disabled = false, isGenerating = false, onSend, onStop, }: ComposerProps) { - const draft = useComposerStore((state) => state.draft); - const setDraft = useComposerStore((state) => state.setDraft); + const value = useComposerStore((state) => state.draft); + const onChange = useComposerStore((state) => state.setDraft); const textareaRef = useRef(null); useEffect(() => { @@ -27,19 +71,14 @@ export function Composer({ function handleSubmit(event: FormEvent): void { event.preventDefault(); - const message = draft.trim(); - if (!message) return; - - setDraft(""); + const message = value.trim(); + if (!message || disabled || isGenerating) return; + onChange(""); onSend(message); } - function handleKeyDown(event: KeyboardEvent): void { - if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; - - event.preventDefault(); - event.currentTarget.form?.requestSubmit(); - } + const audioDisabled = disabled || isGenerating; + const submitDisabled = disabled || !value.trim(); return (
@@ -55,35 +94,16 @@ export function Composer({ className="max-h-48 min-h-16 w-full resize-none overflow-y-auto bg-transparent px-2 py-1 outline-none [field-sizing:content] placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50" disabled={disabled} id="message-input" - onChange={(event) => setDraft(event.target.value)} + onChange={(event) => onChange(event.target.value)} onKeyDown={handleKeyDown} placeholder="Message eve-code" ref={textareaRef} rows={1} - value={draft} + value={value} /> -
- {isGenerating && onStop && ( - - )} - {!isGenerating && ( - - )} +
+ +
diff --git a/components/session/session-start.tsx b/components/session/session-start.tsx index a4b9bd3..1c4579c 100644 --- a/components/session/session-start.tsx +++ b/components/session/session-start.tsx @@ -1,7 +1,7 @@ import { ArrowLeft, ArrowRight, FilePlus2, GitFork, type LucideIcon } from "lucide-react"; import { type FormEvent, type ReactNode, useRef, useState } from "react"; -import { Composer } from "@/components/session/composer"; +import { Composer } from "@/components/composer/composer"; import { Button } from "@/components/ui/button"; import { type GitRepository, parseGitHubRepository } from "@/lib/github"; diff --git a/components/session/session-view.tsx b/components/session/session-view.tsx index c672bfe..afd9e11 100644 --- a/components/session/session-view.tsx +++ b/components/session/session-view.tsx @@ -1,7 +1,7 @@ import { Activity, lazy, type ReactNode, Suspense, useCallback, useState } from "react"; +import { Composer } from "@/components/composer/composer"; import { CommandLogsProvider } from "@/components/session/command-logs"; -import { Composer } from "@/components/session/composer"; import { Conversation } from "@/components/session/conversation"; import { PageHeader } from "@/components/session/page-header"; import { type StoredSession, useSession } from "@/components/session/use-session"; diff --git a/lib/chat-voice-input/README.md b/lib/chat-voice-input/README.md new file mode 100644 index 0000000..80a27aa --- /dev/null +++ b/lib/chat-voice-input/README.md @@ -0,0 +1,57 @@ +# Chat Voice Input + +Chat Voice Input adds live microphone transcription to a React composer. It streams +24 kHz PCM audio to Vercel AI Gateway, writes transcript deltas into a controlled +value, and includes a microphone button, waveform, timer, and error state. It does +not create or persist audio files. + +## Usage + +Use the default component for the standard controls: + +```tsx +import ChatVoiceInput from "@/lib/chat-voice-input"; + +; +``` + +Use the compound components when the layout needs customization: + +```tsx +import ChatVoiceInput, { useChatVoiceInput } from "@/lib/chat-voice-input"; + + + + + + +; +``` + +`useChatVoiceInput` exposes the current status, transcript, media stream, and +`start`/`stop` actions for custom controls. Every component is also available as a +named export. + +## Server + +The browser expects `POST /eve/v1/transcription` to return a short-lived AI Gateway +token. Pass application-specific configuration to the server-only helper: + +```ts +import { createTranscriptionTokenResponse } from "@/lib/chat-voice-input/server"; + +return createTranscriptionTokenResponse({ + apiKey: process.env.TRANSCRIPTION_AI_GATEWAY_API_KEY, +}); +``` + +When `apiKey` is omitted, the AI Gateway provider uses its standard authentication: +`AI_GATEWAY_API_KEY`, then Vercel OIDC. diff --git a/lib/chat-voice-input/chat-voice-input.tsx b/lib/chat-voice-input/chat-voice-input.tsx new file mode 100644 index 0000000..2f1d4bc --- /dev/null +++ b/lib/chat-voice-input/chat-voice-input.tsx @@ -0,0 +1,25 @@ +import { ChatVoiceInputButton, ChatVoiceInputError } from "./controls"; +import { ChatVoiceInputProvider, type ChatVoiceInputProviderProps } from "./provider"; +import { ChatVoiceInputTimer } from "./timer"; +import { ChatVoiceInputWaveform } from "./waveform"; + +export type ChatVoiceInputProps = Omit; + +function ChatVoiceInput(props: ChatVoiceInputProps) { + return ( + + + + + + + ); +} + +export default Object.assign(ChatVoiceInput, { + Button: ChatVoiceInputButton, + Error: ChatVoiceInputError, + Provider: ChatVoiceInputProvider, + Timer: ChatVoiceInputTimer, + Waveform: ChatVoiceInputWaveform, +}); diff --git a/lib/chat-voice-input/controls.tsx b/lib/chat-voice-input/controls.tsx new file mode 100644 index 0000000..f25aea6 --- /dev/null +++ b/lib/chat-voice-input/controls.tsx @@ -0,0 +1,64 @@ +import { LoaderCircle, Mic, Square } from "lucide-react"; + +import { type ChatVoiceInputStatus, useChatVoiceInput } from "./provider"; + +const buttonClassName = + "inline-flex size-8 shrink-0 items-center justify-center rounded-full transition-colors duration-150 outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0"; + +function buttonLabel(state: ChatVoiceInputStatus, error: string): string { + if (state === "recording") return "Stop voice input"; + if (state === "loading") return "Loading voice input"; + if (error) return "Retry voice input"; + return "Start voice input"; +} + +function ButtonIcon({ state }: { readonly state: ChatVoiceInputStatus }) { + if (state === "loading") { + return