diff --git a/.gitignore b/.gitignore index 80c9c10a6..60c2b7e4c 100644 --- a/.gitignore +++ b/.gitignore @@ -340,3 +340,9 @@ appcast.*.xml *.tar.gz .vscode/ Microsoft.AI.DirectML + +# Workspace build artifacts +.dotnet/ +openutau-build/ +openutau-runtime/ +OpenUtau-linux-x64.zip diff --git a/OpenUtau.Core/AgentBridge/BridgeCore.cs b/OpenUtau.Core/AgentBridge/BridgeCore.cs new file mode 100644 index 000000000..4db5bfd31 --- /dev/null +++ b/OpenUtau.Core/AgentBridge/BridgeCore.cs @@ -0,0 +1,656 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using OpenUtau.Api; +using OpenUtau.Core.Format; +using OpenUtau.Core.Render; +using OpenUtau.Core.Ustx; +using OpenUtau.Core.Util; +using Serilog; + +namespace OpenUtau.Core.AgentBridge { + /// In-process MCP request dispatcher and local-file bridge. The HTTP listener is owned by McpService. + public static class BridgeCore { + private const int MaxRequestBytes = 1024 * 1024; + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + private static readonly object Gate = new(); + private static CancellationTokenSource? cancellation; + private static Task? worker; + private static string? bridgeDirectory; + private static string sessionId = Guid.NewGuid().ToString("N"); + private static UProject? currentProject; + private static readonly Dictionary objectIds = new(ReferenceEqualityComparer.Instance); + private static readonly object EventGate = new(); + private static readonly Queue stateEvents = new(); + private const int StateEventCapacity = 256; + private static long nextEventSequence; + private static bool commandObserverAttached; + private static readonly BridgeCommandObserver commandObserver = new(); + private static long nextObjectId; + private static long revision = 1; + private static Action? loadPartAction; + public static object? MainWindow { get; set; } + + public static void SetLoadPartAction(Action action) { + lock (Gate) { + loadPartAction = action; + } + } + + public static void Start() { + lock (Gate) { + if (worker != null) return; + bridgeDirectory = ResolveBridgeDirectory(); + Directory.CreateDirectory(bridgeDirectory); + RecoverInterruptedRequest(bridgeDirectory); + cancellation = new CancellationTokenSource(); + worker = Task.Run(() => RunAsync(bridgeDirectory, cancellation.Token)); + EnsureCommandObserver(); + Log.Information("OpenUtau Agent Bridge started at {BridgeDirectory}.", bridgeDirectory); + } + } + + public static void Stop() { + lock (Gate) { + cancellation?.Cancel(); + cancellation?.Dispose(); + cancellation = null; + worker = null; + } + } + + internal static string ResolveBridgeDirectory() { + var configured = Environment.GetEnvironmentVariable("OPENUTAU_AGENT_BRIDGE_DIR")?.Trim(); + return string.IsNullOrWhiteSpace(configured) ? Path.GetTempPath() : Path.GetFullPath(configured); + } + + private static async Task RunAsync(string directory, CancellationToken token) { + var nextHeartbeat = DateTimeOffset.MinValue; + while (!token.IsCancellationRequested) { + try { + if (DateTimeOffset.UtcNow >= nextHeartbeat) { + PublishStatus(directory, "running"); + nextHeartbeat = DateTimeOffset.UtcNow.AddSeconds(1); + } + ProcessRequest(directory); + } catch (Exception ex) { + Log.Error(ex, "OpenUtau Agent Bridge worker failure."); + } + try { + await Task.Delay(100, token).ConfigureAwait(false); + } catch (OperationCanceledException) { + break; + } + } + PublishStatus(directory, "stopped"); + } + + internal static void ProcessRequest(string directory) { + var requestPath = Path.Combine(directory, BridgeProtocol.RequestFileName); + var processingPath = Path.Combine(directory, BridgeProtocol.ProcessingFileName); + if (!File.Exists(requestPath) || File.Exists(processingPath)) return; + try { + File.Move(requestPath, processingPath); + } catch (IOException) { + return; + } + + object response; + try { + if (new FileInfo(processingPath).Length > MaxRequestBytes) throw new BridgeException("REQUEST_TOO_LARGE", "request exceeds 1 MiB"); + using var document = JsonDocument.Parse(File.ReadAllText(processingPath, Encoding.UTF8)); + response = DispatchOnUiThread(document.RootElement); + } catch (BridgeException ex) { + response = Failure("invalid-request", ex.Code, ex.Message); + } catch (Exception ex) { + Log.Warning(ex, "OpenUtau Agent Bridge request failed."); + response = Failure("invalid-request", "HOST_ERROR", ex.Message); + } + WriteJsonAtomically(Path.Combine(directory, BridgeProtocol.ResponseFileName), response); + try { + File.Move(processingPath, $"{processingPath}.{Guid.NewGuid():N}.completed"); + } catch (IOException ex) { + Log.Warning(ex, "OpenUtau Agent Bridge could not archive processed request."); + } + } + /// Runs a v2 envelope through the UI-thread dispatcher. + internal static object DispatchRequest(JsonElement request) { + EnsureCommandObserver(); + return DispatchOnUiThread(request); + } + + private static object DispatchOnUiThread(JsonElement request) { + var completion = new ManualResetEventSlim(); + object? response = null; + Exception? exception = null; + var copy = request.Clone(); + DocManager.Inst.PostOnUIThread(() => { + try { response = HandleRequest(copy); } catch (Exception ex) { exception = ex; } finally { completion.Set(); } + }); + if (!completion.Wait(TimeSpan.FromSeconds(15))) throw new BridgeException("UI_TIMEOUT", "UI thread did not complete the request"); + if (exception != null) throw exception; + return response!; + } + + private static object HandleRequest(JsonElement request) { + var id = GetString(request, "id") ?? "invalid-request"; + if (!request.TryGetProperty("v", out var version) || !version.TryGetInt32(out var wireVersion) || wireVersion != BridgeProtocol.Version) { + return Failure(id, "PROTOCOL_VERSION_MISMATCH", "expected v2 envelope"); + } + var action = GetString(request, "a"); + if (!BridgeProtocol.IsSupportedAction(action)) return Failure(id, "UNSUPPORTED_ACTION", action ?? "missing action"); + var payload = request.TryGetProperty("p", out var value) && value.ValueKind == JsonValueKind.Object ? value : default; + try { + return Success(id, action switch { + "ping" => new { pong = true, sessionToken = sessionId }, + "get_project_info" => GetProjectInfo(), + "get_state_snapshot" => GetStateSnapshot(payload), + "get_state_events" => GetStateEvents(payload), + "get_bridge_diagnostics" => GetBridgeDiagnostics(), + "set_track_config" => SetTrackConfig(payload), + "set_track_singer" => SetTrackSinger(payload), + "create_part" => CreatePart(payload), + "add_notes_simple" => AddNotesSimple(payload), + "edit_note_simple" => EditNoteSimple(payload), + "delete_notes_simple" => DeleteNotesSimple(payload), + "playback" => Playback(payload), + "get_editor_state" => GetEditorState(), + "save_file" => SaveFile(payload), + "load_file" => LoadFile(payload), + "navigate_editor" => NavigateEditor(), + "open_piano_roll" => OpenPianoRoll(payload), + _ => throw new BridgeException("UNSUPPORTED_ACTION", action!), + }); + } catch (BridgeException ex) { + return Failure(id, ex.Code, ex.Message); + } + } + + private static object GetProjectInfo() { + var project = DocManager.Inst.Project; + RefreshProjectContext(project); + return new { + name = project.name, filePath = project.FilePath, saved = project.Saved, resolution = project.resolution, + endTick = project.EndTick, + tracks = project.tracks.Select((track, index) => new { + index, name = track.TrackName, singerId = track.Singer?.Id, singerName = track.Singer?.Name, + track.Mute, track.Solo, track.Volume, track.Pan, + }).ToArray(), + parts = project.parts.Select((part, index) => new { index, part.name, part.position, duration = part.Duration, part.trackNo, type = part.GetType().Name }).ToArray(), + }; + } + + private static object GetStateSnapshot(JsonElement payload) { + var project = DocManager.Inst.Project; + RefreshProjectContext(project); + var partOffset = Math.Max(0, OptionalInt(payload, "partOffset") ?? 0); + var partLimit = Math.Clamp(OptionalInt(payload, "partLimit") ?? 100, 1, 100); + var noteOffset = Math.Max(0, OptionalInt(payload, "noteOffset") ?? 0); + var noteLimit = Math.Clamp(OptionalInt(payload, "noteLimit") ?? 500, 1, 500); + var fromTick = OptionalInt(payload, "fromTick"); + var toTick = OptionalInt(payload, "toTick"); + if (fromTick is < 0 || toTick is < 0 || (fromTick.HasValue && toTick.HasValue && fromTick > toTick)) { + throw new BridgeException("INVALID_PAYLOAD", "tick range must be non-negative and ordered"); + } + var matchingParts = project.parts + .Select((part, index) => (part, index)) + .Where(item => !fromTick.HasValue || item.part.End >= fromTick.Value) + .Where(item => !toTick.HasValue || item.part.position <= toTick.Value) + .ToArray(); + var page = matchingParts.Skip(partOffset).Take(partLimit).Select(item => SnapshotPart(item.part, item.index, noteOffset, noteLimit)).ToArray(); + var nextPartOffset = partOffset + page.Length; + return new { + sessionId, + revision, + guard = new { sessionId, revision }, + project = new { + id = GetObjectId(project, "project"), project.name, filePath = project.FilePath, project.Saved, + project.resolution, project.key, endTick = project.EndTick, + tempos = project.tempos.Select(tempo => new { tempo.position, tempo.bpm }).ToArray(), + timeSignatures = project.timeSignatures.Select(signature => new { signature.barPosition, signature.beatPerBar, signature.beatUnit }).ToArray(), + tracks = project.tracks.Select((track, index) => new { + id = GetObjectId(track, "track"), index, track.TrackName, track.TrackColor, + singerId = track.Singer?.Id, singerName = track.Singer?.Name, phonemizer = track.Phonemizer?.GetType().FullName, + renderer = track.RendererSettings.Renderer?.GetType().FullName, track.Mute, track.Solo, track.Volume, track.Pan, + expressions = track.GetSupportedExps(project).Select(SnapshotExpression).ToArray(), + }).ToArray(), + parts = page, + }, + playback = new { + playPosTick = DocManager.Inst.playPosTick, rangeStartTick = DocManager.Inst.rangeStartTick, + rangeEndTick = DocManager.Inst.rangeEndTick, playing = PlaybackManager.Inst.PlayingMaster, + starting = PlaybackManager.Inst.StartingToPlay, + }, + editor = new { page = ReadMainWindowPage() }, + pageInfo = new { partOffset, partLimit, totalParts = matchingParts.Length, nextPartOffset = nextPartOffset < matchingParts.Length ? nextPartOffset : (int?)null, noteOffset, noteLimit, fromTick, toTick }, + }; + } + + private static object GetStateEvents(JsonElement payload) { + var afterSequence = OptionalLong(payload, "afterSequence") ?? 0; + if (afterSequence < 0) throw new BridgeException("INVALID_PAYLOAD", "afterSequence must be non-negative"); + lock (EventGate) { + var earliest = stateEvents.Count == 0 ? nextEventSequence + 1 : stateEvents.Peek().Sequence; + var requiresSnapshot = afterSequence > 0 && afterSequence < earliest - 1; + var events = requiresSnapshot + ? Array.Empty() + : stateEvents.Where(stateEvent => stateEvent.Sequence > afterSequence).ToArray(); + return new { + sessionId, + revision, + latestSequence = nextEventSequence, + requiresSnapshot, + events = events.Select(stateEvent => new { + sequence = stateEvent.Sequence, + stateEvent.Revision, + stateEvent.Source, + stateEvent.Type, + }).ToArray(), + }; + } + } + + private static object GetBridgeDiagnostics() { + lock (EventGate) { + return new { + sessionId, + revision, + latestEventSequence = nextEventSequence, + bufferedEventCount = stateEvents.Count, + ipcDirectory = bridgeDirectory, + mcp = McpService.Status, + }; + } + } + + private static object SnapshotPart(UPart part, int index, int noteOffset, int noteLimit) { + var voicePart = part as UVoicePart; + var notes = voicePart?.notes.Skip(noteOffset).Take(noteLimit) + .Select((note, noteIndex) => SnapshotNote(note, noteOffset + noteIndex)).ToArray(); + var totalNotes = voicePart?.notes.Count ?? 0; + var nextNoteOffset = noteOffset + (notes?.Length ?? 0); + return new { + id = GetObjectId(part, "part"), index, kind = part.GetType().Name, part.name, part.comment, + trackIndex = part.trackNo, part.position, duration = part.Duration, end = part.End, notes, + totalNotes, nextNoteOffset = nextNoteOffset < totalNotes ? nextNoteOffset : (int?)null, + }; + } + + internal static object SnapshotNote(UNote note, int index) { + return new { + id = GetObjectId(note, "note"), index, note.position, note.duration, end = note.End, + note.tone, note.lyric, note.tuning, phonemizerOverride = note.PhonemizerOverride, + pitch = new { + snapFirst = note.pitch?.snapFirst ?? false, + points = note.pitch?.data.Select(point => new { + x = point.X, y = point.Y, shape = point.shape.ToString(), point.autoCompleted, + }).ToArray() ?? Array.Empty(), + }, + vibrato = note.vibrato == null ? null : new { + note.vibrato.length, note.vibrato.period, note.vibrato.depth, + fadeIn = note.vibrato.@in, fadeOut = note.vibrato.@out, + note.vibrato.shift, note.vibrato.drift, note.vibrato.volLink, + }, + phonemeExpressions = note.phonemeExpressions.Select(expression => new { + expression.index, expression.abbr, expression.value, + }).ToArray(), + phonemeOverrides = note.phonemeOverrides.Select(overrideValue => new { + overrideValue.index, overrideValue.phoneme, overrideValue.offset, + overrideValue.preutterDelta, overrideValue.overlapDelta, + overrideValue.attackTimeDelta, overrideValue.releaseTimeDelta, + }).ToArray(), + }; + } + + private static object SnapshotExpression(UExpressionDescriptor descriptor) { + return new { + descriptor.name, descriptor.abbr, type = descriptor.type.ToString(), descriptor.min, descriptor.max, + descriptor.defaultValue, customDefaultValue = descriptor.CustomDefaultValue, descriptor.isFlag, + descriptor.flag, options = descriptor.options ?? Array.Empty(), descriptor.skipOutputIfDefault, + }; + } + + private static void RefreshProjectContext(UProject project) { + if (ReferenceEquals(currentProject, project)) return; + currentProject = project; + objectIds.Clear(); + nextObjectId = 0; + sessionId = Guid.NewGuid().ToString("N"); + revision++; + lock (EventGate) { + stateEvents.Clear(); + } + } + + private static void AdvanceRevision() => revision++; + + private static void EnsureCommandObserver() { + if (commandObserverAttached) return; + DocManager.Inst.AddSubscriber(commandObserver); + commandObserverAttached = true; + } + + private static void RecordCommandEvent(UCommand command, bool isUndo) { + if (command.Silent) return; + lock (EventGate) { + var eventRevision = ++revision; + stateEvents.Enqueue(new StateEvent(++nextEventSequence, eventRevision, isUndo ? "undo" : "execute", command.GetType().Name)); + while (stateEvents.Count > StateEventCapacity) stateEvents.Dequeue(); + } + } + + private static string GetObjectId(object value, string kind) { + if (objectIds.TryGetValue(value, out var id)) return id; + id = $"{sessionId}:{kind}:{++nextObjectId}"; + objectIds.Add(value, id); + return id; + } + + private static object SetTrackConfig(JsonElement payload) { + var project = DocManager.Inst.Project; + var index = RequiredInt(payload, "trackIndex"); + if (index < 0 || index >= project.tracks.Count) throw new BridgeException("INVALID_TRACK", "trackIndex is outside the project"); + var track = project.tracks[index]; + if (GetString(payload, "name") is { Length: > 0 } name) { + DocManager.Inst.StartUndoGroup("agentbridge.settrackconfig"); + try { DocManager.Inst.ExecuteCmd(new RenameTrackCommand(project, track, name)); DocManager.Inst.EndUndoGroup(); } + catch { DocManager.Inst.RollBackUndoGroup(); throw; } + } + if (TryBool(payload, "mute", out var mute)) track.Mute = mute; + if (TryBool(payload, "solo", out var solo)) track.Solo = solo; + if (TryDouble(payload, "volume", out var volume)) track.Volume = Math.Clamp(volume, 0, 2); + if (TryDouble(payload, "pan", out var pan)) track.Pan = Math.Clamp(pan, -1, 1); + project.ValidateFull(); + AdvanceRevision(); + return new { trackIndex = index, name = track.TrackName, track.Mute, track.Solo, track.Volume, track.Pan }; + } + + private static object SetTrackSinger(JsonElement payload) { + var project = DocManager.Inst.Project; + var index = RequiredInt(payload, "trackIndex"); + if (index < 0 || index >= project.tracks.Count) throw new BridgeException("INVALID_TRACK", "trackIndex is outside the project"); + var singerId = GetString(payload, "singerId")?.Trim(); + if (string.IsNullOrEmpty(singerId) || singerId.Length > 512) throw new BridgeException("INVALID_PAYLOAD", "singerId must be a non-empty string up to 512 characters"); + if (!SingerManager.Inst.Singers.TryGetValue(singerId, out var singer)) throw new BridgeException("SINGER_NOT_FOUND", "singerId is not installed"); + + var track = project.tracks[index]; + DocManager.Inst.StartUndoGroup("agentbridge.settracksinger"); + try { + DocManager.Inst.ExecuteCmd(new TrackChangeSingerCommand(project, track, singer)); + var preferredPhonemizer = !string.IsNullOrEmpty(singer.Id) && + Preferences.Default.SingerPhonemizers.TryGetValue(singer.Id, out var configuredPhonemizer) + ? configuredPhonemizer + : null; + if (!string.IsNullOrEmpty(preferredPhonemizer) && + TryChangePhonemizer(track, preferredPhonemizer)) { + } else if (!string.IsNullOrEmpty(singer.DefaultPhonemizer) && + TryChangePhonemizer(track, singer.DefaultPhonemizer)) { + } else if (!string.IsNullOrEmpty(preferredPhonemizer) || + !string.IsNullOrEmpty(singer.DefaultPhonemizer)) { + throw new BridgeException("PHONEMIZER_UNAVAILABLE", "no configured phonemizer is available for singerId"); + } + if (!singer.Found || singer.SingerType != track.RendererSettings.Renderer?.SingerType) { + var settings = singer.Found + ? new URenderSettings { renderer = Renderers.GetDefaultRenderer(singer.SingerType) } + : new URenderSettings(); + DocManager.Inst.ExecuteCmd(new TrackChangeRenderSettingCommand(project, track, settings)); + } + DocManager.Inst.EndUndoGroup(); + } catch { + DocManager.Inst.RollBackUndoGroup(); + throw; + } + project.ValidateFull(); + AdvanceRevision(); + return new { + trackIndex = index, singerId = singer.Id, singerName = singer.Name, + phonemizer = track.Phonemizer?.GetType().FullName, + renderer = track.RendererSettings.Renderer?.GetType().FullName, + }; + } + + private static object CreatePart(JsonElement payload) { + var project = DocManager.Inst.Project; + var trackIndex = RequiredInt(payload, "trackIndex"); + if (trackIndex < 0 || trackIndex >= project.tracks.Count) throw new BridgeException("INVALID_TRACK", "trackIndex is outside the project"); + var position = Math.Max(0, RequiredInt(payload, "position")); + var duration = Math.Max(1, RequiredInt(payload, "duration")); + var part = new UVoicePart { trackNo = trackIndex, position = position, duration = duration, name = GetString(payload, "name") ?? "New Part" }; + DocManager.Inst.StartUndoGroup("agentbridge.createpart"); + try { DocManager.Inst.ExecuteCmd(new AddPartCommand(project, part)); DocManager.Inst.EndUndoGroup(); } + catch { DocManager.Inst.RollBackUndoGroup(); throw; } + AdvanceRevision(); + return new { partIndex = project.parts.IndexOf(part), part.name, part.position, part.duration, part.trackNo }; + } + + private static object AddNotesSimple(JsonElement payload) { + var project = DocManager.Inst.Project; + var partIndex = RequiredInt(payload, "partIndex"); + if (partIndex < 0 || partIndex >= project.parts.Count || project.parts[partIndex] is not UVoicePart part) throw new BridgeException("INVALID_PART", "partIndex must identify a voice part"); + if (!payload.TryGetProperty("notes", out var notesValue) || notesValue.ValueKind != JsonValueKind.Array) throw new BridgeException("INVALID_PAYLOAD", "notes must be an array"); + var notes = new List(); + foreach (var note in notesValue.EnumerateArray()) { + var position = Math.Max(0, RequiredInt(note, "position")); + var duration = Math.Max(1, RequiredInt(note, "duration")); + var tone = Math.Clamp(RequiredInt(note, "tone"), 0, 127); + var created = project.CreateNote(tone, position, duration); + created.lyric = GetString(note, "lyric") ?? "a"; + notes.Add(created); + } + if (notes.Count == 0) throw new BridgeException("INVALID_PAYLOAD", "notes must contain at least one note"); + DocManager.Inst.StartUndoGroup("agentbridge.addnotes"); + try { DocManager.Inst.ExecuteCmd(new AddNoteCommand(part, notes)); DocManager.Inst.EndUndoGroup(); } + catch { DocManager.Inst.RollBackUndoGroup(); throw; } + AdvanceRevision(); + return new { partIndex, added = notes.Count, noteCount = part.notes.Count }; + } + + private static object EditNoteSimple(JsonElement payload) { + var (partIndex, part) = RequiredVoicePart(payload); + var noteIndex = RequiredInt(payload, "noteIndex"); + if (noteIndex < 0 || noteIndex >= part.notes.Count) throw new BridgeException("INVALID_NOTE", "noteIndex is outside the voice part"); + var note = part.notes.ElementAt(noteIndex); + var position = OptionalInt(payload, "position"); + var duration = OptionalInt(payload, "duration"); + var tone = OptionalInt(payload, "tone"); + var lyric = GetString(payload, "lyric"); + if (position is < 0) throw new BridgeException("INVALID_PAYLOAD", "position must be non-negative"); + if (duration is < 1) throw new BridgeException("INVALID_PAYLOAD", "duration must be at least 1"); + if (tone is < 0 or > 127) throw new BridgeException("INVALID_PAYLOAD", "tone must be within 0..127"); + if (lyric?.Length > 256) throw new BridgeException("INVALID_PAYLOAD", "lyric must be at most 256 characters"); + if (position == null && duration == null && tone == null && lyric == null) throw new BridgeException("INVALID_PAYLOAD", "provide position, duration, tone, or lyric"); + + DocManager.Inst.StartUndoGroup("agentbridge.editnote"); + try { + if (position != null || tone != null) { + DocManager.Inst.ExecuteCmd(new MoveNoteCommand(part, note, (position ?? note.position) - note.position, (tone ?? note.tone) - note.tone)); + } + if (duration != null) { + DocManager.Inst.ExecuteCmd(new ResizeNoteCommand(part, note, duration.Value - note.duration)); + } + if (lyric != null) { + DocManager.Inst.ExecuteCmd(new ChangeNoteLyricCommand(part, note, lyric)); + } + DocManager.Inst.EndUndoGroup(); + } catch { + DocManager.Inst.RollBackUndoGroup(); + throw; + } + AdvanceRevision(); + return new { partIndex, noteIndex, note.position, note.duration, note.tone, note.lyric }; + } + + private static object DeleteNotesSimple(JsonElement payload) { + var project = DocManager.Inst.Project; + RefreshProjectContext(project); + var (partIndex, part) = ResolveDeletePart(project, payload); + var notes = ResolveDeleteNotes(part, payload); + if (notes.Count == 0) throw new BridgeException("INVALID_PAYLOAD", "provide noteIds or noteIndices"); + DocManager.Inst.StartUndoGroup("agentbridge.deletenotes"); + try { DocManager.Inst.ExecuteCmd(new RemoveNoteCommand(part, notes)); DocManager.Inst.EndUndoGroup(); } + catch { DocManager.Inst.RollBackUndoGroup(); throw; } + AdvanceRevision(); + return new { partIndex, partId = GetObjectId(part, "part"), deleted = notes.Count, noteCount = part.notes.Count, revision }; + } + + private static (int partIndex, UVoicePart part) ResolveDeletePart(UProject project, JsonElement payload) { + var partId = GetString(payload, "partId"); + if (partId == null) return RequiredVoicePart(payload); + RequireCurrentGuard(payload); + var matches = project.parts + .Select((part, index) => (part, index)) + .Where(item => item.part is UVoicePart && GetObjectId(item.part, "part") == partId) + .ToArray(); + if (matches.Length != 1) throw new BridgeException("AMBIGUOUS_TARGET", "partId is not available in the current project session"); + return (matches[0].index, (UVoicePart)matches[0].part); + } + + private static List ResolveDeleteNotes(UVoicePart part, JsonElement payload) { + if (payload.TryGetProperty("noteIds", out var idsValue)) { + RequireCurrentGuard(payload); + if (idsValue.ValueKind != JsonValueKind.Array) throw new BridgeException("INVALID_PAYLOAD", "noteIds must be an array"); + var ids = idsValue.EnumerateArray().Select(item => item.GetString()).ToArray(); + if (ids.Length == 0 || ids.Length > 1024 || ids.Any(string.IsNullOrEmpty) || ids.Distinct(StringComparer.Ordinal).Count() != ids.Length) { + throw new BridgeException("INVALID_PAYLOAD", "noteIds must contain 1..1024 distinct IDs"); + } + var notes = part.notes.Where(note => ids.Contains(GetObjectId(note, "note"), StringComparer.Ordinal)).ToList(); + if (notes.Count != ids.Length) throw new BridgeException("AMBIGUOUS_TARGET", "one or more noteIds are no longer available; read a fresh snapshot"); + return notes; + } + if (!payload.TryGetProperty("noteIndices", out var value) || value.ValueKind != JsonValueKind.Array) throw new BridgeException("INVALID_PAYLOAD", "noteIndices must be an array"); + var indices = new HashSet(); + foreach (var item in value.EnumerateArray()) { + if (!item.TryGetInt32(out var index) || index < 0 || index >= part.notes.Count) throw new BridgeException("INVALID_NOTE", "noteIndices must identify notes in the voice part"); + indices.Add(index); + if (indices.Count > 1024) throw new BridgeException("INVALID_PAYLOAD", "noteIndices supports up to 1024 notes"); + } + if (indices.Count == 0) throw new BridgeException("INVALID_PAYLOAD", "noteIndices must contain at least one note"); + return indices.OrderBy(index => index).Select(index => part.notes.ElementAt(index)).ToList(); + } + + private static void RequireCurrentGuard(JsonElement payload) { + if (GetString(payload, "sessionId") != sessionId || OptionalLong(payload, "revision") != revision) { + throw new BridgeException("STALE_CONTEXT", "sessionId and revision must match a fresh state snapshot"); + } + } + + private static object Playback(JsonElement payload) { + var operation = GetString(payload, "operation") ?? "status"; + var tick = payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty("tick", out var tickValue) && tickValue.TryGetInt32(out var requestedTick) ? Math.Max(0, requestedTick) : DocManager.Inst.playPosTick; + switch (operation) { + case "play": PlaybackManager.Inst.PlayOrPause(tick: tick); break; + case "pause": PlaybackManager.Inst.PausePlayback(); break; + case "stop": PlaybackManager.Inst.StopPlayback(); break; + case "seek": DocManager.Inst.ExecuteCmd(new SeekPlayPosTickNotification(tick)); break; + case "status": break; + default: throw new BridgeException("INVALID_PAYLOAD", "operation must be status, play, pause, stop, or seek"); + } + return new { operation, tick = DocManager.Inst.playPosTick, playing = PlaybackManager.Inst.PlayingMaster }; + } + + private static object GetEditorState() => new { page = ReadMainWindowPage(), playPosTick = DocManager.Inst.playPosTick, rangeStartTick = DocManager.Inst.rangeStartTick, rangeEndTick = DocManager.Inst.rangeEndTick }; + + private static object SaveFile(JsonElement payload) { + var path = RequiredUstxPath(payload); + Format.Ustx.Save(path, DocManager.Inst.Project); + return new { path, saved = DocManager.Inst.Project.Saved }; + } + + private static object LoadFile(JsonElement payload) { + var path = RequiredUstxPath(payload); + if (!File.Exists(path)) throw new BridgeException("FILE_NOT_FOUND", "project file does not exist"); + DocManager.Inst.ExecuteCmd(new LoadProjectNotification(Format.Ustx.Load(path))); + return new { path, loaded = true, name = DocManager.Inst.Project.name }; + } + + private static object NavigateEditor() { + var part = DocManager.Inst.Project.parts.LastOrDefault(part => part is UVoicePart) as UVoicePart; + if (part == null) throw new BridgeException("INVALID_PART", "project has no voice part"); + return ShowPianoRoll(part, part.position); + } + + private static object OpenPianoRoll(JsonElement payload) { + var (partIndex, part) = RequiredVoicePart(payload); + var tick = OptionalInt(payload, "tick") ?? part.position; + if (tick < 0) throw new BridgeException("INVALID_PAYLOAD", "tick must be non-negative"); + return ShowPianoRoll(part, tick, partIndex); + } + + private static object ShowPianoRoll(UVoicePart part, int tick, int? partIndex = null) { + var action = loadPartAction ?? throw new BridgeException("UI_UNAVAILABLE", "editor part loader is unavailable"); + action(part, tick); + return new { navigated = true, page = 1, partIndex, tick }; + } + + private static int? ReadMainWindowPage() { + dynamic? mainWindow = MainWindow; + if (mainWindow?.DataContext is null) return null; + dynamic viewModel = mainWindow.DataContext; + return viewModel.Page; + } + + private static string RequiredUstxPath(JsonElement payload) { + var path = GetString(payload, "path"); + if (string.IsNullOrWhiteSpace(path) || !Path.IsPathFullyQualified(path) || !string.Equals(Path.GetExtension(path), ".ustx", StringComparison.OrdinalIgnoreCase)) throw new BridgeException("INVALID_PATH", "path must be an absolute .ustx path"); + return Path.GetFullPath(path); + } + private static (int partIndex, UVoicePart part) RequiredVoicePart(JsonElement payload) { + var project = DocManager.Inst.Project; + var partIndex = RequiredInt(payload, "partIndex"); + if (partIndex < 0 || partIndex >= project.parts.Count || project.parts[partIndex] is not UVoicePart part) throw new BridgeException("INVALID_PART", "partIndex must identify a voice part"); + return (partIndex, part); + } + private static int? OptionalInt(JsonElement value, string name) { + if (value.ValueKind != JsonValueKind.Object || !value.TryGetProperty(name, out var property)) return null; + return property.TryGetInt32(out var result) ? result : throw new BridgeException("INVALID_PAYLOAD", $"{name} must be an integer"); + } + private static long? OptionalLong(JsonElement value, string name) { + if (value.ValueKind != JsonValueKind.Object || !value.TryGetProperty(name, out var property)) return null; + return property.TryGetInt64(out var result) ? result : throw new BridgeException("INVALID_PAYLOAD", $"{name} must be an integer"); + } + private static bool TryChangePhonemizer(UTrack track, string phonemizerName) { + try { + var phonemizer = PhonemizerFactory.Get(phonemizerName)?.Create(); + if (phonemizer == null) return false; + DocManager.Inst.ExecuteCmd(new TrackChangePhonemizerCommand(DocManager.Inst.Project, track, phonemizer)); + return true; + } catch (Exception e) { + Log.Warning(e, "Agent Bridge could not load phonemizer {PhonemizerName}.", phonemizerName); + return false; + } + } + + private static void PublishStatus(string directory, string state) => WriteJsonAtomically(Path.Combine(directory, BridgeProtocol.StatusFileName), new { v = BridgeProtocol.Version, state, bridgeVersion = BridgeProtocol.BridgeVersion, updatedAtEpochMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), sessionToken = sessionId, ipcDirectory = directory, projectFile = GetProjectFilePathOnUiThread() }); + private static string GetProjectFilePathOnUiThread() { + var completion = new ManualResetEventSlim(); + string? filePath = null; + DocManager.Inst.PostOnUIThread(() => { filePath = DocManager.Inst.Project.FilePath; completion.Set(); }); + if (!completion.Wait(TimeSpan.FromSeconds(15))) throw new BridgeException("UI_TIMEOUT", "UI thread did not provide project status"); + return filePath ?? string.Empty; + } + internal static void WriteJsonAtomically(string path, object value) { var temp = $"{path}.{Guid.NewGuid():N}.tmp"; File.WriteAllText(temp, JsonSerializer.Serialize(value, JsonOptions), new UTF8Encoding(false)); File.Move(temp, path, true); } + private static void RecoverInterruptedRequest(string directory) { var processing = Path.Combine(directory, BridgeProtocol.ProcessingFileName); if (File.Exists(processing)) { try { File.Move(processing, $"{processing}.{Guid.NewGuid():N}.interrupted"); } catch (IOException) { } } } + + private static object Success(string id, object result) => new { v = BridgeProtocol.Version, id, r = result }; + private static object Failure(string id, string code, string message) => new { v = BridgeProtocol.Version, id, e = new { code, message } }; + private static string? GetString(JsonElement value, string name) => value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String ? property.GetString() : null; + private static int RequiredInt(JsonElement value, string name) => value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out var property) && property.TryGetInt32(out var result) ? result : throw new BridgeException("INVALID_PAYLOAD", $"{name} must be an integer"); + private static bool TryBool(JsonElement value, string name, out bool result) { + result = false; + if (value.ValueKind != JsonValueKind.Object || !value.TryGetProperty(name, out var property) || property.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) return false; + result = property.GetBoolean(); + return true; + } + private static bool TryDouble(JsonElement value, string name, out double result) { result = 0; return value.ValueKind == JsonValueKind.Object && value.TryGetProperty(name, out var property) && property.TryGetDouble(out result); } + private sealed class BridgeException : Exception { public string Code { get; } public BridgeException(string code, string message) : base(message) { Code = code; } } + private sealed class BridgeCommandObserver : ICmdSubscriber { + public void OnNext(UCommand command, bool isUndo) => RecordCommandEvent(command, isUndo); + } + private sealed record StateEvent(long Sequence, long Revision, string Source, string Type); + } +} diff --git a/OpenUtau.Core/AgentBridge/BridgeProtocol.cs b/OpenUtau.Core/AgentBridge/BridgeProtocol.cs new file mode 100644 index 000000000..150e72c4b --- /dev/null +++ b/OpenUtau.Core/AgentBridge/BridgeProtocol.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; + +namespace OpenUtau.Core.AgentBridge { + /// Compact v2 envelope shared by the local MCP coordinator and OpenUtau. + public static class BridgeProtocol { + public const int Version = 2; + public const string BridgeVersion = "0.2.0"; + public const string RequestFileName = "openutau-agent-bridge.request.json"; + public const string ProcessingFileName = "openutau-agent-bridge.processing.json"; + public const string ResponseFileName = "openutau-agent-bridge.response.json"; + public const string StatusFileName = "openutau-agent-bridge.status.json"; + private static readonly HashSet Actions = new(StringComparer.Ordinal) { + "ping", "get_project_info", "get_state_snapshot", "get_state_events", "get_bridge_diagnostics", "set_track_config", "set_track_singer", "create_part", "add_notes_simple", + "edit_note_simple", "delete_notes_simple", "playback", "get_editor_state", "save_file", "load_file", + "navigate_editor", "open_piano_roll", + }; + + public static bool IsSupportedAction(string? action) => action != null && Actions.Contains(action); + } +} diff --git a/OpenUtau.Core/AgentBridge/McpService.cs b/OpenUtau.Core/AgentBridge/McpService.cs new file mode 100644 index 000000000..51428800d --- /dev/null +++ b/OpenUtau.Core/AgentBridge/McpService.cs @@ -0,0 +1,414 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using OpenUtau.Core.Util; +using Serilog; + +namespace OpenUtau.Core.AgentBridge { + public enum McpStartupMode { + Manual, + OnOpenUtauStartup, + } + + public sealed record McpServiceOptions(string BindAddress, int Port) { + public static bool TryCreate(string? bindAddress, int port, out McpServiceOptions options, out string? error) { + options = null!; + error = null; + if (!IPAddress.TryParse(bindAddress, out var address) || !IPAddress.IsLoopback(address)) { + error = "MCP binding must use a loopback IP address."; + return false; + } + if (port is < 1 or > 65535) { + error = "MCP port must be within 1..65535."; + return false; + } + options = new McpServiceOptions(address.ToString(), port); + return true; + } + } + + public sealed record McpServiceStatus(bool Running, string BindAddress, int Port, string? Error); + + /// Owns the loopback HTTP listener used by the native MCP transport. + public static class McpService { + private static readonly object Gate = new(); + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + private static HttpListener? listener; + private static CancellationTokenSource? cancellation; + private static Task? worker; + private static McpServiceOptions? options; + private static string? sessionToken; + private static string? lastError; + // Streamable HTTP uses the bearer token as the local trust boundary; + // current MCP clients do not require a transport session header. + private static McpSession? requestContext; + private static readonly SemaphoreSlim bridgeRequests = new(1, 1); + private const int MaxRequestBytes = 1024 * 1024; + private static readonly HashSet ReadActions = new(StringComparer.Ordinal) { + "ping", "get_project_info", "get_state_snapshot", "get_state_events", "get_bridge_diagnostics", "get_editor_state", + }; + private static readonly HashSet WriteActions = new(StringComparer.Ordinal) { + "set_track_config", "set_track_singer", "create_part", "add_notes_simple", "edit_note_simple", "delete_notes_simple", "playback", + }; + + public static McpServiceStatus Status { + get { + lock (Gate) { + return new McpServiceStatus(listener?.IsListening == true, options?.BindAddress ?? string.Empty, options?.Port ?? 0, lastError); + } + } + } + + public static bool Start(McpServiceOptions requestedOptions, out string? error) { + lock (Gate) { + if (listener?.IsListening == true && options == requestedOptions) { + error = null; + return true; + } + StopLocked(); + try { + var created = new HttpListener(); + created.Prefixes.Add(BuildPrefix(requestedOptions)); + created.Start(); + options = requestedOptions; + sessionToken = GetOrCreatePersistentToken(); + requestContext = new McpSession(Guid.NewGuid().ToString("N")); + cancellation = new CancellationTokenSource(); + listener = created; + worker = Task.Run(() => RunAsync(created, cancellation.Token)); + lastError = null; + error = null; + Log.Information("OpenUtau MCP service listening on loopback address {McpBindAddress}:{McpPort}.", requestedOptions.BindAddress, requestedOptions.Port); + return true; + } catch (Exception ex) when (ex is HttpListenerException or ArgumentException) { + lastError = ex.Message; + options = null; + sessionToken = null; + error = lastError; + Log.Warning(ex, "OpenUtau MCP service could not start."); + return false; + } + } + } + + public static void Stop() { + lock (Gate) { + StopLocked(); + } + } + + internal static bool HasCurrentSessionToken(string? token) { + lock (Gate) { + return sessionToken != null && token != null && CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(sessionToken), Encoding.UTF8.GetBytes(token)); + } + } + + public static bool TryGetBearerToken(out string token) { + lock (Gate) { + if (listener?.IsListening != true || sessionToken == null) { + token = string.Empty; + return false; + } + token = sessionToken; + return true; + } + } + + public static void RefreshBearerToken() { + lock (Gate) { + var token = CreateSessionToken(); + Preferences.Default.McpToken = token; + Preferences.Save(); + sessionToken = token; + } + } + + public static bool TryGetConnectionConfiguration(out string configuration) { + lock (Gate) { + if (listener?.IsListening != true || options == null || sessionToken == null) { + configuration = string.Empty; + return false; + } + var host = options.BindAddress.Contains(':') ? $"[{options.BindAddress}]" : options.BindAddress; + configuration = JsonSerializer.Serialize(new { + mcpServers = new { + openutau = new { + url = $"http://{host}:{options.Port}/mcp", + headers = new Dictionary { ["Authorization"] = $"Bearer {sessionToken}" }, + }, + }, + }, JsonOptions); + return true; + } + } + + private static async Task RunAsync(HttpListener activeListener, CancellationToken token) { + while (!token.IsCancellationRequested) { + try { + var context = await activeListener.GetContextAsync().ConfigureAwait(false); + _ = Task.Run(() => HandleRequestAsync(context), token); + } catch (HttpListenerException) when (token.IsCancellationRequested || !activeListener.IsListening) { + break; + } catch (ObjectDisposedException) when (token.IsCancellationRequested) { + break; + } catch (Exception ex) { + Log.Warning(ex, "OpenUtau MCP listener failure."); + } + } + } + + private static async Task HandleRequestAsync(HttpListenerContext context) { + try { + if (context.Request.HttpMethod == "GET" && context.Request.Url?.AbsolutePath == "/healthz") { + var status = Status; + await WriteJsonAsync(context.Response, 200, new { status = "ready", bindAddress = status.BindAddress, port = status.Port }).ConfigureAwait(false); + return; + } + if (context.Request.Url?.AbsolutePath == "/mcp") { + await HandleMcpRequestAsync(context).ConfigureAwait(false); + return; + } + await WriteJsonAsync(context.Response, 404, new { error = "NOT_FOUND" }).ConfigureAwait(false); + } catch (Exception ex) { + Log.Warning(ex, "OpenUtau MCP request failed."); + try { context.Response.Close(); } catch { } + } + } + + private static async Task HandleMcpRequestAsync(HttpListenerContext context) { + if (!HasCurrentSessionToken(ReadBearerToken(context.Request))) { + await WriteJsonAsync(context.Response, 401, new { error = "UNAUTHORIZED" }).ConfigureAwait(false); + return; + } + if (context.Request.HttpMethod is "DELETE" or "GET") { + await WriteJsonAsync(context.Response, 405, new { error = "METHOD_NOT_ALLOWED" }).ConfigureAwait(false); + return; + } + if (context.Request.HttpMethod != "POST") { + await WriteJsonAsync(context.Response, 405, new { error = "METHOD_NOT_ALLOWED" }).ConfigureAwait(false); + return; + } + if (!IsJsonRequest(context.Request)) { + await WriteJsonAsync(context.Response, 415, new { error = "INVALID_CONTENT_TYPE" }).ConfigureAwait(false); + return; + } + if (!AcceptsMcpResponse(context.Request)) { + await WriteJsonAsync(context.Response, 406, new { error = "INVALID_ACCEPT" }).ConfigureAwait(false); + return; + } + JsonDocument document; + try { + document = JsonDocument.Parse(await ReadBodyAsync(context.Request).ConfigureAwait(false)); + } catch (BridgeException ex) { + await WriteJsonAsync(context.Response, 413, new { error = ex.Code }).ConfigureAwait(false); + return; + } catch (JsonException) { + await WriteJsonAsync(context.Response, 400, new { error = "INVALID_JSON" }).ConfigureAwait(false); + return; + } + using (document) { + var request = document.RootElement; + if (request.ValueKind != JsonValueKind.Object || request.GetPropertyOrDefault("jsonrpc") != "2.0" || !request.TryGetProperty("method", out var methodValue)) { + await WriteJsonAsync(context.Response, 400, new { error = "INVALID_JSON_RPC" }).ConfigureAwait(false); + return; + } + var requestId = request.TryGetProperty("id", out var id) ? id.Clone() : JsonSerializer.SerializeToElement(null); + var method = methodValue.GetString(); + var session = GetRequestContext(); + if (session == null) { + await WriteJsonAsync(context.Response, 503, RpcError(requestId, -32000, "SERVICE_NOT_READY")).ConfigureAwait(false); + return; + } + if (!ConsumeRequestQuota(session)) { + await WriteJsonAsync(context.Response, 429, RpcError(requestId, -32002, "RATE_LIMITED")).ConfigureAwait(false); + return; + } + var response = await DispatchMcpMethodAsync(method, request, session).ConfigureAwait(false); + Log.Information("OpenUtau MCP request {McpSession} {McpMethod} {McpResult}", session.Id, method, response.ErrorCode?.ToString() ?? "ok"); + await WriteJsonAsync(context.Response, response.ErrorCode == null ? 200 : 400, response.ToRpc(requestId)).ConfigureAwait(false); + } + } + + private static async Task DispatchMcpMethodAsync(string? method, JsonElement request, McpSession session) { + if (method == "initialize") { + return McpMethodResponse.Success(new { + protocolVersion = "2025-03-26", + capabilities = new { tools = new { }, resources = new { subscribe = false } }, + serverInfo = new { name = "openutau", version = BridgeProtocol.BridgeVersion }, + }); + } + if (method == "notifications/initialized") return McpMethodResponse.Success(new { }); + if (method == "tools/list") return McpMethodResponse.Success(new { + tools = new[] { + Tool("openutau_read", "Read authoritative OpenUtau state.", new[] { "action" }, "action", "payload"), + Tool("openutau_plan", "Create a guarded write plan that expires after confirmation timeout.", new[] { "action" }, "action", "payload", "expiresInSeconds"), + Tool("openutau_apply", "Apply one pending guarded write plan.", new[] { "planId" }, "planId"), + Tool("openutau_diagnostics", "Read Bridge and HTTP diagnostics.", Array.Empty()), + }, + }); + if (method != "tools/call" || !request.TryGetProperty("params", out var parameters)) return McpMethodResponse.Error(-32601, "METHOD_NOT_FOUND"); + var name = parameters.GetPropertyOrDefault("name"); + var arguments = parameters.TryGetProperty("arguments", out var args) && args.ValueKind == JsonValueKind.Object ? args : default; + if (name == "openutau_diagnostics") return await CallBridgeAsync("get_bridge_diagnostics", default).ConfigureAwait(false); + if (name == "openutau_read") { + var action = arguments.GetPropertyOrDefault("action"); + if (action == null || !ReadActions.Contains(action)) return McpMethodResponse.Error(-32602, "UNSUPPORTED_READ_ACTION"); + return await CallBridgeAsync(action, GetPayload(arguments)).ConfigureAwait(false); + } + if (name == "openutau_plan") { + var action = arguments.GetPropertyOrDefault("action"); + if (action == null || !WriteActions.Contains(action)) return McpMethodResponse.Error(-32602, "UNSUPPORTED_WRITE_ACTION"); + var seconds = arguments.TryGetProperty("expiresInSeconds", out var expiry) && expiry.TryGetInt32(out var requested) ? requested : 120; + if (seconds is < 1 or > 600) return McpMethodResponse.Error(-32602, "INVALID_PLAN_EXPIRY"); + var plan = new McpPlan(Guid.NewGuid().ToString("N"), action, GetPayload(arguments), DateTimeOffset.UtcNow.AddSeconds(seconds)); + session.Plans[plan.Id] = plan; + return ToolContent(new { planId = plan.Id, action, expiresAt = plan.ExpiresAt, status = "pending" }); + } + if (name == "openutau_apply") { + var planId = arguments.GetPropertyOrDefault("planId"); + if (planId == null || !session.Plans.Remove(planId, out var plan)) return McpMethodResponse.Error(-32602, "PLAN_NOT_FOUND"); + if (plan.ExpiresAt < DateTimeOffset.UtcNow) return McpMethodResponse.Error(-32602, "PLAN_EXPIRED"); + return await CallBridgeAsync(plan.Action, plan.Payload).ConfigureAwait(false); + } + return McpMethodResponse.Error(-32602, "UNKNOWN_TOOL"); + } + + private static async Task CallBridgeAsync(string action, JsonElement payload) { + await bridgeRequests.WaitAsync().ConfigureAwait(false); + try { + var requestPayload = payload.ValueKind == JsonValueKind.Object ? payload : JsonSerializer.SerializeToElement(new { }); + var envelope = JsonSerializer.SerializeToElement(new { v = BridgeProtocol.Version, id = Guid.NewGuid().ToString("N"), a = action, p = requestPayload }, JsonOptions); + var result = BridgeCore.DispatchRequest(envelope); + return ToolContent(result); + } catch (Exception ex) { + Log.Warning(ex, "OpenUtau MCP bridge dispatch failed for {McpAction}", action); + return McpMethodResponse.Error(-32000, "HOST_ERROR"); + } finally { + bridgeRequests.Release(); + } + } + + private static McpMethodResponse ToolContent(object result) => McpMethodResponse.Success(new { + content = new[] { new { type = "text", text = JsonSerializer.Serialize(result, JsonOptions) } }, + }); + private static object Tool(string name, string description, string[] required, params string[] properties) => new { + name, description, + inputSchema = new { + type = "object", + properties = properties.ToDictionary(property => property, property => property switch { + "payload" => (object)new { type = "object" }, + "expiresInSeconds" => new { type = "integer", minimum = 1, maximum = 600 }, + _ => new { type = "string" }, + }), + required, + additionalProperties = false, + }, + }; + + private static JsonElement GetPayload(JsonElement arguments) => arguments.TryGetProperty("payload", out var payload) && payload.ValueKind == JsonValueKind.Object ? payload.Clone() : JsonSerializer.SerializeToElement(new { }); + private static string? ReadBearerToken(HttpListenerRequest request) { + var value = request.Headers["Authorization"]; + return value?.StartsWith("Bearer ", StringComparison.Ordinal) == true ? value[7..] : null; + } + private static bool IsJsonRequest(HttpListenerRequest request) => request.ContentType?.StartsWith("application/json", StringComparison.OrdinalIgnoreCase) == true; + private static bool AcceptsMcpResponse(HttpListenerRequest request) { + var accept = request.Headers["Accept"]; + return !string.IsNullOrWhiteSpace(accept) && (accept.Contains("application/json", StringComparison.OrdinalIgnoreCase) || accept.Contains("text/event-stream", StringComparison.OrdinalIgnoreCase)); + } + private static async Task ReadBodyAsync(HttpListenerRequest request) { + if (request.ContentLength64 > MaxRequestBytes) throw new BridgeException("REQUEST_TOO_LARGE", "body too large"); + await using var body = new MemoryStream(); + var buffer = new byte[8192]; + int read; + while ((read = await request.InputStream.ReadAsync(buffer).ConfigureAwait(false)) > 0) { + if (body.Length + read > MaxRequestBytes) throw new BridgeException("REQUEST_TOO_LARGE", "body too large"); + await body.WriteAsync(buffer.AsMemory(0, read)).ConfigureAwait(false); + } + return body.ToArray(); + } + private static McpSession? GetRequestContext() { + lock (Gate) return requestContext; + } + private static bool ConsumeRequestQuota(McpSession session) { + lock (Gate) { + var cutoff = DateTimeOffset.UtcNow.AddMinutes(-1); + session.Requests.RemoveAll(time => time < cutoff); + if (session.Requests.Count >= 60) return false; + session.Requests.Add(DateTimeOffset.UtcNow); + return true; + } + } + private static object RpcError(JsonElement id, int code, string message) => new { jsonrpc = "2.0", id, error = new { code, message } }; + + private static async Task WriteJsonAsync(HttpListenerResponse response, int statusCode, object payload) { + var bytes = JsonSerializer.SerializeToUtf8Bytes(payload, JsonOptions); + response.StatusCode = statusCode; + response.ContentType = "application/json; charset=utf-8"; + response.ContentEncoding = Encoding.UTF8; + response.ContentLength64 = bytes.Length; + response.Headers[HttpResponseHeader.CacheControl] = "no-store"; + response.Headers["X-Content-Type-Options"] = "nosniff"; + await response.OutputStream.WriteAsync(bytes).ConfigureAwait(false); + response.Close(); + } + + private static void StopLocked() { + cancellation?.Cancel(); + cancellation?.Dispose(); + cancellation = null; + listener?.Close(); + listener = null; + worker = null; + options = null; + sessionToken = null; + requestContext = null; + } + + private static string BuildPrefix(McpServiceOptions serviceOptions) { + var host = serviceOptions.BindAddress.Contains(':') ? $"[{serviceOptions.BindAddress}]" : serviceOptions.BindAddress; + return $"http://{host}:{serviceOptions.Port}/"; + } + + private static string CreateSessionToken() { + var bytes = RandomNumberGenerator.GetBytes(32); + return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } + private static string GetOrCreatePersistentToken() { + if (!string.IsNullOrWhiteSpace(Preferences.Default.McpToken)) { + return Preferences.Default.McpToken; + } + var token = CreateSessionToken(); + Preferences.Default.McpToken = token; + Preferences.Save(); + return token; + } + + private sealed class McpSession { + public McpSession(string id) { Id = id; } + public string Id { get; } + public List Requests { get; } = new(); + public Dictionary Plans { get; } = new(); + } + private sealed record McpPlan(string Id, string Action, JsonElement Payload, DateTimeOffset ExpiresAt); + private sealed record McpMethodResponse(object? Result, int? ErrorCode, string? ErrorMessage) { + public static McpMethodResponse Success(object result) => new(result, null, null); + public static McpMethodResponse Error(int code, string message) => new(null, code, message); + public object ToRpc(JsonElement id) => ErrorCode == null + ? new { jsonrpc = "2.0", id, result = Result } + : RpcError(id, ErrorCode.Value, ErrorMessage!); + } + private sealed class BridgeException : Exception { + public BridgeException(string code, string message) : base(message) { Code = code; } + public string Code { get; } + } + private static string? GetPropertyOrDefault(this JsonElement element, string name) => element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; + } +} diff --git a/OpenUtau.Core/Util/PathManager.cs b/OpenUtau.Core/Util/PathManager.cs index 5a3e08afb..e4696a0ee 100644 --- a/OpenUtau.Core/Util/PathManager.cs +++ b/OpenUtau.Core/Util/PathManager.cs @@ -47,7 +47,8 @@ public PathManager() { DataPath = exePath; } else { string dataHome = Environment.GetFolderPath(Environment.SpecialFolder.Personal); - DataPath = Path.Combine(dataHome, "OpenUtau"); + var dataDirectoryName = File.Exists(Path.Combine(exePath, "installed-mcp.txt")) ? "OpenUtau MCP" : "OpenUtau"; + DataPath = Path.Combine(dataHome, dataDirectoryName); } CachePath = Path.Combine(DataPath, "Cache"); HomePathIsAscii = true; diff --git a/OpenUtau.Core/Util/Preferences.cs b/OpenUtau.Core/Util/Preferences.cs index 79de981f3..c4382da91 100644 --- a/OpenUtau.Core/Util/Preferences.cs +++ b/OpenUtau.Core/Util/Preferences.cs @@ -181,6 +181,11 @@ public class SerializablePreferences { public Dictionary SingerPhonemizers = new Dictionary(); public List RecentPhonemizers = new List(); public bool PreferPortAudio = false; + public bool McpEnabled = false; + public AgentBridge.McpStartupMode McpStartupMode = AgentBridge.McpStartupMode.Manual; + public string McpBindAddress = "127.0.0.1"; + public int McpPort = 43102; + public string McpToken = string.Empty; public bool UseSystemDefaultAudioDevice = true; public double PlayPosMarkerMargin = 0.9; public int LockStartTime = 0; diff --git a/OpenUtau.Test/AgentBridge/BridgeProtocolTest.cs b/OpenUtau.Test/AgentBridge/BridgeProtocolTest.cs new file mode 100644 index 000000000..0a3615820 --- /dev/null +++ b/OpenUtau.Test/AgentBridge/BridgeProtocolTest.cs @@ -0,0 +1,166 @@ +using OpenUtau.Core.AgentBridge; +using OpenUtau.Core.Util; +using OpenUtau.Core.Ustx; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Xunit; + +namespace OpenUtau.Test.AgentBridge { + public class BridgeProtocolTest { + [Fact] + public void V2ProtocolExposesOnlyBuiltInActions() { + Assert.Equal(2, BridgeProtocol.Version); + Assert.True(BridgeProtocol.IsSupportedAction("add_notes_simple")); + Assert.True(BridgeProtocol.IsSupportedAction("get_state_snapshot")); + Assert.True(BridgeProtocol.IsSupportedAction("get_state_events")); + Assert.True(BridgeProtocol.IsSupportedAction("get_bridge_diagnostics")); + Assert.False(BridgeProtocol.IsSupportedAction("apply_transaction")); + Assert.True(BridgeProtocol.IsSupportedAction("save_file")); + Assert.True(BridgeProtocol.IsSupportedAction("load_file")); + Assert.True(BridgeProtocol.IsSupportedAction("navigate_editor")); + Assert.True(BridgeProtocol.IsSupportedAction("open_piano_roll")); + Assert.False(BridgeProtocol.IsSupportedAction("ui_click")); + } + + [Fact] + public void StateSnapshotIncludesEditableNoteDetails() { + var note = UNote.Create(); + note.position = 120; + note.duration = 480; + note.tone = 60; + note.lyric = "a"; + note.tuning = 12; + note.pitch.AddPoint(new PitchPoint(25, 15, PitchPointShape.l)); + note.vibrato.length = 65; + note.phonemeExpressions.Add(new UExpression("vel") { index = 0, value = 80 }); + note.phonemeOverrides.Add(new UPhonemeOverride { index = 0, phoneme = "ka", offset = 10 }); + + using var document = JsonDocument.Parse(JsonSerializer.Serialize(BridgeCore.SnapshotNote(note, 3))); + var snapshot = document.RootElement; + + Assert.Equal(3, snapshot.GetProperty("index").GetInt32()); + Assert.Equal(25, snapshot.GetProperty("pitch").GetProperty("points")[0].GetProperty("x").GetSingle()); + Assert.Equal("l", snapshot.GetProperty("pitch").GetProperty("points")[0].GetProperty("shape").GetString()); + Assert.Equal(65, snapshot.GetProperty("vibrato").GetProperty("length").GetSingle()); + Assert.Equal("vel", snapshot.GetProperty("phonemeExpressions")[0].GetProperty("abbr").GetString()); + Assert.Equal("ka", snapshot.GetProperty("phonemeOverrides")[0].GetProperty("phoneme").GetString()); + Assert.Equal(10, snapshot.GetProperty("phonemeOverrides")[0].GetProperty("offset").GetInt32()); + } + + [Fact] + public async Task HttpMcpRequiresTokenAndIgnoresLegacySessionHeaders() { + var port = GetAvailablePort(); + var savedToken = Preferences.Default.McpToken; + Preferences.Default.McpToken = string.Empty; + Assert.True(McpServiceOptions.TryCreate("127.0.0.1", port, out var options, out _)); + Assert.True(McpService.Start(options, out _)); + try { + using var client = new HttpClient(); + var endpoint = $"http://127.0.0.1:{port}/mcp"; + var anonymous = await client.GetAsync(endpoint); + Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode); + + Assert.True(McpService.TryGetBearerToken(out var token)); + Assert.NotEmpty(token); + McpService.Stop(); + Assert.True(McpService.Start(options, out _)); + Assert.True(McpService.TryGetBearerToken(out var restartedToken)); + Assert.Equal(token, restartedToken); + McpService.RefreshBearerToken(); + Assert.True(McpService.TryGetBearerToken(out var refreshedToken)); + Assert.NotEqual(token, refreshedToken); + Assert.True(McpService.TryGetConnectionConfiguration(out var configuration)); + using var configDocument = JsonDocument.Parse(configuration); + var authorization = configDocument.RootElement + .GetProperty("mcpServers").GetProperty("openutau").GetProperty("headers") + .GetProperty("Authorization").GetString(); + Assert.Equal($"Bearer {refreshedToken}", authorization); + + using var expiredRequest = new HttpRequestMessage(HttpMethod.Post, endpoint) { + Content = new StringContent("{\"jsonrpc\":\"2.0\",\"id\":0,\"method\":\"initialize\"}", Encoding.UTF8, "application/json"), + }; + expiredRequest.Headers.TryAddWithoutValidation("Authorization", $"Bearer {token}"); + expiredRequest.Headers.TryAddWithoutValidation("Accept", "application/json"); + var expiredResponse = await client.SendAsync(expiredRequest); + Assert.Equal(HttpStatusCode.Unauthorized, expiredResponse.StatusCode); + + using var request = new HttpRequestMessage(HttpMethod.Post, endpoint) { + Content = new StringContent("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}", Encoding.UTF8, "application/json"), + }; + request.Headers.TryAddWithoutValidation("Authorization", authorization); + request.Headers.TryAddWithoutValidation("Accept", "application/json"); + var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.False(response.Headers.Contains("mcp-session-id")); + + using var toolsRequest = new HttpRequestMessage(HttpMethod.Post, endpoint) { + Content = new StringContent("{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\"}", Encoding.UTF8, "application/json"), + }; + toolsRequest.Headers.TryAddWithoutValidation("Authorization", authorization); + toolsRequest.Headers.TryAddWithoutValidation("Accept", "application/json"); + toolsRequest.Headers.TryAddWithoutValidation("mcp-session-id", "legacy-client-session"); + var toolsResponse = await client.SendAsync(toolsRequest); + Assert.Equal(HttpStatusCode.OK, toolsResponse.StatusCode); + Assert.Equal("no-store", toolsResponse.Headers.CacheControl?.ToString()); + Assert.True(toolsResponse.Headers.TryGetValues("X-Content-Type-Options", out var contentTypeOptions)); + Assert.Contains("nosniff", contentTypeOptions); + using var toolsDocument = JsonDocument.Parse(await toolsResponse.Content.ReadAsStringAsync()); + var tools = toolsDocument.RootElement.GetProperty("result").GetProperty("tools").EnumerateArray().ToArray(); + var readTool = tools.Single(tool => tool.GetProperty("name").GetString() == "openutau_read"); + var readSchema = readTool.GetProperty("inputSchema"); + Assert.Equal("object", readSchema.GetProperty("properties").GetProperty("payload").GetProperty("type").GetString()); + Assert.Equal("action", readSchema.GetProperty("required")[0].GetString()); + Assert.False(readSchema.GetProperty("additionalProperties").GetBoolean()); + + using var planRequest = new HttpRequestMessage(HttpMethod.Post, endpoint) { + Content = new StringContent("{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"openutau_plan\",\"arguments\":{\"action\":\"create_part\"}}}", Encoding.UTF8, "application/json"), + }; + planRequest.Headers.TryAddWithoutValidation("Authorization", authorization); + planRequest.Headers.TryAddWithoutValidation("Accept", "application/json"); + var planResponse = await client.SendAsync(planRequest); + Assert.Equal(HttpStatusCode.OK, planResponse.StatusCode); + using var planDocument = JsonDocument.Parse(await planResponse.Content.ReadAsStringAsync()); + var planText = planDocument.RootElement.GetProperty("result").GetProperty("content")[0].GetProperty("text").GetString(); + using var planPayload = JsonDocument.Parse(planText!); + Assert.Equal("pending", planPayload.RootElement.GetProperty("status").GetString()); + Assert.False(string.IsNullOrWhiteSpace(planPayload.RootElement.GetProperty("planId").GetString())); + + using var callRequest = new HttpRequestMessage(HttpMethod.Post, endpoint) { + Content = new StringContent("{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{\"name\":\"unknown\"}}", Encoding.UTF8, "application/json"), + }; + callRequest.Headers.TryAddWithoutValidation("Authorization", authorization); + callRequest.Headers.TryAddWithoutValidation("Accept", "application/json"); + callRequest.Headers.TryAddWithoutValidation("mcp-session-id", "legacy-client-session"); + var callResponse = await client.SendAsync(callRequest); + Assert.Equal(HttpStatusCode.BadRequest, callResponse.StatusCode); + using var callDocument = JsonDocument.Parse(await callResponse.Content.ReadAsStringAsync()); + Assert.Equal(-32602, callDocument.RootElement.GetProperty("error").GetProperty("code").GetInt32()); + } finally { + McpService.Stop(); + Preferences.Default.McpToken = savedToken; + Preferences.Save(); + } + } + + [Fact] + public void McpServiceOptionsAcceptOnlyLoopbackAddressesAndValidPorts() { + Assert.True(McpServiceOptions.TryCreate("::1", 43102, out _, out _)); + Assert.False(McpServiceOptions.TryCreate("0.0.0.0", 43102, out _, out _)); + Assert.False(McpServiceOptions.TryCreate("127.0.0.1", 0, out _, out _)); + Assert.False(McpServiceOptions.TryCreate("127.0.0.1", 65536, out _, out _)); + } + + private static int GetAvailablePort() { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + } +} diff --git a/OpenUtau/Strings/Strings.axaml b/OpenUtau/Strings/Strings.axaml index fe9830797..908df049e 100644 --- a/OpenUtau/Strings/Strings.axaml +++ b/OpenUtau/Strings/Strings.axaml @@ -591,6 +591,37 @@ Warning: this option removes custom presets. DiffSinger Editing General + MCP + MCP local service + Provides OpenUtau state and controls to local automation tools. The service accepts loopback connections only. + Enable MCP service + Startup mode + Start manually + Start when OpenUtau starts + Bind address + Port + Use the MCP menu next to Help to start the service, check its status, or copy the connection configuration. + MCP + Start MCP service + Stop MCP service + MCP service status + Copy MCP token + Refresh MCP token + Copy MCP connection configuration + MCP configuration is invalid. + MCP service failed to start. + Start the MCP service before copying its token. + Start the MCP service before copying its connection configuration. + MCP service started.\nEndpoint: {0} + MCP service stopped. + running + stopped + \nError: {0} + MCP service is {0}.\nEndpoint: {1}{2} + The current MCP token has been copied. Clear your clipboard after use. + Refreshing the token requires connected MCP clients to update their configuration. Continue? + MCP token refreshed. Update connected clients with the new token. + The MCP connection configuration has been copied. Clear your clipboard after use. Note: please restart OpenUtau after changing this item. Oto Editor Default Oto Editor diff --git a/OpenUtau/ViewModels/PreferencesViewModel.cs b/OpenUtau/ViewModels/PreferencesViewModel.cs index 30033cc38..10caf0e41 100644 --- a/OpenUtau/ViewModels/PreferencesViewModel.cs +++ b/OpenUtau/ViewModels/PreferencesViewModel.cs @@ -8,6 +8,7 @@ using OpenUtau.Audio; using OpenUtau.Classic; using OpenUtau.Core; +using OpenUtau.Core.AgentBridge; using OpenUtau.Core.Util; using ReactiveUI; using ReactiveUI.Fody.Helpers; @@ -129,6 +130,12 @@ public int SafeMaxThreadCount { [Reactive] public bool RememberVsqx { get; set; } public string WinePath => Preferences.Default.WinePath; + // MCP + [Reactive] public bool McpEnabled { get; set; } + [Reactive] public McpStartupMode McpStartupMode { get; set; } + [Reactive] public string McpBindAddress { get; set; } + [Reactive] public int McpPort { get; set; } + public PreferencesViewModel() { var audioOutput = PlaybackManager.Inst.AudioOutput; if (audioOutput != null) { @@ -192,6 +199,10 @@ public PreferencesViewModel() { RememberUst = Preferences.Default.RememberUst; RememberVsqx = Preferences.Default.RememberVsqx; ClearCacheOnQuit = Preferences.Default.ClearCacheOnQuit; + McpEnabled = Preferences.Default.McpEnabled; + McpStartupMode = Preferences.Default.McpStartupMode; + McpBindAddress = Preferences.Default.McpBindAddress; + McpPort = Preferences.Default.McpPort; MessageBus.Current.Listen() .Subscribe(_ => this.RaisePropertyChanged(nameof(IsThemeEditorOpen))); @@ -364,6 +375,30 @@ public PreferencesViewModel() { Preferences.Default.ClearCacheOnQuit = index; Preferences.Save(); }); + this.WhenAnyValue(vm => vm.McpEnabled) + .Skip(1) + .Subscribe(enabled => { + Preferences.Default.McpEnabled = enabled; + Preferences.Save(); + if (!enabled) { + McpService.Stop(); + } + }); + this.WhenAnyValue(vm => vm.McpStartupMode) + .Subscribe(mode => { + Preferences.Default.McpStartupMode = mode; + Preferences.Save(); + }); + this.WhenAnyValue(vm => vm.McpBindAddress) + .Subscribe(address => { + Preferences.Default.McpBindAddress = address; + Preferences.Save(); + }); + this.WhenAnyValue(vm => vm.McpPort) + .Subscribe(port => { + Preferences.Default.McpPort = port; + Preferences.Save(); + }); this.WhenAnyValue(vm => vm.DiffSingerSteps) .Subscribe(index => { Preferences.Default.DiffSingerSteps = index; diff --git a/OpenUtau/Views/MainWindow.axaml b/OpenUtau/Views/MainWindow.axaml index 94523d8b1..f0ff2c711 100644 --- a/OpenUtau/Views/MainWindow.axaml +++ b/OpenUtau/Views/MainWindow.axaml @@ -73,15 +73,23 @@ - - - - + + + + - - + + + + + + + + + + @@ -419,4 +427,4 @@ - \ No newline at end of file + diff --git a/OpenUtau/Views/MainWindow.axaml.cs b/OpenUtau/Views/MainWindow.axaml.cs index e895c1d5a..51862044e 100644 --- a/OpenUtau/Views/MainWindow.axaml.cs +++ b/OpenUtau/Views/MainWindow.axaml.cs @@ -18,6 +18,7 @@ using OpenUtau.Classic; using OpenUtau.Core; using OpenUtau.Core.Analysis; +using OpenUtau.Core.AgentBridge; using OpenUtau.Core.DiffSinger; using OpenUtau.Core.Format; using OpenUtau.Core.Ustx; @@ -117,6 +118,68 @@ public void InitProject() { viewModel.InitProject(this); } + public void LoadPartInPianoRoll(UVoicePart part, int tick) { + LoadPartInPianoRoll(part, tick, false); + } + + public void LoadPartInDetachedPianoRoll(UVoicePart part, int tick) { + LoadPartInPianoRoll(part, tick, true); + } + + private void LoadPartInPianoRoll(UVoicePart part, int tick, bool forceDetach) { + if (!Dispatcher.UIThread.CheckAccess()) { + throw new InvalidOperationException("Piano roll loading must run on the Avalonia UI thread."); + } + if (forceDetach && !Preferences.Default.DetachPianoRoll) { + Preferences.Default.DetachPianoRoll = true; + Preferences.Save(); + } + if (pianoRoll == null) { + LoadingWindow.BeginLoading(this); + try { + // The control and its view models subscribe to UI-bound state during construction. + var createdPianoRoll = new PianoRoll(new PianoRollViewModel()) { + MainWindow = this + }; + createdPianoRoll.ViewModel.PlaybackViewModel = viewModel.PlaybackViewModel; + + if (forceDetach || Preferences.Default.DetachPianoRoll) { + viewModel.ShowPianoRoll = false; + pianoRollWindow = new(createdPianoRoll); + } else { + PianoRollContainer.Content = createdPianoRoll; + } + + createdPianoRoll.InitializePianoRollWindowAsync(); + pianoRoll = createdPianoRoll; + } catch (Exception e) { + Log.Error(e, "Failed to initialize piano roll."); + throw; + } finally { + LoadingWindow.EndLoading(); + } + } + if (forceDetach && pianoRollWindow == null) { + PianoRollContainer.Content = null; + viewModel.ShowPianoRoll = false; + pianoRollWindow = new(pianoRoll); + } + viewModel.Page = 1; + if (pianoRollWindow != null) { + pianoRollWindow.Show(); + pianoRollWindow.Activate(); + } else { + viewModel.ShowPianoRoll = true; + pianoRoll.Focus(); + } + viewModel.TracksViewModel.DeselectParts(); + viewModel.TracksViewModel.SelectPart(part); + Dispatcher.UIThread.Post( + () => DocManager.Inst.ExecuteCmd(new LoadPartNotification(part, DocManager.Inst.Project, tick)), + DispatcherPriority.Loaded); + pianoRoll.AttachExpressions(); + } + void OnEditTimeSignature(object sender, PointerPressedEventArgs args) { var project = DocManager.Inst.Project; var timeSig = project.timeSignatures[0]; @@ -659,6 +722,68 @@ void OnMenuPreferences(object sender, RoutedEventArgs args) { } } + void OnMenuMcpStart(object sender, RoutedEventArgs args) { + if (!McpServiceOptions.TryCreate(Preferences.Default.McpBindAddress, Preferences.Default.McpPort, out var options, out var error)) { + _ = MessageBox.Show(this, error ?? ThemeManager.GetString("mcp.error.invalidconfig"), ThemeManager.GetString("mcp.caption"), MessageBox.MessageBoxButtons.Ok); + return; + } + if (!McpService.Start(options, out error)) { + _ = MessageBox.Show(this, error ?? ThemeManager.GetString("mcp.error.startfailed"), ThemeManager.GetString("mcp.caption"), MessageBox.MessageBoxButtons.Ok); + return; + } + _ = MessageBox.Show(this, string.Format(ThemeManager.GetString("mcp.started"), GetMcpEndpoint()), ThemeManager.GetString("mcp.caption"), MessageBox.MessageBoxButtons.Ok); + } + + void OnMenuMcpStop(object sender, RoutedEventArgs args) { + McpService.Stop(); + _ = MessageBox.Show(this, ThemeManager.GetString("mcp.stopped"), ThemeManager.GetString("mcp.caption"), MessageBox.MessageBoxButtons.Ok); + } + + void OnMenuMcpStatus(object sender, RoutedEventArgs args) { + var status = McpService.Status; + var state = ThemeManager.GetString(status.Running ? "mcp.status.running" : "mcp.status.stopped"); + var detail = string.IsNullOrWhiteSpace(status.Error) ? string.Empty : string.Format(ThemeManager.GetString("mcp.status.error"), status.Error); + _ = MessageBox.Show(this, string.Format(ThemeManager.GetString("mcp.status"), state, GetMcpEndpoint(), detail), ThemeManager.GetString("mcp.caption"), MessageBox.MessageBoxButtons.Ok); + } + + async void OnMenuMcpCopyToken(object sender, RoutedEventArgs args) { + if (!McpService.TryGetBearerToken(out var token)) { + _ = MessageBox.Show(this, ThemeManager.GetString("mcp.error.notrunning.copytoken"), ThemeManager.GetString("mcp.caption"), MessageBox.MessageBoxButtons.Ok); + return; + } + if (Clipboard != null) { + await Clipboard.SetTextAsync(token); + } + _ = MessageBox.Show(this, ThemeManager.GetString("mcp.token.copied"), ThemeManager.GetString("mcp.caption"), MessageBox.MessageBoxButtons.Ok); + } + + async void OnMenuMcpRefreshToken(object sender, RoutedEventArgs args) { + var result = await MessageBox.Show(this, ThemeManager.GetString("mcp.token.refresh.confirm"), ThemeManager.GetString("mcp.caption"), MessageBox.MessageBoxButtons.YesNo); + if (result != MessageBox.MessageBoxResult.Yes) { + return; + } + McpService.RefreshBearerToken(); + _ = MessageBox.Show(this, ThemeManager.GetString("mcp.token.refreshed"), ThemeManager.GetString("mcp.caption"), MessageBox.MessageBoxButtons.Ok); + } + + async void OnMenuMcpCopyEndpoint(object sender, RoutedEventArgs args) { + if (!McpService.TryGetConnectionConfiguration(out var configuration)) { + _ = MessageBox.Show(this, ThemeManager.GetString("mcp.error.notrunning.copyconfig"), ThemeManager.GetString("mcp.caption"), MessageBox.MessageBoxButtons.Ok); + return; + } + if (Clipboard != null) { + await Clipboard.SetTextAsync(configuration); + } + _ = MessageBox.Show(this, ThemeManager.GetString("mcp.configuration.copied"), ThemeManager.GetString("mcp.caption"), MessageBox.MessageBoxButtons.Ok); + } + + private static string GetMcpEndpoint() { + var host = Preferences.Default.McpBindAddress.Contains(':') + ? $"[{Preferences.Default.McpBindAddress}]" + : Preferences.Default.McpBindAddress; + return $"http://{host}:{Preferences.Default.McpPort}/mcp"; + } + void OnMenuFullScreen(object sender, RoutedEventArgs args) { this.WindowState = this.WindowState == WindowState.FullScreen ? WindowState.Normal @@ -1221,46 +1346,14 @@ public void PartsCanvasPointerReleased(object sender, PointerReleasedEventArgs a Cursor = null; } - public async void PartsCanvasDoubleTapped(object sender, TappedEventArgs args) { + public void PartsCanvasDoubleTapped(object sender, TappedEventArgs args) { if (sender is not Canvas canvas) { return; } var control = canvas.InputHitTest(args.GetPosition(canvas)); - if (control is PartControl partControl && partControl.part is UVoicePart) { - if (pianoRoll == null) { - LoadingWindow.BeginLoading(this); - - var model = await Task.Run(() => new PianoRollViewModel()); - - // Let's attach when needed to avoid startup slowdowns - pianoRoll = new PianoRoll(model) { - MainWindow = this - }; - - if (Preferences.Default.DetachPianoRoll) { - viewModel.ShowPianoRoll = false; - pianoRollWindow = new(pianoRoll); - } else { - PianoRollContainer.Content = pianoRoll; - } - - await Task.Run(() => - pianoRoll.InitializePianoRollWindowAsync() - ); - LoadingWindow.EndLoading(); - - pianoRoll.ViewModel.PlaybackViewModel = viewModel.PlaybackViewModel; - } - if (pianoRollWindow != null) { - pianoRollWindow.Show(); - pianoRollWindow.Activate(); - } else { - viewModel.ShowPianoRoll = true; - pianoRoll.Focus(); - } + if (control is PartControl partControl && partControl.part is UVoicePart part) { int tick = viewModel.TracksViewModel.PointToTick(args.GetPosition(canvas)); - DocManager.Inst.ExecuteCmd(new LoadPartNotification(partControl.part, DocManager.Inst.Project, tick)); - pianoRoll.AttachExpressions(); + LoadPartInPianoRoll(part, tick); } } @@ -1829,6 +1922,8 @@ void SetVoiceColorRemapping(UTrack track, IEnumerable parts, VoiceCo public void WindowClosing(object? sender, WindowClosingEventArgs e) { if (forceClose || DocManager.Inst.ChangesSaved) { + Core.AgentBridge.McpService.Stop(); + Core.AgentBridge.BridgeCore.Stop(); if (Preferences.Default.ClearCacheOnQuit) { Log.Information("Clearing cache..."); PathManager.Inst.ClearCache(); diff --git a/OpenUtau/Views/PreferencesDialog.axaml b/OpenUtau/Views/PreferencesDialog.axaml index 661cff5b5..f17510f9f 100644 --- a/OpenUtau/Views/PreferencesDialog.axaml +++ b/OpenUtau/Views/PreferencesDialog.axaml @@ -58,8 +58,9 @@ - - + + + + + + + + + + + + + + + + + + + + + + + diff --git a/OpenUtau/Views/SplashWindow.axaml.cs b/OpenUtau/Views/SplashWindow.axaml.cs index 6e090c539..24741c021 100644 --- a/OpenUtau/Views/SplashWindow.axaml.cs +++ b/OpenUtau/Views/SplashWindow.axaml.cs @@ -8,6 +8,8 @@ using OpenUtau.App; using OpenUtau.Classic; using OpenUtau.Core; +using OpenUtau.Core.AgentBridge; +using OpenUtau.Core.Util; using ReactiveUI; using Serilog; @@ -51,6 +53,7 @@ private void Start() { SingerManager.Inst.Initialize(); DocManager.Inst.Initialize(mainThread, mainScheduler); DocManager.Inst.PostOnUIThread = action => Avalonia.Threading.Dispatcher.UIThread.Post(action); + OpenUtau.Core.AgentBridge.BridgeCore.Start(); Log.Information("Initialized OpenUtau."); InitAudio(); }).ContinueWith(t => { @@ -61,9 +64,18 @@ private void Start() { } if (App.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { var mainWindow = new MainWindow(); + Core.AgentBridge.BridgeCore.MainWindow = mainWindow; + Core.AgentBridge.BridgeCore.SetLoadPartAction(mainWindow.LoadPartInDetachedPianoRoll); mainWindow.Show(); desktop.MainWindow = mainWindow; mainWindow.InitProject(); + if (Preferences.Default.McpEnabled && Preferences.Default.McpStartupMode == McpStartupMode.OnOpenUtauStartup) { + if (McpServiceOptions.TryCreate(Preferences.Default.McpBindAddress, Preferences.Default.McpPort, out var options, out var error)) { + McpService.Start(options, out _); + } else { + Log.Warning("OpenUtau MCP service has invalid preferences: {McpError}", error); + } + } LoadingWindow.InitializeLoadingWindow(); Close(); } diff --git a/README.md b/README.md index 160f79194..75365ba60 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,56 @@ -# OpenUtau +# OpenUtau MCP + +此分支将原生 HTTP MCP 集成到 OpenUtau 桌面应用。MCP 服务与应用进程一同运行,面向本机 OpenUtau 实例和本地 MCP 客户端。 + +## 原生 HTTP MCP + +实现位于 `OpenUtau.Core/AgentBridge/`,使用 Streamable HTTP、JSON-RPC 2.0 和 Bearer Token。服务仅绑定回环地址,默认端点为 `http://127.0.0.1:43102/mcp`。 + +### 启动与连接 + +1. 在 OpenUtau 菜单中打开 `MCP`。 +2. 选择 `启动 MCP 服务`。 +3. 选择 `复制 MCP 连接配置`,将剪贴板中的 JSON 粘贴到 MCP 客户端配置中。 + +复制出的配置形如以下示例。Token 由应用生成,示例中的值仅为占位符: + +```json +{ + "mcpServers": { + "openutau": { + "url": "http://127.0.0.1:43102/mcp", + "headers": { + "Authorization": "Bearer " + } + } + } +} +``` + +服务提供 `openutau_read`、`openutau_plan`、`openutau_apply` 和 `openutau_diagnostics`。写入操作先通过 `openutau_plan` 创建短期计划,再由 `openutau_apply` 以 `planId` 确认执行。 + +MCP 菜单还提供状态查看、停止服务、复制 Token 与刷新 Token。Token 在应用重启后保持有效;选择 `刷新 MCP Token` 并确认后,客户端需要粘贴新的连接配置。Token 属于本机凭据,请避免记录到日志、版本库或共享渠道。 + +### 构建与验证 + +```bash +/workspace/.dotnet/dotnet build OpenUtau/OpenUtau.csproj -p:BaseOutputPath=/workspace/openutau-build/ + +/workspace/.dotnet/dotnet test OpenUtau.Test/OpenUtau.Test.csproj --filter "FullyQualifiedName~BridgeProtocolTest" -p:BaseOutputPath=/workspace/openutau-build/ +``` + +### 音频理解工作流 + +面向 AI 辅助工程编辑的外部音频理解工作流位于 [`.monkeycode/docs/AUDIO_UNDERSTANDING_WORKFLOW.md`](.monkeycode/docs/AUDIO_UNDERSTANDING_WORKFLOW.md)。该设计稿涵盖用户校对歌词、音素强制对齐、连续音高提取、结构化音频证据和可审核的工程修改计划。 + +### 相关 Bridge 分支 + +`origin/Insert` 分支保留 stdio Bridge 协调器和文件 IPC 方案。此分支专注于应用内原生 HTTP MCP。 + +原版 OpenUtau 与 MCP 修改版适合采用独立安装目录和独立用户数据目录并行部署。发布脚本应明确配置安装器目录、应用标识与用户数据重定向。 + +## OpenUtau OpenUtau is a free, open-source editor made for the UTAU community. diff --git a/installer/OpenUtauMcp.iss b/installer/OpenUtauMcp.iss new file mode 100644 index 000000000..0e156fcb4 --- /dev/null +++ b/installer/OpenUtauMcp.iss @@ -0,0 +1,46 @@ +#define AppName "OpenUtau MCP" +#ifndef AppVersion + #define AppVersion "0.0.0" +#endif +#define AppPublisher "OpenUtau MCP Contributors" +#define AppExeName "OpenUtau.exe" + +#ifndef SourceDir + #define SourceDir "..\publish\win-x64" +#endif + +[Setup] +AppId={{C1A4602B-0F36-4D8A-B7D8-4733DB0B2638} +AppName={#AppName} +AppVersion={#AppVersion} +AppPublisher={#AppPublisher} +DefaultDirName={localappdata}\Programs\OpenUtau MCP +DefaultGroupName={#AppName} +DisableProgramGroupPage=yes +OutputBaseFilename=OpenUtau-MCP-Setup +Compression=lzma2 +SolidCompression=yes +ArchitecturesInstallIn64BitMode=x64 +PrivilegesRequired=lowest +UninstallDisplayName={#AppName} + +[Files] +Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs + +[Icons] +Name: "{autoprograms}\{#AppName}"; Filename: "{app}\{#AppExeName}" +Name: "{autodesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: desktopicon + +[Tasks] +Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription: "Additional shortcuts:" + +[Run] +Filename: "{app}\{#AppExeName}"; Description: "Launch {#AppName}"; Flags: nowait postinstall skipifsilent + +[Code] +procedure CurStepChanged(CurStep: TSetupStep); +begin + if CurStep = ssPostInstall then begin + SaveStringToFile(ExpandConstant('{app}\installed-mcp.txt'), 'OpenUtau MCP installation marker' + #13#10, False); + end; +end;