0 &&
+ !samplePaths.has(samplePath)
+ ) {
+ issues.push("Sample path was not found in the current Browser index.");
+ }
+
+ if (
+ (operation.type === "assign_sample_to_channel" ||
+ operation.type === "add_sample_as_channel" ||
+ operation.type === "add_playlist_audio_clip") &&
+ !samplePath
+ ) {
+ issues.push("Missing samplePath.");
+ }
+
+ if (operation.type === "set_fx_slot_effect") {
+ const effectType = asString(payload.effectType || "none");
+ if (effectType !== "none" && !PLUGIN_EFFECT_TYPES.has(effectType)) {
+ issues.push("Unsupported effectType: " + effectType);
+ }
+ }
+
+ if (operation.type === "assign_plugin_to_channel") {
+ const pluginRef = asString(payload.pluginRef);
+ if (!pluginRef) {
+ issues.push("Missing pluginRef.");
+ } else if (!PLUGIN_INSTRUMENT_BY_REF[pluginRef]) {
+ issues.push("Unknown instrument pluginRef: " + pluginRef);
+ }
+ }
+
+ if (operation.type === "clear_channel_pattern") {
+ const mode = asString(payload.mode || "all").toLowerCase();
+ if (!["all", "notes", "steps"].includes(mode)) {
+ issues.push("Unsupported clear mode: " + mode);
+ }
+ }
+
+ // Warn when notes are added to a channel that has no instrument or sample
+ // assigned — the notes will be silent.
+ if (
+ operation.type === "add_piano_notes" ||
+ operation.type === "add_chord_progression"
+ ) {
+ const targetChannelId = resolveChannelId(dawState, payload);
+ const targetChannel = (channels || []).find(function (item) {
+ return item.id === targetChannelId;
+ });
+ if (
+ targetChannel &&
+ !asString(targetChannel.pluginRef) &&
+ !asString(targetChannel.sampleRef)
+ ) {
+ issues.push(
+ "Channel " + targetChannelId + " has no instrument assigned. Use assign_plugin_to_channel first.",
+ );
+ }
+ }
+
+ if (operation.type === "add_chord_progression") {
+ const chords = Array.isArray(payload.chords) ? payload.chords : [];
+ if (chords.length === 0) {
+ issues.push("Missing chords.");
+ }
+
+ chords.forEach(function (chord, index) {
+ const pitches = Array.isArray(chord?.pitches) ? chord.pitches : [];
+ if (pitches.length < 2) {
+ issues.push("Chord " + (index + 1) + " must contain at least 2 pitches.");
+ }
+ });
+ }
+
+ return issues;
+}
+
+export function validatePreparedAiOperations(
+ operations,
+ { dawState, availableSamples } = {},
+) {
+ const stepKeys = new Set();
+
+ return (Array.isArray(operations) ? operations : []).map(function (operation) {
+ const issues = validateAiOperation(operation, dawState || {}, availableSamples);
+
+ if (operation.type === "set_step") {
+ const payload = operation.payload || {};
+ const key = [
+ resolvePatternId(dawState || {}, payload),
+ resolveChannelId(dawState || {}, payload),
+ Math.round(clampNumber(payload.stepIndex, 0, 127, 0)),
+ ].join(":");
+ if (stepKeys.has(key)) {
+ issues.push("Duplicate set_step for the same channel and stepIndex.");
+ }
+ stepKeys.add(key);
+ }
+
+ return {
+ ...operation,
+ issues,
+ status: issues.length > 0 ? "warning" : "ready",
+ };
+ });
+}
+
+function applyClearChannelPattern(operation, dispatch, getState) {
+ const dawState = getDawState(getState());
+ const payload = operation.payload || {};
+ const patternId = resolvePatternId(dawState, payload);
+ const channelId = resolveChannelId(dawState, payload);
+ const pattern = getPatternById(dawState, patternId);
+ const mode = asString(payload.mode || "all").toLowerCase();
+ const shouldClearNotes = mode === "all" || mode === "notes";
+ const shouldClearSteps = mode === "all" || mode === "steps";
+ const removals = [];
+
+ if (shouldClearNotes) {
+ (Array.isArray(pattern?.pianoPreview?.[channelId])
+ ? pattern.pianoPreview[channelId]
+ : []
+ ).forEach(function (note) {
+ removals.push({
+ id: note.id,
+ start: note.start,
+ pitch: note.pitch,
+ source: "piano",
+ });
+ });
+ }
+
+ if (shouldClearSteps) {
+ (Array.isArray(pattern?.stepGrid?.[channelId])
+ ? pattern.stepGrid[channelId]
+ : []
+ ).forEach(function (value, stepIndex) {
+ if (value) {
+ removals.push({ start: stepIndex, source: "step" });
+ }
+ });
+ }
+
+ if (removals.length > 0) {
+ dispatch(removePianoNotesBatch({ patternId, channelId, notes: removals }));
+ }
+}
+
+function applyCreatePattern(operation, dispatch, getState) {
+ const payload = operation.payload || {};
+ dispatch(createPattern({ lengthSteps: payload.lengthSteps || payload.length }));
+
+ const nextState = getDawState(getState());
+ const newPatternId = getActivePatternId(nextState);
+ const name = asString(payload.name);
+ if (newPatternId && name) {
+ dispatch(renamePattern({ patternId: newPatternId, name }));
+ }
+}
+
+function applyAddChannel(operation, dispatch, getState) {
+ const payload = operation.payload || {};
+ dispatch(addChannel());
+
+ const nextState = getDawState(getState());
+ const newChannelId = getActiveChannelId(nextState);
+ const name = asString(payload.name);
+ if (newChannelId && name) {
+ dispatch(renameChannel({ channelId: newChannelId, name }));
+ }
+
+ // Auto-assign instrument if pluginRef is provided and valid, so the
+ // agent can skip a separate assign_plugin_to_channel step.
+ const pluginRef = asString(payload.pluginRef);
+ if (newChannelId && pluginRef && PLUGIN_INSTRUMENT_BY_REF[pluginRef]) {
+ const plugin = PLUGIN_INSTRUMENT_BY_REF[pluginRef];
+ dispatch(assignPluginToChannel({
+ channelId: newChannelId,
+ pluginRef,
+ pluginName: plugin.name,
+ }));
+ }
+}
+
+function applySetStep(operation, dispatch, getState) {
+ const dawState = getDawState(getState());
+ const payload = operation.payload || {};
+ const patternId = resolvePatternId(dawState, payload);
+ const channelId = resolveChannelId(dawState, payload);
+ const pattern = (dawState.project?.patterns || []).find(function (item) {
+ return item.id === patternId;
+ });
+ const stepIndex = Math.round(clampNumber(payload.stepIndex, 0, 127, 0));
+ const desiredValue = payload.value !== false;
+ const currentValue = Boolean(pattern?.stepGrid?.[channelId]?.[stepIndex]);
+
+ if (currentValue !== desiredValue) {
+ dispatch(toggleStep({ patternId, channelId, stepIndex }));
+ }
+}
+
+function normalizeAiVelocity(rawVelocity) {
+ const numeric = Number(rawVelocity);
+ if (!Number.isFinite(numeric) || numeric <= 0) {
+ return DEFAULT_AI_NOTE_VELOCITY;
+ }
+
+ if (numeric <= 1) {
+ return Math.max(1, Math.min(127, Math.round(numeric * 127)));
+ }
+
+ return Math.max(1, Math.min(127, Math.round(numeric)));
+}
+
+function normalizeAiPitch(rawPitch) {
+ return Math.max(0, Math.min(127, Math.round(Number(rawPitch || 72))));
+}
+
+function normalizeAiNoteLength(rawLength, fallbackLength, maxLength) {
+ const numeric = Number(rawLength);
+ const fallback = Number.isFinite(Number(fallbackLength))
+ ? Number(fallbackLength)
+ : DEFAULT_AI_NOTE_LENGTH;
+ const nextLength = Number.isFinite(numeric) && numeric > 0 ? numeric : fallback;
+ return Math.max(0.0625, Math.min(Math.max(0.0625, maxLength), nextLength));
+}
+
+function getPatternById(dawState, patternId) {
+ return (dawState.project?.patterns || []).find(function (item) {
+ return item.id === patternId;
+ });
+}
+
+function getPatternLength(dawState, patternId) {
+ const pattern = getPatternById(dawState, patternId);
+ return Math.max(1, Number(pattern?.lengthSteps || 16));
+}
+
+function getMaxPianoNoteEnd(notes) {
+ return (Array.isArray(notes) ? notes : []).reduce(function (maxEnd, note) {
+ const start = Math.max(0, Number(note?.start || 0));
+ const length = Math.max(0.0625, Number(note?.length || DEFAULT_AI_NOTE_LENGTH));
+ return Math.max(maxEnd, start + length);
+ }, 0);
+}
+
+function getMaxChordEnd(chords) {
+ return (Array.isArray(chords) ? chords : []).reduce(function (maxEnd, chord) {
+ const start = Math.max(0, Number(chord?.start || 0));
+ const length = Math.max(0.0625, Number(chord?.length || DEFAULT_AI_CHORD_LENGTH));
+ return Math.max(maxEnd, start + length);
+ }, 0);
+}
+
+function ensurePatternLengthForEnd(dispatch, dawState, patternId, maxEnd) {
+ const currentLength = getPatternLength(dawState, patternId);
+ if (maxEnd <= currentLength) {
+ return currentLength;
+ }
+
+ const nextLength = Math.max(4, Math.min(128, Math.ceil(maxEnd)));
+ if (nextLength > currentLength) {
+ dispatch(setPatternLength({ patternId, length: nextLength }));
+ return nextLength;
+ }
+ return currentLength;
+}
+
+function normalizeAiPianoNotes(rawNotes, patternLength, fallbackLength = DEFAULT_AI_NOTE_LENGTH) {
+ return (Array.isArray(rawNotes) ? rawNotes : []).map(function (note) {
+ const start = Math.max(
+ 0,
+ Math.min(patternLength - 0.0625, Number(note?.start || 0)),
+ );
+ const maxLength = Math.max(0.0625, patternLength - start);
+ return {
+ ...note,
+ start,
+ length: normalizeAiNoteLength(note?.length, fallbackLength, maxLength),
+ pitch: normalizeAiPitch(note?.pitch),
+ velocity: normalizeAiVelocity(note?.velocity),
+ };
+ });
+}
+
+function normalizeAiChordProgression(chords, patternLength) {
+ const notes = [];
+
+ (Array.isArray(chords) ? chords : []).forEach(function (chord, chordIndex) {
+ const pitches = Array.isArray(chord?.pitches) ? chord.pitches : [];
+ if (pitches.length < 2) {
+ return;
+ }
+
+ const start = Math.max(
+ 0,
+ Math.min(patternLength - 0.0625, Number(chord?.start || 0)),
+ );
+ const maxLength = Math.max(0.0625, patternLength - start);
+ const length = Math.max(
+ MIN_AI_CHORD_LENGTH,
+ normalizeAiNoteLength(chord?.length, DEFAULT_AI_CHORD_LENGTH, maxLength),
+ );
+ const safeLength = Math.min(length, maxLength);
+ const velocity = normalizeAiVelocity(chord?.velocity);
+
+ pitches.forEach(function (pitch, pitchIndex) {
+ notes.push({
+ id: "ai-chord-" + chordIndex + "-" + pitchIndex,
+ start,
+ length: safeLength,
+ pitch: normalizeAiPitch(pitch),
+ velocity,
+ });
+ });
+ });
+
+ return notes;
+}
+
+function applyAiOperation(operation, dispatch, getState) {
+ const dawState = getDawState(getState());
+ const payload = operation.payload || {};
+
+ if (operation.type === "create_pattern") {
+ applyCreatePattern(operation, dispatch, getState);
+ return true;
+ }
+
+ if (operation.type === "set_bpm") {
+ dispatch(setBpm(payload.bpm || payload.value));
+ return true;
+ }
+
+ if (operation.type === "set_active_pattern") {
+ dispatch(setActivePattern(resolvePatternId(dawState, payload)));
+ return true;
+ }
+
+ if (operation.type === "rename_pattern") {
+ dispatch(renamePattern({
+ patternId: resolvePatternId(dawState, payload),
+ name: payload.name,
+ }));
+ return true;
+ }
+
+ if (operation.type === "set_pattern_length") {
+ dispatch(setPatternLength({
+ patternId: resolvePatternId(dawState, payload),
+ length: payload.lengthSteps || payload.length,
+ }));
+ return true;
+ }
+
+ if (operation.type === "clear_channel_pattern") {
+ applyClearChannelPattern(operation, dispatch, getState);
+ return true;
+ }
+
+ if (operation.type === "add_channel") {
+ applyAddChannel(operation, dispatch, getState);
+ return true;
+ }
+
+ if (operation.type === "assign_sample_to_channel") {
+ dispatch(assignSampleToChannel({
+ channelId: resolveChannelId(dawState, payload),
+ sampleRef: payload.samplePath || payload.sampleRef,
+ sampleName: payload.sampleName || payload.clipName,
+ }));
+ return true;
+ }
+
+ if (operation.type === "assign_plugin_to_channel") {
+ const pluginRef = asString(payload.pluginRef);
+ const plugin = PLUGIN_INSTRUMENT_BY_REF[pluginRef];
+ dispatch(assignPluginToChannel({
+ channelId: resolveChannelId(dawState, payload),
+ pluginRef,
+ pluginName: payload.pluginName || plugin?.name,
+ }));
+ return true;
+ }
+
+ if (operation.type === "add_sample_as_channel") {
+ dispatch(addPlaylistSampleAsChannel({
+ samplePath: payload.samplePath || payload.sampleRef,
+ clipName: payload.clipName || payload.sampleName,
+ trackId: resolveTrackId(dawState, payload),
+ barStart: payload.barStart,
+ barLength: payload.barLength,
+ }));
+ return true;
+ }
+
+ if (operation.type === "set_step") {
+ applySetStep(operation, dispatch, getState);
+ return true;
+ }
+
+ if (operation.type === "add_piano_notes") {
+ const patternId = resolvePatternId(dawState, payload);
+ const patternLength = ensurePatternLengthForEnd(
+ dispatch,
+ dawState,
+ patternId,
+ getMaxPianoNoteEnd(payload.notes),
+ );
+ dispatch(addPianoNotesBatch({
+ patternId,
+ channelId: resolveChannelId(dawState, payload),
+ notes: normalizeAiPianoNotes(payload.notes, patternLength),
+ allowOverlaps: Boolean(payload.allowOverlaps),
+ }));
+ return true;
+ }
+
+ if (operation.type === "add_chord_progression") {
+ const patternId = resolvePatternId(dawState, payload);
+ const patternLength = ensurePatternLengthForEnd(
+ dispatch,
+ dawState,
+ patternId,
+ getMaxChordEnd(payload.chords),
+ );
+ dispatch(addPianoNotesBatch({
+ patternId,
+ channelId: resolveChannelId(dawState, payload),
+ notes: normalizeAiChordProgression(payload.chords, patternLength),
+ allowOverlaps: true,
+ }));
+ return true;
+ }
+
+ if (operation.type === "set_channel_volume") {
+ dispatch(setChannelVolume({
+ channelId: resolveChannelId(dawState, payload),
+ value: clampNumber(payload.value, 0, 1, 1),
+ }));
+ return true;
+ }
+
+ if (operation.type === "set_channel_pan") {
+ dispatch(setChannelPan({
+ channelId: resolveChannelId(dawState, payload),
+ value: clampNumber(payload.value, -1, 1, 0),
+ }));
+ return true;
+ }
+
+ if (operation.type === "set_channel_mute") {
+ dispatch(setChannelMute({
+ channelId: resolveChannelId(dawState, payload),
+ value: Boolean(payload.value),
+ }));
+ return true;
+ }
+
+ if (operation.type === "set_channel_solo") {
+ dispatch(setChannelSolo({
+ channelId: resolveChannelId(dawState, payload),
+ value: Boolean(payload.value),
+ }));
+ return true;
+ }
+
+ if (operation.type === "set_channel_input_mode") {
+ dispatch(setChannelInputMode({
+ channelId: resolveChannelId(dawState, payload),
+ mode: payload.mode === "piano" ? "piano" : "steps",
+ }));
+ return true;
+ }
+
+ if (operation.type === "set_channel_mixer_insert") {
+ dispatch(setChannelMixerInsert({
+ channelId: resolveChannelId(dawState, payload),
+ insertId: payload.insertId,
+ }));
+ return true;
+ }
+
+ if (operation.type === "add_playlist_pattern_clip") {
+ dispatch(addPlaylistPatternClip({
+ patternId: resolvePatternId(dawState, payload),
+ trackId: resolveTrackId(dawState, payload),
+ barStart: payload.barStart,
+ barLength: payload.barLength,
+ }));
+ return true;
+ }
+
+ if (operation.type === "add_playlist_audio_clip") {
+ dispatch(addPlaylistAudioClip({
+ samplePath: payload.samplePath || payload.sampleRef,
+ clipName: payload.clipName || payload.sampleName,
+ channelId: payload.channelId ? resolveChannelId(dawState, payload) : undefined,
+ trackId: resolveTrackId(dawState, payload),
+ barStart: payload.barStart,
+ barLength: payload.barLength,
+ sourceOffsetSteps: payload.sourceOffsetSteps,
+ }));
+ return true;
+ }
+
+ if (operation.type === "add_mixer_track") {
+ dispatch(addMixerTrack());
+ return true;
+ }
+
+ if (operation.type === "set_insert_fader") {
+ dispatch(setInsertFader({ insertId: payload.insertId, value: payload.value }));
+ return true;
+ }
+
+ if (operation.type === "set_insert_pan") {
+ dispatch(setInsertPan({ insertId: payload.insertId, value: payload.value }));
+ return true;
+ }
+
+ if (operation.type === "set_insert_stereo") {
+ dispatch(setInsertStereo({ insertId: payload.insertId, value: payload.value }));
+ return true;
+ }
+
+ if (operation.type === "set_fx_slot_effect") {
+ const effectType = asString(payload.effectType || "none");
+ const slotId = payload.slotId || "slot-1";
+ dispatch(setFxSlotEffectType({
+ insertId: payload.insertId,
+ slotId,
+ effectType,
+ }));
+
+ const updatedState = getDawState(getState());
+ const insert = (updatedState.mixer?.inserts || []).find(function (item) {
+ return item.id === payload.insertId;
+ });
+ const slot = (insert?.fxSlots || []).find(function (item) {
+ return item.id === slotId;
+ });
+ const desiredEnabled = effectType !== "none" && payload.enabled !== false;
+
+ if (Boolean(slot?.enabled) !== desiredEnabled) {
+ dispatch(toggleFxSlot({
+ insertId: payload.insertId,
+ slotId,
+ }));
+ }
+ return true;
+ }
+
+ if (operation.type === "set_fx_reverb_param") {
+ dispatch(setFxSlotReverbParam({
+ insertId: payload.insertId,
+ slotId: payload.slotId || "slot-1",
+ param: payload.param,
+ value: payload.value,
+ }));
+ return true;
+ }
+
+ if (operation.type === "set_fx_maximizer_param") {
+ dispatch(setFxSlotMaximizerParam({
+ insertId: payload.insertId,
+ slotId: payload.slotId || "slot-1",
+ param: payload.param,
+ value: payload.value,
+ }));
+ return true;
+ }
+
+ if (operation.type === "set_fx_graphic_eq_band_gain") {
+ dispatch(setFxSlotGraphicEqBandGain({
+ insertId: payload.insertId,
+ slotId: payload.slotId || "slot-1",
+ bandIndex: payload.bandIndex,
+ gainDb: payload.gainDb,
+ }));
+ return true;
+ }
+
+ return false;
+}
+
+export function applyAiOperations(operations, { dispatch, getState }) {
+ const results = [];
+
+ dispatch(beginAiOperationBatch());
+
+ try {
+ (Array.isArray(operations) ? operations : []).forEach(function (operation) {
+ try {
+ const wasApplied = applyAiOperation(operation, dispatch, getState);
+ results.push({
+ id: operation.id,
+ description: operation.description || operation.type,
+ status: wasApplied ? "applied" : "skipped",
+ });
+ } catch (error) {
+ results.push({
+ id: operation.id,
+ description: operation.description || operation.type,
+ status: "failed",
+ reason: String(error?.message || error),
+ });
+ }
+ });
+ } finally {
+ dispatch(endAiOperationBatch());
+ }
+
+ const applied = results.filter(function (result) {
+ return result.status === "applied";
+ });
+ const skipped = results.filter(function (result) {
+ return result.status !== "applied";
+ });
+
+ return { applied, skipped, results };
+}
diff --git a/src/ai/aiAgentOperations.test.js b/src/ai/aiAgentOperations.test.js
new file mode 100644
index 0000000..894c3d6
--- /dev/null
+++ b/src/ai/aiAgentOperations.test.js
@@ -0,0 +1,604 @@
+import { describe, it, expect } from "vitest";
+import {
+ applyAiOperations,
+ prepareAiOperations,
+ validatePreparedAiOperations,
+} from "./aiAgentOperations";
+
+function createDawState() {
+ return {
+ project: {
+ activePatternId: "pat-1",
+ activeChannelId: "ch-kick",
+ patterns: [
+ {
+ id: "pat-1",
+ name: "Pattern 1",
+ lengthSteps: 16,
+ stepGrid: {
+ "ch-kick": Array(16).fill(false),
+ },
+ pianoPreview: {},
+ },
+ ],
+ channels: [
+ {
+ id: "ch-kick",
+ name: "Kick",
+ },
+ ],
+ playlistTracks: [{ id: "trk-1", name: "Track 1" }],
+ },
+ mixer: {
+ inserts: [
+ {
+ id: "insert-1",
+ fxSlots: [
+ {
+ id: "slot-1",
+ enabled: false,
+ effectType: "none",
+ },
+ ],
+ },
+ ],
+ },
+ };
+}
+
+describe("prepareAiOperations", function () {
+ it("keeps allowed operations and rejects unsupported ones", function () {
+ const result = prepareAiOperations([
+ { type: "set_step", payload: { stepIndex: 0, value: true } },
+ { type: "delete_everything", payload: {} },
+ ]);
+
+ expect(result.operations).toHaveLength(1);
+ expect(result.operations[0].type).toBe("set_step");
+ expect(result.rejected).toHaveLength(1);
+ });
+});
+
+describe("validatePreparedAiOperations", function () {
+ it("marks operations with missing referenced ids as warnings", function () {
+ const prepared = prepareAiOperations([
+ {
+ type: "set_channel_volume",
+ payload: { channelId: "missing-channel", value: 0.5 },
+ },
+ ]).operations;
+
+ const validated = validatePreparedAiOperations(prepared, {
+ dawState: createDawState(),
+ availableSamples: [],
+ });
+
+ expect(validated[0].status).toBe("warning");
+ expect(validated[0].issues[0]).toContain("Channel id does not exist");
+ });
+
+ it("warns when an instrument pluginRef is unknown", function () {
+ const prepared = prepareAiOperations([
+ {
+ type: "assign_plugin_to_channel",
+ payload: { channelId: "ch-kick", pluginRef: "missing-plugin" },
+ },
+ ]).operations;
+
+ const validated = validatePreparedAiOperations(prepared, {
+ dawState: createDawState(),
+ availableSamples: [],
+ });
+
+ expect(validated[0].status).toBe("warning");
+ expect(validated[0].issues[0]).toContain("Unknown instrument pluginRef");
+ });
+
+ it("warns when a chord progression contains single-note chords", function () {
+ const prepared = prepareAiOperations([
+ {
+ type: "add_chord_progression",
+ payload: {
+ chords: [{ start: 0, length: 16, pitches: [60], velocity: 95 }],
+ },
+ },
+ ]).operations;
+
+ const validated = validatePreparedAiOperations(prepared, {
+ dawState: createDawState(),
+ availableSamples: [],
+ });
+
+ expect(validated[0].status).toBe("warning");
+ expect(validated[0].issues).toEqual(
+ expect.arrayContaining([
+ expect.stringContaining("at least 2 pitches"),
+ ]),
+ );
+ });
+
+ it("warns on duplicate set_step for the same channel and step", function () {
+ const prepared = prepareAiOperations([
+ { type: "set_step", payload: { channelId: "ch-kick", stepIndex: 0 } },
+ { type: "set_step", payload: { channelId: "ch-kick", stepIndex: 0 } },
+ ]).operations;
+
+ const validated = validatePreparedAiOperations(prepared, {
+ dawState: createDawState(),
+ availableSamples: [],
+ });
+
+ expect(validated[0].status).toBe("ready");
+ expect(validated[1].status).toBe("warning");
+ expect(validated[1].issues).toEqual(
+ expect.arrayContaining([
+ expect.stringContaining("Duplicate set_step"),
+ ]),
+ );
+ });
+
+ it("does not warn when set_step uses different stepIndex values", function () {
+ const prepared = prepareAiOperations([
+ { type: "set_step", payload: { channelId: "ch-kick", stepIndex: 0 } },
+ { type: "set_step", payload: { channelId: "ch-kick", stepIndex: 1 } },
+ ]).operations;
+
+ const validated = validatePreparedAiOperations(prepared, {
+ dawState: createDawState(),
+ availableSamples: [],
+ });
+
+ expect(validated[0].status).toBe("ready");
+ expect(validated[1].status).toBe("ready");
+ });
+});
+
+describe("applyAiOperations", function () {
+ it("dispatches a step toggle only when the desired value differs", function () {
+ const dawState = createDawState();
+ const dispatched = [];
+ const dispatch = function (action) {
+ dispatched.push(action);
+ };
+ const getState = function () {
+ return { daw: dawState };
+ };
+
+ applyAiOperations(
+ prepareAiOperations([
+ { type: "set_step", payload: { stepIndex: 0, value: true } },
+ ]).operations,
+ { dispatch, getState },
+ );
+
+ expect(dispatched).toHaveLength(3);
+ expect(dispatched[0].type).toBe("daw/beginAiOperationBatch");
+ expect(dispatched[1]).toMatchObject({
+ type: "daw/toggleStep",
+ payload: {
+ patternId: "pat-1",
+ channelId: "ch-kick",
+ stepIndex: 0,
+ },
+ });
+ expect(dispatched[2].type).toBe("daw/endAiOperationBatch");
+
+ dawState.project.patterns[0].stepGrid["ch-kick"][0] = true;
+ dispatched.length = 0;
+
+ applyAiOperations(
+ prepareAiOperations([
+ { type: "set_step", payload: { stepIndex: 0, value: true } },
+ ]).operations,
+ { dispatch, getState },
+ );
+
+ expect(dispatched).toHaveLength(2);
+ expect(dispatched[0].type).toBe("daw/beginAiOperationBatch");
+ expect(dispatched[1].type).toBe("daw/endAiOperationBatch");
+ });
+
+ it("dispatches instrument assignment operations", function () {
+ const dawState = createDawState();
+ const dispatched = [];
+ const dispatch = function (action) {
+ dispatched.push(action);
+ };
+ const getState = function () {
+ return { daw: dawState };
+ };
+
+ applyAiOperations(
+ prepareAiOperations([
+ {
+ type: "assign_plugin_to_channel",
+ payload: {
+ channelId: "ch-kick",
+ pluginRef: "openstudio-piano",
+ pluginName: "Piano",
+ },
+ },
+ ]).operations,
+ { dispatch, getState },
+ );
+
+ expect(dispatched[1]).toMatchObject({
+ type: "daw/assignPluginToChannel",
+ payload: {
+ channelId: "ch-kick",
+ pluginRef: "openstudio-piano",
+ pluginName: "Piano",
+ },
+ });
+ });
+
+ it("normalizes AI note velocity before dispatching piano notes", function () {
+ const dawState = createDawState();
+ const dispatched = [];
+ const dispatch = function (action) {
+ dispatched.push(action);
+ };
+ const getState = function () {
+ return { daw: dawState };
+ };
+
+ applyAiOperations(
+ prepareAiOperations([
+ {
+ type: "add_piano_notes",
+ payload: {
+ channelId: "ch-kick",
+ notes: [
+ { start: 0, length: 1, pitch: 60, velocity: 0.8 },
+ { start: 1, length: 1, pitch: 62, velocity: 0 },
+ ],
+ },
+ },
+ ]).operations,
+ { dispatch, getState },
+ );
+
+ expect(dispatched[1].type).toBe("daw/addPianoNotesBatch");
+ expect(dispatched[1].payload.notes[0].velocity).toBe(102);
+ expect(dispatched[1].payload.notes[1].velocity).toBe(95);
+ });
+
+ it("expands chord progressions into long stacked piano notes", function () {
+ const dawState = createDawState();
+ dawState.project.patterns[0].lengthSteps = 64;
+ const dispatched = [];
+ const dispatch = function (action) {
+ dispatched.push(action);
+ };
+ const getState = function () {
+ return { daw: dawState };
+ };
+
+ applyAiOperations(
+ prepareAiOperations([
+ {
+ type: "add_chord_progression",
+ payload: {
+ channelId: "ch-kick",
+ chords: [
+ { start: 0, length: 16, pitches: [57, 60, 64], velocity: 0.75 },
+ { start: 16, length: 16, pitches: [53, 57, 60], velocity: 95 },
+ ],
+ },
+ },
+ ]).operations,
+ { dispatch, getState },
+ );
+
+ expect(dispatched[1].type).toBe("daw/addPianoNotesBatch");
+ expect(dispatched[1].payload.allowOverlaps).toBe(true);
+ expect(dispatched[1].payload.notes).toHaveLength(6);
+ expect(dispatched[1].payload.notes.slice(0, 3)).toEqual([
+ expect.objectContaining({ start: 0, length: 16, pitch: 57, velocity: 95 }),
+ expect.objectContaining({ start: 0, length: 16, pitch: 60, velocity: 95 }),
+ expect.objectContaining({ start: 0, length: 16, pitch: 64, velocity: 95 }),
+ ]);
+ });
+
+ it("dispatches BPM changes", function () {
+ const dawState = createDawState();
+ const dispatched = [];
+ const dispatch = function (action) {
+ dispatched.push(action);
+ };
+ const getState = function () {
+ return { daw: dawState };
+ };
+
+ applyAiOperations(
+ prepareAiOperations([
+ { type: "set_bpm", payload: { bpm: 150 } },
+ ]).operations,
+ { dispatch, getState },
+ );
+
+ expect(dispatched[1]).toMatchObject({
+ type: "daw/setBpm",
+ payload: 150,
+ });
+ });
+
+ it("clears existing piano notes and active sequencer steps", function () {
+ const dawState = createDawState();
+ dawState.project.patterns[0].pianoPreview = {
+ "ch-kick": [
+ { id: "n-1", start: 0, length: 4, pitch: 60, velocity: 95 },
+ ],
+ };
+ dawState.project.patterns[0].stepGrid["ch-kick"][0] = true;
+ dawState.project.patterns[0].stepGrid["ch-kick"][4] = true;
+ const dispatched = [];
+ const dispatch = function (action) {
+ dispatched.push(action);
+ };
+ const getState = function () {
+ return { daw: dawState };
+ };
+
+ applyAiOperations(
+ prepareAiOperations([
+ {
+ type: "clear_channel_pattern",
+ payload: { patternId: "pat-1", channelId: "ch-kick", mode: "all" },
+ },
+ ]).operations,
+ { dispatch, getState },
+ );
+
+ const clearAction = dispatched.find(function (action) {
+ return action.type === "daw/removePianoNotesBatch";
+ });
+ expect(clearAction).toBeDefined();
+ expect(clearAction.payload.notes).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ id: "n-1", source: "piano" }),
+ expect.objectContaining({ start: 0, source: "step" }),
+ expect.objectContaining({ start: 4, source: "step" }),
+ ]),
+ );
+ });
+
+ it("auto-expands pattern length before adding notes beyond the current pattern", function () {
+ const dawState = createDawState();
+ const dispatched = [];
+ const dispatch = function (action) {
+ dispatched.push(action);
+ };
+ const getState = function () {
+ return { daw: dawState };
+ };
+
+ applyAiOperations(
+ prepareAiOperations([
+ {
+ type: "add_piano_notes",
+ payload: {
+ channelId: "ch-kick",
+ notes: [{ start: 30, length: 4, pitch: 60, velocity: 95 }],
+ },
+ },
+ ]).operations,
+ { dispatch, getState },
+ );
+
+ const lengthAction = dispatched.find(function (action) {
+ return action.type === "daw/setPatternLength";
+ });
+ const notesAction = dispatched.find(function (action) {
+ return action.type === "daw/addPianoNotesBatch";
+ });
+ expect(lengthAction.payload).toEqual({ patternId: "pat-1", length: 34 });
+ expect(notesAction.payload.notes[0]).toEqual(
+ expect.objectContaining({ start: 30, length: 4 }),
+ );
+ });
+
+ it("auto-expands pattern length before adding chord progressions", function () {
+ const dawState = createDawState();
+ const dispatched = [];
+ const dispatch = function (action) {
+ dispatched.push(action);
+ };
+ const getState = function () {
+ return { daw: dawState };
+ };
+
+ applyAiOperations(
+ prepareAiOperations([
+ {
+ type: "add_chord_progression",
+ payload: {
+ channelId: "ch-kick",
+ chords: [{ start: 24, length: 8, pitches: [60, 64, 67], velocity: 95 }],
+ },
+ },
+ ]).operations,
+ { dispatch, getState },
+ );
+
+ const lengthAction = dispatched.find(function (action) {
+ return action.type === "daw/setPatternLength";
+ });
+ const notesAction = dispatched.find(function (action) {
+ return action.type === "daw/addPianoNotesBatch";
+ });
+ expect(lengthAction.payload).toEqual({ patternId: "pat-1", length: 32 });
+ expect(notesAction.payload.notes).toHaveLength(3);
+ expect(notesAction.payload.notes[0]).toEqual(
+ expect.objectContaining({ start: 24, length: 8 }),
+ );
+ });
+
+ it("auto-assigns pluginRef when add_channel includes one", function () {
+ const dawState = createDawState();
+ const dispatched = [];
+ const dispatch = function (action) {
+ dispatched.push(action);
+ };
+ const getState = function () {
+ return { daw: dawState };
+ };
+
+ applyAiOperations(
+ prepareAiOperations([
+ {
+ type: "add_channel",
+ payload: {
+ name: "Piano Track",
+ pluginRef: "openstudio-piano",
+ },
+ },
+ ]).operations,
+ { dispatch, getState },
+ );
+
+ const assignAction = dispatched.find(function (action) {
+ return action.type === "daw/assignPluginToChannel";
+ });
+ expect(assignAction).toBeDefined();
+ expect(assignAction.payload.pluginRef).toBe("openstudio-piano");
+ expect(assignAction.payload.pluginName).toBe("Piano");
+ });
+
+ it("does not auto-assign when pluginRef is invalid", function () {
+ const dawState = createDawState();
+ const dispatched = [];
+ const dispatch = function (action) {
+ dispatched.push(action);
+ };
+ const getState = function () {
+ return { daw: dawState };
+ };
+
+ applyAiOperations(
+ prepareAiOperations([
+ {
+ type: "add_channel",
+ payload: {
+ name: "Mystery",
+ pluginRef: "nonexistent-instrument",
+ },
+ },
+ ]).operations,
+ { dispatch, getState },
+ );
+
+ const assignAction = dispatched.find(function (action) {
+ return action.type === "daw/assignPluginToChannel";
+ });
+ expect(assignAction).toBeUndefined();
+ });
+
+ it("warns when add_piano_notes targets a channel without instrument", function () {
+ const dawState = createDawState();
+
+ const validated = validatePreparedAiOperations(
+ prepareAiOperations([
+ {
+ type: "add_piano_notes",
+ payload: {
+ channelId: "ch-kick",
+ notes: [{ start: 0, length: 4, pitch: 60, velocity: 95 }],
+ },
+ },
+ ]).operations,
+ { dawState },
+ );
+
+ // ch-kick has no pluginRef or sampleRef in createDawState, so the
+ // validation should flag it.
+ expect(validated[0].issues).toEqual(
+ expect.arrayContaining([
+ expect.stringContaining("has no instrument assigned"),
+ ]),
+ );
+ expect(validated[0].status).toBe("warning");
+ });
+
+ it("does not warn when channel has a pluginRef", function () {
+ const dawState = createDawState();
+ dawState.project.channels[0].pluginRef = "openstudio-piano";
+
+ const validated = validatePreparedAiOperations(
+ prepareAiOperations([
+ {
+ type: "add_piano_notes",
+ payload: {
+ channelId: "ch-kick",
+ notes: [{ start: 0, length: 4, pitch: 60, velocity: 95 }],
+ },
+ },
+ ]).operations,
+ { dawState },
+ );
+
+ expect(validated[0].issues).not.toContain(
+ expect.stringContaining("has no instrument assigned"),
+ );
+ expect(validated[0].status).toBe("ready");
+ });
+
+ it("treats $new like $active in validation (no missing-channel warning)", function () {
+ const validated = validatePreparedAiOperations(
+ prepareAiOperations([
+ {
+ type: "add_piano_notes",
+ payload: {
+ channelId: "$new",
+ notes: [{ start: 0, length: 4, pitch: 60, velocity: 95 }],
+ },
+ },
+ ]).operations,
+ { dawState: createDawState() },
+ );
+
+ expect(validated[0].issues).not.toContain(
+ expect.stringContaining("Channel id does not exist"),
+ );
+ });
+});
+
+describe("applyAiOperations with $new channel reference", function () {
+ it("resolves $new to the channel just created by add_channel", function () {
+ const dawState = createDawState();
+ const dispatched = [];
+ const dispatch = function (action) {
+ dispatched.push(action);
+ };
+ const getState = function () {
+ return { daw: dawState };
+ };
+
+ applyAiOperations(
+ prepareAiOperations([
+ {
+ type: "add_channel",
+ payload: { name: "Melody", pluginRef: "openstudio-piano" },
+ },
+ {
+ type: "add_piano_notes",
+ payload: {
+ channelId: "$new",
+ notes: [{ start: 0, length: 4, pitch: 60, velocity: 95 }],
+ },
+ },
+ ]).operations,
+ { dispatch, getState },
+ );
+
+ // addPianoNotesBatch should have been dispatched with a real channel id,
+ // not the literal string "$new".
+ const notesAction = dispatched.find(function (action) {
+ return action.type === "daw/addPianoNotesBatch";
+ });
+ expect(notesAction).toBeDefined();
+ expect(notesAction.payload.channelId).not.toBe("$new");
+ expect(notesAction.payload.channelId).not.toBe("$active");
+ expect(notesAction.payload.notes).toHaveLength(1);
+ });
+});
diff --git a/src/ai/aiAgentPrompt.js b/src/ai/aiAgentPrompt.js
new file mode 100644
index 0000000..474815f
--- /dev/null
+++ b/src/ai/aiAgentPrompt.js
@@ -0,0 +1,68 @@
+export const AI_AGENT_DEFAULT_MODEL = "gpt-5.5";
+
+export const AI_AGENT_OPERATION_TYPES = [
+ "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 function getAiAgentSystemPrompt() {
+ return [
+ "You are OpenStudio AI Agent, a DAW assistant inside a React/Web Audio music app.",
+ "You must return JSON only. Do not include Markdown.",
+ "Never claim that changes are applied. You only prepare a preview plan.",
+ "Only use operation types from the allowed list.",
+ "Prefer exact ids from the project summary. If a user refers to the active pattern or active channel, use \"$active\". Use \"$new\" to reference a channel just created by add_channel in the same plan.",
+ "Use sample paths exactly as provided in availableSamples. Do not invent file paths.",
+ "Use pluginRef values exactly as provided in availableInstruments. Do not invent instrument plugin refs.",
+ "If the user asks for an instrument not in availableInstruments, pick the closest match from the list. Channel names are labels only — the actual sound comes from the pluginRef.",
+ "When creating a melody or instrument track, always follow these steps in order:",
+ "1. add_channel with a descriptive name. You may include pluginRef in the payload to auto-assign the instrument.",
+ "2. assign_plugin_to_channel with a pluginRef from availableInstruments. This is mandatory — a channel without an instrument produces no sound. Never skip this step.",
+ "3. set_channel_input_mode to piano.",
+ "4. add_piano_notes with the melody.",
+ "The active pattern includes existing notes in `notes` and steps in `steps`. Read them to understand the current melody and harmonize.",
+ "When replacing, fixing, rewriting, or making a new melody/drum part on an existing channel, use clear_channel_pattern first. Use mode \"notes\" for melodies, \"steps\" for drum sequencer rows, and \"all\" when replacing both.",
+ "Only skip clear_channel_pattern when the user explicitly asks to add, layer, double, or keep existing material.",
+ "When adding chords to an existing melody, read the melody pitches from the active pattern and choose a harmonically matching chord progression.",
+ "Prefer adding chords on a separate channel or in a lower octave to avoid overlapping the melody.",
+ "If the user asks for chords or a chord progression, use add_chord_progression with chords containing start, length, pitches and velocity.",
+ "Use MIDI velocity values from 1 to 127. Prefer 80-105 for musical notes.",
+ "For chord progressions, use longer note lengths such as 8 or 16 steps, not tiny one-step notes.",
+ "Use set_bpm when the user asks to change project tempo or BPM.",
+ "OpenStudio timing: 1 Channel Rack block = 1 step. 1 sequencer step = 1/16 bar. 16 steps = 1 bar, 32 steps = 2 bars, 64 steps = 4 bars, 128 steps = 8 bars.",
+ "Use set_pattern_length before creating patterns longer than the current pattern. For 4 bars use 64 steps, for 8 bars use 128 steps.",
+ "For drum patterns, use set_step with different stepIndex values for each hit. For a 1-bar pattern use indices 0-15: kick often 0, 8; clap/snare often 8. For longer patterns repeat clap/snare every 16 steps: 8, 24, 40, 56, etc. Hats often use 0,2,4,6,8,10,12,14 or every step 0-15. For 2 bars continue 16-31.",
+ "Do not repeat set_step for the same stepIndex on the same channel — each step is a toggle, calling it twice cancels the first call.",
+ "Each drum sound needs its own channel with a sample assigned via assign_sample_to_channel or add_sample_as_channel. Use sample paths from availableSamples.",
+ "Keep edits small and musically useful. If unsure, ask for clarification in the message and return an empty operations array.",
+ "Return this shape: {\"message\": string, \"operations\": [{\"type\": string, \"payload\": object}] }.",
+ "Allowed operation types: " + AI_AGENT_OPERATION_TYPES.join(", "),
+ ].join("\n");
+}
diff --git a/src/ai/aiClient.js b/src/ai/aiClient.js
new file mode 100644
index 0000000..d0552aa
--- /dev/null
+++ b/src/ai/aiClient.js
@@ -0,0 +1,29 @@
+import {
+ requestAiAgentPlan as requestOpenAiAgentPlan,
+ testOpenAiConnection,
+} from "./openAiClient";
+import { requestGeminiAgentPlan, testGeminiConnection } from "./geminiClient";
+import {
+ AI_AGENT_PROVIDER_GEMINI,
+ AI_AGENT_PROVIDER_OPENAI,
+ getAiProviderConfig,
+} from "./aiProviders";
+
+export async function requestAiAgentPlan({ provider, ...params }) {
+ const config = getAiProviderConfig(provider);
+ if (config.id === AI_AGENT_PROVIDER_GEMINI) {
+ return requestGeminiAgentPlan(params);
+ }
+ return requestOpenAiAgentPlan(params);
+}
+
+export async function testAiConnection({ provider, ...params }) {
+ const config = getAiProviderConfig(provider);
+ if (config.id === AI_AGENT_PROVIDER_GEMINI) {
+ return testGeminiConnection(params);
+ }
+ if (config.id === AI_AGENT_PROVIDER_OPENAI) {
+ return testOpenAiConnection(params);
+ }
+ return testOpenAiConnection(params);
+}
diff --git a/src/ai/aiClient.test.js b/src/ai/aiClient.test.js
new file mode 100644
index 0000000..e1926d5
--- /dev/null
+++ b/src/ai/aiClient.test.js
@@ -0,0 +1,67 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("./openAiClient", function () {
+ return {
+ requestAiAgentPlan: vi.fn(async function () {
+ return { provider: "openai" };
+ }),
+ testOpenAiConnection: vi.fn(async function () {
+ return { model: "gpt-5.5" };
+ }),
+ };
+});
+
+vi.mock("./geminiClient", function () {
+ return {
+ requestGeminiAgentPlan: vi.fn(async function () {
+ return { provider: "gemini" };
+ }),
+ testGeminiConnection: vi.fn(async function () {
+ return { model: "gemini-3.5-flash" };
+ }),
+ };
+});
+
+import { requestAiAgentPlan, testAiConnection } from "./aiClient";
+import {
+ requestAiAgentPlan as requestOpenAiAgentPlan,
+ testOpenAiConnection,
+} from "./openAiClient";
+import { requestGeminiAgentPlan, testGeminiConnection } from "./geminiClient";
+
+describe("aiClient", function () {
+ beforeEach(function () {
+ vi.clearAllMocks();
+ });
+
+ it("routes plan requests to OpenAI by default", async function () {
+ const result = await requestAiAgentPlan({
+ apiKey: "sk-test",
+ model: "gpt-5.5",
+ });
+
+ expect(result.provider).toBe("openai");
+ expect(requestOpenAiAgentPlan).toHaveBeenCalledTimes(1);
+ expect(requestGeminiAgentPlan).not.toHaveBeenCalled();
+ });
+
+ it("routes plan requests to Gemini", async function () {
+ const result = await requestAiAgentPlan({
+ provider: "gemini",
+ apiKey: "gemini-key",
+ model: "gemini-3.5-flash",
+ });
+
+ expect(result.provider).toBe("gemini");
+ expect(requestGeminiAgentPlan).toHaveBeenCalledTimes(1);
+ expect(requestOpenAiAgentPlan).not.toHaveBeenCalled();
+ });
+
+ it("routes connection tests to the selected provider", async function () {
+ await testAiConnection({ provider: "openai", apiKey: "sk-test" });
+ await testAiConnection({ provider: "gemini", apiKey: "gemini-key" });
+
+ expect(testOpenAiConnection).toHaveBeenCalledTimes(1);
+ expect(testGeminiConnection).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/ai/aiProjectSummary.js b/src/ai/aiProjectSummary.js
new file mode 100644
index 0000000..5442929
--- /dev/null
+++ b/src/ai/aiProjectSummary.js
@@ -0,0 +1,171 @@
+import { PLUGIN_EFFECTS } from "../data/pluginEffects";
+import { PLUGIN_INSTRUMENTS } from "../data/pluginInstruments";
+
+// Returns { channelId: [{ start, length, pitch, velocity }] } without note ids.
+// Only used for the active pattern so the payload stays compact.
+function summarizePianoPreview(pianoPreview) {
+ if (!pianoPreview || typeof pianoPreview !== "object") {
+ return {};
+ }
+
+ const result = {};
+ Object.keys(pianoPreview).forEach(function (channelId) {
+ const notes = Array.isArray(pianoPreview[channelId])
+ ? pianoPreview[channelId]
+ : [];
+ if (notes.length === 0) {
+ return;
+ }
+ result[channelId] = notes.map(function (note) {
+ return {
+ start: note.start,
+ length: note.length,
+ pitch: note.pitch,
+ velocity: note.velocity,
+ };
+ });
+ });
+ return result;
+}
+
+// Returns { channelId: [bool, bool, ...] } for the active pattern only.
+function summarizeStepGrid(stepGrid) {
+ if (!stepGrid || typeof stepGrid !== "object") {
+ return {};
+ }
+
+ const result = {};
+ Object.keys(stepGrid).forEach(function (channelId) {
+ const row = Array.isArray(stepGrid[channelId]) ? stepGrid[channelId] : [];
+ if (row.length === 0) {
+ return;
+ }
+ result[channelId] = row.map(Boolean);
+ });
+ return result;
+}
+
+// Lightweight count so the agent knows a non-active pattern is not empty
+// without receiving every note (keeps the token budget reasonable).
+function countPianoNotes(pianoPreview) {
+ if (!pianoPreview || typeof pianoPreview !== "object") {
+ return 0;
+ }
+ return Object.keys(pianoPreview).reduce(function (total, channelId) {
+ return total + (Array.isArray(pianoPreview[channelId])
+ ? pianoPreview[channelId].length
+ : 0);
+ }, 0);
+}
+
+function summarizeFxSlots(insert) {
+ return (Array.isArray(insert?.fxSlots) ? insert.fxSlots : [])
+ .filter(function (slot) {
+ return slot?.effectType && slot.effectType !== "none";
+ })
+ .map(function (slot) {
+ return {
+ id: slot.id,
+ name: slot.name,
+ enabled: Boolean(slot.enabled),
+ effectType: slot.effectType,
+ };
+ });
+}
+
+export function buildAiProjectSummary(dawState, availableSamples = []) {
+ const project = dawState?.project || {};
+ const mixer = dawState?.mixer || {};
+ const transport = dawState?.transport || {};
+
+ return {
+ transport: {
+ bpm: transport.bpm,
+ mode: transport.mode,
+ },
+ activePatternId: project.activePatternId,
+ activeChannelId: project.activeChannelId,
+ patterns: (Array.isArray(project.patterns) ? project.patterns : []).map(
+ function (pattern) {
+ const isActive = pattern.id === project.activePatternId;
+ return {
+ id: pattern.id,
+ name: pattern.name,
+ lengthSteps: pattern.lengthSteps,
+ notes: isActive ? summarizePianoPreview(pattern.pianoPreview) : null,
+ steps: isActive ? summarizeStepGrid(pattern.stepGrid) : null,
+ noteCount: isActive ? null : countPianoNotes(pattern.pianoPreview),
+ };
+ },
+ ),
+ channels: (Array.isArray(project.channels) ? project.channels : []).map(
+ function (channel) {
+ return {
+ id: channel.id,
+ name: channel.name,
+ sampleRef: channel.sampleRef,
+ pluginRef: channel.pluginRef,
+ volume: channel.volume,
+ pan: channel.pan,
+ muted: Boolean(channel.muted),
+ solo: Boolean(channel.solo),
+ inputMode: channel.inputMode,
+ mixerInsertId: channel.mixerInsertId,
+ };
+ },
+ ),
+ mixerInserts: (Array.isArray(mixer.inserts) ? mixer.inserts : []).map(
+ function (insert) {
+ return {
+ id: insert.id,
+ name: insert.name,
+ isMaster: Boolean(insert.isMaster),
+ active: Boolean(insert.active),
+ fader: insert.fader,
+ pan: insert.pan,
+ stereoSeparation: insert.stereoSeparation,
+ fxSlots: summarizeFxSlots(insert),
+ };
+ },
+ ),
+ availableInstruments: PLUGIN_INSTRUMENTS.map(function (instrument) {
+ return {
+ pluginRef: instrument.pluginRef,
+ name: instrument.name,
+ description: instrument.description,
+ };
+ }),
+ availableEffects: PLUGIN_EFFECTS.map(function (effect) {
+ return {
+ effectType: effect.effectType,
+ name: effect.name,
+ description: effect.description,
+ };
+ }),
+ playlistTracks: (Array.isArray(project.playlistTracks)
+ ? project.playlistTracks
+ : []
+ ).map(function (track) {
+ return {
+ id: track.id,
+ name: track.name,
+ };
+ }),
+ playlistClips: (Array.isArray(project.playlistClips)
+ ? project.playlistClips
+ : []
+ ).slice(0, 80).map(function (clip) {
+ return {
+ id: clip.id,
+ clipType: clip.clipType,
+ patternId: clip.patternId,
+ samplePath: clip.samplePath,
+ channelId: clip.channelId,
+ trackId: clip.trackId,
+ barStart: clip.barStart,
+ barLength: clip.barLength,
+ };
+ }),
+ availableSamples,
+ };
+}
diff --git a/src/ai/aiProjectSummary.test.js b/src/ai/aiProjectSummary.test.js
new file mode 100644
index 0000000..948e37e
--- /dev/null
+++ b/src/ai/aiProjectSummary.test.js
@@ -0,0 +1,122 @@
+import { describe, it, expect } from "vitest";
+import { buildAiProjectSummary } from "./aiProjectSummary";
+
+describe("buildAiProjectSummary", function () {
+ it("includes available plugin instruments and effects", function () {
+ const summary = buildAiProjectSummary({
+ transport: { bpm: 140, mode: "pattern" },
+ project: {
+ activePatternId: "pat-1",
+ activeChannelId: "ch-1",
+ patterns: [],
+ channels: [],
+ playlistTracks: [],
+ playlistClips: [],
+ },
+ mixer: { inserts: [] },
+ });
+
+ expect(summary.availableInstruments).toContainEqual(
+ expect.objectContaining({
+ pluginRef: "openstudio-piano",
+ name: "Piano",
+ }),
+ );
+ expect(summary.availableEffects).toContainEqual(
+ expect.objectContaining({
+ effectType: "reverb",
+ name: "Reverb",
+ }),
+ );
+ });
+
+ it("includes notes and steps for the active pattern", function () {
+ const summary = buildAiProjectSummary({
+ transport: { bpm: 140, mode: "pattern" },
+ project: {
+ activePatternId: "pat-1",
+ activeChannelId: "ch-1",
+ patterns: [
+ {
+ id: "pat-1",
+ name: "Pattern 1",
+ lengthSteps: 16,
+ pianoPreview: {
+ "ch-1": [
+ { id: "n-1", start: 0, length: 4, pitch: 60, velocity: 95 },
+ { id: "n-2", start: 4, length: 4, pitch: 64, velocity: 100 },
+ ],
+ },
+ stepGrid: {
+ "ch-1": [true, false, false, false],
+ },
+ },
+ ],
+ channels: [{ id: "ch-1", name: "Piano" }],
+ playlistTracks: [],
+ playlistClips: [],
+ },
+ mixer: { inserts: [] },
+ });
+
+ expect(summary.patterns[0].notes).toEqual({
+ "ch-1": [
+ { start: 0, length: 4, pitch: 60, velocity: 95 },
+ { start: 4, length: 4, pitch: 64, velocity: 100 },
+ ],
+ });
+ expect(summary.patterns[0].steps).toEqual({
+ "ch-1": [true, false, false, false],
+ });
+ expect(summary.patterns[0].noteCount).toBeNull();
+ });
+
+ it("sends only noteCount for inactive patterns", function () {
+ const summary = buildAiProjectSummary({
+ transport: { bpm: 140, mode: "pattern" },
+ project: {
+ activePatternId: "pat-1",
+ activeChannelId: "ch-1",
+ patterns: [
+ {
+ id: "pat-1",
+ name: "Active",
+ lengthSteps: 16,
+ pianoPreview: {},
+ stepGrid: {},
+ },
+ {
+ id: "pat-2",
+ name: "Inactive",
+ lengthSteps: 32,
+ pianoPreview: {
+ "ch-1": [
+ { id: "n-1", start: 0, length: 4, pitch: 60, velocity: 95 },
+ ],
+ },
+ stepGrid: { "ch-1": [true] },
+ },
+ ],
+ channels: [{ id: "ch-1", name: "Piano" }],
+ playlistTracks: [],
+ playlistClips: [],
+ },
+ mixer: { inserts: [] },
+ });
+
+ const activePattern = summary.patterns.find(function (p) {
+ return p.id === "pat-1";
+ });
+ const inactivePattern = summary.patterns.find(function (p) {
+ return p.id === "pat-2";
+ });
+
+ expect(activePattern.notes).toEqual({});
+ expect(activePattern.steps).toEqual({});
+ expect(activePattern.noteCount).toBeNull();
+
+ expect(inactivePattern.notes).toBeNull();
+ expect(inactivePattern.steps).toBeNull();
+ expect(inactivePattern.noteCount).toBe(1);
+ });
+});
diff --git a/src/ai/aiProviders.js b/src/ai/aiProviders.js
new file mode 100644
index 0000000..64e1693
--- /dev/null
+++ b/src/ai/aiProviders.js
@@ -0,0 +1,49 @@
+export const AI_AGENT_PROVIDER_OPENAI = "openai";
+export const AI_AGENT_PROVIDER_GEMINI = "gemini";
+export const AI_AGENT_DEFAULT_PROVIDER = AI_AGENT_PROVIDER_OPENAI;
+
+export const AI_AGENT_PROVIDERS = [
+ {
+ id: AI_AGENT_PROVIDER_OPENAI,
+ label: "OpenAI",
+ defaultModel: "gpt-5.5",
+ keyStorage: "openstudio.ai.keys.openai",
+ modelStorage: "openstudio.ai.models.openai",
+ keyPlaceholder: "sk-...",
+ models: [
+ { value: "gpt-5.5", label: "GPT-5.5" },
+ { value: "gpt-5.4", label: "GPT-5.4" },
+ { value: "gpt-5.4-mini", label: "GPT-5.4 mini" },
+ ],
+ },
+ {
+ id: AI_AGENT_PROVIDER_GEMINI,
+ label: "Gemini",
+ defaultModel: "gemini-3.5-flash",
+ keyStorage: "openstudio.ai.keys.gemini",
+ modelStorage: "openstudio.ai.models.gemini",
+ keyPlaceholder: "AIza...",
+ models: [
+ { value: "gemini-3.5-flash", label: "Gemini 3.5 Flash" },
+ { value: "gemini-3.1-flash-lite", label: "Gemini 3.1 Flash Lite" },
+ { value: "gemini-3-flash-preview", label: "Gemini 3 Flash Preview" },
+ { value: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro Preview" },
+ ],
+ },
+];
+
+export function getAiProviderConfig(providerId) {
+ const safeProviderId = String(providerId || "").trim();
+ return AI_AGENT_PROVIDERS.find(function (provider) {
+ return provider.id === safeProviderId;
+ }) || AI_AGENT_PROVIDERS[0];
+}
+
+export function getAiProviderModel(providerId, model) {
+ const provider = getAiProviderConfig(providerId);
+ const safeModel = String(model || "").trim();
+ const hasModel = provider.models.some(function (item) {
+ return item.value === safeModel;
+ });
+ return hasModel ? safeModel : provider.defaultModel;
+}
diff --git a/src/ai/aiSampleIndex.js b/src/ai/aiSampleIndex.js
new file mode 100644
index 0000000..74483e7
--- /dev/null
+++ b/src/ai/aiSampleIndex.js
@@ -0,0 +1,142 @@
+function makePacksPath(relativePath) {
+ const cleanRelative = String(relativePath || "")
+ .replace(/^\/+/, "")
+ .trim();
+ const isFileProtocol =
+ typeof window !== "undefined" && window.location.protocol === "file:";
+
+ if (isFileProtocol) {
+ const normalized = cleanRelative.replace(/^packs\/?/i, "");
+ return (
+ "openstudio://packs/" +
+ normalized
+ .split("/")
+ .filter(Boolean)
+ .map(function (segment) {
+ return encodeURIComponent(segment);
+ })
+ .join("/")
+ );
+ }
+
+ return "/" + cleanRelative;
+}
+
+function normalizePackItemPath(rawPath) {
+ const input = String(rawPath || "").trim();
+ if (!input) {
+ return "";
+ }
+
+ if (/^(https?:|file:|openstudio:)/i.test(input)) {
+ return input;
+ }
+
+ const noLeadingSlash = input.replace(/^\/+/, "");
+ if (noLeadingSlash.startsWith("packs/")) {
+ return makePacksPath(noLeadingSlash);
+ }
+
+ return makePacksPath("packs/" + noLeadingSlash);
+}
+
+export async function loadAiSampleIndex(limit = 240) {
+ try {
+ const response = await fetch(
+ makePacksPath("packs/manifest.json") + "?ts=" + Date.now(),
+ { cache: "no-store" },
+ );
+
+ if (!response.ok) {
+ return [];
+ }
+
+ const manifest = await response.json();
+ const folders = Array.isArray(manifest?.folders) ? manifest.folders : [];
+ const samples = [];
+
+ folders.forEach(function (folder) {
+ const folderName = String(folder?.folder || "Packs");
+ const items = Array.isArray(folder?.items) ? folder.items : [];
+ items.forEach(function (item) {
+ const name = typeof item === "string"
+ ? item.split("/").pop() || item
+ : String(item?.name || item?.path || "Sample").split("/").pop();
+ const path = typeof item === "string"
+ ? normalizePackItemPath(item)
+ : normalizePackItemPath(item?.path);
+
+ if (!path) {
+ return;
+ }
+
+ samples.push({
+ name,
+ path,
+ folder: folderName,
+ });
+ });
+ });
+
+ return samples.slice(0, limit);
+ } catch {
+ return [];
+ }
+}
+
+function tokenizeSampleQuery(query) {
+ return String(query || "")
+ .toLowerCase()
+ .split(/[^a-z0-9]+/i)
+ .map(function (token) {
+ return token.trim();
+ })
+ .filter(function (token) {
+ return token.length >= 2;
+ });
+}
+
+function scoreSample(sample, tokens) {
+ const haystack = [sample?.name, sample?.folder, sample?.path]
+ .join(" ")
+ .toLowerCase();
+
+ return tokens.reduce(function (score, token) {
+ if (!haystack.includes(token)) {
+ return score;
+ }
+
+ const name = String(sample?.name || "").toLowerCase();
+ return score + (name.includes(token) ? 3 : 1);
+ }, 0);
+}
+
+export function searchAiSamples(samples, query, limit = 80) {
+ const source = Array.isArray(samples) ? samples : [];
+ const tokens = tokenizeSampleQuery(query);
+
+ if (tokens.length === 0) {
+ return source.slice(0, limit);
+ }
+
+ return source
+ .map(function (sample) {
+ return {
+ sample,
+ score: scoreSample(sample, tokens),
+ };
+ })
+ .filter(function (item) {
+ return item.score > 0;
+ })
+ .sort(function (a, b) {
+ if (a.score !== b.score) {
+ return b.score - a.score;
+ }
+ return String(a.sample?.name || "").localeCompare(String(b.sample?.name || ""));
+ })
+ .slice(0, limit)
+ .map(function (item) {
+ return item.sample;
+ });
+}
diff --git a/src/ai/aiSampleIndex.test.js b/src/ai/aiSampleIndex.test.js
new file mode 100644
index 0000000..04cf789
--- /dev/null
+++ b/src/ai/aiSampleIndex.test.js
@@ -0,0 +1,17 @@
+import { describe, it, expect } from "vitest";
+import { searchAiSamples } from "./aiSampleIndex";
+
+describe("searchAiSamples", function () {
+ it("prioritizes samples matching the user prompt", function () {
+ const samples = [
+ { name: "Soft Piano.wav", folder: "Keys", path: "/packs/keys/piano.wav" },
+ { name: "Trap Kick.wav", folder: "Drums", path: "/packs/drums/kick.wav" },
+ { name: "Open Hat.wav", folder: "Drums", path: "/packs/drums/hat.wav" },
+ ];
+
+ const result = searchAiSamples(samples, "make a trap kick pattern", 2);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].name).toBe("Trap Kick.wav");
+ });
+});
diff --git a/src/ai/geminiClient.js b/src/ai/geminiClient.js
new file mode 100644
index 0000000..7026c27
--- /dev/null
+++ b/src/ai/geminiClient.js
@@ -0,0 +1,160 @@
+import { getAiAgentSystemPrompt } from "./aiAgentPrompt";
+import { getAiProviderConfig, AI_AGENT_PROVIDER_GEMINI } from "./aiProviders";
+
+const GEMINI_API_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models";
+
+function parseAiJsonContent(content) {
+ const raw = String(content || "").trim();
+ if (!raw) {
+ throw new Error("AI returned an empty response.");
+ }
+
+ try {
+ return JSON.parse(raw);
+ } catch {
+ const jsonMatch = raw.match(/\{[\s\S]*\}/);
+ if (!jsonMatch) {
+ throw new Error("AI response was not valid JSON.");
+ }
+ return JSON.parse(jsonMatch[0]);
+ }
+}
+
+function buildGeminiContents({ userMessage, projectSummary, conversationHistory }) {
+ const historyContents = (Array.isArray(conversationHistory)
+ ? conversationHistory
+ : []
+ )
+ .filter(function (msg) {
+ return msg && (msg.role === "user" || msg.role === "assistant") && msg.content;
+ })
+ .map(function (msg) {
+ return {
+ role: msg.role === "assistant" ? "model" : "user",
+ parts: [{ text: String(msg.content) }],
+ };
+ });
+
+ return historyContents.concat({
+ role: "user",
+ parts: [
+ {
+ text: JSON.stringify({
+ request: userMessage,
+ project: projectSummary,
+ }),
+ },
+ ],
+ });
+}
+
+function makeGeminiGenerateUrl(model, apiKey) {
+ return (
+ GEMINI_API_BASE_URL +
+ "/" +
+ encodeURIComponent(model) +
+ ":generateContent?key=" +
+ encodeURIComponent(apiKey)
+ );
+}
+
+export async function requestGeminiAgentPlan({
+ apiKey,
+ model,
+ userMessage,
+ projectSummary,
+ conversationHistory = [],
+}) {
+ const provider = getAiProviderConfig(AI_AGENT_PROVIDER_GEMINI);
+ const safeApiKey = String(apiKey || "").trim();
+ const safeMessage = String(userMessage || "").trim();
+ const safeModel = String(model || provider.defaultModel).trim() || provider.defaultModel;
+
+ if (!safeApiKey) {
+ throw new Error("Paste your Gemini API key before sending a message.");
+ }
+
+ if (!safeMessage) {
+ throw new Error("Write a request for the AI Agent first.");
+ }
+
+ const response = await fetch(makeGeminiGenerateUrl(safeModel, safeApiKey), {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ },
+ body: JSON.stringify({
+ systemInstruction: {
+ parts: [{ text: getAiAgentSystemPrompt() }],
+ },
+ contents: buildGeminiContents({
+ userMessage: safeMessage,
+ projectSummary,
+ conversationHistory,
+ }),
+ generationConfig: {
+ responseMimeType: "application/json",
+ },
+ }),
+ });
+
+ const result = await response.json().catch(function () {
+ return null;
+ });
+
+ if (!response.ok) {
+ throw new Error(
+ result?.error?.message ||
+ "Gemini request failed with status " + response.status,
+ );
+ }
+
+ const content = result?.candidates?.[0]?.content?.parts?.[0]?.text;
+ const parsed = parseAiJsonContent(content);
+
+ return {
+ message: String(parsed?.message || "I prepared a project edit plan."),
+ operations: Array.isArray(parsed?.operations) ? parsed.operations : [],
+ };
+}
+
+export async function testGeminiConnection({ apiKey, model }) {
+ const provider = getAiProviderConfig(AI_AGENT_PROVIDER_GEMINI);
+ const safeApiKey = String(apiKey || "").trim();
+ const safeModel = String(model || provider.defaultModel).trim() || provider.defaultModel;
+
+ if (!safeApiKey) {
+ throw new Error("Paste your Gemini API key before testing the connection.");
+ }
+
+ const response = await fetch(makeGeminiGenerateUrl(safeModel, safeApiKey), {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ },
+ body: JSON.stringify({
+ contents: [
+ {
+ role: "user",
+ parts: [{ text: "Return {\"ok\":true} as JSON." }],
+ },
+ ],
+ generationConfig: {
+ responseMimeType: "application/json",
+ },
+ }),
+ });
+
+ const result = await response.json().catch(function () {
+ return null;
+ });
+
+ if (!response.ok) {
+ throw new Error(
+ result?.error?.message ||
+ "Gemini connection test failed with status " + response.status,
+ );
+ }
+
+ return { model: safeModel };
+}
diff --git a/src/ai/geminiClient.test.js b/src/ai/geminiClient.test.js
new file mode 100644
index 0000000..b32df12
--- /dev/null
+++ b/src/ai/geminiClient.test.js
@@ -0,0 +1,128 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { requestGeminiAgentPlan, testGeminiConnection } from "./geminiClient";
+
+describe("geminiClient", function () {
+ afterEach(function () {
+ vi.restoreAllMocks();
+ });
+
+ it("parses JSON generateContent responses", async function () {
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
+ ok: true,
+ json: async function () {
+ return {
+ candidates: [
+ {
+ content: {
+ parts: [
+ {
+ text: JSON.stringify({
+ message: "Plan ready",
+ operations: [{ type: "create_pattern", payload: {} }],
+ }),
+ },
+ ],
+ },
+ },
+ ],
+ };
+ },
+ });
+
+ const result = await requestGeminiAgentPlan({
+ apiKey: "gemini-key",
+ model: "gemini-3.5-flash",
+ userMessage: "create pattern",
+ projectSummary: { patterns: [] },
+ });
+
+ expect(result.message).toBe("Plan ready");
+ expect(result.operations).toHaveLength(1);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(fetchMock.mock.calls[0][0]).toContain(
+ "/gemini-3.5-flash:generateContent?key=gemini-key",
+ );
+
+ const sentBody = JSON.parse(fetchMock.mock.calls[0][1].body);
+ expect(sentBody.systemInstruction.parts[0].text).toContain("OpenStudio");
+ expect(sentBody.generationConfig.responseMimeType).toBe("application/json");
+ });
+
+ it("maps conversation history to Gemini roles", async function () {
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
+ ok: true,
+ json: async function () {
+ return {
+ candidates: [
+ {
+ content: {
+ parts: [
+ { text: JSON.stringify({ message: "ok", operations: [] }) },
+ ],
+ },
+ },
+ ],
+ };
+ },
+ });
+
+ await requestGeminiAgentPlan({
+ apiKey: "gemini-key",
+ model: "gemini-3.1-pro-preview",
+ userMessage: "add a snare",
+ projectSummary: { tempo: 140 },
+ conversationHistory: [
+ { role: "user", content: "create a beat" },
+ { role: "assistant", content: "I created kick and hats." },
+ { role: "system", content: "Applied 2 operations." },
+ ],
+ });
+
+ const sentBody = JSON.parse(fetchMock.mock.calls[0][1].body);
+ expect(sentBody.contents).toHaveLength(3);
+ expect(sentBody.contents[0]).toEqual({
+ role: "user",
+ parts: [{ text: "create a beat" }],
+ });
+ expect(sentBody.contents[1]).toEqual({
+ role: "model",
+ parts: [{ text: "I created kick and hats." }],
+ });
+ expect(sentBody.contents[2].role).toBe("user");
+ expect(JSON.parse(sentBody.contents[2].parts[0].text)).toEqual({
+ request: "add a snare",
+ project: { tempo: 140 },
+ });
+ });
+
+ it("tests access with a lightweight JSON request", async function () {
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
+ ok: true,
+ json: async function () {
+ return { candidates: [] };
+ },
+ });
+
+ const result = await testGeminiConnection({
+ apiKey: "gemini-key",
+ model: "gemini-3.1-flash-lite",
+ });
+
+ expect(result.model).toBe("gemini-3.1-flash-lite");
+ const sentBody = JSON.parse(fetchMock.mock.calls[0][1].body);
+ expect(sentBody.contents[0].parts[0].text).toBe(
+ "Return {\"ok\":true} as JSON.",
+ );
+ });
+
+ it("requires a Gemini API key", async function () {
+ await expect(
+ requestGeminiAgentPlan({
+ apiKey: "",
+ model: "gemini-3.5-flash",
+ userMessage: "hi",
+ projectSummary: {},
+ }),
+ ).rejects.toThrow("Paste your Gemini API key");
+ });
+});
diff --git a/src/ai/openAiClient.js b/src/ai/openAiClient.js
new file mode 100644
index 0000000..8747535
--- /dev/null
+++ b/src/ai/openAiClient.js
@@ -0,0 +1,148 @@
+import { AI_AGENT_DEFAULT_MODEL, getAiAgentSystemPrompt } from "./aiAgentPrompt";
+
+const OPENAI_CHAT_COMPLETIONS_URL = "https://api.openai.com/v1/chat/completions";
+const OPENAI_MODELS_URL = "https://api.openai.com/v1/models";
+
+function parseAiJsonContent(content) {
+ const raw = String(content || "").trim();
+ if (!raw) {
+ throw new Error("AI returned an empty response.");
+ }
+
+ try {
+ return JSON.parse(raw);
+ } catch {
+ const jsonMatch = raw.match(/\{[\s\S]*\}/);
+ if (!jsonMatch) {
+ throw new Error("AI response was not valid JSON.");
+ }
+ return JSON.parse(jsonMatch[0]);
+ }
+}
+
+export async function requestAiAgentPlan({
+ apiKey,
+ model = AI_AGENT_DEFAULT_MODEL,
+ userMessage,
+ projectSummary,
+ conversationHistory = [],
+}) {
+ const safeApiKey = String(apiKey || "").trim();
+ const safeMessage = String(userMessage || "").trim();
+ const safeModel = String(model || AI_AGENT_DEFAULT_MODEL).trim() || AI_AGENT_DEFAULT_MODEL;
+
+ if (!safeApiKey) {
+ throw new Error("Paste your OpenAI API key before sending a message.");
+ }
+
+ if (!safeMessage) {
+ throw new Error("Write a request for the AI Agent first.");
+ }
+
+ // GPT-5.5 (and other reasoning models) reject custom temperature values,
+ // only the default (1) is supported. Older GPT-5.4 variants still accept it.
+ const supportsTemperature = !safeModel.startsWith("gpt-5.5");
+
+ // Build the full message list: system prompt, prior conversation turns,
+ // then the current user request with the live project summary. This gives
+ // the model context from earlier in the chat (loaded from Supabase or
+ // built up during the current session).
+ const historyMessages = (Array.isArray(conversationHistory)
+ ? conversationHistory
+ : []
+ )
+ .filter(function (msg) {
+ return msg && (msg.role === "user" || msg.role === "assistant") && msg.content;
+ })
+ .map(function (msg) {
+ return { role: msg.role, content: msg.content };
+ });
+
+ const requestBody = {
+ model: safeModel,
+ response_format: { type: "json_object" },
+ messages: [
+ {
+ role: "system",
+ content: getAiAgentSystemPrompt(),
+ },
+ ...historyMessages,
+ {
+ role: "user",
+ content: JSON.stringify({
+ request: safeMessage,
+ project: projectSummary,
+ }),
+ },
+ ],
+ };
+
+ if (supportsTemperature) {
+ requestBody.temperature = 0.35;
+ }
+
+ const response = await fetch(OPENAI_CHAT_COMPLETIONS_URL, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ authorization: "Bearer " + safeApiKey,
+ },
+ body: JSON.stringify(requestBody),
+ });
+
+ const result = await response.json().catch(function () {
+ return null;
+ });
+
+ if (!response.ok) {
+ const message =
+ result?.error?.message ||
+ "OpenAI request failed with status " + response.status;
+ throw new Error(message);
+ }
+
+ const content = result?.choices?.[0]?.message?.content;
+ const parsed = parseAiJsonContent(content);
+
+ return {
+ message: String(parsed?.message || "I prepared a project edit plan."),
+ operations: Array.isArray(parsed?.operations) ? parsed.operations : [],
+ };
+}
+
+export async function testOpenAiConnection({
+ apiKey,
+ model = AI_AGENT_DEFAULT_MODEL,
+}) {
+ const safeApiKey = String(apiKey || "").trim();
+ const safeModel = String(model || AI_AGENT_DEFAULT_MODEL).trim() || AI_AGENT_DEFAULT_MODEL;
+
+ if (!safeApiKey) {
+ throw new Error("Paste your OpenAI API key before testing the connection.");
+ }
+
+ const response = await fetch(
+ OPENAI_MODELS_URL + "/" + encodeURIComponent(safeModel),
+ {
+ method: "GET",
+ headers: {
+ authorization: "Bearer " + safeApiKey,
+ },
+ },
+ );
+
+ const result = await response.json().catch(function () {
+ return null;
+ });
+
+ if (!response.ok) {
+ throw new Error(
+ result?.error?.message ||
+ "OpenAI connection test failed with status " + response.status,
+ );
+ }
+
+ return {
+ model: result?.id || safeModel,
+ };
+}
diff --git a/src/ai/openAiClient.test.js b/src/ai/openAiClient.test.js
new file mode 100644
index 0000000..fe4fbce
--- /dev/null
+++ b/src/ai/openAiClient.test.js
@@ -0,0 +1,189 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { requestAiAgentPlan, testOpenAiConnection } from "./openAiClient";
+
+describe("openAiClient", function () {
+ afterEach(function () {
+ vi.restoreAllMocks();
+ });
+
+ it("parses JSON chat completion responses", async function () {
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
+ ok: true,
+ json: async function () {
+ return {
+ choices: [
+ {
+ message: {
+ content: JSON.stringify({
+ message: "Plan ready",
+ operations: [{ type: "create_pattern", payload: {} }],
+ }),
+ },
+ },
+ ],
+ };
+ },
+ });
+
+ const result = await requestAiAgentPlan({
+ apiKey: "sk-test",
+ model: "gpt-5.5",
+ userMessage: "create pattern",
+ projectSummary: { patterns: [] },
+ });
+
+ expect(result.message).toBe("Plan ready");
+ expect(result.operations).toHaveLength(1);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+
+ // GPT-5.5 must not send temperature (only default 1 is supported)
+ const sentBody = JSON.parse(fetchMock.mock.calls[0][1].body);
+ expect(sentBody).not.toHaveProperty("temperature");
+ });
+
+ it("sends temperature for models that support it", async function () {
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
+ ok: true,
+ json: async function () {
+ return {
+ choices: [
+ {
+ message: {
+ content: JSON.stringify({ message: "ok", operations: [] }),
+ },
+ },
+ ],
+ };
+ },
+ });
+
+ await requestAiAgentPlan({
+ apiKey: "sk-test",
+ model: "gpt-5.4",
+ userMessage: "hi",
+ projectSummary: {},
+ });
+
+ const sentBody = JSON.parse(fetchMock.mock.calls[0][1].body);
+ expect(sentBody.temperature).toBe(0.35);
+ });
+
+ it("tests model access through the models endpoint", async function () {
+ vi.spyOn(globalThis, "fetch").mockResolvedValue({
+ ok: true,
+ json: async function () {
+ return { id: "gpt-5.5" };
+ },
+ });
+
+ const result = await testOpenAiConnection({
+ apiKey: "sk-test",
+ model: "gpt-5.5",
+ });
+
+ expect(result.model).toBe("gpt-5.5");
+ });
+
+ it("includes conversation history in the messages array", async function () {
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
+ ok: true,
+ json: async function () {
+ return {
+ choices: [
+ {
+ message: {
+ content: JSON.stringify({ message: "ok", operations: [] }),
+ },
+ },
+ ],
+ };
+ },
+ });
+
+ await requestAiAgentPlan({
+ apiKey: "sk-test",
+ model: "gpt-5.4",
+ userMessage: "add a snare",
+ projectSummary: {},
+ conversationHistory: [
+ { role: "user", content: "create a trap beat" },
+ { role: "assistant", content: "I created a pattern with kick and hats." },
+ ],
+ });
+
+ const sentBody = JSON.parse(fetchMock.mock.calls[0][1].body);
+ expect(sentBody.messages).toHaveLength(4);
+ expect(sentBody.messages[0].role).toBe("system");
+ expect(sentBody.messages[1]).toEqual({
+ role: "user",
+ content: "create a trap beat",
+ });
+ expect(sentBody.messages[2].role).toBe("assistant");
+ expect(sentBody.messages[3].role).toBe("user");
+ });
+
+ it("works without conversation history (empty array default)", async function () {
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
+ ok: true,
+ json: async function () {
+ return {
+ choices: [
+ {
+ message: {
+ content: JSON.stringify({ message: "ok", operations: [] }),
+ },
+ },
+ ],
+ };
+ },
+ });
+
+ await requestAiAgentPlan({
+ apiKey: "sk-test",
+ model: "gpt-5.4",
+ userMessage: "hi",
+ projectSummary: {},
+ });
+
+ const sentBody = JSON.parse(fetchMock.mock.calls[0][1].body);
+ expect(sentBody.messages).toHaveLength(2);
+ expect(sentBody.messages[0].role).toBe("system");
+ expect(sentBody.messages[1].role).toBe("user");
+ });
+
+ it("filters out system-role messages from conversation history", async function () {
+ const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
+ ok: true,
+ json: async function () {
+ return {
+ choices: [
+ {
+ message: {
+ content: JSON.stringify({ message: "ok", operations: [] }),
+ },
+ },
+ ],
+ };
+ },
+ });
+
+ await requestAiAgentPlan({
+ apiKey: "sk-test",
+ model: "gpt-5.4",
+ userMessage: "hi",
+ projectSummary: {},
+ conversationHistory: [
+ { role: "user", content: "create a beat" },
+ { role: "system", content: "Applied 2 operations." },
+ ],
+ });
+
+ const sentBody = JSON.parse(fetchMock.mock.calls[0][1].body);
+ // system prompt + 1 history (user only) + current user message = 3
+ expect(sentBody.messages).toHaveLength(3);
+ expect(sentBody.messages[1]).toEqual({
+ role: "user",
+ content: "create a beat",
+ });
+ });
+});
diff --git a/src/audio/core/useSampleSettingsPreview.js b/src/audio/core/useSampleSettingsPreview.js
index b1e229d..4bf2373 100644
--- a/src/audio/core/useSampleSettingsPreview.js
+++ b/src/audio/core/useSampleSettingsPreview.js
@@ -15,6 +15,8 @@ import {
getPluginInstrumentCacheKey,
} from "./usePluginInstruments";
import { BASE_CHANNEL_TRIGGER_GAIN } from "../domain/constants";
+import { DEFAULT_SAMPLE_MIDI_PITCH } from "../domain/pitch";
+import { getSafeSampleSettings } from "../domain/sampleSettings";
const SAMPLE_SETTINGS_PREVIEW_PLAY_EVENT =
"openstudio:sample-settings-preview-play";
@@ -128,6 +130,9 @@ export function useSampleSettingsPreview({
hasPluginInstrument &&
typeof schedulePluginInstrumentRef.current === "function"
) {
+ const channelSettings = getSafeSampleSettings(
+ channel.sampleSettings,
+ );
schedulePluginInstrumentRef.current(
pluginRef,
startAt,
@@ -135,6 +140,9 @@ export function useSampleSettingsPreview({
channel.pan,
channel,
outputNode,
+ DEFAULT_SAMPLE_MIDI_PITCH,
+ 1,
+ channelSettings,
);
return;
}
diff --git a/src/audio/core/useTransportScheduler.js b/src/audio/core/useTransportScheduler.js
index 01ea8e0..fbac626 100644
--- a/src/audio/core/useTransportScheduler.js
+++ b/src/audio/core/useTransportScheduler.js
@@ -102,6 +102,7 @@ export function useTransportScheduler({
mixerGraphRef,
mixerSettingsRef,
ensureMixerGraph,
+ startMixerInsertModulators,
applyMixerSettingsToGraph,
getInsertInputNodeForChannel,
updateMixerMeters,
diff --git a/src/components/AiAgentWindow.jsx b/src/components/AiAgentWindow.jsx
new file mode 100644
index 0000000..7167822
--- /dev/null
+++ b/src/components/AiAgentWindow.jsx
@@ -0,0 +1,176 @@
+import { useState } from "react";
+import { Bot } from "lucide-react";
+import { AiAgentConversationList } from "./ai-agent/AiAgentConversationList";
+import { AiAgentMessages } from "./ai-agent/AiAgentMessages";
+import { AiAgentPlanPanel } from "./ai-agent/AiAgentPlanPanel";
+import { useAiAgentController } from "./ai-agent/useAiAgentController";
+
+function AiAgentSelect({ value, options, onChange }) {
+ const [isOpen, setIsOpen] = useState(false);
+ const activeOption = options.find(function (option) {
+ return option.value === value;
+ }) || options[0];
+
+ return (
+
+
+ {activeOption?.label || "Select"}
+ v
+
+ {isOpen ? (
+
+ {options.map(function (option) {
+ const isActive = option.value === value;
+ return (
+
+ {option.label}
+
+ );
+ })}
+
+ ) : null}
+
+ );
+}
+
+export function AiAgentWindow() {
+ const agent = useAiAgentController();
+
+ return (
+
+
+
+
+
+
+
Project assistant
+
AI Agent
+
+
+
+
+
+ Provider
+
+
+
+ {agent.providerLabel} API key
+
+
+
+ Model
+
+
+
+
+ Remember on this device
+
+
+
+ {agent.isTestingConnection ? "Testing..." : "Test connection"}
+
+
+ {agent.connectionStatus ? (
+
+ {agent.connectionStatus}
+
+ ) : null}
+
+
+
+
+
+
+
+
+ {agent.error ?
{agent.error}
: null}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/ai-agent/AiAgentConversationList.jsx b/src/components/ai-agent/AiAgentConversationList.jsx
new file mode 100644
index 0000000..b198ba4
--- /dev/null
+++ b/src/components/ai-agent/AiAgentConversationList.jsx
@@ -0,0 +1,119 @@
+import { MessageSquarePlus, Trash2 } from "lucide-react";
+
+/**
+ * @fileoverview AiAgentConversationList — Sidebar showing saved AI chat
+ * conversations for authenticated users. Unauthenticated users see a
+ * sign-in prompt instead of the list.
+ */
+
+function formatRelativeTime(isoString) {
+ if (!isoString) {
+ return "";
+ }
+
+ const now = Date.now();
+ const then = new Date(isoString).getTime();
+ if (Number.isNaN(then)) {
+ return "";
+ }
+
+ const diffMs = now - then;
+ const diffMin = Math.floor(diffMs / 60000);
+ const diffHr = Math.floor(diffMin / 60);
+ const diffDay = Math.floor(diffHr / 24);
+
+ if (diffMin < 1) {
+ return "just now";
+ }
+ if (diffMin < 60) {
+ return diffMin + "m ago";
+ }
+ if (diffHr < 24) {
+ return diffHr + "h ago";
+ }
+ if (diffDay < 7) {
+ return diffDay + "d ago";
+ }
+ return new Date(then).toLocaleDateString();
+}
+
+export function AiAgentConversationList({
+ conversations,
+ activeConversationId,
+ onSelect,
+ onNew,
+ onDelete,
+ isLoading,
+ isAuthenticated,
+}) {
+ if (!isAuthenticated) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/ai-agent/AiAgentMessages.jsx b/src/components/ai-agent/AiAgentMessages.jsx
new file mode 100644
index 0000000..8b31dd1
--- /dev/null
+++ b/src/components/ai-agent/AiAgentMessages.jsx
@@ -0,0 +1,45 @@
+import { useEffect, useRef } from "react";
+import { Sparkles } from "lucide-react";
+
+export function AiAgentMessages({ messages }) {
+ const scrollRef = useRef(null);
+
+ useEffect(
+ function () {
+ const el = scrollRef.current;
+ if (el) {
+ el.scrollTop = el.scrollHeight;
+ }
+ },
+ [messages],
+ );
+
+ if (!Array.isArray(messages) || messages.length === 0) {
+ return (
+
+
+ Ready for project-aware editing
+
+ Paste your own OpenAI API key, describe the change, then review the
+ generated operation plan before applying it.
+
+
+ );
+ }
+
+ return (
+
+ {messages.map(function (message, index) {
+ return (
+
+
{message.role}
+
{message.content}
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/ai-agent/AiAgentPlanPanel.jsx b/src/components/ai-agent/AiAgentPlanPanel.jsx
new file mode 100644
index 0000000..5718689
--- /dev/null
+++ b/src/components/ai-agent/AiAgentPlanPanel.jsx
@@ -0,0 +1,84 @@
+import { CheckCircle2 } from "lucide-react";
+
+export function AiAgentPlanPanel({
+ operations,
+ operationResults,
+ rejectedOperations,
+ isApplying,
+ 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;
+
+ return (
+
+
+
+ Preview + Apply
+
+
+ {hasOperations ? (
+
+ {operations.map(function (operation) {
+ return (
+
+ {operation.description}
+ {Array.isArray(operation.issues) && operation.issues.length > 0 ? (
+ {operation.issues.join(" ")}
+ ) : null}
+
+ );
+ })}
+
+ ) : hasResults ? (
+
+ {operationResults.map(function (result) {
+ return (
+
+ {result.description}
+ {result.reason ? {result.reason} : null}
+
+ );
+ })}
+
+ ) : (
+
+ The agent will never mutate the DAW state directly. It prepares a
+ validated operation list first, then you decide whether to apply it.
+
+ )}
+
+ {hasRejected ? (
+
+ Ignored unsafe operations
+ {rejectedOperations.map(function (item) {
+ return {item.reason} ;
+ })}
+
+ ) : null}
+
+
+
+ {isApplying ? "Applying..." : "Apply Plan"}
+
+
+ Undo
+
+
+
+ );
+}
diff --git a/src/components/ai-agent/useAiAgentController.js b/src/components/ai-agent/useAiAgentController.js
new file mode 100644
index 0000000..5c584df
--- /dev/null
+++ b/src/components/ai-agent/useAiAgentController.js
@@ -0,0 +1,542 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useDispatch, useSelector, useStore } from "react-redux";
+import {
+ applyAiOperations,
+ prepareAiOperations,
+ validatePreparedAiOperations,
+} from "../../ai/aiAgentOperations";
+import {
+ AI_AGENT_DEFAULT_PROVIDER,
+ AI_AGENT_PROVIDERS,
+ getAiProviderConfig,
+ getAiProviderModel,
+} from "../../ai/aiProviders";
+import { buildAiProjectSummary } from "../../ai/aiProjectSummary";
+import { loadAiSampleIndex, searchAiSamples } from "../../ai/aiSampleIndex";
+import { requestAiAgentPlan, testAiConnection } from "../../ai/aiClient";
+import {
+ createConversation,
+ deleteConversation as deleteConversationApi,
+ fetchConversations,
+ loadConversation,
+ updateConversation,
+} from "../../lib/aiConversationsApi";
+import { loadProjectFromFile } from "../../store";
+
+const AI_AGENT_PROVIDER_STORAGE = "openstudio.ai.provider";
+const AI_AGENT_LEGACY_OPENAI_KEY_STORAGE = "openstudio.ai.openaiKey";
+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 = "") {
+ if (typeof window === "undefined") {
+ return fallback;
+ }
+
+ try {
+ return window.localStorage.getItem(key) || fallback;
+ } catch {
+ return fallback;
+ }
+}
+
+function writeStoredValue(key, value) {
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ try {
+ if (value) {
+ window.localStorage.setItem(key, value);
+ } else {
+ window.localStorage.removeItem(key);
+ }
+ } catch {
+ // Storage can be blocked in privacy modes; the in-memory key still works.
+ }
+}
+
+function readStoredProvider() {
+ const storedProvider = readStoredValue(
+ AI_AGENT_PROVIDER_STORAGE,
+ AI_AGENT_DEFAULT_PROVIDER,
+ );
+ return getAiProviderConfig(storedProvider).id;
+}
+
+function readProviderKey(providerId) {
+ const provider = getAiProviderConfig(providerId);
+ const storedKey = readStoredValue(provider.keyStorage);
+ if (storedKey) {
+ return storedKey;
+ }
+ if (provider.id === "openai") {
+ return readStoredValue(AI_AGENT_LEGACY_OPENAI_KEY_STORAGE);
+ }
+ return "";
+}
+
+function readProviderModel(providerId) {
+ const provider = getAiProviderConfig(providerId);
+ const storedModel =
+ readStoredValue(provider.modelStorage) ||
+ (provider.id === "openai"
+ ? readStoredValue(AI_AGENT_LEGACY_MODEL_STORAGE)
+ : "");
+ return getAiProviderModel(provider.id, storedModel);
+}
+
+function cloneSerializable(value) {
+ return JSON.parse(JSON.stringify(value));
+}
+
+/**
+ * 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) {
+ const raw = String(firstMessage || "").trim();
+ if (!raw) {
+ return "New chat";
+ }
+ if (raw.length <= TITLE_MAX_LENGTH) {
+ return raw;
+ }
+ const slice = raw.slice(0, TITLE_MAX_LENGTH);
+ const lastSpace = slice.lastIndexOf(" ");
+ return (lastSpace > 20 ? slice.slice(0, lastSpace) : slice) + "...";
+}
+
+export function useAiAgentController() {
+ const dispatch = useDispatch();
+ const store = useStore();
+ const currentUser = useSelector(function (state) {
+ return state.user.currentUser;
+ });
+ const isAuthenticated = Boolean(currentUser);
+ const [provider, setProviderState] = useState(readStoredProvider);
+
+ const [apiKey, setApiKey] = useState(function () {
+ return readProviderKey(provider);
+ });
+ const [rememberKey, setRememberKey] = useState(function () {
+ return Boolean(readProviderKey(provider));
+ });
+ const [model, setModel] = useState(function () {
+ return readProviderModel(provider);
+ });
+ const [input, setInput] = 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([]);
+
+ // Conversation history (Supabase-backed, authenticated users only)
+ const [conversations, setConversations] = useState([]);
+ const [activeConversationId, setActiveConversationId] = useState(null);
+ const [isLoadingConversations, setIsLoadingConversations] = useState(false);
+
+ useEffect(
+ function () {
+ const providerConfig = getAiProviderConfig(provider);
+ writeStoredValue(
+ providerConfig.modelStorage,
+ getAiProviderModel(providerConfig.id, model),
+ );
+ },
+ [model, provider],
+ );
+
+ useEffect(
+ function () {
+ const providerConfig = getAiProviderConfig(provider);
+ writeStoredValue(providerConfig.keyStorage, rememberKey ? apiKey : "");
+ },
+ [apiKey, rememberKey, provider],
+ );
+
+ const setProvider = useCallback(
+ function (nextProvider) {
+ const currentProviderConfig = getAiProviderConfig(provider);
+ writeStoredValue(
+ currentProviderConfig.keyStorage,
+ rememberKey ? apiKey : "",
+ );
+
+ const nextProviderConfig = getAiProviderConfig(nextProvider);
+ const nextProviderId = nextProviderConfig.id;
+ const nextKey = readProviderKey(nextProviderId);
+
+ writeStoredValue(AI_AGENT_PROVIDER_STORAGE, nextProviderId);
+ setProviderState(nextProviderId);
+ setApiKey(nextKey);
+ setRememberKey(Boolean(nextKey));
+ setModel(readProviderModel(nextProviderId));
+ setConnectionStatus("");
+ setError("");
+ },
+ [apiKey, provider, rememberKey],
+ );
+
+ // Load conversation list when the user signs in; clear when signed out.
+ const refreshConversations = useCallback(
+ async function () {
+ if (!isAuthenticated) {
+ setConversations([]);
+ setActiveConversationId(null);
+ return;
+ }
+
+ setIsLoadingConversations(true);
+ try {
+ const list = await fetchConversations();
+ setConversations(list);
+ } catch {
+ // Non-fatal: the chat still works without persisted history.
+ } finally {
+ setIsLoadingConversations(false);
+ }
+ },
+ [isAuthenticated],
+ );
+
+ useEffect(
+ function () {
+ refreshConversations();
+ },
+ [refreshConversations],
+ );
+
+ const startNewConversation = useCallback(function () {
+ appliedPlanSnapshotsRef.current = [];
+ setCanUndoAppliedPlan(false);
+ setActiveConversationId(null);
+ setMessages([]);
+ setPendingOperations([]);
+ setOperationResults([]);
+ setRejectedOperations([]);
+ setError("");
+ }, []);
+
+ const selectConversation = useCallback(
+ async function (conversationId) {
+ if (!conversationId) {
+ return;
+ }
+
+ setIsLoadingConversations(true);
+ try {
+ const convo = await loadConversation(conversationId);
+ if (!convo) {
+ return;
+ }
+
+ setActiveConversationId(conversationId);
+ setMessages(Array.isArray(convo.messages) ? convo.messages : []);
+ setPendingOperations(
+ Array.isArray(convo.pending_operations)
+ ? convo.pending_operations
+ : [],
+ );
+ setOperationResults(
+ Array.isArray(convo.operation_results)
+ ? convo.operation_results
+ : [],
+ );
+ setRejectedOperations(
+ Array.isArray(convo.rejected_operations)
+ ? convo.rejected_operations
+ : [],
+ );
+ appliedPlanSnapshotsRef.current = [];
+ setCanUndoAppliedPlan(false);
+ setError("");
+ } catch {
+ // If load fails, stay on the current conversation.
+ } finally {
+ setIsLoadingConversations(false);
+ }
+ },
+ [],
+ );
+
+ const deleteConversation = useCallback(
+ async function (conversationId) {
+ if (!conversationId) {
+ return;
+ }
+
+ try {
+ await deleteConversationApi(conversationId);
+ setConversations(function (current) {
+ return current.filter(function (item) {
+ return item.id !== conversationId;
+ });
+ });
+ if (conversationId === activeConversationId) {
+ startNewConversation();
+ }
+ } catch {
+ // Non-fatal: keep the conversation in the list if delete fails.
+ }
+ },
+ [activeConversationId, startNewConversation],
+ );
+
+ const sendMessage = async function (event) {
+ event?.preventDefault();
+
+ const userMessage = String(input || "").trim();
+ if (!userMessage || isSending) {
+ return;
+ }
+
+ setError("");
+ setInput("");
+ setIsSending(true);
+ setPendingOperations([]);
+ setOperationResults([]);
+ setRejectedOperations([]);
+
+ // Snapshot the conversation history before adding the new user message,
+ // so the API receives prior turns as context (works for loaded chats too).
+ const conversationHistory = messages.slice();
+
+ setMessages(function (current) {
+ return current.concat({ role: "user", content: userMessage });
+ });
+
+ try {
+ const allSamples = 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);
+ const availableSamples = searched.length >= 20
+ ? searched
+ : allSamples.slice(0, 200);
+ const currentDawState = store.getState().daw;
+ const projectSummary = buildAiProjectSummary(
+ currentDawState,
+ availableSamples,
+ );
+ const response = await requestAiAgentPlan({
+ provider,
+ apiKey,
+ model,
+ userMessage,
+ projectSummary,
+ conversationHistory,
+ });
+ const prepared = prepareAiOperations(response.operations);
+ const validatedOperations = validatePreparedAiOperations(
+ prepared.operations,
+ {
+ dawState: currentDawState,
+ availableSamples,
+ },
+ );
+
+ setPendingOperations(validatedOperations);
+ setRejectedOperations(prepared.rejected);
+
+ const assistantMessage = { role: "assistant", content: response.message };
+ const updatedMessages = messages.concat(
+ { role: "user", content: userMessage },
+ assistantMessage,
+ );
+ setMessages(updatedMessages);
+
+ // Persist to Supabase if the user is authenticated.
+ if (isAuthenticated) {
+ if (!activeConversationId) {
+ const created = await createConversation({
+ title: deriveTitle(userMessage),
+ messages: updatedMessages,
+ pendingOperations: validatedOperations,
+ operationResults: [],
+ rejectedOperations: prepared.rejected,
+ });
+ setActiveConversationId(created.id);
+ setConversations(function (current) {
+ return [
+ {
+ id: created.id,
+ title: created.title,
+ created_at: created.created_at,
+ updated_at: created.updated_at,
+ },
+ ].concat(current);
+ });
+ } else {
+ await updateConversation(activeConversationId, {
+ messages: updatedMessages,
+ pendingOperations: validatedOperations,
+ operationResults: [],
+ rejectedOperations: prepared.rejected,
+ });
+ setConversations(function (current) {
+ return current.map(function (item) {
+ if (item.id !== activeConversationId) {
+ return item;
+ }
+ return Object.assign({}, item, {
+ updated_at: new Date().toISOString(),
+ });
+ });
+ });
+ }
+ }
+ } catch (sendError) {
+ const message = String(sendError?.message || sendError);
+ setError(message);
+ setMessages(function (current) {
+ return current.concat({
+ role: "assistant",
+ content: "I could not prepare a plan: " + message,
+ });
+ });
+ } finally {
+ setIsSending(false);
+ }
+ };
+
+ const applyPendingOperations = function () {
+ if (pendingOperations.length === 0 || isApplying) {
+ return;
+ }
+
+ setIsApplying(true);
+ appliedPlanSnapshotsRef.current.push(cloneSerializable(store.getState().daw));
+ setCanUndoAppliedPlan(true);
+ const result = applyAiOperations(pendingOperations, {
+ dispatch,
+ getState: store.getState,
+ });
+ setOperationResults(result.results);
+ setPendingOperations([]);
+ setRejectedOperations([]);
+
+ const systemMessage = {
+ role: "system",
+ content:
+ "Applied " +
+ result.applied.length +
+ " operation" +
+ (result.applied.length === 1 ? "" : "s") +
+ (result.skipped.length > 0
+ ? ". Skipped " + result.skipped.length + "."
+ : "."),
+ };
+ const updatedMessages = messages.concat(systemMessage);
+ setMessages(updatedMessages);
+
+ // Persist the applied state to Supabase.
+ if (isAuthenticated && activeConversationId) {
+ updateConversation(activeConversationId, {
+ messages: updatedMessages,
+ pendingOperations: [],
+ operationResults: result.results,
+ rejectedOperations: [],
+ })
+ .then(function (updated) {
+ setConversations(function (current) {
+ return current.map(function (item) {
+ if (item.id !== activeConversationId) {
+ return item;
+ }
+ return Object.assign({}, item, {
+ updated_at: updated.updated_at,
+ });
+ });
+ });
+ })
+ .catch(function () {
+ // Non-fatal: the operations are already applied in Redux.
+ });
+ }
+
+ setIsApplying(false);
+ };
+
+ const testConnection = async function () {
+ if (isTestingConnection) {
+ return;
+ }
+
+ setError("");
+ setConnectionStatus("");
+ setIsTestingConnection(true);
+
+ try {
+ const result = await testAiConnection({ provider, apiKey, model });
+ const providerLabel = getAiProviderConfig(provider).label;
+ setConnectionStatus("Connected to " + providerLabel + " " + result.model + ".");
+ } catch (testError) {
+ const message = String(testError?.message || testError);
+ setError(message);
+ setConnectionStatus("");
+ } finally {
+ setIsTestingConnection(false);
+ }
+ };
+
+ const undoLastAppliedChange = function () {
+ const snapshot = appliedPlanSnapshotsRef.current.pop();
+ if (!snapshot) {
+ setCanUndoAppliedPlan(false);
+ return;
+ }
+
+ dispatch(loadProjectFromFile(snapshot));
+ setCanUndoAppliedPlan(appliedPlanSnapshotsRef.current.length > 0);
+ };
+
+ return {
+ provider,
+ setProvider,
+ providerOptions: AI_AGENT_PROVIDERS.map(function (item) {
+ return { value: item.id, label: item.label };
+ }),
+ providerLabel: getAiProviderConfig(provider).label,
+ apiKeyPlaceholder: getAiProviderConfig(provider).keyPlaceholder,
+ apiKey,
+ setApiKey,
+ rememberKey,
+ setRememberKey,
+ model,
+ setModel,
+ modelOptions: getAiProviderConfig(provider).models,
+ input,
+ setInput,
+ messages,
+ pendingOperations,
+ operationResults,
+ rejectedOperations,
+ error,
+ connectionStatus,
+ isSending,
+ isApplying,
+ isTestingConnection,
+ sendMessage,
+ applyPendingOperations,
+ testConnection,
+ undoLastAppliedChange,
+ canUndoAppliedPlan,
+ isAuthenticated,
+ conversations,
+ activeConversationId,
+ isLoadingConversations,
+ selectConversation,
+ startNewConversation,
+ deleteConversation,
+ };
+}
diff --git a/src/components/top-toolbar/WindowToggleButtons.jsx b/src/components/top-toolbar/WindowToggleButtons.jsx
index 3e9ddc0..a2b1840 100644
--- a/src/components/top-toolbar/WindowToggleButtons.jsx
+++ b/src/components/top-toolbar/WindowToggleButtons.jsx
@@ -1,4 +1,5 @@
import {
+ Bot,
Grid2X2,
ListMusic,
Music2,
@@ -14,6 +15,7 @@ const WINDOW_BUTTONS = [
{ id: "patternList", label: "Patterns", Icon: ListMusic },
{ id: "pianoRoll", label: "Piano Roll", Icon: Music2 },
{ id: "mixer", label: "Mixer", Icon: SlidersHorizontal },
+ { id: "aiAgent", label: "AI Agent", Icon: Bot },
];
export function WindowToggleButtons() {
diff --git a/src/lib/aiConversationsApi.js b/src/lib/aiConversationsApi.js
new file mode 100644
index 0000000..2d52641
--- /dev/null
+++ b/src/lib/aiConversationsApi.js
@@ -0,0 +1,175 @@
+/**
+ * @fileoverview aiConversationsApi — Supabase CRUD for AI Agent chat history.
+ * Mirrors the projectApi.js pattern: all reads/writes are scoped to the
+ * authenticated user via RLS policies on the ai_conversations table.
+ */
+
+import { assertSupabaseConfigured, supabase } from "./supabase";
+
+async function getAuthenticatedUserId() {
+ assertSupabaseConfigured();
+
+ const {
+ data: { user },
+ error,
+ } = await supabase.auth.getUser();
+
+ if (error) {
+ throw new Error(error.message);
+ }
+
+ if (!user) {
+ throw new Error("You must be signed in to use AI chat history.");
+ }
+
+ return user.id;
+}
+
+/**
+ * Fetch the conversation list (headers only — no messages payload) for the
+ * authenticated user, ordered by most-recently-updated first.
+ *
+ * @returns {Promise>}
+ */
+export async function fetchConversations() {
+ const userId = await getAuthenticatedUserId();
+
+ const { data, error } = await supabase
+ .from("ai_conversations")
+ .select("id, title, created_at, updated_at")
+ .eq("user_id", userId)
+ .order("updated_at", { ascending: false });
+
+ if (error) {
+ throw new Error(error.message);
+ }
+
+ return data || [];
+}
+
+/**
+ * Load a single conversation with full message + operation payloads.
+ *
+ * @param {string} conversationId
+ * @returns {Promise}
+ */
+export async function loadConversation(conversationId) {
+ const userId = await getAuthenticatedUserId();
+
+ const { data, error } = await supabase
+ .from("ai_conversations")
+ .select("*")
+ .eq("id", conversationId)
+ .eq("user_id", userId)
+ .single();
+
+ if (error) {
+ throw new Error(error.message);
+ }
+
+ return data || null;
+}
+
+/**
+ * Insert a new conversation row for the authenticated user.
+ *
+ * @param {object} params
+ * @param {string} params.title
+ * @param {Array} params.messages
+ * @param {Array} [params.pendingOperations]
+ * @param {Array} [params.operationResults]
+ * @param {Array} [params.rejectedOperations]
+ * @returns {Promise} The created row (including generated id).
+ */
+export async function createConversation({
+ title,
+ messages,
+ pendingOperations = [],
+ operationResults = [],
+ rejectedOperations = [],
+}) {
+ const userId = await getAuthenticatedUserId();
+
+ const { data, error } = await supabase
+ .from("ai_conversations")
+ .insert({
+ user_id: userId,
+ title: String(title || "New chat").slice(0, 200),
+ messages,
+ pending_operations: pendingOperations,
+ operation_results: operationResults,
+ rejected_operations: rejectedOperations,
+ })
+ .select("*")
+ .single();
+
+ if (error) {
+ throw new Error(error.message);
+ }
+
+ return data;
+}
+
+/**
+ * Patch an existing conversation. Only the provided fields are updated;
+ * updated_at is bumped automatically by the database trigger or here.
+ *
+ * @param {string} conversationId
+ * @param {object} patch — any of: title, messages, pendingOperations,
+ * operationResults, rejectedOperations
+ * @returns {Promise} The updated row.
+ */
+export async function updateConversation(conversationId, patch) {
+ const userId = await getAuthenticatedUserId();
+
+ const dbPatch = {};
+ if (patch.title !== undefined) {
+ dbPatch.title = String(patch.title).slice(0, 200);
+ }
+ if (patch.messages !== undefined) {
+ dbPatch.messages = patch.messages;
+ }
+ if (patch.pendingOperations !== undefined) {
+ dbPatch.pending_operations = patch.pendingOperations;
+ }
+ if (patch.operationResults !== undefined) {
+ dbPatch.operation_results = patch.operationResults;
+ }
+ if (patch.rejectedOperations !== undefined) {
+ dbPatch.rejected_operations = patch.rejectedOperations;
+ }
+ dbPatch.updated_at = new Date().toISOString();
+
+ const { data, error } = await supabase
+ .from("ai_conversations")
+ .update(dbPatch)
+ .eq("id", conversationId)
+ .eq("user_id", userId)
+ .select("*")
+ .single();
+
+ if (error) {
+ throw new Error(error.message);
+ }
+
+ return data;
+}
+
+/**
+ * Delete a conversation owned by the authenticated user.
+ *
+ * @param {string} conversationId
+ */
+export async function deleteConversation(conversationId) {
+ const userId = await getAuthenticatedUserId();
+
+ const { error } = await supabase
+ .from("ai_conversations")
+ .delete()
+ .eq("id", conversationId)
+ .eq("user_id", userId);
+
+ if (error) {
+ throw new Error(error.message);
+ }
+}
diff --git a/src/lib/aiConversationsApi.test.js b/src/lib/aiConversationsApi.test.js
new file mode 100644
index 0000000..ff1d286
--- /dev/null
+++ b/src/lib/aiConversationsApi.test.js
@@ -0,0 +1,284 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("./supabase", () => {
+ const mockSupabase = {
+ auth: { getUser: vi.fn() },
+ from: vi.fn(),
+ };
+ return {
+ SUPABASE_UNCONFIGURED_MESSAGE:
+ "Cloud login is unavailable because Supabase is not configured.",
+ assertSupabaseConfigured: vi.fn(),
+ isSupabaseConfigured: true,
+ supabase: mockSupabase,
+ };
+});
+
+import { assertSupabaseConfigured, supabase } from "./supabase";
+import {
+ fetchConversations,
+ loadConversation,
+ createConversation,
+ updateConversation,
+ deleteConversation,
+} from "./aiConversationsApi";
+
+describe("aiConversationsApi", () => {
+ const userId = "user-123";
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ assertSupabaseConfigured.mockImplementation(function () {});
+ supabase.auth.getUser.mockResolvedValue({
+ data: { user: { id: userId } },
+ error: null,
+ });
+ });
+
+ it("throws when not authenticated", async () => {
+ supabase.auth.getUser.mockResolvedValue({
+ data: { user: null },
+ error: null,
+ });
+ await expect(fetchConversations()).rejects.toThrow("signed in");
+ });
+
+ describe("fetchConversations", () => {
+ it("returns conversation headers ordered by updated_at desc", async () => {
+ const orderMock = vi.fn().mockResolvedValue({
+ data: [
+ { id: "c1", title: "Chat 1", updated_at: "2025-01-02" },
+ { id: "c2", title: "Chat 2", updated_at: "2025-01-01" },
+ ],
+ error: null,
+ });
+ const eqMock = vi.fn().mockReturnValue({ order: orderMock });
+ const selectMock = vi.fn().mockReturnValue({ eq: eqMock });
+ supabase.from.mockReturnValue({ select: selectMock });
+
+ const result = await fetchConversations();
+
+ expect(result).toHaveLength(2);
+ expect(result[0].id).toBe("c1");
+ expect(selectMock).toHaveBeenCalledWith(
+ "id, title, created_at, updated_at",
+ );
+ expect(eqMock).toHaveBeenCalledWith("user_id", userId);
+ expect(orderMock).toHaveBeenCalledWith("updated_at", {
+ ascending: false,
+ });
+ });
+
+ it("returns empty array when no conversations", async () => {
+ const orderMock = vi.fn().mockResolvedValue({ data: null, error: null });
+ const eqMock = vi.fn().mockReturnValue({ order: orderMock });
+ const selectMock = vi.fn().mockReturnValue({ eq: eqMock });
+ supabase.from.mockReturnValue({ select: selectMock });
+
+ const result = await fetchConversations();
+ expect(result).toEqual([]);
+ });
+
+ it("throws on database error", async () => {
+ const orderMock = vi.fn().mockResolvedValue({
+ data: null,
+ error: { message: "DB error" },
+ });
+ const eqMock = vi.fn().mockReturnValue({ order: orderMock });
+ const selectMock = vi.fn().mockReturnValue({ eq: eqMock });
+ supabase.from.mockReturnValue({ select: selectMock });
+
+ await expect(fetchConversations()).rejects.toThrow("DB error");
+ });
+ });
+
+ describe("loadConversation", () => {
+ it("returns full conversation with messages", async () => {
+ const singleMock = vi.fn().mockResolvedValue({
+ data: {
+ id: "c1",
+ title: "Chat 1",
+ messages: [{ role: "user", content: "hi" }],
+ pending_operations: [],
+ operation_results: [],
+ rejected_operations: [],
+ },
+ error: null,
+ });
+ const secondEqMock = vi.fn().mockReturnValue({ single: singleMock });
+ const firstEqMock = vi.fn().mockReturnValue({ eq: secondEqMock });
+ const selectMock = vi.fn().mockReturnValue({ eq: firstEqMock });
+ supabase.from.mockReturnValue({ select: selectMock });
+
+ const result = await loadConversation("c1");
+
+ expect(result.id).toBe("c1");
+ expect(result.messages).toHaveLength(1);
+ expect(firstEqMock).toHaveBeenCalledWith("id", "c1");
+ expect(secondEqMock).toHaveBeenCalledWith("user_id", userId);
+ });
+
+ it("returns null when not found", async () => {
+ const singleMock = vi.fn().mockResolvedValue({ data: null, error: null });
+ const secondEqMock = vi.fn().mockReturnValue({ single: singleMock });
+ const firstEqMock = vi.fn().mockReturnValue({ eq: secondEqMock });
+ const selectMock = vi.fn().mockReturnValue({ eq: firstEqMock });
+ supabase.from.mockReturnValue({ select: selectMock });
+
+ const result = await loadConversation("missing");
+ expect(result).toBeNull();
+ });
+ });
+
+ describe("createConversation", () => {
+ it("inserts a new conversation and returns the row", async () => {
+ const singleMock = vi.fn().mockResolvedValue({
+ data: { id: "new-id", title: "New chat", messages: [] },
+ error: null,
+ });
+ const selectMock = vi.fn().mockReturnValue({ single: singleMock });
+ const insertMock = vi.fn().mockReturnValue({ select: selectMock });
+ supabase.from.mockReturnValue({ insert: insertMock });
+
+ const result = await createConversation({
+ title: "My chat",
+ messages: [{ role: "user", content: "hello" }],
+ pendingOperations: [{ type: "set_bpm", payload: { bpm: 120 } }],
+ });
+
+ expect(result.id).toBe("new-id");
+ expect(insertMock).toHaveBeenCalledOnce();
+ const inserted = insertMock.mock.calls[0][0];
+ expect(inserted.user_id).toBe(userId);
+ expect(inserted.title).toBe("My chat");
+ expect(inserted.messages).toEqual([
+ { role: "user", content: "hello" },
+ ]);
+ expect(inserted.pending_operations).toEqual([
+ { type: "set_bpm", payload: { bpm: 120 } },
+ ]);
+ });
+
+ it("truncates very long titles", async () => {
+ const singleMock = vi.fn().mockResolvedValue({
+ data: { id: "new-id" },
+ error: null,
+ });
+ const selectMock = vi.fn().mockReturnValue({ single: singleMock });
+ const insertMock = vi.fn().mockReturnValue({ select: selectMock });
+ supabase.from.mockReturnValue({ insert: insertMock });
+
+ const longTitle = "A".repeat(300);
+ await createConversation({ title: longTitle, messages: [] });
+
+ expect(insertMock.mock.calls[0][0].title.length).toBe(200);
+ });
+
+ it("throws on insert error", async () => {
+ const singleMock = vi.fn().mockResolvedValue({
+ data: null,
+ error: { message: "Insert failed" },
+ });
+ const selectMock = vi.fn().mockReturnValue({ single: singleMock });
+ const insertMock = vi.fn().mockReturnValue({ select: selectMock });
+ supabase.from.mockReturnValue({ insert: insertMock });
+
+ await expect(
+ createConversation({ title: "Test", messages: [] }),
+ ).rejects.toThrow("Insert failed");
+ });
+ });
+
+ describe("updateConversation", () => {
+ it("updates only provided fields and returns the row", async () => {
+ const singleMock = vi.fn().mockResolvedValue({
+ data: { id: "c1", title: "Updated" },
+ error: null,
+ });
+ const selectMock = vi.fn().mockReturnValue({ single: singleMock });
+ const secondEqMock = vi.fn().mockReturnValue({ select: selectMock });
+ const firstEqMock = vi.fn().mockReturnValue({ eq: secondEqMock });
+ const updateMock = vi.fn().mockReturnValue({ eq: firstEqMock });
+ supabase.from.mockReturnValue({ update: updateMock });
+
+ const result = await updateConversation("c1", {
+ title: "Updated",
+ messages: [{ role: "user", content: "test" }],
+ });
+
+ expect(result.id).toBe("c1");
+ const patch = updateMock.mock.calls[0][0];
+ expect(patch.title).toBe("Updated");
+ expect(patch.messages).toEqual([
+ { role: "user", content: "test" },
+ ]);
+ expect(patch).not.toHaveProperty("pending_operations");
+ expect(patch.updated_at).toBeDefined();
+ });
+
+ it("converts camelCase operation fields to snake_case", async () => {
+ const singleMock = vi.fn().mockResolvedValue({
+ data: { id: "c1" },
+ error: null,
+ });
+ const selectMock = vi.fn().mockReturnValue({ single: singleMock });
+ const secondEqMock = vi.fn().mockReturnValue({ select: selectMock });
+ const firstEqMock = vi.fn().mockReturnValue({ eq: secondEqMock });
+ const updateMock = vi.fn().mockReturnValue({ eq: firstEqMock });
+ supabase.from.mockReturnValue({ update: updateMock });
+
+ await updateConversation("c1", {
+ pendingOperations: [{ type: "set_bpm" }],
+ operationResults: [],
+ rejectedOperations: [],
+ });
+
+ const patch = updateMock.mock.calls[0][0];
+ expect(patch.pending_operations).toEqual([{ type: "set_bpm" }]);
+ expect(patch.operation_results).toEqual([]);
+ expect(patch.rejected_operations).toEqual([]);
+ });
+
+ it("throws on update error", async () => {
+ const singleMock = vi.fn().mockResolvedValue({
+ data: null,
+ error: { message: "Update failed" },
+ });
+ const selectMock = vi.fn().mockReturnValue({ single: singleMock });
+ const secondEqMock = vi.fn().mockReturnValue({ select: selectMock });
+ const firstEqMock = vi.fn().mockReturnValue({ eq: secondEqMock });
+ const updateMock = vi.fn().mockReturnValue({ eq: firstEqMock });
+ supabase.from.mockReturnValue({ update: updateMock });
+
+ await expect(
+ updateConversation("c1", { title: "x" }),
+ ).rejects.toThrow("Update failed");
+ });
+ });
+
+ describe("deleteConversation", () => {
+ it("deletes the conversation scoped to the user", async () => {
+ const secondEqMock = vi.fn().mockResolvedValue({ error: null });
+ const firstEqMock = vi.fn().mockReturnValue({ eq: secondEqMock });
+ const deleteMock = vi.fn().mockReturnValue({ eq: firstEqMock });
+ supabase.from.mockReturnValue({ delete: deleteMock });
+
+ await deleteConversation("c1");
+
+ expect(deleteMock).toHaveBeenCalledOnce();
+ expect(firstEqMock).toHaveBeenCalledWith("id", "c1");
+ expect(secondEqMock).toHaveBeenCalledWith("user_id", userId);
+ });
+
+ it("throws on delete error", async () => {
+ const secondEqMock = vi.fn().mockResolvedValue({
+ error: { message: "Delete failed" },
+ });
+ const firstEqMock = vi.fn().mockReturnValue({ eq: secondEqMock });
+ const deleteMock = vi.fn().mockReturnValue({ eq: firstEqMock });
+ supabase.from.mockReturnValue({ delete: deleteMock });
+
+ await expect(deleteConversation("c1")).rejects.toThrow("Delete failed");
+ });
+ });
+});
diff --git a/src/store.js b/src/store.js
index 4f295f3..3117098 100644
--- a/src/store.js
+++ b/src/store.js
@@ -14,8 +14,11 @@ import { userReducer } from "./store/userSlice";
// ------------------------------------------------------------------
const undoLastChange = createAction("daw/undoLastChange");
+const beginAiOperationBatch = createAction("daw/beginAiOperationBatch");
+const endAiOperationBatch = createAction("daw/endAiOperationBatch");
const undoPastStates = [];
const undoFutureStates = [];
+let undoBatchBaseState = null;
const LOAD_PROJECT_FROM_FILE_ACTION = "daw/loadProjectFromFile";
const RESET_TO_DEFAULT_PROJECT_ACTION = "daw/resetToDefaultProject";
@@ -31,6 +34,8 @@ const nonUndoableActionTypes = new Set([
"daw/setTheme",
"daw/bringWindowToFront",
"daw/setPianoRollScale",
+ beginAiOperationBatch.type,
+ endAiOperationBatch.type,
LOAD_PROJECT_FROM_FILE_ACTION,
RESET_TO_DEFAULT_PROJECT_ACTION,
]);
@@ -114,11 +119,31 @@ const dawReducerWithUndo = function (state = initialState, action) {
return previousState;
}
+ if (action.type === beginAiOperationBatch.type) {
+ if (!undoBatchBaseState) {
+ undoBatchBaseState = state;
+ }
+ return state;
+ }
+
+ if (action.type === endAiOperationBatch.type) {
+ if (undoBatchBaseState && undoBatchBaseState !== state) {
+ undoPastStates.push(undoBatchBaseState);
+ if (undoPastStates.length > 140) {
+ undoPastStates.shift();
+ }
+ undoFutureStates.length = 0;
+ }
+ undoBatchBaseState = null;
+ return state;
+ }
+
if (action.type === LOAD_PROJECT_FROM_FILE_ACTION) {
const loadedState = dawSlice.reducer(state, action);
if (loadedState !== state) {
undoPastStates.length = 0;
undoFutureStates.length = 0;
+ undoBatchBaseState = null;
}
return loadedState;
}
@@ -128,6 +153,7 @@ const dawReducerWithUndo = function (state = initialState, action) {
if (resetState !== state) {
undoPastStates.length = 0;
undoFutureStates.length = 0;
+ undoBatchBaseState = null;
}
return resetState;
}
@@ -137,7 +163,7 @@ const dawReducerWithUndo = function (state = initialState, action) {
return state;
}
- if (shouldTrackUndoForAction(action)) {
+ if (shouldTrackUndoForAction(action) && !undoBatchBaseState) {
undoPastStates.push(state);
if (undoPastStates.length > 140) {
undoPastStates.shift();
@@ -238,4 +264,4 @@ export const {
setInsertMeter,
} = dawSlice.actions;
-export { undoLastChange };
+export { undoLastChange, beginAiOperationBatch, endAiOperationBatch };
diff --git a/src/store/initialState.js b/src/store/initialState.js
index 50fa403..a65e785 100644
--- a/src/store/initialState.js
+++ b/src/store/initialState.js
@@ -130,6 +130,16 @@ export const initialState = {
isMaximized: false,
restoreRect: null,
},
+ aiAgent: {
+ open: false,
+ z: 11,
+ x: 620,
+ y: 100,
+ width: 760,
+ height: 580,
+ isMaximized: false,
+ restoreRect: null,
+ },
},
},
project: {
diff --git a/src/store/undoBatch.test.js b/src/store/undoBatch.test.js
new file mode 100644
index 0000000..313bb64
--- /dev/null
+++ b/src/store/undoBatch.test.js
@@ -0,0 +1,31 @@
+import { describe, it, expect, afterEach } from "vitest";
+import {
+ addChannel,
+ beginAiOperationBatch,
+ endAiOperationBatch,
+ resetToDefaultProject,
+ store,
+ undoLastChange,
+} from "../store";
+
+describe("AI operation undo batching", function () {
+ afterEach(function () {
+ store.dispatch(resetToDefaultProject());
+ });
+
+ it("undoes a batched AI plan as one history entry", function () {
+ store.dispatch(resetToDefaultProject());
+ const initialCount = store.getState().daw.project.channels.length;
+
+ store.dispatch(beginAiOperationBatch());
+ store.dispatch(addChannel());
+ store.dispatch(addChannel());
+ store.dispatch(endAiOperationBatch());
+
+ expect(store.getState().daw.project.channels.length).toBe(initialCount + 2);
+
+ store.dispatch(undoLastChange());
+
+ expect(store.getState().daw.project.channels.length).toBe(initialCount);
+ });
+});
diff --git a/src/styles/ai-agent.css b/src/styles/ai-agent.css
new file mode 100644
index 0000000..a59c193
--- /dev/null
+++ b/src/styles/ai-agent.css
@@ -0,0 +1,593 @@
+.ai-agent-shell {
+ height: 100%;
+ box-sizing: border-box;
+ display: grid;
+ grid-template-rows: auto auto 1fr;
+ gap: 14px;
+ padding: 16px;
+ color: var(--app-text);
+ background: var(--ai-agent-shell-glow), var(--ai-agent-shell-bg);
+}
+
+.ai-agent-hero {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 12px;
+ align-items: start;
+}
+
+.ai-agent-avatar {
+ width: 42px;
+ height: 42px;
+ border: 1px solid var(--ai-agent-avatar-border);
+ border-radius: var(--app-radius-lg);
+ display: grid;
+ place-items: center;
+ color: var(--app-accent);
+ background: var(--ai-agent-avatar-bg);
+ box-shadow: var(--ai-agent-avatar-shadow);
+}
+
+.ai-agent-kicker {
+ margin: 0 0 3px;
+ color: var(--app-accent);
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.ai-agent-hero h2 {
+ margin: 0;
+ font-size: 20px;
+ line-height: 1.1;
+}
+
+.ai-agent-hero p:not(.ai-agent-kicker) {
+ max-width: 560px;
+ margin: 6px 0 0;
+ color: var(--app-text-muted);
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+.ai-agent-layout {
+ min-height: 0;
+ display: grid;
+ grid-template-columns: 140px minmax(0, 1fr) 190px;
+ gap: 12px;
+}
+
+.ai-agent-settings-card {
+ display: grid;
+ grid-template-columns: 120px minmax(180px, 1fr) 170px auto auto;
+ gap: 10px;
+ align-items: end;
+ border: 1px solid var(--app-border);
+ border-radius: var(--app-radius-lg);
+ padding: 10px;
+ background: var(--ai-agent-panel-bg);
+}
+
+.ai-agent-key-actions {
+ display: flex;
+ gap: 6px;
+ align-items: center;
+ height: 34px;
+}
+
+.ai-agent-key-actions button {
+ height: 30px;
+ border: 1px solid var(--app-border);
+ border-radius: var(--app-radius-md);
+ padding: 0 9px;
+ color: var(--app-text);
+ background: var(--ai-agent-secondary-bg);
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.ai-agent-key-actions button:first-child {
+ border-color: var(--ai-agent-primary-border);
+ color: var(--app-accent);
+}
+
+.ai-agent-key-actions button:disabled {
+ cursor: not-allowed;
+ opacity: 0.5;
+}
+
+.ai-agent-connection-status {
+ grid-column: 1 / -1;
+ color: var(--app-accent);
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.ai-agent-settings-card label {
+ display: grid;
+ gap: 5px;
+ color: var(--app-text-muted);
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.ai-agent-settings-card input[type="password"],
+.ai-agent-model-select {
+ height: 34px;
+ box-sizing: border-box;
+ border: 1px solid var(--app-border);
+ border-radius: var(--app-radius-md);
+ color: var(--app-text);
+ background: var(--ai-agent-control-bg);
+ outline: none;
+}
+
+.ai-agent-settings-card input[type="password"] {
+ padding: 0 10px;
+}
+
+.ai-agent-model-select .rack-modern-select-trigger {
+ height: 100%;
+ padding: 0 10px;
+}
+
+.ai-agent-model-select .rack-modern-select-dropdown {
+ box-sizing: border-box;
+ left: 0;
+ right: 0;
+ width: 100%;
+ min-width: 100%;
+ max-width: none;
+ z-index: 80;
+}
+
+.ai-agent-model-select .rack-modern-select-option {
+ min-width: 0;
+}
+
+.ai-agent-settings-card input:focus {
+ border-color: var(--ai-agent-focus-border);
+ box-shadow: var(--ai-agent-focus-ring);
+}
+
+.ai-agent-remember-key {
+ grid-template-columns: auto 1fr;
+ align-items: center;
+ height: 34px;
+ user-select: none;
+}
+
+.ai-agent-remember-key input[type="checkbox"] {
+ appearance: none;
+ -webkit-appearance: none;
+ width: 16px;
+ height: 16px;
+ border-radius: var(--app-radius-sm);
+ border: 1px solid var(--auth-checkbox-border);
+ background: var(--auth-checkbox-bg);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ transition: border-color 120ms ease, box-shadow 120ms ease;
+}
+
+.ai-agent-remember-key input[type="checkbox"]:hover {
+ border-color: var(--app-border-strong);
+}
+
+.ai-agent-remember-key input[type="checkbox"]:checked {
+ border-color: var(--app-accent-strong);
+ background: var(--auth-checkbox-checked-bg);
+}
+
+.ai-agent-remember-key input[type="checkbox"]:checked::after {
+ content: "";
+ width: 8px;
+ height: 5px;
+ border-left: 2px solid var(--auth-submit-text);
+ border-bottom: 2px solid var(--auth-submit-text);
+ transform: rotate(-55deg) translateY(-1px);
+ display: block;
+}
+
+.ai-agent-chat-card,
+.ai-agent-plan-card {
+ min-height: 0;
+ border: 1px solid var(--app-border);
+ border-radius: var(--app-radius-lg);
+ background: var(--ai-agent-card-bg);
+ box-shadow: var(--ai-agent-inset-shadow);
+}
+
+.ai-agent-chat-card {
+ display: grid;
+ grid-template-rows: 1fr auto auto;
+ overflow: hidden;
+}
+
+.ai-agent-messages {
+ min-height: 0;
+ overflow: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 9px;
+ padding: 12px;
+}
+
+.ai-agent-message {
+ max-width: 86%;
+ border: 1px solid var(--app-border);
+ border-radius: var(--app-radius-lg);
+ padding: 9px 10px;
+ background: var(--ai-agent-message-bg);
+}
+
+.ai-agent-message.is-user {
+ align-self: flex-end;
+ border-color: var(--ai-agent-user-border);
+ background: var(--ai-agent-user-bg);
+}
+
+.ai-agent-message.is-system {
+ max-width: 100%;
+ align-self: center;
+ border-color: var(--ai-agent-system-border);
+ color: var(--app-text-muted);
+ background: var(--ai-agent-system-bg);
+}
+
+.ai-agent-message span {
+ display: block;
+ margin-bottom: 4px;
+ color: var(--app-text-muted);
+ font-size: 10px;
+ font-weight: 800;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.ai-agent-message p {
+ margin: 0;
+ white-space: pre-wrap;
+ font-size: 12px;
+ line-height: 1.5;
+ -webkit-user-select: text;
+ user-select: text;
+}
+
+.ai-agent-message span {
+ -webkit-user-select: text;
+ user-select: text;
+}
+
+.ai-agent-empty-state {
+ display: grid;
+ place-items: center;
+ align-content: center;
+ gap: 8px;
+ padding: 28px;
+ color: var(--app-text-muted);
+ text-align: center;
+}
+
+.ai-agent-empty-state svg {
+ color: var(--app-accent);
+}
+
+.ai-agent-empty-state strong {
+ color: var(--app-text);
+ font-size: 14px;
+}
+
+.ai-agent-empty-state span,
+.ai-agent-plan-card p {
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+.ai-agent-composer {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 8px;
+ padding: 10px;
+ border-top: 1px solid var(--app-border);
+}
+
+.ai-agent-composer textarea {
+ min-height: 48px;
+ max-height: 96px;
+ resize: vertical;
+ border: 1px solid var(--app-border);
+ border-radius: var(--app-radius-md);
+ padding: 10px;
+ color: var(--app-text);
+ background: var(--ai-agent-control-bg);
+ font: inherit;
+ font-size: 12px;
+ outline: none;
+}
+
+.ai-agent-composer textarea:focus {
+ border-color: var(--ai-agent-focus-border);
+ box-shadow: var(--ai-agent-focus-ring);
+}
+
+.ai-agent-composer button {
+ align-self: end;
+ height: 38px;
+ border: 1px solid var(--ai-agent-primary-border);
+ border-radius: var(--app-radius-md);
+ padding: 0 14px;
+ color: var(--ai-agent-primary-text);
+ background: var(--app-accent);
+ font-weight: 700;
+}
+
+.ai-agent-composer textarea:disabled,
+.ai-agent-composer button:disabled {
+ cursor: not-allowed;
+ opacity: 0.56;
+}
+
+.ai-agent-error {
+ border-top: 1px solid var(--ai-agent-error-border);
+ padding: 8px 10px;
+ color: var(--ai-agent-error-text);
+ background: var(--ai-agent-error-bg);
+ font-size: 12px;
+}
+
+.ai-agent-plan-card {
+ padding: 12px;
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr) auto auto;
+ gap: 10px;
+}
+
+.ai-agent-plan-header {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ color: var(--app-accent);
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.ai-agent-plan-card p {
+ margin: 10px 0 0;
+ color: var(--app-text-muted);
+}
+
+.ai-agent-operation-list {
+ min-height: 0;
+ overflow: auto;
+ margin: 0;
+ padding-left: 18px;
+ color: var(--app-text);
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.ai-agent-operation-list li + li {
+ margin-top: 7px;
+}
+
+.ai-agent-operation-list li span {
+ display: block;
+}
+
+.ai-agent-operation-list li small {
+ display: block;
+ margin-top: 3px;
+ color: var(--ai-agent-warning-text);
+ font-size: 10px;
+ line-height: 1.35;
+}
+
+.ai-agent-operation-list li.has-warning,
+.ai-agent-operation-list li.is-skipped,
+.ai-agent-operation-list li.is-failed {
+ color: var(--ai-agent-warning-text);
+}
+
+.ai-agent-operation-list li.is-applied {
+ color: var(--app-accent);
+}
+
+.ai-agent-rejected-list {
+ display: grid;
+ gap: 4px;
+ border: 1px solid var(--ai-agent-warning-border);
+ border-radius: var(--app-radius-md);
+ padding: 8px;
+ color: var(--ai-agent-warning-text);
+ background: var(--ai-agent-warning-bg);
+ font-size: 11px;
+}
+
+.ai-agent-rejected-list span {
+ color: var(--app-text-muted);
+}
+
+.ai-agent-apply-btn {
+ width: 100%;
+ height: 36px;
+ border: 1px solid var(--ai-agent-primary-border);
+ border-radius: var(--app-radius-md);
+ color: var(--ai-agent-primary-text);
+ background: var(--app-accent);
+ font-weight: 800;
+}
+
+.ai-agent-plan-actions {
+ display: grid;
+ gap: 8px;
+}
+
+.ai-agent-undo-btn {
+ width: 100%;
+ height: 34px;
+ border: 1px solid var(--app-border);
+ border-radius: var(--app-radius-md);
+ color: var(--app-text);
+ background: var(--ai-agent-secondary-bg);
+ font-weight: 700;
+}
+
+.ai-agent-undo-btn:disabled {
+ cursor: not-allowed;
+ opacity: 0.48;
+}
+
+.ai-agent-apply-btn:disabled {
+ cursor: not-allowed;
+ opacity: 0.48;
+}
+
+@media (max-width: 900px) {
+ .ai-agent-settings-card,
+ .ai-agent-layout {
+ grid-template-columns: 1fr;
+ }
+
+ .ai-agent-conversation-list {
+ display: none;
+ }
+}
+
+/* ---- Conversation sidebar ---- */
+
+.ai-agent-conversation-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ min-height: 0;
+ overflow: hidden;
+ border: 1px solid var(--app-border);
+ border-radius: var(--app-radius-lg);
+ background: var(--ai-agent-panel-bg);
+ padding: 8px;
+}
+
+.ai-agent-conversation-new {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 5px;
+ width: 100%;
+ box-sizing: border-box;
+ padding: 7px 8px;
+ border: 1px solid var(--ai-agent-primary-border);
+ border-radius: var(--app-radius-md);
+ background: var(--ai-agent-conversation-new-bg);
+ color: var(--app-accent);
+ font-size: 11px;
+ font-weight: 700;
+ cursor: pointer;
+ transition: background 0.15s;
+}
+
+.ai-agent-conversation-new:hover {
+ background: var(--ai-agent-conversation-new-hover-bg);
+}
+
+.ai-agent-conversation-loading,
+.ai-agent-conversation-empty {
+ padding: 10px 6px;
+ color: var(--app-text-muted);
+ font-size: 11px;
+ line-height: 1.4;
+ text-align: center;
+}
+
+.ai-agent-conversation-items {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ overflow-y: auto;
+ min-height: 0;
+ flex: 1;
+}
+
+.ai-agent-conversation-item {
+ position: relative;
+ border-radius: var(--app-radius-md);
+ overflow: hidden;
+}
+
+.ai-agent-conversation-item.is-active {
+ background: var(--ai-agent-conversation-active-bg);
+}
+
+.ai-agent-conversation-select {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ width: 100%;
+ box-sizing: border-box;
+ padding: 7px 24px 7px 8px;
+ border: none;
+ border-radius: var(--app-radius-md);
+ background: transparent;
+ color: var(--app-text);
+ text-align: left;
+ cursor: pointer;
+ transition: background 0.15s;
+}
+
+.ai-agent-conversation-select:hover {
+ background: var(--ai-agent-secondary-bg);
+}
+
+.ai-agent-conversation-item.is-active .ai-agent-conversation-select:hover {
+ background: var(--ai-agent-conversation-active-hover-bg);
+}
+
+.ai-agent-conversation-title {
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 1.3;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.ai-agent-conversation-time {
+ font-size: 9px;
+ color: var(--app-text-muted);
+ letter-spacing: 0.04em;
+}
+
+.ai-agent-conversation-delete {
+ position: absolute;
+ top: 50%;
+ right: 4px;
+ transform: translateY(-50%);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 20px;
+ height: 20px;
+ border: none;
+ border-radius: var(--app-radius-sm);
+ background: transparent;
+ color: var(--app-text-muted);
+ cursor: pointer;
+ opacity: 0;
+ transition: opacity 0.15s, color 0.15s, background 0.15s;
+}
+
+.ai-agent-conversation-item:hover .ai-agent-conversation-delete {
+ opacity: 1;
+}
+
+.ai-agent-conversation-delete:hover {
+ color: var(--ai-agent-delete-hover-text);
+ background: var(--ai-agent-delete-hover-bg);
+}
diff --git a/src/styles/app-shell.css b/src/styles/app-shell.css
index deafa9b..74a9d2e 100644
--- a/src/styles/app-shell.css
+++ b/src/styles/app-shell.css
@@ -46,7 +46,11 @@
.pattern-list-body,
.mixer-left,
.sample-settings-panel,
-.load-project-window-table-body {
+.load-project-window-table-body,
+.ai-agent-messages,
+.ai-agent-operation-list,
+.ai-agent-conversation-items,
+.ai-agent-composer textarea {
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb-bg) var(--scrollbar-track-bg);
}
@@ -58,7 +62,11 @@
.pattern-list-body::-webkit-scrollbar,
.mixer-left::-webkit-scrollbar,
.sample-settings-panel::-webkit-scrollbar,
-.load-project-window-table-body::-webkit-scrollbar {
+.load-project-window-table-body::-webkit-scrollbar,
+.ai-agent-messages::-webkit-scrollbar,
+.ai-agent-operation-list::-webkit-scrollbar,
+.ai-agent-conversation-items::-webkit-scrollbar,
+.ai-agent-composer textarea::-webkit-scrollbar {
width: 10px;
height: 10px;
}
@@ -70,7 +78,11 @@
.pattern-list-body::-webkit-scrollbar-track,
.mixer-left::-webkit-scrollbar-track,
.sample-settings-panel::-webkit-scrollbar-track,
-.load-project-window-table-body::-webkit-scrollbar-track {
+.load-project-window-table-body::-webkit-scrollbar-track,
+.ai-agent-messages::-webkit-scrollbar-track,
+.ai-agent-operation-list::-webkit-scrollbar-track,
+.ai-agent-conversation-items::-webkit-scrollbar-track,
+.ai-agent-composer textarea::-webkit-scrollbar-track {
background: var(--scrollbar-track-bg);
border: 1px solid var(--scrollbar-track-border);
border-radius: 999px;
@@ -84,7 +96,11 @@
.pattern-list-body::-webkit-scrollbar-thumb,
.mixer-left::-webkit-scrollbar-thumb,
.sample-settings-panel::-webkit-scrollbar-thumb,
-.load-project-window-table-body::-webkit-scrollbar-thumb {
+.load-project-window-table-body::-webkit-scrollbar-thumb,
+.ai-agent-messages::-webkit-scrollbar-thumb,
+.ai-agent-operation-list::-webkit-scrollbar-thumb,
+.ai-agent-conversation-items::-webkit-scrollbar-thumb,
+.ai-agent-composer textarea::-webkit-scrollbar-thumb {
border-radius: 999px;
border: 2px solid var(--scrollbar-thumb-border);
background: var(--scrollbar-thumb-bg);
@@ -98,12 +114,20 @@
.pattern-list-body::-webkit-scrollbar-thumb:hover,
.mixer-left::-webkit-scrollbar-thumb:hover,
.sample-settings-panel::-webkit-scrollbar-thumb:hover,
-.load-project-window-table-body::-webkit-scrollbar-thumb:hover {
+.load-project-window-table-body::-webkit-scrollbar-thumb:hover,
+.ai-agent-messages::-webkit-scrollbar-thumb:hover,
+.ai-agent-operation-list::-webkit-scrollbar-thumb:hover,
+.ai-agent-conversation-items::-webkit-scrollbar-thumb:hover,
+.ai-agent-composer textarea::-webkit-scrollbar-thumb:hover {
filter: brightness(1.08);
}
.sample-settings-panel::-webkit-scrollbar-corner,
-.load-project-window-table-body::-webkit-scrollbar-corner {
+.load-project-window-table-body::-webkit-scrollbar-corner,
+.ai-agent-messages::-webkit-scrollbar-corner,
+.ai-agent-operation-list::-webkit-scrollbar-corner,
+.ai-agent-conversation-items::-webkit-scrollbar-corner,
+.ai-agent-composer textarea::-webkit-scrollbar-corner {
background: var(--scrollbar-track-bg);
}
diff --git a/src/styles/theme-main.css b/src/styles/theme-main.css
index b837654..f60a0eb 100644
--- a/src/styles/theme-main.css
+++ b/src/styles/theme-main.css
@@ -110,6 +110,41 @@
--window-shadow: 0 18px 35px rgba(0, 0, 0, 0.52), inset 0 1px 0 rgba(255, 255, 255, 0.08);
--window-control-btn-radius: var(--titlebar-btn-radius);
+ /* =============================================
+ AI AGENT
+ ============================================= */
+ --ai-agent-shell-glow: radial-gradient(circle at top left, rgba(61, 217, 118, 0.16), transparent 36%);
+ --ai-agent-shell-bg: linear-gradient(180deg, rgba(15, 23, 34, 0.98), rgba(8, 12, 18, 0.98));
+ --ai-agent-card-bg: rgba(7, 11, 18, 0.78);
+ --ai-agent-panel-bg: rgba(7, 11, 18, 0.62);
+ --ai-agent-control-bg: rgba(0, 0, 0, 0.28);
+ --ai-agent-secondary-bg: rgba(255, 255, 255, 0.05);
+ --ai-agent-inset-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
+ --ai-agent-avatar-border: rgba(61, 217, 118, 0.4);
+ --ai-agent-avatar-bg: rgba(61, 217, 118, 0.1);
+ --ai-agent-avatar-shadow: 0 0 18px rgba(61, 217, 118, 0.16);
+ --ai-agent-focus-border: rgba(61, 217, 118, 0.52);
+ --ai-agent-focus-ring: 0 0 0 2px rgba(61, 217, 118, 0.12);
+ --ai-agent-message-bg: rgba(255, 255, 255, 0.04);
+ --ai-agent-user-border: rgba(61, 217, 118, 0.34);
+ --ai-agent-user-bg: rgba(61, 217, 118, 0.1);
+ --ai-agent-system-border: rgba(146, 215, 255, 0.28);
+ --ai-agent-system-bg: rgba(146, 215, 255, 0.08);
+ --ai-agent-warning-text: #ffd89a;
+ --ai-agent-warning-border: rgba(255, 183, 77, 0.22);
+ --ai-agent-warning-bg: rgba(255, 183, 77, 0.08);
+ --ai-agent-error-text: #ffb4b4;
+ --ai-agent-error-border: rgba(255, 87, 87, 0.24);
+ --ai-agent-error-bg: rgba(255, 87, 87, 0.08);
+ --ai-agent-primary-border: rgba(61, 217, 118, 0.38);
+ --ai-agent-primary-text: var(--app-accent-text);
+ --ai-agent-conversation-active-bg: rgba(61, 217, 118, 0.12);
+ --ai-agent-conversation-active-hover-bg: rgba(61, 217, 118, 0.16);
+ --ai-agent-conversation-new-bg: rgba(61, 217, 118, 0.1);
+ --ai-agent-conversation-new-hover-bg: rgba(61, 217, 118, 0.18);
+ --ai-agent-delete-hover-text: #ff6b6b;
+ --ai-agent-delete-hover-bg: rgba(255, 107, 107, 0.12);
+
/* =============================================
BROWSER
============================================= */
diff --git a/src/styles/themes/theme-aero.css b/src/styles/themes/theme-aero.css
index fd57ecb..c359036 100644
--- a/src/styles/themes/theme-aero.css
+++ b/src/styles/themes/theme-aero.css
@@ -115,7 +115,42 @@
--window-control-btn-radius: var(--titlebar-btn-radius);
/* =============================================
- BROWSER
+ AI AGENT
+ ============================================= */
+ --ai-agent-shell-glow: radial-gradient(circle at top left, rgba(180, 215, 255, 0.42), transparent 42%);
+ --ai-agent-shell-bg: linear-gradient(180deg, rgba(237, 243, 250, 0.98), rgba(226, 236, 247, 0.98));
+ --ai-agent-card-bg: rgba(237, 243, 250, 0.86);
+ --ai-agent-panel-bg: rgba(228, 236, 245, 0.82);
+ --ai-agent-control-bg: #d8e4f0;
+ --ai-agent-secondary-bg: rgba(255, 255, 255, 0.45);
+ --ai-agent-inset-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72);
+ --ai-agent-avatar-border: rgba(74, 144, 226, 0.34);
+ --ai-agent-avatar-bg: rgba(74, 144, 226, 0.1);
+ --ai-agent-avatar-shadow: 0 0 18px rgba(140, 200, 255, 0.18);
+ --ai-agent-focus-border: rgba(74, 144, 226, 0.46);
+ --ai-agent-focus-ring: 0 0 0 2px rgba(74, 144, 226, 0.16);
+ --ai-agent-message-bg: rgba(255, 255, 255, 0.55);
+ --ai-agent-user-border: rgba(74, 144, 226, 0.42);
+ --ai-agent-user-bg: rgba(74, 144, 226, 0.12);
+ --ai-agent-system-border: rgba(90, 160, 220, 0.28);
+ --ai-agent-system-bg: rgba(180, 220, 255, 0.2);
+ --ai-agent-warning-text: #8a6422;
+ --ai-agent-warning-border: rgba(200, 154, 58, 0.3);
+ --ai-agent-warning-bg: rgba(200, 154, 58, 0.1);
+ --ai-agent-error-text: #a04a58;
+ --ai-agent-error-border: rgba(224, 92, 106, 0.24);
+ --ai-agent-error-bg: rgba(224, 92, 106, 0.1);
+ --ai-agent-primary-border: rgba(74, 144, 226, 0.42);
+ --ai-agent-primary-text: #f0f6ff;
+ --ai-agent-conversation-active-bg: rgba(74, 144, 226, 0.12);
+ --ai-agent-conversation-active-hover-bg: rgba(74, 144, 226, 0.18);
+ --ai-agent-conversation-new-bg: rgba(74, 144, 226, 0.1);
+ --ai-agent-conversation-new-hover-bg: rgba(74, 144, 226, 0.16);
+ --ai-agent-delete-hover-text: #c04f62;
+ --ai-agent-delete-hover-bg: rgba(224, 92, 106, 0.12);
+
+ /* =============================================
+ BROWSER
============================================= */
--browser-bg: #edf3fa;
--browser-border: var(--app-border);
diff --git a/src/styles/themes/theme-studio95.css b/src/styles/themes/theme-studio95.css
index 4476875..86e40e7 100644
--- a/src/styles/themes/theme-studio95.css
+++ b/src/styles/themes/theme-studio95.css
@@ -160,6 +160,49 @@
inset 2px 2px 0px #dfdfdf;
--window-control-btn-radius: 0px;
+ /* =============================================
+ AI AGENT
+ ============================================= */
+ --ai-agent-shell-glow: none;
+ --ai-agent-shell-bg: #c0c0c0;
+ --ai-agent-card-bg: #d4d0c8;
+ --ai-agent-panel-bg: #c0c0c0;
+ --ai-agent-control-bg: #ffffff;
+ --ai-agent-secondary-bg: #c0c0c0;
+ --ai-agent-inset-shadow:
+ inset -1px -1px 0px #000000,
+ inset 1px 1px 0px #ffffff,
+ inset -2px -2px 0px #808080,
+ inset 2px 2px 0px #dfdfdf;
+ --ai-agent-avatar-border: rgba(0, 0, 0, 0.5);
+ --ai-agent-avatar-bg: #c0c0c0;
+ --ai-agent-avatar-shadow:
+ inset -1px -1px 0px #000000,
+ inset 1px 1px 0px #ffffff,
+ inset -2px -2px 0px #808080,
+ inset 2px 2px 0px #dfdfdf;
+ --ai-agent-focus-border: rgba(0, 0, 128, 0.8);
+ --ai-agent-focus-ring: 0 0 0 1px rgba(0, 0, 128, 0.35);
+ --ai-agent-message-bg: #ffffff;
+ --ai-agent-user-border: rgba(0, 0, 128, 0.6);
+ --ai-agent-user-bg: rgba(0, 0, 128, 0.12);
+ --ai-agent-system-border: rgba(0, 0, 0, 0.35);
+ --ai-agent-system-bg: rgba(255, 255, 255, 0.5);
+ --ai-agent-warning-text: #5f4f00;
+ --ai-agent-warning-border: rgba(128, 128, 0, 0.55);
+ --ai-agent-warning-bg: rgba(128, 128, 0, 0.12);
+ --ai-agent-error-text: #7a0000;
+ --ai-agent-error-border: rgba(128, 0, 0, 0.55);
+ --ai-agent-error-bg: rgba(204, 0, 0, 0.12);
+ --ai-agent-primary-border: rgba(0, 0, 128, 0.75);
+ --ai-agent-primary-text: #ffffff;
+ --ai-agent-conversation-active-bg: rgba(0, 0, 128, 0.14);
+ --ai-agent-conversation-active-hover-bg: rgba(0, 0, 128, 0.2);
+ --ai-agent-conversation-new-bg: #c0c0c0;
+ --ai-agent-conversation-new-hover-bg: #d4d0c8;
+ --ai-agent-delete-hover-text: #cc0000;
+ --ai-agent-delete-hover-bg: rgba(204, 0, 0, 0.12);
+
/* =============================================
BROWSER
============================================= */
diff --git a/src/styles/themes/theme-tealslate.css b/src/styles/themes/theme-tealslate.css
index 829b0d2..c9dff9f 100644
--- a/src/styles/themes/theme-tealslate.css
+++ b/src/styles/themes/theme-tealslate.css
@@ -110,6 +110,41 @@ WINDOW / FLOATING
--window-shadow: 0 16px 32px rgba(0, 0, 0, 0.35);
--window-control-btn-radius: 4px;
+/* =============================================
+AI AGENT
+============================================= */
+--ai-agent-shell-glow: radial-gradient(circle at top left, rgba(0, 173, 181, 0.18), transparent 38%);
+--ai-agent-shell-bg: linear-gradient(180deg, rgba(34, 40, 49, 0.98), rgba(28, 33, 40, 0.98));
+--ai-agent-card-bg: rgba(34, 40, 49, 0.82);
+--ai-agent-panel-bg: rgba(44, 51, 59, 0.78);
+--ai-agent-control-bg: #1c2128;
+--ai-agent-secondary-bg: rgba(238, 238, 238, 0.05);
+--ai-agent-inset-shadow: inset 0 1px 0 rgba(238, 238, 238, 0.04);
+--ai-agent-avatar-border: rgba(0, 173, 181, 0.34);
+--ai-agent-avatar-bg: rgba(0, 173, 181, 0.12);
+--ai-agent-avatar-shadow: 0 0 18px rgba(0, 173, 181, 0.14);
+--ai-agent-focus-border: rgba(0, 173, 181, 0.48);
+--ai-agent-focus-ring: 0 0 0 2px rgba(0, 173, 181, 0.14);
+--ai-agent-message-bg: rgba(238, 238, 238, 0.04);
+--ai-agent-user-border: rgba(0, 173, 181, 0.38);
+--ai-agent-user-bg: rgba(0, 173, 181, 0.12);
+--ai-agent-system-border: rgba(77, 208, 225, 0.28);
+--ai-agent-system-bg: rgba(77, 208, 225, 0.1);
+--ai-agent-warning-text: #f0c978;
+--ai-agent-warning-border: rgba(212, 160, 74, 0.26);
+--ai-agent-warning-bg: rgba(212, 160, 74, 0.1);
+--ai-agent-error-text: #f4c4c7;
+--ai-agent-error-border: rgba(224, 85, 85, 0.24);
+--ai-agent-error-bg: rgba(224, 85, 85, 0.1);
+--ai-agent-primary-border: rgba(0, 173, 181, 0.42);
+--ai-agent-primary-text: #111111;
+--ai-agent-conversation-active-bg: rgba(0, 173, 181, 0.14);
+--ai-agent-conversation-active-hover-bg: rgba(0, 173, 181, 0.18);
+--ai-agent-conversation-new-bg: rgba(0, 173, 181, 0.12);
+--ai-agent-conversation-new-hover-bg: rgba(0, 173, 181, 0.18);
+--ai-agent-delete-hover-text: #f07d7d;
+--ai-agent-delete-hover-bg: rgba(224, 85, 85, 0.14);
+
/* =============================================
BROWSER
============================================= */
diff --git a/supabase-setup.sql b/supabase-setup.sql
index 2db5f6c..ceb743a 100644
--- a/supabase-setup.sql
+++ b/supabase-setup.sql
@@ -43,3 +43,25 @@ create policy "Users CRUD own projects"
on projects for all
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
+
+-- Tabela ai_conversations (historia czatów AI Agent)
+create table ai_conversations (
+ id uuid default gen_random_uuid() primary key,
+ user_id uuid references auth.users on delete cascade not null,
+ title text not null default 'New chat',
+ messages jsonb not null default '[]'::jsonb,
+ pending_operations jsonb not null default '[]'::jsonb,
+ operation_results jsonb not null default '[]'::jsonb,
+ rejected_operations jsonb not null default '[]'::jsonb,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
+);
+
+-- Włącz RLS
+alter table ai_conversations enable row level security;
+
+-- Polityki RLS
+create policy "Users CRUD own AI conversations"
+ on ai_conversations for all
+ using (auth.uid() = user_id)
+ with check (auth.uid() = user_id);