diff --git a/src/components/ai-agent/AiAgentPlanPanel.jsx b/src/components/ai-agent/AiAgentPlanPanel.tsx
similarity index 79%
rename from src/components/ai-agent/AiAgentPlanPanel.jsx
rename to src/components/ai-agent/AiAgentPlanPanel.tsx
index 5718689..9fb42da 100644
--- a/src/components/ai-agent/AiAgentPlanPanel.jsx
+++ b/src/components/ai-agent/AiAgentPlanPanel.tsx
@@ -1,4 +1,19 @@
import { CheckCircle2 } from "lucide-react";
+import type {
+ AiOperationResult,
+ AiPreparedOperation,
+ AiRejectedOperation,
+} from "../../types/ai"
+
+interface AiAgentPlanPanelProps {
+ operations: AiPreparedOperation[]
+ operationResults: AiOperationResult[]
+ rejectedOperations: AiRejectedOperation[]
+ isApplying: boolean
+ onApply: () => void
+ onUndo: () => void
+ canUndo: boolean
+}
export function AiAgentPlanPanel({
operations,
@@ -8,11 +23,10 @@ export function AiAgentPlanPanel({
onApply,
onUndo,
canUndo,
-}) {
- const hasOperations = Array.isArray(operations) && operations.length > 0;
- const hasResults = Array.isArray(operationResults) && operationResults.length > 0;
- const hasRejected =
- Array.isArray(rejectedOperations) && rejectedOperations.length > 0;
+}: AiAgentPlanPanelProps) {
+ const hasOperations = operations.length > 0;
+ const hasResults = operationResults.length > 0;
+ const hasRejected = rejectedOperations.length > 0;
return (
@@ -27,7 +41,7 @@ export function AiAgentPlanPanel({
return (
{operation.description}
- {Array.isArray(operation.issues) && operation.issues.length > 0 ? (
+ {operation.issues && operation.issues.length > 0 ? (
{operation.issues.join(" ")}
) : null}
diff --git a/src/components/ai-agent/useAiAgentController.js b/src/components/ai-agent/useAiAgentController.ts
similarity index 84%
rename from src/components/ai-agent/useAiAgentController.js
rename to src/components/ai-agent/useAiAgentController.ts
index 5c584df..d230ef7 100644
--- a/src/components/ai-agent/useAiAgentController.js
+++ b/src/components/ai-agent/useAiAgentController.ts
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
+import type { FormEvent, KeyboardEvent } from "react"
import { useDispatch, useSelector, useStore } from "react-redux";
import {
applyAiOperations,
@@ -22,6 +23,17 @@ import {
updateConversation,
} from "../../lib/aiConversationsApi";
import { loadProjectFromFile } from "../../store";
+import type {
+ AiChatMessage,
+ AiConversation,
+ AiConversationSummary,
+ AiOperationResult,
+ AiPreparedOperation,
+ AiProviderId,
+ AiRejectedOperation,
+ AiSample,
+} from "../../types/ai"
+import type { DawState } from "../../types/daw"
const AI_AGENT_PROVIDER_STORAGE = "openstudio.ai.provider";
const AI_AGENT_LEGACY_OPENAI_KEY_STORAGE = "openstudio.ai.openaiKey";
@@ -30,7 +42,12 @@ const AI_AGENT_LEGACY_MODEL_STORAGE = "openstudio.ai.model";
// Max characters for the auto-generated conversation title.
const TITLE_MAX_LENGTH = 50;
-function readStoredValue(key, fallback = "") {
+interface DawStore {
+ getState: () => { daw: DawState }
+ dispatch: (action: unknown) => unknown
+}
+
+function readStoredValue(key: string, fallback = ""): string {
if (typeof window === "undefined") {
return fallback;
}
@@ -42,7 +59,7 @@ function readStoredValue(key, fallback = "") {
}
}
-function writeStoredValue(key, value) {
+function writeStoredValue(key: string, value: string): void {
if (typeof window === "undefined") {
return;
}
@@ -58,7 +75,7 @@ function writeStoredValue(key, value) {
}
}
-function readStoredProvider() {
+function readStoredProvider(): AiProviderId {
const storedProvider = readStoredValue(
AI_AGENT_PROVIDER_STORAGE,
AI_AGENT_DEFAULT_PROVIDER,
@@ -66,7 +83,7 @@ function readStoredProvider() {
return getAiProviderConfig(storedProvider).id;
}
-function readProviderKey(providerId) {
+function readProviderKey(providerId: string): string {
const provider = getAiProviderConfig(providerId);
const storedKey = readStoredValue(provider.keyStorage);
if (storedKey) {
@@ -78,7 +95,7 @@ function readProviderKey(providerId) {
return "";
}
-function readProviderModel(providerId) {
+function readProviderModel(providerId: string): string {
const provider = getAiProviderConfig(providerId);
const storedModel =
readStoredValue(provider.modelStorage) ||
@@ -88,15 +105,19 @@ function readProviderModel(providerId) {
return getAiProviderModel(provider.id, storedModel);
}
-function cloneSerializable(value) {
- return JSON.parse(JSON.stringify(value));
+function cloneSerializable(value: T): T {
+ return JSON.parse(JSON.stringify(value)) as T
+}
+
+function getErrorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error)
}
/**
* Derives a short title from the first user message, truncated on a word
* boundary so the title does not cut mid-word.
*/
-function deriveTitle(firstMessage) {
+function deriveTitle(firstMessage: string): string {
const raw = String(firstMessage || "").trim();
if (!raw) {
return "New chat";
@@ -110,13 +131,13 @@ function deriveTitle(firstMessage) {
}
export function useAiAgentController() {
- const dispatch = useDispatch();
- const store = useStore();
- const currentUser = useSelector(function (state) {
+ const dispatch = useDispatch() as DawStore["dispatch"];
+ const store = useStore() as unknown as DawStore;
+ const currentUser = useSelector(function (state: { user: { currentUser: unknown } }) {
return state.user.currentUser;
});
const isAuthenticated = Boolean(currentUser);
- const [provider, setProviderState] = useState(readStoredProvider);
+ const [provider, setProviderState] = useState(readStoredProvider);
const [apiKey, setApiKey] = useState(function () {
return readProviderKey(provider);
@@ -128,21 +149,21 @@ export function useAiAgentController() {
return readProviderModel(provider);
});
const [input, setInput] = useState("");
- const [messages, setMessages] = useState([]);
- const [pendingOperations, setPendingOperations] = useState([]);
- const [operationResults, setOperationResults] = useState([]);
- const [rejectedOperations, setRejectedOperations] = useState([]);
+ const [messages, setMessages] = useState([]);
+ const [pendingOperations, setPendingOperations] = useState([]);
+ const [operationResults, setOperationResults] = useState([]);
+ const [rejectedOperations, setRejectedOperations] = useState([]);
const [error, setError] = useState("");
const [connectionStatus, setConnectionStatus] = useState("");
const [isSending, setIsSending] = useState(false);
const [isApplying, setIsApplying] = useState(false);
const [isTestingConnection, setIsTestingConnection] = useState(false);
const [canUndoAppliedPlan, setCanUndoAppliedPlan] = useState(false);
- const appliedPlanSnapshotsRef = useRef([]);
+ const appliedPlanSnapshotsRef = useRef([]);
// Conversation history (Supabase-backed, authenticated users only)
- const [conversations, setConversations] = useState([]);
- const [activeConversationId, setActiveConversationId] = useState(null);
+ const [conversations, setConversations] = useState([]);
+ const [activeConversationId, setActiveConversationId] = useState(null);
const [isLoadingConversations, setIsLoadingConversations] = useState(false);
useEffect(
@@ -165,7 +186,7 @@ export function useAiAgentController() {
);
const setProvider = useCallback(
- function (nextProvider) {
+ function (nextProvider: string) {
const currentProviderConfig = getAiProviderConfig(provider);
writeStoredValue(
currentProviderConfig.keyStorage,
@@ -228,14 +249,14 @@ export function useAiAgentController() {
}, []);
const selectConversation = useCallback(
- async function (conversationId) {
+ async function (conversationId: string) {
if (!conversationId) {
return;
}
setIsLoadingConversations(true);
try {
- const convo = await loadConversation(conversationId);
+ const convo = await loadConversation(conversationId) as AiConversation | null;
if (!convo) {
return;
}
@@ -270,7 +291,7 @@ export function useAiAgentController() {
);
const deleteConversation = useCallback(
- async function (conversationId) {
+ async function (conversationId: string) {
if (!conversationId) {
return;
}
@@ -292,7 +313,9 @@ export function useAiAgentController() {
[activeConversationId, startNewConversation],
);
- const sendMessage = async function (event) {
+ const sendMessage = async function (
+ event?: FormEvent | KeyboardEvent,
+ ): Promise {
event?.preventDefault();
const userMessage = String(input || "").trim();
@@ -316,7 +339,7 @@ export function useAiAgentController() {
});
try {
- const allSamples = await loadAiSampleIndex(800);
+ const allSamples: AiSample[] = await loadAiSampleIndex(800);
// Always send a broad sample catalog so the agent knows what's
// available without the user having to mention specific names.
const searched = searchAiSamples(allSamples, userMessage, 120);
@@ -348,8 +371,11 @@ export function useAiAgentController() {
setPendingOperations(validatedOperations);
setRejectedOperations(prepared.rejected);
- const assistantMessage = { role: "assistant", content: response.message };
- const updatedMessages = messages.concat(
+ const assistantMessage: AiChatMessage = {
+ role: "assistant",
+ content: response.message,
+ };
+ const updatedMessages: AiChatMessage[] = messages.concat(
{ role: "user", content: userMessage },
assistantMessage,
);
@@ -364,7 +390,7 @@ export function useAiAgentController() {
pendingOperations: validatedOperations,
operationResults: [],
rejectedOperations: prepared.rejected,
- });
+ }) as AiConversation;
setActiveConversationId(created.id);
setConversations(function (current) {
return [
@@ -396,7 +422,7 @@ export function useAiAgentController() {
}
}
} catch (sendError) {
- const message = String(sendError?.message || sendError);
+ const message = getErrorMessage(sendError);
setError(message);
setMessages(function (current) {
return current.concat({
@@ -425,7 +451,7 @@ export function useAiAgentController() {
setPendingOperations([]);
setRejectedOperations([]);
- const systemMessage = {
+ const systemMessage: AiChatMessage = {
role: "system",
content:
"Applied " +
@@ -436,18 +462,18 @@ export function useAiAgentController() {
? ". Skipped " + result.skipped.length + "."
: "."),
};
- const updatedMessages = messages.concat(systemMessage);
+ const updatedMessages: AiChatMessage[] = messages.concat(systemMessage);
setMessages(updatedMessages);
// Persist the applied state to Supabase.
if (isAuthenticated && activeConversationId) {
- updateConversation(activeConversationId, {
+ (updateConversation(activeConversationId, {
messages: updatedMessages,
pendingOperations: [],
operationResults: result.results,
rejectedOperations: [],
- })
- .then(function (updated) {
+ }) as Promise)
+ .then(function (updated: AiConversation) {
setConversations(function (current) {
return current.map(function (item) {
if (item.id !== activeConversationId) {
@@ -481,7 +507,7 @@ export function useAiAgentController() {
const providerLabel = getAiProviderConfig(provider).label;
setConnectionStatus("Connected to " + providerLabel + " " + result.model + ".");
} catch (testError) {
- const message = String(testError?.message || testError);
+ const message = getErrorMessage(testError);
setError(message);
setConnectionStatus("");
} finally {
diff --git a/src/types/ai.ts b/src/types/ai.ts
new file mode 100644
index 0000000..dc36e57
--- /dev/null
+++ b/src/types/ai.ts
@@ -0,0 +1,130 @@
+export type AiProviderId = "openai" | "gemini"
+
+export type AiChatRole = "user" | "assistant" | "system"
+
+export type AiOperationType =
+ | "set_bpm"
+ | "create_pattern"
+ | "set_active_pattern"
+ | "rename_pattern"
+ | "set_pattern_length"
+ | "add_channel"
+ | "assign_plugin_to_channel"
+ | "assign_sample_to_channel"
+ | "add_sample_as_channel"
+ | "clear_channel_pattern"
+ | "set_step"
+ | "add_piano_notes"
+ | "add_chord_progression"
+ | "set_channel_volume"
+ | "set_channel_pan"
+ | "set_channel_mute"
+ | "set_channel_solo"
+ | "set_channel_input_mode"
+ | "set_channel_mixer_insert"
+ | "add_playlist_pattern_clip"
+ | "add_playlist_audio_clip"
+ | "add_mixer_track"
+ | "set_insert_fader"
+ | "set_insert_pan"
+ | "set_insert_stereo"
+ | "set_fx_slot_effect"
+ | "set_fx_reverb_param"
+ | "set_fx_maximizer_param"
+ | "set_fx_graphic_eq_band_gain"
+
+export interface AiProviderModel {
+ value: string
+ label: string
+}
+
+export interface AiProviderConfig {
+ id: AiProviderId
+ label: string
+ defaultModel: string
+ keyStorage: string
+ modelStorage: string
+ keyPlaceholder: string
+ models: readonly AiProviderModel[]
+}
+
+export interface AiChatMessage {
+ role: AiChatRole
+ content: string
+}
+
+export interface AiSample {
+ name: string
+ path: string
+ folder: string
+}
+
+export interface AiRawOperation {
+ type?: unknown
+ payload?: unknown
+}
+
+export interface AiPreparedOperation {
+ id: string
+ type: AiOperationType
+ payload: Record
+ description: string
+ status?: "warning" | "ready"
+ issues?: string[]
+}
+
+export interface AiRejectedOperation {
+ index: number
+ reason: string
+}
+
+export interface AiOperationResult {
+ id: string
+ description: string
+ status: "applied" | "skipped" | "failed"
+ reason?: string
+}
+
+export interface AiAgentPlan {
+ message: string
+ operations: AiRawOperation[]
+}
+
+export interface AiPlanRequest {
+ apiKey: string
+ model?: string
+ userMessage: string
+ projectSummary: AiProjectSummary
+ conversationHistory?: AiChatMessage[]
+}
+
+export interface AiProjectSummary {
+ transport: {
+ bpm?: number
+ mode?: string
+ }
+ activePatternId?: string
+ activeChannelId?: string
+ patterns: Array>
+ channels: Array>
+ mixerInserts: Array>
+ availableInstruments: Array>
+ availableEffects: Array>
+ playlistTracks: Array>
+ playlistClips: Array>
+ availableSamples: AiSample[]
+}
+
+export interface AiConversationSummary {
+ id: string
+ title: string
+ created_at: string
+ updated_at: string
+}
+
+export interface AiConversation extends AiConversationSummary {
+ messages: AiChatMessage[]
+ pending_operations: AiPreparedOperation[]
+ operation_results: AiOperationResult[]
+ rejected_operations: AiRejectedOperation[]
+}
diff --git a/src/types/daw.ts b/src/types/daw.ts
new file mode 100644
index 0000000..d94834c
--- /dev/null
+++ b/src/types/daw.ts
@@ -0,0 +1,80 @@
+export interface PianoNote {
+ id?: string
+ start: number
+ length: number
+ pitch: number
+ velocity: number
+}
+
+export interface DawChannel {
+ id: string
+ name: string
+ sampleRef: string
+ pluginRef: string
+ volume: number
+ pan: number
+ muted: boolean
+ solo: boolean
+ inputMode: string
+ mixerInsertId: string
+}
+
+export interface DawPattern {
+ id: string
+ name: string
+ lengthSteps: number
+ pianoPreview: Record
+ stepGrid: Record
+}
+
+export interface DawFxSlot {
+ id: string
+ name: string
+ enabled: boolean
+ effectType: string
+}
+
+export interface DawMixerInsert {
+ id: string
+ name: string
+ isMaster: boolean
+ active: boolean
+ fader: number
+ pan: number
+ stereoSeparation: number
+ fxSlots: DawFxSlot[]
+}
+
+export interface DawPlaylistTrack {
+ id: string
+ name: string
+}
+
+export interface DawPlaylistClip {
+ id: string
+ clipType?: string
+ patternId?: string
+ samplePath?: string
+ channelId?: string
+ trackId: string
+ barStart: number
+ barLength: number
+}
+
+export interface DawState {
+ transport: {
+ bpm: number
+ mode: string
+ }
+ project: {
+ activePatternId: string
+ activeChannelId: string
+ patterns: DawPattern[]
+ channels: DawChannel[]
+ playlistTracks: DawPlaylistTrack[]
+ playlistClips: DawPlaylistClip[]
+ }
+ mixer: {
+ inserts: DawMixerInsert[]
+ }
+}
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
new file mode 100644
index 0000000..cdf55a8
--- /dev/null
+++ b/src/vite-env.d.ts
@@ -0,0 +1,6 @@
+///
+
+declare module "*.css?raw" {
+ const css: string
+ export default css
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..5fc17bf
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "allowJs": true,
+ "checkJs": false,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "types": ["vitest/globals"]
+ },
+ "include": ["src", "vite.config.js", "eslint.config.js"],
+ "exclude": ["dist", "release", "node_modules"]
+}