diff --git a/.vscode/tasks.json b/.vscode/tasks.json index f018d91b..8a1c11d1 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -2,7 +2,7 @@ "version": "2.0.0", "tasks": [ { - "label": "Release", + "label": "CT: Release", "command": "dotnet", "type": "process", "args": [ @@ -15,7 +15,7 @@ "problemMatcher": "$msCompile" }, { - "label": "Build All", + "label": "CT: Build All", "command": "dotnet", "type": "process", "args": [ @@ -26,7 +26,7 @@ "problemMatcher": "$msCompile" }, { - "label": "Build Server", + "label": "CT: Build Server", "command": "dotnet", "type": "process", "args": [ @@ -37,7 +37,7 @@ "problemMatcher": "$msCompile" }, { - "label": "UI Install", + "label": "CT: UI Install", "type": "shell", "command": "npm install", "options": { @@ -52,9 +52,12 @@ } }, { - "label": "UI Build", + "label": "CT: UI Build", "type": "shell", "command": "npm run build --prefix app && xcopy /y /e /i dist ${workspaceFolder}\\bin\\x64\\Debug\\net472\\dist", + "options": { + "cwd": "${workspaceFolder}" + }, "windows": { "command": "cmd", "args": [ @@ -71,12 +74,12 @@ } }, { - "label": "Fast Run", + "label": "CT: Fast Run", "command": "${workspaceFolder}/bin/x64/Debug/net472/ConverseTek.exe", "type": "shell" }, { - "label": "UI-Only Run", + "label": "CT: UI-Only Run", "command": "npm start", "options": { "cwd": "${workspaceFolder}/app/" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..dd4a7b05 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,36 @@ +# ConverseTek Agent Guidance + +This file is the canonical local guidance for coding agents in this repository. Also follow the workspace-level `AGENTS.md` when working from the multi-root ConverseTek workspace. + +## Language + +Use UK English in prose, docs, comments, commit messages, PR text, and issue text. Keep existing identifiers as-is when they already use US spelling. + +## Project Shape + +ConverseTek is a React + TypeScript + MobX frontend in `app/`, hosted by a C#/.NET Framework 4.7.2 Chromely backend at the repository root. The backend reads and writes BattleTech protobuf `.bytes` conversation files through the game DLLs in `libs/`. + +Read `docs/architecture/overview.md` before larger changes, then the specific architecture document for the area being touched. + +## Chromely Routing + +Do not register GET and POST handlers on the exact same route path. This Chromely version can collide or shadow routes by path even when the HTTP verb differs, causing frontend GET promises to never resolve. Use distinct paths such as `GET /ai/settings/current` and `POST /ai/settings`. + +## Frontend CSS + +The embedded Chromely/CEF runtime may lag behind modern browser CSS support. Avoid relying on `gap` for flex layouts in app UI; use explicit margins or margin fallbacks for spacing between flex children, especially in modal controls, tag lists, toolbars, and wrapped button rows. CSS Grid `gap` is acceptable where already verified in the runtime. + +## AI Drafting + +AI-assisted conversation drafting is advisory only. Drafts must stay outside `dataStore.unsavedActiveConversationAsset` until the user accepts them. + +- Global AI settings live in `config/ai.json`. +- Workspace AI settings are keyed by conversation folder inside `config/ai.json`; do not create loose settings files in mod `conversations/` folders. +- Workspace cast personalities live inside `config/ai.json`; built-in default personalities live in `config/ai-personalities.json`. Keep defaults editable, preserve explicit empty lists, and apply them through provider prompts rather than hard-coding character voice into draft conversion. +- Backend provider work goes through `Services/AiProviderService.cs`; keep provider names generic so Codex, Claude Code, and future CLIs can share the interface. +- Frontend draft conversion lives in `app/src/utils/ai-draft-utils.ts`; keep these functions pure and return fresh conversation assets or explicit patch objects. +- Accepting a full draft must use "Turn Into Real Conversation"; node or branch suggestions must remain "Accept" / "Reject" until accepted. + +## Verification + +Always run the relevant lint and test commands before handing work back. For frontend changes, run `npm run ts-check` from `app/`, the configured lint command if present, and the relevant Vitest suite. For backend changes, run `dotnet build ConverseTek.csproj /t:BuildServer` from the repo root when game DLLs are available. If a lint or test command is missing, blocked, or not relevant to the touched area, say that explicitly in the final response. diff --git a/CLAUDE.md b/CLAUDE.md index 6a56d5f7..d988d19f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md -Guidance for Claude Code when working in this repository. +Guidance for Claude Code when working in this repository. `AGENTS.md` is the canonical local guidance for coding agents; keep this file compatible with it. ## What this project is @@ -35,6 +35,15 @@ For architecture detail, read the docs under [`docs/architecture/`](./docs/archi - Backend controllers live in `Controllers/`, services in `Services/`. Services follow a singleton `getInstance()` pattern. - Prefer editing existing files over creating new ones. Don't add comments that explain *what* the code does — only *why* when non-obvious. +## AI drafting + +- AI-assisted drafting is advisory until accepted. Keep draft preview data outside `dataStore.unsavedActiveConversationAsset`. +- Global AI settings live in `config/ai.json`; workspace AI context is keyed by conversation folder inside that file. Do not write loose AI settings into a mod `conversations/` folder. +- Workspace cast personalities live in `config/ai.json`; built-in default personalities live in `config/ai-personalities.json`. Defaults should remain editable, explicit empty lists should be preserved, and character voice rules should be applied through provider prompts. +- Backend AI providers go through `Services/AiProviderService.cs`; Codex CLI is the first provider, but provider names should stay generic for future CLIs. +- Frontend draft conversion lives in `app/src/utils/ai-draft-utils.ts` and should return fresh conversation assets or explicit patch objects. +- Full drafts use "Turn Into Real Conversation"; node/branch suggestions use "Accept" / "Reject". + ## Important quirks - Chromely's pinned version only supports `GET` and `POST` over the JS bridge. PUT/DELETE are emulated as POSTs with a `method` field — see `app/src/services/api.ts` calls like `post(url, params, { method: 'PUT', ... })`. diff --git a/Controllers/AiController.cs b/Controllers/AiController.cs new file mode 100644 index 00000000..feacf1ca --- /dev/null +++ b/Controllers/AiController.cs @@ -0,0 +1,222 @@ +namespace ConverseTek.Controllers { + using System; + using System.IO; + + using Newtonsoft.Json; + using Newtonsoft.Json.Linq; + + using Chromely.Core.RestfulService; + using Chromely.Core.Infrastructure; + + using ProtoBuf.Meta; + using isogame; + + using ConverseTek.Data; + using ConverseTek.Services; + + [ControllerProperty(Name = "AiController", Route = "ai")] + public class AiController : ChromelyController { + public AiController() { + this.RegisterGetRequest("/ai/settings/current", this.GetSettings); + this.RegisterPostRequest("/ai/settings", this.SaveSettings); + this.RegisterPostRequest("/ai/models", this.GetModels); + this.RegisterPostRequest("/ai/draft", this.CreateDraft); + this.RegisterPostRequest("/ai/draft-artifact", this.GetDraftArtifact); + this.RegisterPostRequest("/ai/validate-conversation", this.ValidateConversation); + } + + private ChromelyResponse GetSettings(ChromelyRequest request) { + ConfigService configService = ConfigService.getInstance(); + AiSettings settings = configService.GetAiSettings(); + + ChromelyResponse response = new ChromelyResponse(); + response.Data = SerializeAiSettingsResponse(configService, settings); + return response; + } + + private ChromelyResponse SaveSettings(ChromelyRequest request) { + string postDataJson = (string)request.PostData.EnsureJson(); + JObject data = JObject.Parse(postDataJson); + ConfigService configService = ConfigService.getInstance(); + + if (data["settings"] != null) { + AiSettings settings = JsonConvert.DeserializeObject(data["settings"].ToString()); + settings = configService.SaveAiSettings(settings); + ChromelyResponse settingsResponse = new ChromelyResponse(); + settingsResponse.Data = SerializeAiSettingsResponse(configService, settings); + return settingsResponse; + } + + if (data["workspaceSettings"] != null) { + AiWorkspaceSettings workspaceSettings = JsonConvert.DeserializeObject(data["workspaceSettings"].ToString()); + AiSettings settings = configService.SaveAiWorkspaceSettings(workspaceSettings); + ChromelyResponse workspaceResponse = new ChromelyResponse(); + workspaceResponse.Data = SerializeAiSettingsResponse(configService, settings); + return workspaceResponse; + } + + ChromelyResponse response = new ChromelyResponse(); + response.Data = SerializeAiSettingsResponse(configService, configService.GetAiSettings()); + return response; + } + + private string SerializeAiSettingsResponse(ConfigService configService, AiSettings settings) { + JObject response = JObject.FromObject(settings); + response["DefaultCastPersonalities"] = JArray.FromObject(configService.GetAiCastPersonalityDefaults()); + return response.ToString(Formatting.None); + } + + private ChromelyResponse GetModels(ChromelyRequest request) { + ChromelyResponse response = new ChromelyResponse(); + + try { + if (ConfigService.getInstance().GetAiSettings().Enabled == false) { + response.Data = JsonConvert.SerializeObject(new AiModelCatalogResult { + Success = false, + Error = "AI features are disabled in config/ai.json.", + Provider = "unknown" + }); + return response; + } + + string postDataJson = (string)request.PostData.EnsureJson(); + JObject data = JObject.Parse(postDataJson); + bool forceRefresh = data["forceRefresh"] != null && data["forceRefresh"].Value(); + AiSettings settings = data["settings"] == null + ? ConfigService.getInstance().GetAiSettings() + : JsonConvert.DeserializeObject(data["settings"].ToString()); + + AiModelCatalogResult result = AiProviderService.getInstance().GetModels(settings, forceRefresh); + response.Data = JsonConvert.SerializeObject(result); + } catch (Exception error) { + response.Data = JsonConvert.SerializeObject(new AiModelCatalogResult { + Success = false, + Error = error.Message, + Provider = "unknown" + }); + } + + return response; + } + + private ChromelyResponse CreateDraft(ChromelyRequest request) { + ChromelyResponse response = new ChromelyResponse(); + + try { + if (ConfigService.getInstance().GetAiSettings().Enabled == false) { + response.Data = JsonConvert.SerializeObject(new AiDraftRunResult { + Success = false, + Error = "AI features are disabled in config/ai.json.", + Provider = "unknown" + }); + return response; + } + + string postDataJson = (string)request.PostData.EnsureJson(); + JObject data = JObject.Parse(postDataJson); + AiDraftRequest draftRequest = JsonConvert.DeserializeObject(data["request"].ToString()); + AiDraftRunResult result = AiProviderService.getInstance().RunDraft(draftRequest); + response.Data = JsonConvert.SerializeObject(result); + } catch (Exception error) { + response.Data = JsonConvert.SerializeObject(new AiDraftRunResult { + Success = false, + Error = error.Message, + Provider = "unknown" + }); + } + + return response; + } + + private ChromelyResponse GetDraftArtifact(ChromelyRequest request) { + ChromelyResponse response = new ChromelyResponse(); + AiDraftArtifactResult result = new AiDraftArtifactResult { + Success = false, + Error = "", + Content = "", + Path = "", + Truncated = false + }; + + try { + if (ConfigService.getInstance().GetAiSettings().Enabled == false) { + result.Error = "AI features are disabled in config/ai.json."; + response.Data = JsonConvert.SerializeObject(result); + return response; + } + + string postDataJson = (string)request.PostData.EnsureJson(); + JObject data = JObject.Parse(postDataJson); + string path = data["path"] == null ? "" : data["path"].ToString(); + result.Path = path; + + if (string.IsNullOrEmpty(path)) { + result.Error = "No AI diagnostics artifact path was provided."; + response.Data = JsonConvert.SerializeObject(result); + return response; + } + + string diagnosticsRoot = Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "logs", "ai-drafts")); + string fullPath = Path.GetFullPath(path); + string diagnosticsRootWithSeparator = diagnosticsRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; + + if (!fullPath.StartsWith(diagnosticsRootWithSeparator, StringComparison.OrdinalIgnoreCase)) { + result.Error = "AI diagnostics artifact is outside the AI draft logs folder."; + response.Data = JsonConvert.SerializeObject(result); + return response; + } + + if (!File.Exists(fullPath)) { + result.Error = "AI diagnostics artifact does not exist."; + response.Data = JsonConvert.SerializeObject(result); + return response; + } + + const int maxCharacters = 200000; + string content = File.ReadAllText(fullPath); + if (content.Length > maxCharacters) { + content = content.Substring(0, maxCharacters); + result.Truncated = true; + } + + result.Content = content; + result.Success = true; + } catch (Exception error) { + result.Success = false; + result.Error = error.Message; + } + + response.Data = JsonConvert.SerializeObject(result); + return response; + } + + private ChromelyResponse ValidateConversation(ChromelyRequest request) { + ChromelyResponse response = new ChromelyResponse(); + ConversationValidationResult result = new ConversationValidationResult { + Success = false, + Error = "" + }; + + try { + string postDataJson = (string)request.PostData.EnsureJson(); + JObject data = JObject.Parse(postDataJson); + ConversationAsset conversationAsset = JsonConvert.DeserializeObject(data["conversationAsset"].ToString()); + + RuntimeTypeModel runtimeTypeModel = TypeModel.Create(); + using (MemoryStream memoryStream = new MemoryStream()) { + runtimeTypeModel.Serialize(memoryStream, conversationAsset.Conversation); + memoryStream.Position = 0; + Conversation roundTrippedConversation = runtimeTypeModel.Deserialize(memoryStream, null, typeof(Conversation)) as Conversation; + result.Success = roundTrippedConversation != null; + result.Error = result.Success ? "" : "The generated conversation could not be read back after serialisation."; + } + } catch (Exception error) { + result.Success = false; + result.Error = error.Message; + } + + response.Data = JsonConvert.SerializeObject(result); + return response; + } + } +} diff --git a/Controllers/FileSystemController.cs b/Controllers/FileSystemController.cs index 0ffa41f5..0e51f73c 100644 --- a/Controllers/FileSystemController.cs +++ b/Controllers/FileSystemController.cs @@ -45,6 +45,16 @@ private ChromelyResponse GetDirectories(ChromelyRequest request) { IDictionary requestParams = request.Parameters; string path = (string)requestParams["path"]; bool includeFiles = (bool)requestParams["includeFiles"]; + List fileExtensions = new List(); + + if (requestParams.ContainsKey("fileExtensions") && requestParams["fileExtensions"] != null) { + JArray extensionTokens = requestParams["fileExtensions"] as JArray; + if (extensionTokens != null) { + foreach (JToken extensionToken in extensionTokens) { + fileExtensions.Add(extensionToken.ToString()); + } + } + } if (path == "Desktop") { path = Environment.GetFolderPath(Environment.SpecialFolder.Desktop); @@ -59,7 +69,7 @@ private ChromelyResponse GetDirectories(ChromelyRequest request) { List files = new List(); if (includeFiles) { - files = fileSystemService.GetFiles(path); + files = fileSystemService.GetFiles(path, fileExtensions); } FsView fsView = new FsView(); @@ -173,4 +183,4 @@ private ChromelyResponse GetDependencyStatus(ChromelyRequest request) { return response; } } -} \ No newline at end of file +} diff --git a/ConverseTek.csproj b/ConverseTek.csproj index 6ef4f898..42f0be12 100644 --- a/ConverseTek.csproj +++ b/ConverseTek.csproj @@ -65,10 +65,16 @@ + + + + - + + + @@ -102,4 +108,4 @@ - \ No newline at end of file + diff --git a/Data/AiDraft.cs b/Data/AiDraft.cs new file mode 100644 index 00000000..73078861 --- /dev/null +++ b/Data/AiDraft.cs @@ -0,0 +1,68 @@ +namespace ConverseTek.Data { + using System.Collections.Generic; + + public class AiDraftRequest { + public string Mode { get; set; } + public string Brief { get; set; } + public string WorkingDirectory { get; set; } + public string ConversationJson { get; set; } + public string SelectedNodeJson { get; set; } + public string DefinitionsJson { get; set; } + } + + public class AiDraftProviderRequest { + public AiDraftRequest Request { get; set; } + public AiSettings Settings { get; set; } + public AiWorkspaceSettings WorkspaceSettings { get; set; } + public List ContextFiles { get; set; } + } + + public class AiContextFile { + public string Path { get; set; } + public string Content { get; set; } + public bool Truncated { get; set; } + } + + public class AiDraftRunResult { + public bool Success { get; set; } + public string Error { get; set; } + public string Provider { get; set; } + public string DiagnosticsDirectory { get; set; } + public string RequestPath { get; set; } + public string PromptPath { get; set; } + public string OutputPath { get; set; } + public string StdoutPath { get; set; } + public string StderrPath { get; set; } + public string DraftJson { get; set; } + } + + public class AiDraftArtifactResult { + public bool Success { get; set; } + public string Error { get; set; } + public string Path { get; set; } + public string Content { get; set; } + public bool Truncated { get; set; } + } + + public class ConversationValidationResult { + public bool Success { get; set; } + public string Error { get; set; } + } + + public class AiModelOption { + public string Slug { get; set; } + public string DisplayName { get; set; } + public string Description { get; set; } + public string DefaultReasoningLevel { get; set; } + public int Priority { get; set; } + } + + public class AiModelCatalogResult { + public bool Success { get; set; } + public string Error { get; set; } + public string Provider { get; set; } + public string DefaultModel { get; set; } + public bool FromCache { get; set; } + public List Models { get; set; } + } +} diff --git a/Data/AiSettings.cs b/Data/AiSettings.cs new file mode 100644 index 00000000..29accbb9 --- /dev/null +++ b/Data/AiSettings.cs @@ -0,0 +1,55 @@ +namespace ConverseTek.Data { + using System.Collections.Generic; + + public class AiSettings { + public bool? Enabled { get; set; } + public string SelectedProvider { get; set; } + public string CodexCommand { get; set; } + public string CodexModel { get; set; } + public string CodexProfile { get; set; } + public int TimeoutSeconds { get; set; } + public Dictionary ModelCatalogs { get; set; } + public Dictionary Workspaces { get; set; } + + public static AiSettings CreateDefault() { + return new AiSettings { + Enabled = true, + SelectedProvider = "codex", + CodexCommand = "codex", + CodexModel = "", + CodexProfile = "", + TimeoutSeconds = 300, + ModelCatalogs = new Dictionary(), + Workspaces = new Dictionary() + }; + } + } + + public class AiWorkspaceSettings { + public string WorkingDirectory { get; set; } + public List ContextPaths { get; set; } + public string HouseStyleNotes { get; set; } + public string DefaultCampaignBrief { get; set; } + public List CastPersonalities { get; set; } + + public static AiWorkspaceSettings CreateDefault(string workingDirectory) { + return new AiWorkspaceSettings { + WorkingDirectory = workingDirectory, + ContextPaths = new List(), + HouseStyleNotes = "", + DefaultCampaignBrief = "", + CastPersonalities = null + }; + } + } + + public class AiCastPersonality { + public string Id { get; set; } + public string Label { get; set; } + public List CastIds { get; set; } + public List SpeakerIds { get; set; } + public string Rules { get; set; } + public bool? Enabled { get; set; } + public string DefaultKey { get; set; } + } +} diff --git a/Program.cs b/Program.cs index 381756b0..db8c9411 100644 --- a/Program.cs +++ b/Program.cs @@ -62,8 +62,8 @@ public static int Main(string[] args) { string startUrl = "local://dist/index.html"; - int defaultScreenWidth = 1480; - int defaultScreenHeight = 900; + int defaultScreenWidth = 1720; + int defaultScreenHeight = 1000; int screenWidth = Screen.PrimaryScreen.Bounds.Width - 100; int screenHeight = Screen.PrimaryScreen.Bounds.Height - 100; diff --git a/Services/AiProviderService.cs b/Services/AiProviderService.cs new file mode 100644 index 00000000..24a62491 --- /dev/null +++ b/Services/AiProviderService.cs @@ -0,0 +1,665 @@ +namespace ConverseTek.Services { + using System; + using System.Collections.Generic; + using System.Diagnostics; + using System.IO; + using System.Text; + + using Chromely.Core.Infrastructure; + using Newtonsoft.Json; + using Newtonsoft.Json.Linq; + + using ConverseTek.Data; + + public interface IAiProvider { + AiDraftRunResult RunDraft(AiDraftProviderRequest request); + } + + public class AiProviderService { + private static AiProviderService instance; + + public static AiProviderService getInstance() { + if (instance == null) instance = new AiProviderService(); + return instance; + } + + public AiDraftRunResult RunDraft(AiDraftRequest request) { + ConfigService configService = ConfigService.getInstance(); + AiSettings settings = configService.GetAiSettings(); + if (settings.Enabled == false) { + return new AiDraftRunResult { + Success = false, + Provider = settings.SelectedProvider ?? "unknown", + Error = "AI features are disabled in config/ai.json." + }; + } + + AiWorkspaceSettings workspaceSettings = configService.GetAiWorkspaceSettings(request.WorkingDirectory); + + AiDraftProviderRequest providerRequest = new AiDraftProviderRequest { + Request = request, + Settings = settings, + WorkspaceSettings = workspaceSettings, + ContextFiles = LoadContextFiles(workspaceSettings) + }; + + IAiProvider provider = CreateProvider(settings.SelectedProvider); + return provider.RunDraft(providerRequest); + } + + public AiModelCatalogResult GetModels(AiSettings settings, bool forceRefresh) { + settings = settings ?? ConfigService.getInstance().GetAiSettings(); + if (settings.Enabled == false) { + return new AiModelCatalogResult { + Success = false, + Error = "AI features are disabled in config/ai.json.", + Provider = settings.SelectedProvider ?? "unknown", + DefaultModel = "", + FromCache = false, + Models = new List() + }; + } + + string normalisedProviderName = (settings.SelectedProvider ?? "").Trim().ToLowerInvariant(); + if (normalisedProviderName == "") normalisedProviderName = "codex"; + + ConfigService configService = ConfigService.getInstance(); + AiSettings savedSettings = configService.GetAiSettings(); + AiModelCatalogResult cachedResult = GetCachedModelCatalog(savedSettings, normalisedProviderName); + + if (!forceRefresh && cachedResult != null) { + cachedResult.FromCache = true; + return cachedResult; + } + + if (normalisedProviderName == "" || normalisedProviderName == "codex") { + AiModelCatalogResult result = new CodexCliAiProvider().GetModels(settings); + + if (result.Success && result.Models != null && result.Models.Count > 0) { + result.FromCache = false; + savedSettings.ModelCatalogs[normalisedProviderName] = result; + configService.SaveAiSettings(savedSettings); + return result; + } + + if (cachedResult != null) { + cachedResult.FromCache = true; + cachedResult.Error = "Could not refresh provider models. Showing the last saved model list. " + result.Error; + return cachedResult; + } + + return result; + } + + return new AiModelCatalogResult { + Success = false, + Error = "AI provider '" + settings.SelectedProvider + "' does not expose model polling yet.", + Provider = normalisedProviderName, + DefaultModel = "", + FromCache = false, + Models = new List() + }; + } + + private AiModelCatalogResult GetCachedModelCatalog(AiSettings settings, string providerName) { + if (settings == null || settings.ModelCatalogs == null) return null; + if (!settings.ModelCatalogs.ContainsKey(providerName)) return null; + + AiModelCatalogResult cachedResult = settings.ModelCatalogs[providerName]; + if (cachedResult == null || cachedResult.Models == null || cachedResult.Models.Count == 0) return null; + return cachedResult; + } + + private IAiProvider CreateProvider(string providerName) { + string normalisedProviderName = (providerName ?? "").Trim().ToLowerInvariant(); + if (normalisedProviderName == "" || normalisedProviderName == "codex") { + return new CodexCliAiProvider(); + } + + throw new NotSupportedException("AI provider '" + providerName + "' is not supported yet."); + } + + private List LoadContextFiles(AiWorkspaceSettings workspaceSettings) { + List files = new List(); + if (workspaceSettings == null || workspaceSettings.ContextPaths == null) return files; + + foreach (string path in workspaceSettings.ContextPaths) { + if (files.Count >= 16) break; + AddContextPath(files, path); + } + + return files; + } + + private void AddContextPath(List files, string path) { + if (string.IsNullOrEmpty(path)) return; + + try { + if (File.Exists(path)) { + AddContextFile(files, path); + return; + } + + if (!Directory.Exists(path)) return; + + string[] candidateFiles = Directory.GetFiles(path, "*.*", SearchOption.AllDirectories); + Array.Sort(candidateFiles, StringComparer.OrdinalIgnoreCase); + foreach (string candidateFile in candidateFiles) { + if (files.Count >= 16) break; + if (IsSupportedContextFile(candidateFile)) { + AddContextFile(files, candidateFile); + } + } + } catch (Exception error) { + Log.Error("[AiProviderService] Could not load AI context path " + path + ": " + error.Message); + } + } + + private void AddContextFile(List files, string path) { + if (!IsSupportedContextFile(path)) return; + + string source = File.ReadAllText(path, Encoding.UTF8); + bool truncated = false; + if (source.Length > 40000) { + source = source.Substring(0, 40000); + truncated = true; + } + + files.Add(new AiContextFile { + Path = path, + Content = source, + Truncated = truncated + }); + } + + private bool IsSupportedContextFile(string path) { + string extension = Path.GetExtension(path).ToLowerInvariant(); + return extension == ".md" || + extension == ".txt" || + extension == ".json" || + extension == ".jsonc" || + extension == ".html" || + extension == ".cs"; + } + } + + public class CodexCliAiProvider : IAiProvider { + private static string BASE_DIRECTORY = AppDomain.CurrentDomain.BaseDirectory; + + public AiModelCatalogResult GetModels(AiSettings settings) { + AiModelCatalogResult result = new AiModelCatalogResult { + Success = false, + Provider = "codex", + DefaultModel = "", + FromCache = false, + Models = new List() + }; + + try { + Process process = CreateCodexModelsProcess(settings); + string stdout = ""; + string stderr = ""; + + process.Start(); + stdout = process.StandardOutput.ReadToEnd(); + stderr = process.StandardError.ReadToEnd(); + bool exited = process.WaitForExit(30000); + + if (!exited) { + try { + process.Kill(); + } catch { + } + + result.Error = "Codex model polling timed out after 30 seconds."; + return result; + } + + if (process.ExitCode != 0) { + result.Error = "Codex model polling exited with code " + process.ExitCode + ". " + FormatProcessOutputForDisplay(stderr == "" ? stdout : stderr); + return result; + } + + JObject catalog = JObject.Parse(ExtractJsonObject(stdout)); + JArray models = catalog["models"] as JArray; + if (models == null) { + result.Error = "Codex model polling returned no models array."; + return result; + } + + foreach (JToken model in models) { + string visibility = model.Value("visibility") ?? ""; + if (visibility != "" && visibility != "list") continue; + + result.Models.Add(new AiModelOption { + Slug = model.Value("slug") ?? "", + DisplayName = model.Value("display_name") ?? model.Value("slug") ?? "", + Description = model.Value("description") ?? "", + DefaultReasoningLevel = model.Value("default_reasoning_level") ?? "", + Priority = model.Value("priority") ?? 0 + }); + } + + result.Models.Sort((left, right) => left.Priority.CompareTo(right.Priority)); + result.Success = true; + return result; + } catch (Exception error) { + result.Error = error.Message; + return result; + } + } + + public AiDraftRunResult RunDraft(AiDraftProviderRequest providerRequest) { + AiDraftRunResult result = new AiDraftRunResult { + Success = false, + Provider = "codex" + }; + + string runDirectory = CreateRunDirectory(); + result.DiagnosticsDirectory = runDirectory; + result.RequestPath = Path.Combine(runDirectory, "request.json"); + result.PromptPath = Path.Combine(runDirectory, "prompt.md"); + result.OutputPath = Path.Combine(runDirectory, "draft.json"); + result.StdoutPath = Path.Combine(runDirectory, "stdout.log"); + result.StderrPath = Path.Combine(runDirectory, "stderr.log"); + string commandPath = Path.Combine(runDirectory, "command.txt"); + + string requestJson = JsonConvert.SerializeObject(providerRequest, Formatting.Indented); + string prompt = BuildPrompt(providerRequest, requestJson); + File.WriteAllText(result.RequestPath, requestJson, Encoding.UTF8); + File.WriteAllText(result.PromptPath, prompt, Encoding.UTF8); + File.WriteAllText(result.StdoutPath, "", Encoding.UTF8); + File.WriteAllText(result.StderrPath, "", Encoding.UTF8); + + try { + Process process = CreateCodexProcess(providerRequest.Settings, result.OutputPath, commandPath); + StringBuilder stdoutBuilder = new StringBuilder(); + StringBuilder stderrBuilder = new StringBuilder(); + process.OutputDataReceived += (sender, args) => { + if (args.Data != null) stdoutBuilder.AppendLine(args.Data); + }; + process.ErrorDataReceived += (sender, args) => { + if (args.Data != null) stderrBuilder.AppendLine(args.Data); + }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + process.StandardInput.Write(prompt); + process.StandardInput.Close(); + + bool exited = process.WaitForExit(providerRequest.Settings.TimeoutSeconds * 1000); + + if (!exited) { + try { + process.Kill(); + } catch { + } + + result.Error = "Codex timed out after " + providerRequest.Settings.TimeoutSeconds + " seconds."; + return result; + } + + process.WaitForExit(); + string stdout = stdoutBuilder.ToString(); + string stderr = stderrBuilder.ToString(); + File.WriteAllText(result.StdoutPath, stdout, Encoding.UTF8); + File.WriteAllText(result.StderrPath, stderr, Encoding.UTF8); + + if (process.ExitCode != 0) { + result.Error = "Codex exited with code " + process.ExitCode + ". " + FormatProcessOutputForDisplay(stderr == "" ? stdout : stderr); + return result; + } + + if (!File.Exists(result.OutputPath)) { + result.Error = "Codex completed but did not write a draft output file."; + return result; + } + + result.DraftJson = ExtractJsonObject(File.ReadAllText(result.OutputPath, Encoding.UTF8)); + result.Success = true; + return result; + } catch (Exception error) { + result.Error = error.Message; + return result; + } + } + + private Process CreateCodexProcess(AiSettings settings, string outputPath, string commandPath) { + string codexCommand = FindCodexCommand(settings); + string schemaPath = Path.Combine(BASE_DIRECTORY, "config", "ai-draft-output.schema.json"); + if (!File.Exists(schemaPath)) { + schemaPath = Path.GetFullPath(Path.Combine(BASE_DIRECTORY, "..", "..", "..", "..", "config", "ai-draft-output.schema.json")); + } + + StringBuilder command = new StringBuilder(); + command.Append(QuoteForCmd(codexCommand)); + command.Append(" --ask-for-approval never exec"); + command.Append(" --sandbox read-only"); + command.Append(" --ephemeral"); + if (!string.IsNullOrEmpty(settings.CodexModel)) { + command.Append(" --model ").Append(QuoteForCmd(settings.CodexModel)); + } + if (!string.IsNullOrEmpty(settings.CodexProfile)) { + command.Append(" --profile ").Append(QuoteForCmd(settings.CodexProfile)); + } + command.Append(" --output-schema ").Append(QuoteForCmd(schemaPath)); + command.Append(" --output-last-message ").Append(QuoteForCmd(outputPath)); + command.Append(" -"); + + File.WriteAllText(commandPath, command.ToString(), Encoding.UTF8); + + ProcessStartInfo startInfo = new ProcessStartInfo(); + startInfo.FileName = "cmd.exe"; + startInfo.Arguments = "/d /s /c \"" + command + "\""; + startInfo.WorkingDirectory = BASE_DIRECTORY; + startInfo.UseShellExecute = false; + startInfo.RedirectStandardInput = true; + startInfo.RedirectStandardOutput = true; + startInfo.RedirectStandardError = true; + startInfo.CreateNoWindow = true; + + Process process = new Process(); + process.StartInfo = startInfo; + return process; + } + + private Process CreateCodexModelsProcess(AiSettings settings) { + string codexCommand = FindCodexCommand(settings); + StringBuilder command = new StringBuilder(); + command.Append(QuoteForCmd(codexCommand)); + if (!string.IsNullOrEmpty(settings.CodexProfile)) { + command.Append(" --profile ").Append(QuoteForCmd(settings.CodexProfile)); + } + command.Append(" debug models"); + + ProcessStartInfo startInfo = new ProcessStartInfo(); + startInfo.FileName = "cmd.exe"; + startInfo.Arguments = "/d /s /c \"" + command + "\""; + startInfo.WorkingDirectory = BASE_DIRECTORY; + startInfo.UseShellExecute = false; + startInfo.RedirectStandardOutput = true; + startInfo.RedirectStandardError = true; + startInfo.CreateNoWindow = true; + + Process process = new Process(); + process.StartInfo = startInfo; + return process; + } + + private string BuildPrompt(AiDraftProviderRequest providerRequest, string requestJson) { + StringBuilder sb = new StringBuilder(); + sb.AppendLine("# ConverseTek AI Conversation Draft"); + sb.AppendLine(); + sb.AppendLine("You are drafting BattleTech dropship/SimGame conversations for ConverseTek."); + sb.AppendLine("Return only JSON matching the supplied schema."); + sb.AppendLine(); + sb.AppendLine("Rules:"); + sb.AppendLine("- Use British English."); + sb.AppendLine("- Do not edit files."); + sb.AppendLine("- Produce an advisory draft only; ConverseTek will create real ids, indexes, and files after the user accepts."); + sb.AppendLine("- Keep dialogue in BattleTech dropship conversation style: concise, voiced by the shown speaker, and readable in short UI bubbles."); + sb.AppendLine("- Use speaker.type castId for known cast ids. Known cast ids include: " + FormatKnownCastIds(providerRequest.WorkspaceSettings) + "."); + sb.AppendLine("- Use speaker.type none only when the line should inherit the current BattleTech conversation speaker. BattleTech does not have a separate narration speaker for SimGame conversation nodes."); + sb.AppendLine("- Every root or choice must either set targetKey to an existing node key or set endsConversation to true."); + sb.AppendLine("- For fullConversation, make the first root text an empty string and point it at the opening prompt node. Do not use Continue for that first root."); + sb.AppendLine("- For every other root or choice, use explicit short choices such as Understood, Ask Yang, or End conversation."); + sb.AppendLine("- For nodeSuggestion, return a text rewrite only. If the selected node is a prompt node, put the rewrite in nodes[0].text. If the selected node is a root or response, put the rewrite in roots[0].text. Leave the unused array empty."); + sb.AppendLine("- Write comment fields as short authoring notes that explain the beat, branch purpose, or condition context. Do not use draft keys such as darius_check as comments."); + sb.AppendLine("- For operation intents, only use operation names present in the supplied definitions JSON. If unsure, leave operations empty and describe the concern in warnings."); + sb.AppendLine("- Use mode from the request exactly."); + sb.AppendLine(); + AiWorkspaceSettings workspaceSettings = providerRequest.WorkspaceSettings; + if (workspaceSettings != null) { + sb.AppendLine("Workspace house style notes:"); + sb.AppendLine(string.IsNullOrEmpty(workspaceSettings.HouseStyleNotes) ? "(none)" : workspaceSettings.HouseStyleNotes); + sb.AppendLine(); + sb.AppendLine("Default campaign brief:"); + sb.AppendLine(string.IsNullOrEmpty(workspaceSettings.DefaultCampaignBrief) ? "(none)" : workspaceSettings.DefaultCampaignBrief); + sb.AppendLine(); + } + + sb.AppendLine("Cast personality rules:"); + List relevantPersonalities = SelectRelevantCastPersonalities(providerRequest); + if (relevantPersonalities.Count == 0) { + sb.AppendLine("(none)"); + } else { + sb.AppendLine("Apply these rules whenever a draft line uses a matching cast id or speaker id. Do not mention these rules in the draft output."); + foreach (AiCastPersonality personality in relevantPersonalities) { + sb.AppendLine("## " + (string.IsNullOrEmpty(personality.Label) ? personality.Id : personality.Label)); + sb.AppendLine("Cast ids: " + FormatIdList(personality.CastIds)); + sb.AppendLine("Speaker ids: " + FormatIdList(personality.SpeakerIds)); + sb.AppendLine(personality.Rules); + } + } + sb.AppendLine(); + + sb.AppendLine("Context files:"); + if (providerRequest.ContextFiles == null || providerRequest.ContextFiles.Count == 0) { + sb.AppendLine("(none)"); + } else { + foreach (AiContextFile contextFile in providerRequest.ContextFiles) { + sb.AppendLine("## " + contextFile.Path + (contextFile.Truncated ? " (truncated)" : "")); + sb.AppendLine("```"); + sb.AppendLine(contextFile.Content); + sb.AppendLine("```"); + } + } + + sb.AppendLine(); + sb.AppendLine("Request JSON:"); + sb.AppendLine("```json"); + sb.AppendLine(requestJson); + sb.AppendLine("```"); + return sb.ToString(); + } + + private string FormatKnownCastIds(AiWorkspaceSettings workspaceSettings) { + List castIds = new List { + "DariusDefault", + "SumireDefault", + "YangDefault", + "FarahDefault", + "KrakenIsabella", + "BladesKai", + "DEFAULT", + "HOLOGRAM" + }; + + if (workspaceSettings != null && workspaceSettings.CastPersonalities != null) { + foreach (AiCastPersonality personality in workspaceSettings.CastPersonalities) { + if (personality == null || personality.Enabled == false || personality.CastIds == null) continue; + foreach (string castId in personality.CastIds) { + AddUnique(castIds, castId); + } + } + } + + return string.Join(", ", castIds.ToArray()); + } + + private List SelectRelevantCastPersonalities(AiDraftProviderRequest providerRequest) { + List relevantPersonalities = new List(); + if (providerRequest == null || providerRequest.WorkspaceSettings == null || providerRequest.WorkspaceSettings.CastPersonalities == null) { + return relevantPersonalities; + } + + string searchText = BuildCastPersonalitySearchText(providerRequest); + foreach (AiCastPersonality personality in providerRequest.WorkspaceSettings.CastPersonalities) { + if (!IsUsablePersonality(personality)) continue; + if (PersonalityMatchesText(personality, searchText)) { + relevantPersonalities.Add(personality); + } + } + + if (relevantPersonalities.Count > 0) return relevantPersonalities; + + foreach (AiCastPersonality personality in providerRequest.WorkspaceSettings.CastPersonalities) { + if (!IsUsablePersonality(personality)) continue; + if (!string.IsNullOrEmpty(personality.DefaultKey)) { + relevantPersonalities.Add(personality); + } + } + + return relevantPersonalities; + } + + private string BuildCastPersonalitySearchText(AiDraftProviderRequest providerRequest) { + if (providerRequest == null || providerRequest.Request == null) return ""; + + StringBuilder sb = new StringBuilder(); + sb.AppendLine(providerRequest.Request.Brief ?? ""); + sb.AppendLine(providerRequest.Request.ConversationJson ?? ""); + sb.AppendLine(providerRequest.Request.SelectedNodeJson ?? ""); + return sb.ToString(); + } + + private bool IsUsablePersonality(AiCastPersonality personality) { + return personality != null && personality.Enabled != false && !string.IsNullOrWhiteSpace(personality.Rules); + } + + private bool PersonalityMatchesText(AiCastPersonality personality, string searchText) { + if (string.IsNullOrEmpty(searchText)) return false; + + if (ContainsToken(searchText, personality.Label)) return true; + if (ContainsToken(searchText, personality.DefaultKey)) return true; + + if (personality.CastIds != null) { + foreach (string castId in personality.CastIds) { + if (ContainsToken(searchText, castId)) return true; + if (!string.IsNullOrEmpty(castId) && castId.StartsWith("castDef_", StringComparison.OrdinalIgnoreCase)) { + if (ContainsToken(searchText, castId.Substring("castDef_".Length))) return true; + } + } + } + + if (personality.SpeakerIds != null) { + foreach (string speakerId in personality.SpeakerIds) { + if (ContainsToken(searchText, speakerId)) return true; + } + } + + return false; + } + + private bool ContainsToken(string searchText, string token) { + if (string.IsNullOrWhiteSpace(token)) return false; + return searchText.IndexOf(token.Trim(), StringComparison.OrdinalIgnoreCase) >= 0; + } + + private string FormatIdList(List ids) { + if (ids == null || ids.Count == 0) return "(none)"; + + List normalisedIds = new List(); + foreach (string id in ids) { + AddUnique(normalisedIds, id); + } + + return normalisedIds.Count == 0 ? "(none)" : string.Join(", ", normalisedIds.ToArray()); + } + + private void AddUnique(List values, string value) { + if (string.IsNullOrWhiteSpace(value)) return; + + string trimmedValue = value.Trim(); + foreach (string existingValue in values) { + if (string.Equals(existingValue, trimmedValue, StringComparison.OrdinalIgnoreCase)) return; + } + + values.Add(trimmedValue); + } + + private string CreateRunDirectory() { + string directory = Path.Combine(BASE_DIRECTORY, "logs", "ai-drafts", DateTime.UtcNow.ToString("yyyyMMdd-HHmmss-fff")); + Directory.CreateDirectory(directory); + return directory; + } + + private string FindCodexCommand(AiSettings settings) { + if (!string.IsNullOrEmpty(settings.CodexCommand) && settings.CodexCommand.Trim().ToLowerInvariant() != "codex") { + return settings.CodexCommand; + } + + string path = Environment.GetEnvironmentVariable("PATH") ?? ""; + string[] directories = path.Split(Path.PathSeparator); + string[] candidates = new string[] { "codex.cmd", "codex.exe", "codex" }; + + for (int i = 0; i < directories.Length; i++) { + for (int j = 0; j < candidates.Length; j++) { + string candidate = Path.Combine(directories[i], candidates[j]); + if (File.Exists(candidate)) { + return candidate; + } + } + } + + string[] knownWindowsPaths = new string[] { + @"d:\Program Files\nodejs\codex.cmd", + @"C:\Program Files\nodejs\codex.cmd", + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "npm", "codex.cmd") + }; + + for (int i = 0; i < knownWindowsPaths.Length; i++) { + if (File.Exists(knownWindowsPaths[i])) { + return knownWindowsPaths[i]; + } + } + + return "codex"; + } + + private string ExtractJsonObject(string value) { + if (string.IsNullOrEmpty(value)) return "{}"; + int start = value.IndexOf('{'); + int end = value.LastIndexOf('}'); + if (start >= 0 && end >= start) { + return value.Substring(start, end - start + 1); + } + return value; + } + + private string TrimForDisplay(string value) { + if (string.IsNullOrEmpty(value)) return ""; + value = value.Trim(); + return value.Length > 1600 ? value.Substring(0, 1600) + "..." : value; + } + + private string FormatProcessOutputForDisplay(string value) { + string providerError = ExtractProviderErrorMessage(value); + if (!string.IsNullOrEmpty(providerError)) return providerError; + return TrimForDisplay(value); + } + + private string ExtractProviderErrorMessage(string value) { + if (string.IsNullOrEmpty(value)) return ""; + + int markerIndex = value.LastIndexOf("ERROR:", StringComparison.OrdinalIgnoreCase); + if (markerIndex < 0) return ""; + + string candidate = value.Substring(markerIndex + "ERROR:".Length).Trim(); + string json = ExtractJsonObject(candidate); + if (string.IsNullOrEmpty(json) || json == "{}") return ""; + + try { + JObject errorEnvelope = JObject.Parse(json); + JToken errorToken = errorEnvelope["error"]; + if (errorToken == null) return ""; + + string code = errorToken.Value("code") ?? ""; + string message = errorToken.Value("message") ?? ""; + string status = errorEnvelope["status"] == null ? "" : errorEnvelope["status"].ToString(); + if (string.IsNullOrEmpty(message)) return ""; + + string prefix = string.IsNullOrEmpty(code) ? "" : code + ": "; + string suffix = string.IsNullOrEmpty(status) ? "" : " (status " + status + ")"; + return prefix + message + suffix; + } catch { + return ""; + } + } + + private string QuoteForCmd(string value) { + return "\"" + value.Replace("\"", "\\\"") + "\""; + } + } +} diff --git a/Services/ConfigService.cs b/Services/ConfigService.cs index e762afd6..7162677f 100644 --- a/Services/ConfigService.cs +++ b/Services/ConfigService.cs @@ -6,8 +6,11 @@ using Chromely.Core.Infrastructure; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; namespace ConverseTek.Services { + using ConverseTek.Data; + public class ConfigService { private static ConfigService instance; private static string BASE_DIRECTORY = AppDomain.CurrentDomain.BaseDirectory; @@ -15,6 +18,8 @@ public class ConfigService { private static string QUICKLINKS_PATH = $"{CONFIG_PATH}/quicklinks.json"; private static string COLOURS_PATH = $"{CONFIG_PATH}/colours.json"; + private static string AI_PATH = $"{CONFIG_PATH}/ai.json"; + private static string AI_PERSONALITIES_PATH = $"{CONFIG_PATH}/ai-personalities.json"; public static ConfigService getInstance() { if (instance == null) instance = new ConfigService(); @@ -35,6 +40,10 @@ private ConfigService() { if (!File.Exists(COLOURS_PATH)) { File.WriteAllText(COLOURS_PATH, JsonConvert.SerializeObject(new object { }, Formatting.Indented)); } + + if (!File.Exists(AI_PATH)) { + SaveAiSettings(AiSettings.CreateDefault()); + } } public Dictionary GetQuickLinksConfig() { @@ -93,5 +102,138 @@ public Dictionary> GetColourConfig() { return null; } } + + public AiSettings GetAiSettings() { + try { + if (!File.Exists(AI_PATH)) { + SaveAiSettings(AiSettings.CreateDefault()); + } + + string json = File.ReadAllText(AI_PATH); + AiSettings settings = JsonConvert.DeserializeObject(json); + return NormaliseAiSettings(settings); + } catch (Exception error) { + Log.Error(error.ToString()); + return AiSettings.CreateDefault(); + } + } + + public AiSettings SaveAiSettings(AiSettings settings) { + if (settings == null) settings = AiSettings.CreateDefault(); + + AiSettings existingSettings = null; + if (File.Exists(AI_PATH)) { + existingSettings = JsonConvert.DeserializeObject(File.ReadAllText(AI_PATH)); + } + + if ((settings.ModelCatalogs == null || settings.ModelCatalogs.Count == 0) && + existingSettings != null && + existingSettings.ModelCatalogs != null && + existingSettings.ModelCatalogs.Count > 0) { + settings.ModelCatalogs = existingSettings.ModelCatalogs; + } + + settings = NormaliseAiSettings(settings); + File.WriteAllText(AI_PATH, JsonConvert.SerializeObject(settings, Formatting.Indented)); + return settings; + } + + public AiWorkspaceSettings GetAiWorkspaceSettings(string workingDirectory) { + AiSettings settings = GetAiSettings(); + string key = NormaliseWorkspaceKey(workingDirectory); + + if (string.IsNullOrEmpty(key)) { + return AiWorkspaceSettings.CreateDefault(""); + } + + if (!settings.Workspaces.ContainsKey(key) || settings.Workspaces[key] == null) { + settings.Workspaces[key] = AiWorkspaceSettings.CreateDefault(workingDirectory); + SaveAiSettings(settings); + } + + return NormaliseAiWorkspaceSettings(settings.Workspaces[key], workingDirectory); + } + + public AiSettings SaveAiWorkspaceSettings(AiWorkspaceSettings workspaceSettings) { + AiSettings settings = GetAiSettings(); + workspaceSettings = NormaliseAiWorkspaceSettings(workspaceSettings, workspaceSettings == null ? "" : workspaceSettings.WorkingDirectory); + string key = NormaliseWorkspaceKey(workspaceSettings.WorkingDirectory); + + if (!string.IsNullOrEmpty(key)) { + settings.Workspaces[key] = workspaceSettings; + } + + return SaveAiSettings(settings); + } + + public List GetAiCastPersonalityDefaults() { + try { + if (!File.Exists(AI_PERSONALITIES_PATH)) return new List(); + + JObject config = JObject.Parse(File.ReadAllText(AI_PERSONALITIES_PATH)); + JToken personalitiesToken = config["castPersonalities"] ?? config["CastPersonalities"]; + if (personalitiesToken == null) return new List(); + + List personalities = JsonConvert.DeserializeObject>(personalitiesToken.ToString()); + if (personalities == null) return new List(); + + for (int i = 0; i < personalities.Count; i++) { + personalities[i] = NormaliseAiCastPersonality(personalities[i]); + } + + return personalities; + } catch (Exception error) { + Log.Error(error.ToString()); + return new List(); + } + } + + private AiSettings NormaliseAiSettings(AiSettings settings) { + if (settings == null) settings = AiSettings.CreateDefault(); + if (settings.Enabled == null) settings.Enabled = true; + if (string.IsNullOrEmpty(settings.SelectedProvider)) settings.SelectedProvider = "codex"; + if (string.IsNullOrEmpty(settings.CodexCommand)) settings.CodexCommand = "codex"; + if (settings.CodexModel == null) settings.CodexModel = ""; + if (settings.CodexProfile == null) settings.CodexProfile = ""; + if (settings.TimeoutSeconds <= 0) settings.TimeoutSeconds = 300; + if (settings.ModelCatalogs == null) settings.ModelCatalogs = new Dictionary(); + if (settings.Workspaces == null) settings.Workspaces = new Dictionary(); + foreach (string key in new List(settings.Workspaces.Keys)) { + AiWorkspaceSettings workspaceSettings = settings.Workspaces[key]; + string fallbackWorkingDirectory = workspaceSettings == null ? "" : workspaceSettings.WorkingDirectory; + settings.Workspaces[key] = NormaliseAiWorkspaceSettings(workspaceSettings, fallbackWorkingDirectory); + } + return settings; + } + + private AiWorkspaceSettings NormaliseAiWorkspaceSettings(AiWorkspaceSettings workspaceSettings, string fallbackWorkingDirectory) { + if (workspaceSettings == null) workspaceSettings = AiWorkspaceSettings.CreateDefault(fallbackWorkingDirectory); + if (workspaceSettings.WorkingDirectory == null) workspaceSettings.WorkingDirectory = fallbackWorkingDirectory ?? ""; + if (workspaceSettings.ContextPaths == null) workspaceSettings.ContextPaths = new List(); + if (workspaceSettings.HouseStyleNotes == null) workspaceSettings.HouseStyleNotes = ""; + if (workspaceSettings.DefaultCampaignBrief == null) workspaceSettings.DefaultCampaignBrief = ""; + if (workspaceSettings.CastPersonalities == null) workspaceSettings.CastPersonalities = GetAiCastPersonalityDefaults(); + for (int i = 0; i < workspaceSettings.CastPersonalities.Count; i++) { + workspaceSettings.CastPersonalities[i] = NormaliseAiCastPersonality(workspaceSettings.CastPersonalities[i]); + } + return workspaceSettings; + } + + private AiCastPersonality NormaliseAiCastPersonality(AiCastPersonality personality) { + if (personality == null) personality = new AiCastPersonality(); + if (string.IsNullOrEmpty(personality.Id)) personality.Id = Guid.NewGuid().ToString("N"); + if (personality.Label == null) personality.Label = ""; + if (personality.CastIds == null) personality.CastIds = new List(); + if (personality.SpeakerIds == null) personality.SpeakerIds = new List(); + if (personality.Rules == null) personality.Rules = ""; + if (personality.Enabled == null) personality.Enabled = true; + if (personality.DefaultKey == null) personality.DefaultKey = ""; + return personality; + } + + private string NormaliseWorkspaceKey(string workingDirectory) { + if (string.IsNullOrEmpty(workingDirectory)) return ""; + return workingDirectory.Trim().Replace("\\", "/").TrimEnd('/').ToLowerInvariant(); + } } -} \ No newline at end of file +} diff --git a/Services/FileSystemService.cs b/Services/FileSystemService.cs index 4bdd1906..26078585 100644 --- a/Services/FileSystemService.cs +++ b/Services/FileSystemService.cs @@ -86,14 +86,22 @@ public List GetDirectories(string path) { return directories; } - public List GetFiles(string path) { + public List GetFiles(string path, List fileExtensions = null) { List files = new List(); // Guard: No files at root if (path == "{drives}") return files; try { - string[] filePaths = Directory.GetFiles(path, "*.json"); + List searchExtensions = fileExtensions == null || fileExtensions.Count == 0 ? new List { ".json" } : fileExtensions; + List filePaths = new List(); + + foreach (string extension in searchExtensions) { + string normalisedExtension = extension.StartsWith(".") ? extension : "." + extension; + filePaths.AddRange(Directory.GetFiles(path, "*" + normalisedExtension)); + } + + filePaths.Sort(StringComparer.OrdinalIgnoreCase); foreach (string filePath in filePaths) { FileInfo fileInfo = new FileInfo(filePath); FsFile file = new FsFile(); @@ -110,4 +118,4 @@ public List GetFiles(string path) { return files; } } -} \ No newline at end of file +} diff --git a/app/package.json b/app/package.json index 0cd54cd8..cf9e103b 100644 --- a/app/package.json +++ b/app/package.json @@ -10,6 +10,8 @@ "build": "npm run verify && cross-env NODE_ENV=development CT_ENV=development webpack", "prod": "cross-env NODE_ENV=production CT_ENV=production webpack", "verify": "npm run ts-check", + "lint": "eslint src --ext .ts,.tsx,.js,.jsx", + "lint:fix": "eslint src --ext .ts,.tsx,.js,.jsx --fix", "test": "vitest run", "test:watch": "vitest", "ts-watch": "tsc --watch", diff --git a/app/src/components/AiContextPathPicker/AiContextPathPicker.css b/app/src/components/AiContextPathPicker/AiContextPathPicker.css new file mode 100644 index 00000000..662f81b2 --- /dev/null +++ b/app/src/components/AiContextPathPicker/AiContextPathPicker.css @@ -0,0 +1,70 @@ +.ai-context-path-picker { + display: flex; + flex-direction: column; + min-height: 360px; +} + +.ai-context-path-picker__alert { + margin-bottom: 10px; +} + +.ai-context-path-picker__path { + margin-bottom: 8px; + padding: 6px 8px; + background: #fafafa; + color: #262626; + font-size: 12px; + overflow-wrap: anywhere; +} + +.ai-context-path-picker__list { + min-height: 300px; + max-height: 52vh; + overflow: auto; +} + +.ai-context-path-picker__loading { + display: flex; + align-items: center; + justify-content: center; + min-height: 260px; +} + +.ai-context-path-picker__item { + cursor: pointer; + padding: 0; +} + +.ai-context-path-picker__item-content { + display: flex; + align-items: center; + width: 100%; + min-height: 40px; + padding: 10px 8px; +} + +.ai-context-path-picker__item:hover, +.ai-context-path-picker__item:focus, +.ai-context-path-picker__item--active { + background-color: #2ab0f77d; +} + +.ai-context-path-picker__directory-icon, +.ai-context-path-picker__file-icon { + flex: 0 0 auto; + margin-right: 10px; +} + +.ai-context-path-picker__name { + flex: 1 1 auto; + min-width: 0; + overflow-wrap: anywhere; + line-height: 1.25; +} + +.ai-context-path-picker__kind { + flex: 0 0 auto; + margin-left: 10px; + color: #595959; + font-size: 12px; +} diff --git a/app/src/components/AiContextPathPicker/AiContextPathPicker.tsx b/app/src/components/AiContextPathPicker/AiContextPathPicker.tsx new file mode 100644 index 00000000..3a8768cf --- /dev/null +++ b/app/src/components/AiContextPathPicker/AiContextPathPicker.tsx @@ -0,0 +1,150 @@ +import React, { Fragment, useEffect, useRef, useState } from 'react'; +import { Alert, Icon, List, Spin } from 'antd'; +import classnames from 'classnames'; +import sortBy from 'lodash.sortby'; + +import { useStore } from 'hooks/useStore'; +import { ModalStore } from 'stores/modalStore/modal-store'; +import { getDirectories, getRootDrives } from 'services/api'; +import { DirectoryItemType, FileItemSystemType, FileSystemItemType } from 'types'; + +import './AiContextPathPicker.css'; + +const ListItem = List.Item; +const contextFileExtensions = ['.md', '.txt', '.json', '.jsonc', '.html', '.cs']; + +type Props = { + globalModalId: string; + initialPath?: string; + onSelectPath: (path: string) => void; +}; + +function getItemIcon(item: FileSystemItemType) { + if (item.isFile) return ; + if (item.name === '..') return ; + return ; +} + +function AiContextPathPicker({ globalModalId, initialPath, onSelectPath }: Props) { + const modalStore = useStore('modal'); + const [loading, setLoading] = useState(false); + const [selectedItem, setSelectedItem] = useState(null); + const [directories, setDirectories] = useState([]); + const [files, setFiles] = useState([]); + const [currentPath, setCurrentPath] = useState(initialPath || ''); + const listItemRefs = useRef<{ [key: string]: HTMLDivElement | null }>({}); + + const loadPath = (path: string) => { + setLoading(true); + setSelectedItem(null); + modalStore.setDisableOk(true, globalModalId); + + if (path === '') { + void getRootDrives() + .then((updatedDirectories: DirectoryItemType[]) => { + setDirectories(updatedDirectories); + setFiles([]); + setCurrentPath(''); + }) + .finally(() => setLoading(false)); + return; + } + + void getDirectories(path, true, contextFileExtensions) + .then(({ directories: updatedDirectories, files: updatedFiles }: { directories: DirectoryItemType[]; files: FileItemSystemType[] }) => { + setDirectories(updatedDirectories); + setFiles(updatedFiles); + setCurrentPath(path); + }) + .finally(() => setLoading(false)); + }; + + const selectItem = (item: FileSystemItemType) => { + if (loading || item.name === '..') return; + + const nextSelectedItem = selectedItem && selectedItem.path === item.path ? null : item; + setSelectedItem(nextSelectedItem); + modalStore.setDisableOk(nextSelectedItem == null, globalModalId); + }; + + const openDirectory = (item: DirectoryItemType) => { + if (loading) return; + loadPath(item.path === '{drives}' ? '' : item.path); + }; + + const setupModal = () => { + modalStore.setTitle(`Select AI Context Path${selectedItem ? ' - ' + selectedItem.name : ''}`, globalModalId); + modalStore.setWidth('58vw', globalModalId); + modalStore.setOkLabel('Add Path', globalModalId); + modalStore.setCancelLabel('Cancel', globalModalId); + modalStore.setDisableOk(selectedItem == null, globalModalId); + modalStore.setOnOk(() => { + if (!selectedItem) return; + onSelectPath(selectedItem.path); + modalStore.closeModal(globalModalId); + }, globalModalId); + }; + + useEffect(() => { + loadPath(initialPath || ''); + }, []); + + useEffect(() => { + setupModal(); + }); + + useEffect(() => { + listItemRefs.current = {}; + }, [directories, files]); + + const items = [...sortBy(directories, (item) => item.name.toLowerCase()), ...sortBy(files, (item) => item.name.toLowerCase())]; + + return ( +
+ + {currentPath &&
{currentPath}
} +
+ {loading ? ( +
+ +
+ ) : ( + { + const itemClasses = classnames('ai-context-path-picker__item', { + 'ai-context-path-picker__item--active': selectedItem != null && selectedItem.path === item.path, + }); + + return ( + +
(listItemRefs.current[item.path] = el)} + className="ai-context-path-picker__item-content" + onClick={() => selectItem(item)} + onDoubleClick={() => item.isDirectory && openDirectory(item)} + > + {getItemIcon(item)} + {item.name} + {item.isDirectory && item.name !== '..' && ( + + folder + + )} +
+
+ ); + }} + /> + )} +
+
+ ); +} + +export const ObservingAiContextPathPicker = AiContextPathPicker; diff --git a/app/src/components/AiContextPathPicker/index.ts b/app/src/components/AiContextPathPicker/index.ts new file mode 100644 index 00000000..a2c42c09 --- /dev/null +++ b/app/src/components/AiContextPathPicker/index.ts @@ -0,0 +1 @@ +export { ObservingAiContextPathPicker as AiContextPathPicker } from './AiContextPathPicker'; diff --git a/app/src/components/AiDraftModal/AiDraftArtifactViewer.tsx b/app/src/components/AiDraftModal/AiDraftArtifactViewer.tsx new file mode 100644 index 00000000..f7ef2465 --- /dev/null +++ b/app/src/components/AiDraftModal/AiDraftArtifactViewer.tsx @@ -0,0 +1,61 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Alert, Button } from 'antd'; + +import { useStore } from 'hooks/useStore'; +import { ModalStore } from 'stores/modalStore/modal-store'; +import { formatAiDraftArtifactContent } from 'utils/ai-artifact-utils'; + +type Props = { + globalModalId: string; + title: string; + path: string; + content: string; + truncated: boolean; +}; + +export function AiDraftArtifactViewer({ globalModalId, title, path, content, truncated }: Props) { + const modalStore = useStore('modal'); + const [viewMode, setViewMode] = useState<'formatted' | 'raw'>('formatted'); + const formattedContent = useMemo(() => formatAiDraftArtifactContent(content), [content]); + const displayedContent = viewMode === 'formatted' ? formattedContent.formattedContent : content; + + useEffect(() => { + modalStore.setTitle(title, globalModalId); + modalStore.setWidth('72vw', globalModalId); + modalStore.setShowOkButton(false, globalModalId); + modalStore.setShowCancelButton(true, globalModalId); + modalStore.setCancelLabel('Close', globalModalId); + }, []); + + return ( +
+
{path}
+
+ + + + + {viewMode === 'formatted' && formattedContent.notes.length > 0 && ( + {formattedContent.notes.join(' ')} + )} +
+ {truncated && ( + + )} +
{displayedContent || '(empty)'}
+
+ ); +} diff --git a/app/src/components/AiDraftModal/AiDraftConversationTreePreview.tsx b/app/src/components/AiDraftModal/AiDraftConversationTreePreview.tsx new file mode 100644 index 00000000..3aec7b95 --- /dev/null +++ b/app/src/components/AiDraftModal/AiDraftConversationTreePreview.tsx @@ -0,0 +1,302 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { Icon } from 'antd'; +import { useSize } from 'ahooks'; +import SortableTree from 'react-sortable-tree'; + +import 'react-sortable-tree/style.css'; + +import { ConverseTekNodeRenderer, ConversationTreeNodeStore, ConverseTekNodeRendererProps } from 'components/DialogEditor/ConverseTekNodeRenderer'; +import type { OnNodeContextMenuProps } from 'components/DialogEditor/DialogEditor'; +import { ScalableScrollbar } from 'components/ScalableScrollbar'; +import { useStore } from 'hooks/useStore'; +import { DataStore } from 'stores/dataStore/data-store'; +import { ConversationAssetType, ElementNodeType, OperationCallType, PromptNodeType } from 'types'; +import { getId } from 'utils/conversation-utils'; + +type PreviewNodeStore = ConversationTreeNodeStore & { + buildTreeData: () => PreviewTreeNode[]; + recordTreeIndex: (nodeId: string | undefined, treeIndex: number) => void; +}; + +type PreviewTreeNode = RSTNode & { + previewTreeKey: string; +}; + +type PreviewRowHeightProps = { + node: PreviewTreeNode; +}; + +type Props = { + conversationAsset: ConversationAssetType; +}; + +export function AiDraftConversationTreePreview({ conversationAsset }: Props) { + const dataStore = useStore('data'); + const treeContainerRef = useRef(null); + const treeContainerSize = useSize(treeContainerRef); + const expansionByNodeId = useRef(new Map()); + const [activeNodeId, setActiveNodeId] = useState(null); + const [treeData, setTreeData] = useState([]); + + const previewNodeStore = useMemo( + () => createPreviewNodeStore(conversationAsset, expansionByNodeId.current, setActiveNodeId), + [conversationAsset], + ); + + useEffect(() => { + setActiveNodeId(null); + setTreeData(previewNodeStore.buildTreeData()); + }, [previewNodeStore]); + + const ignoreContextMenu = ({ event }: OnNodeContextMenuProps) => { + event.preventDefault(); + }; + + const treeWidth = treeContainerSize?.width || 0; + + return ( +
+
+ + Conversation tree preview + + Read-only +
+
+ {treeData.length > 0 && treeWidth > 0 && ( + + setTreeData(nextTreeData)} + getNodeKey={({ node, treeIndex }: { node: PreviewTreeNode; treeIndex: number }) => { + previewNodeStore.recordTreeIndex(node.id, treeIndex); + return node.previewTreeKey || node.id || treeIndex; + }} + rowHeight={getPreviewRowHeight} + canDrag={() => false} + canDrop={() => false} + generateNodeProps={({ node }: { node: PreviewTreeNode }) => ({ + dataStore, + nodeStore: previewNodeStore, + activeNodeId, + previousNodeId: null, + onNodeContextMenu: ignoreContextMenu, + isContextMenuVisible: false, + buttons: buildPreviewNodeButtons(node, previewNodeStore), + zoomLevel: 1, + })} + nodeContentRenderer={(rendererProps: ConverseTekNodeRendererProps) => } + reactVirtualizedListProps={{ + width: treeWidth, + }} + slideRegionSize={100} + /> + + )} +
+
+ ); +} + +function createPreviewNodeStore( + conversationAsset: ConversationAssetType, + expansionByNodeId: Map, + setActiveNodeId: (nodeId: string | null) => void, +): PreviewNodeStore { + const nodeById = new Map(); + const promptNodeByIndex = new Map(); + const treeIndexByNodeId = new Map(); + + conversationAsset.conversation.roots.forEach((root) => { + nodeById.set(getId(root), root); + }); + + conversationAsset.conversation.nodes.forEach((promptNode) => { + nodeById.set(getId(promptNode), promptNode); + promptNodeByIndex.set(promptNode.index, promptNode); + + promptNode.branches.forEach((branch) => { + nodeById.set(getId(branch), branch); + }); + }); + + const previewNodeStore: PreviewNodeStore = { + buildTreeData: () => [ + { + title: conversationAsset.conversation.uiName || 'AI Draft Conversation', + id: '0', + previewTreeKey: 'core-0', + type: 'core', + parentId: '-1', + children: conversationAsset.conversation.roots.map((root, index) => + buildElementTreeNode(root, 'root', null, `core-0/root-${index}`, previewNodeStore), + ), + expanded: true, + canDrag: false, + }, + ], + recordTreeIndex: (nodeId, treeIndex) => { + if (nodeId) treeIndexByNodeId.set(nodeId, treeIndex); + }, + getNode: (nodeId) => { + if (!nodeId) return null; + return nodeById.get(nodeId) || null; + }, + getPromptNodeByIndex: (index) => promptNodeByIndex.get(index) || null, + getTreeIndex: (nodeId) => treeIndexByNodeId.get(nodeId) ?? 0, + setActiveNode: () => setActiveNodeId(null), + initScrollToNode: () => undefined, + isNodeVisible: () => true, + setFocusedTreeNode: () => undefined, + getMaxTreeHorizontalNodePosition: () => 0, + setNodeExpansion: (nodeId, flag) => { + if (nodeId) expansionByNodeId.set(nodeId, flag); + }, + isNodeExpanded: (nodeId) => { + if (!nodeId) return false; + return expansionByNodeId.get(nodeId) ?? true; + }, + }; + + return previewNodeStore; +} + +function buildElementTreeNode( + elementNode: ElementNodeType, + type: 'root' | 'response', + parentId: string | null, + previewTreeKey: string, + previewNodeStore: PreviewNodeStore, +): PreviewTreeNode { + const elementNodeId = getId(elementNode); + + return { + title: elementNode.responseText, + subtitle: formatElementSubtitle(elementNode), + id: elementNodeId, + previewTreeKey, + parentId, + type, + expanded: previewNodeStore.isNodeExpanded(elementNodeId), + canDrag: false, + children: buildElementChildren(elementNode, elementNodeId, previewTreeKey, previewNodeStore), + }; +} + +function buildElementChildren( + elementNode: ElementNodeType, + elementNodeId: string, + parentTreeKey: string, + previewNodeStore: PreviewNodeStore, +): PreviewTreeNode[] | null { + if (elementNode.nextNodeIndex === -1) return null; + + if (elementNode.auxiliaryLink) { + const linkedPromptNode = previewNodeStore.getPromptNodeByIndex(elementNode.nextNodeIndex); + return [ + { + title: `[Link to NODE ${elementNode.nextNodeIndex}]`, + id: `link-${elementNodeId}-${elementNode.nextNodeIndex}`, + previewTreeKey: `${parentTreeKey}/link-${elementNode.nextNodeIndex}`, + type: 'link', + linkId: linkedPromptNode ? getId(linkedPromptNode) : null, + linkIndex: elementNode.nextNodeIndex, + canDrag: false, + parentId: elementNodeId, + }, + ]; + } + + const childPromptNode = previewNodeStore.getPromptNodeByIndex(elementNode.nextNodeIndex); + if (!childPromptNode) return null; + + return [buildPromptTreeNode(childPromptNode, elementNodeId, `${parentTreeKey}/node-${elementNode.nextNodeIndex}`, previewNodeStore)]; +} + +function buildPromptTreeNode( + promptNode: PromptNodeType, + parentId: string, + previewTreeKey: string, + previewNodeStore: PreviewNodeStore, +): PreviewTreeNode { + const promptNodeId = getId(promptNode); + + return { + title: promptNode.text, + subtitle: formatPromptSubtitle(promptNode), + id: promptNodeId, + previewTreeKey, + parentId, + type: 'node', + expanded: previewNodeStore.isNodeExpanded(promptNodeId), + canDrag: false, + children: promptNode.branches.map((branch, index) => + buildElementTreeNode(branch, 'response', promptNodeId, `${previewTreeKey}/response-${index}`, previewNodeStore), + ), + }; +} + +function getPreviewRowHeight({ node }: PreviewRowHeightProps): number { + const title = typeof node.title === 'string' ? node.title : ''; + const subtitle = typeof node.subtitle === 'string' ? node.subtitle : ''; + const estimatedCharactersPerLine = node.type === 'response' ? 70 : 86; + const titleLines = Math.max(1, Math.ceil(title.length / estimatedCharactersPerLine)); + const subtitleLines = subtitle.length > 0 ? Math.max(1, Math.ceil(subtitle.length / 96)) : 0; + const estimatedHeight = 40 + titleLines * 18 + subtitleLines * 14; + + return Math.min(180, Math.max(64, estimatedHeight)); +} + +function formatPromptSubtitle(promptNode: PromptNodeType): string { + return [ + `NODE ${promptNode.index}`, + promptNode.comment ? `draft key ${promptNode.comment}` : '', + formatOperationCount('action', promptNode.actions?.ops), + ] + .filter(Boolean) + .join(' | '); +} + +function formatElementSubtitle(elementNode: ElementNodeType): string { + const target = elementNode.nextNodeIndex === -1 ? 'END' : `${elementNode.auxiliaryLink ? 'link to' : 'to'} NODE ${elementNode.nextNodeIndex}`; + + return [ + target, + formatOperationCount('condition', elementNode.conditions?.ops), + formatOperationCount('action', elementNode.actions?.ops), + ] + .filter(Boolean) + .join(' | '); +} + +function formatSpeaker(promptNode: PromptNodeType): string { + if (promptNode.speakerType === 'castId' && promptNode.sourceInSceneRef?.id) return promptNode.sourceInSceneRef.id; + if (promptNode.speakerType === 'speakerId' && promptNode.speakerOverrideId) return promptNode.speakerOverrideId; + return 'Inherits'; +} + +function buildPreviewNodeButtons(node: PreviewTreeNode, previewNodeStore: PreviewNodeStore): JSX.Element[] { + if (node.type !== 'node') return []; + + const promptNode = previewNodeStore.getNode(node.id); + if (promptNode == null || promptNode.type !== 'node') return []; + + const speaker = formatSpeaker(promptNode); + const displaySpeaker = speaker.replace(/Default$/i, '') || speaker; + + return [ + + {displaySpeaker} + , + ]; +} + +function formatOperationCount(label: string, operations: OperationCallType[] | null | undefined): string { + const operationCount = operations?.length || 0; + if (operationCount === 0) return ''; + + return `${operationCount} ${label}${operationCount === 1 ? '' : 's'}`; +} diff --git a/app/src/components/AiDraftModal/AiDraftModal.css b/app/src/components/AiDraftModal/AiDraftModal.css new file mode 100644 index 00000000..d451103d --- /dev/null +++ b/app/src/components/AiDraftModal/AiDraftModal.css @@ -0,0 +1,663 @@ +.ai-draft-modal { + display: flex; + flex-direction: column; + gap: 14px; + max-height: calc(100vh - 170px); + overflow-y: auto; + padding-right: 4px; + padding-bottom: 4px; +} + +.ai-draft-modal--has-draft { + max-height: calc(100vh - 150px); +} + +.ai-draft-modal textarea, +.ai-draft-modal input { + font-family: inherit; +} + +.ai-draft-modal .ant-form-item-label label { + color: #262626; + font-weight: 600; +} + +.ai-draft-modal .ant-input, +.ai-draft-modal .ant-select-selection { + border-color: #5f6b7a; +} + +.ai-draft-modal .ant-input::-webkit-input-placeholder { + color: #c8d1dc; + opacity: 1; +} + +.ai-draft-modal .ant-input::placeholder { + color: #c8d1dc; + opacity: 1; +} + +.ai-draft-modal__field-label { + display: inline-flex; + align-items: center; + max-width: 100%; +} + +.ai-draft-modal__help-icon { + margin-left: 6px; + padding: 4px; + color: #59728a; + cursor: help; + line-height: 1; +} + +.ai-draft-modal__help-icon:hover, +.ai-draft-modal__help-icon:focus { + color: #1f5f8b; +} + +.ai-draft-modal__model-row { + display: flex; + align-items: center; +} + +.ai-draft-modal__model-row .ant-select { + flex: 1 1 auto; + min-width: 0; +} + +.ai-draft-modal__model-row .ant-btn { + flex: 0 0 auto; + margin-left: 8px; + min-height: 32px; +} + +.ai-draft-modal__inline-alert { + margin-top: 8px; +} + +.ai-draft-modal__field-note { + margin-top: 6px; + color: #595959; + font-size: 12px; +} + +.ai-draft-modal__field-actions { + display: flex; + margin-top: 8px; +} + +.ai-draft-modal__field-actions .ant-btn { + min-height: 32px; +} + +.ai-draft-modal__alert { + margin-bottom: 12px; + white-space: pre-line; +} + +.ai-draft-modal__meta { + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid #d9d9d9; + background: #fafafa; +} + +.ai-draft-modal__meta > div { + display: grid; + grid-template-columns: 88px minmax(0, 1fr); + align-items: start; + gap: 8px; +} + +.ai-draft-modal__meta span { + color: #595959; + font-size: 12px; + font-weight: 600; + line-height: 24px; + text-transform: uppercase; +} + +.ai-draft-modal__meta code { + display: block; + min-width: 0; + overflow-wrap: anywhere; + padding: 4px 6px; + border: 1px solid #e8e8e8; + background: #fff; + color: #262626; + font-size: 12px; +} + +.ai-draft-modal__artifact-actions { + padding-top: 4px; +} + +.ai-draft-modal__artifact-actions > div { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.ai-draft-modal__artifact-actions .ant-btn { + min-height: 28px; +} + +.ai-draft-modal__actions { + display: flex; + flex-wrap: wrap; + margin: 8px 0 12px; +} + +.ai-draft-modal__actions .ant-btn { + margin-right: 8px; + margin-bottom: 8px; + min-height: 32px; +} + +.ai-draft-modal__actions .ant-btn:last-child { + margin-right: 0; +} + +.ai-draft-modal__loading { + display: flex; + align-items: center; + padding: 10px 0; + color: #595959; +} + +.ai-draft-modal__loading span { + margin-left: 10px; +} + +.ai-draft-personalities { + padding-top: 16px; +} + +.ai-draft-personalities__toolbar { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + margin-bottom: 12px; +} + +.ai-draft-personalities__toolbar .ant-btn { + min-width: 80px; +} + +.ai-draft-personalities__toolbar .ant-btn, +.ai-draft-personalities__list-actions .ant-btn, +.ai-draft-personalities__editor-heading .ant-btn { + min-height: 32px; +} + +.ai-draft-personalities__list { + display: flex; + flex-direction: column; + max-height: calc(100vh - 380px); + min-height: 320px; + overflow-y: auto; + padding: 2px 3px 2px 2px; +} + +.ai-draft-personalities__empty, +.ai-draft-personalities__editor-empty { + padding: 16px; + background: #fafafa; + color: #595959; + box-shadow: inset 0 0 0 1px rgba(31, 47, 67, 0.1); + text-wrap: pretty; +} + +.ai-draft-personalities__item { + display: flex; + flex-direction: column; + width: 100%; + min-height: 76px; + padding: 11px 12px; + border: 0; + border-radius: 4px; + background: #fff; + box-shadow: 0 0 0 1px rgba(31, 47, 67, 0.12), 0 2px 8px rgba(31, 47, 67, 0.08); + color: #262626; + cursor: pointer; + text-align: left; + transition-property: background, box-shadow, opacity, transform; + transition-duration: 140ms; + transition-timing-function: cubic-bezier(0.2, 0, 0, 1); +} + +.ai-draft-personalities__item + .ai-draft-personalities__item { + margin-top: 8px; +} + +.ai-draft-personalities__item:hover, +.ai-draft-personalities__item:focus { + outline: none; + background: #f6f8fb; + box-shadow: 0 0 0 1px rgba(31, 95, 139, 0.3), 0 4px 12px rgba(31, 47, 67, 0.12); +} + +.ai-draft-personalities__item:active { + transform: scale(0.96); +} + +.ai-draft-personalities__item--active { + background: #eef6fb; + box-shadow: 0 0 0 2px rgba(31, 95, 139, 0.34), 0 4px 12px rgba(31, 47, 67, 0.14); +} + +.ai-draft-personalities__item--disabled { + opacity: 0.58; +} + +.ai-draft-personalities__item-header { + display: flex; + align-items: center; + justify-content: space-between; + min-width: 0; + margin-bottom: 8px; + font-weight: 600; +} + +.ai-draft-personalities__item-header > span:first-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ai-draft-personalities__item-ids { + display: flex; + flex-wrap: wrap; + margin: -3px -5px -3px 0; +} + +.ai-draft-personalities__item-ids .ant-tag, +.ai-draft-personalities__item-header .ant-tag { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; +} + +.ai-draft-personalities__item-header .ant-tag { + flex: 0 0 auto; + margin-right: 0; + margin-left: 8px; +} + +.ai-draft-personalities__item-ids .ant-tag { + margin: 3px 5px 3px 0; +} + +.ai-draft-personalities__list-actions { + display: flex; + flex-wrap: wrap; + margin-top: 12px; +} + +.ai-draft-personalities__list-actions .ant-btn { + margin-right: 10px; + margin-bottom: 8px; +} + +.ai-draft-personalities__editor { + min-height: 320px; +} + +.ai-draft-personalities__editor-heading { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 42px; + margin-bottom: 16px; + padding-bottom: 12px; + box-shadow: inset 0 -1px 0 rgba(31, 47, 67, 0.12); +} + +.ai-draft-personalities__editor .ant-form-item { + margin-bottom: 18px; +} + +.ai-draft-personalities__editor .ant-form-item:last-child { + margin-bottom: 0; +} + +.ai-draft-personalities__editor .ant-form-item-label { + padding-bottom: 4px; +} + +.ai-draft-personalities__editor .ant-select-selection--multiple { + min-height: 34px; + padding-bottom: 2px; +} + +.ai-draft-personalities__editor .ant-select-selection--multiple .ant-select-selection__rendered { + min-height: 30px; + margin-right: 6px; + margin-bottom: -2px; + margin-left: 6px; + line-height: 30px; +} + +.ai-draft-personalities__editor .ant-select-selection__choice { + max-width: 100%; +} + +.ai-draft-personalities__editor .ant-select-selection--multiple .ant-select-selection__choice { + height: 24px; + margin-right: 5px; + margin-top: 4px; + line-height: 22px; +} + +.ai-draft-personalities__editor .ant-select-selection--multiple .ant-select-search--inline { + height: 28px; + line-height: 28px; +} + +.ai-draft-personalities__editor .ant-select-selection--multiple .ant-select-search__field { + height: 24px; + margin-top: 3px; +} + +.ai-draft-preview { + display: flex; + flex-direction: column; + gap: 14px; + margin-top: 8px; +} + +.ai-draft-preview__summary { + padding: 12px 0; + border-top: 1px solid #e8e8e8; + border-bottom: 1px solid #e8e8e8; +} + +.ai-draft-preview__summary h3 { + color: #262626; + margin: 0 0 6px; +} + +.ai-draft-preview__summary p, +.ai-draft-preview__suggestion p { + margin: 0; + white-space: pre-wrap; +} + +.ai-draft-preview__cast { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.ai-draft-preview__suggestion { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px; + border: 1px solid #d9d9d9; + background: #fff; +} + +.ai-draft-preview__suggestion h4 { + margin: 0; + color: #262626; +} + +.ai-draft-preview-tree { + display: flex; + flex-direction: column; + min-height: 480px; + overflow: hidden; + border-radius: 4px; + background: #f3f6fa; + box-shadow: 0 0 0 1px rgba(31, 47, 67, 0.12), 0 10px 24px rgba(31, 47, 67, 0.12); +} + +.ai-draft-preview-tree__toolbar { + display: flex; + justify-content: space-between; + align-items: center; + min-height: 36px; + padding: 0 12px; + background: #fff; + color: #4b6178; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + box-shadow: 0 1px 0 rgba(31, 47, 67, 0.12); +} + +.ai-draft-preview-tree__toolbar .anticon { + margin-right: 6px; +} + +.ai-draft-preview-tree__canvas { + position: relative; + height: calc(100vh - 430px); + min-height: 400px; + max-height: 620px; + min-width: 0; + overflow: hidden; + background: #f3f6fa; +} + +/* Keep these tree overrides scoped to the AI preview. The real DialogEditor tree uses the same renderer classes. */ +.ai-draft-preview-tree__canvas .rst__lineHalfHorizontalRight::before, +.ai-draft-preview-tree__canvas .rst__lineFullVertical::after, +.ai-draft-preview-tree__canvas .rst__lineHalfVerticalTop::after, +.ai-draft-preview-tree__canvas .rst__lineHalfVerticalBottom::after, +.ai-draft-preview-tree__canvas .rst__lineChildren::after { + background-color: #9cadbf; +} + +.ai-draft-preview-tree__canvas .rst__rowWrapper { + padding-top: 8px; + padding-bottom: 8px; +} + +.ai-draft-preview-tree__canvas .rst__row { + align-items: stretch; + white-space: normal; +} + +.ai-draft-preview-tree__canvas .rst__rowContents { + align-items: flex-start; + flex: 0 1 auto; + width: auto; + min-width: 340px; + max-width: 760px; + height: auto; + min-height: 40px; + padding: 7px 10px; + border: 0; + border-radius: 4px; + box-shadow: 0 0 0 1px rgba(31, 47, 67, 0.12), 0 3px 10px rgba(31, 47, 67, 0.14); +} + +.ai-draft-preview-tree__canvas .node-renderer__row-contents section { + display: flex; + align-items: flex-start; + width: 100%; + min-width: 0; +} + +.ai-draft-preview-tree__canvas .node-renderer__row-contents-logic { + flex: 0 0 auto; + padding-top: 1px; +} + +.ai-draft-preview-tree__canvas .node-renderer__row-contents-logic .anticon { + font-size: 15px !important; +} + +.ai-draft-preview-tree__canvas .rst__rowLabel { + flex: 1 1 auto; + min-width: 0; + padding-right: 0; +} + +.ai-draft-preview-tree__canvas .rst__rowTitle, +.ai-draft-preview-tree__canvas .rst__rowSubtitle { + max-width: 100%; + white-space: normal; + overflow-wrap: anywhere; +} + +.ai-draft-preview-tree__canvas .rst__rowTitle { + line-height: 1.35; + text-wrap: pretty; +} + +.ai-draft-preview-tree__canvas .rst__rowTitleWithSubtitle { + display: block; + height: auto; + margin-bottom: 2px; + font-size: 12px; +} + +.ai-draft-preview-tree__canvas .rst__rowSubtitle { + display: block; + color: #595959; + font-size: 10px; + line-height: 1.25; +} + +.ai-draft-preview-tree__canvas .node-renderer__response-row-contents .rst__rowSubtitle { + color: #d8e1ec; +} + +.ai-draft-preview-tree__canvas .rst__rowToolbar { + flex: 0 0 auto; + margin-left: 10px; +} + +.ai-draft-preview-tree__speaker-badge { + display: inline-flex; + align-items: center; + min-height: 20px; + max-width: 150px; + overflow: hidden; + padding: 0 7px; + border-radius: 3px; + background: #003a8c; + color: #fff; + font-size: 10px; + font-weight: 700; + line-height: 20px; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; +} + +.ai-draft-preview-tree__canvas .node-renderer__response-row-contents .ai-draft-preview-tree__speaker-badge { + background: #243447; +} + +.ai-draft-preview-tree__canvas .rst__moveHandle { + flex: 0 0 34px; + width: 34px; + border: 0; + border-radius: 4px 0 0 4px; + box-shadow: 0 0 0 1px rgba(31, 47, 67, 0.12), 0 3px 10px rgba(31, 47, 67, 0.12); +} + +.ai-draft-preview-tree__canvas .node-renderer__row { + box-shadow: none !important; +} + +.ai-draft-preview-tree__canvas .rst__rowContents { + cursor: default; +} + +.ai-draft-preview-tree__canvas .rst__moveHandle { + cursor: default; +} + +.ai-draft-preview-tree__canvas .ReactVirtualized__Grid { + background: #f3f6fa; + outline: none; +} + +.ai-draft-preview-tree__canvas .ReactVirtualized__Grid__innerScrollContainer { + padding-bottom: 36px; +} + +.ai-draft-preview-tree__canvas .rst__collapseButton, +.ai-draft-preview-tree__canvas .rst__expandButton { + box-shadow: 0 0 0 1px #7f91a5, 0 2px 4px rgba(31, 47, 67, 0.18); +} + +.ai-draft-artifact-viewer { + display: flex; + flex-direction: column; + gap: 12px; + max-height: calc(100vh - 240px); +} + +.ai-draft-artifact-viewer__path { + overflow-wrap: anywhere; + padding: 6px 8px; + border: 1px solid #e8e8e8; + background: #fafafa; + color: #595959; + font-family: Consolas, 'Courier New', monospace; + font-size: 12px; +} + +.ai-draft-artifact-viewer__toolbar { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 8px 16px; + padding-top: 2px; + color: #595959; + font-size: 12px; +} + +.ai-draft-artifact-viewer__toolbar .ant-btn-group { + flex: 0 0 auto; +} + +.ai-draft-artifact-viewer__toolbar .ant-btn { + min-height: 30px; + padding-right: 12px; + padding-left: 12px; +} + +.ai-draft-artifact-viewer__note { + flex: 1 0 100%; + min-width: 0; + padding: 8px 10px; + border-radius: 3px; + background: #f6f8fb; + box-shadow: inset 0 0 0 1px rgba(31, 47, 67, 0.08); + color: #5f6b7a; + line-height: 1.4; + text-wrap: pretty; +} + +.ai-draft-artifact-viewer__alert { + margin-bottom: 0; +} + +.ai-draft-artifact-viewer pre { + min-height: 360px; + max-height: calc(100vh - 330px); + margin: 0; + overflow: auto; + padding: 12px; + border: 1px solid #d9d9d9; + background: #1f1f1f; + color: #e8e8e8; + font-family: Consolas, 'Courier New', monospace; + font-size: 12px; + line-height: 1.45; + white-space: pre-wrap; + overflow-wrap: anywhere; + user-select: text; +} diff --git a/app/src/components/AiDraftModal/AiDraftModal.tsx b/app/src/components/AiDraftModal/AiDraftModal.tsx new file mode 100644 index 00000000..945cdbdb --- /dev/null +++ b/app/src/components/AiDraftModal/AiDraftModal.tsx @@ -0,0 +1,1185 @@ +import React, { ChangeEvent, useEffect, useMemo, useState } from 'react'; +import { toJS } from 'mobx'; +import { runInAction } from 'mobx'; +import { observer } from 'mobx-react'; +import { Alert, Button, Checkbox, Col, Form, Icon, Input, message, Row, Select, Spin, Tabs, Tag, Tooltip } from 'antd'; +import classnames from 'classnames'; + +import { useStore } from 'hooks/useStore'; +import { DataStore } from 'stores/dataStore/data-store'; +import { DefStore } from 'stores/defStore/def-store'; +import { ModalStore } from 'stores/modalStore/modal-store'; +import { NodeStore } from 'stores/nodeStore/node-store'; +import { AiContextPathPicker } from 'components/AiContextPathPicker'; +import { AiDraftConversationTreePreview } from './AiDraftConversationTreePreview'; +import { AiDraftArtifactViewer } from './AiDraftArtifactViewer'; +import { + createAiDraft, + getAiDraftArtifact, + getAiModels, + getAiSettings, + saveAiSettings, + saveAiWorkspaceSettings, + validateConversationRoundTrip, +} from 'services/api'; +import { addNodes, getId, updatePromptNode, updateResponseNode, updateRootNode } from 'utils/conversation-utils'; +import { + buildAcceptedDraft, + buildPreviewConversationAssetFromDraft, + getSuggestedNodeText, + parseAiDraft, + validateAiDraft, +} from 'utils/ai-draft-utils'; +import { + AiCastPersonalityType, + AiConversationDraftType, + AiDraftModeType, + AiDraftRunResultType, + AiDraftValidationResultType, + AiModelCatalogResultType, + AiSettingsType, + AiWorkspaceSettingsType, + ElementNodeType, + PromptNodeType, +} from 'types'; + +import './AiDraftModal.css'; + +const { TextArea } = Input; +const { Option } = Select; +const { TabPane } = Tabs; +const AI_DEBUG_PREFIX = '[ConverseTek AI Modal]'; + +type Props = { + globalModalId: string; + mode: AiDraftModeType; + selectedNodeId?: string; +}; + +const emptySettings: AiSettingsType = { + enabled: true, + selectedProvider: 'codex', + codexCommand: 'codex', + codexModel: '', + codexProfile: '', + timeoutSeconds: 300, + modelCatalogs: {}, + workspaces: {}, + defaultCastPersonalities: [], +}; + +type ProviderUi = { + displayName: string; + commandLabel: string; + commandPlaceholder: string; + modelLabel: string; + profileLabel: string; + profilePlaceholder: string; +}; + +function getModeTitle(mode: AiDraftModeType): string { + if (mode === 'fullConversation') return 'AI Draft Conversation'; + if (mode === 'branchExpansion') return 'AI Expand Branch'; + return 'AI Suggest Node Text'; +} + +function getAcceptLabel(mode: AiDraftModeType): string { + if (mode === 'fullConversation') return 'Open In Main Editor'; + return 'Accept'; +} + +function getDefaultCommandForProvider(providerName: string): string { + return providerName === 'claudecode' ? 'claude' : 'codex'; +} + +function getProviderUi(providerName: string): ProviderUi { + if (providerName === 'claudecode') { + return { + displayName: 'Claude Code CLI', + commandLabel: 'Claude Code command', + commandPlaceholder: 'claude', + modelLabel: 'Claude model', + profileLabel: 'Claude profile', + profilePlaceholder: 'Default Claude Code profile', + }; + } + + return { + displayName: 'Codex CLI', + commandLabel: 'Codex command', + commandPlaceholder: 'codex', + modelLabel: 'Codex model', + profileLabel: 'Codex profile', + profilePlaceholder: 'Default Codex profile', + }; +} + +function FieldLabel({ label, help }: { label: string; help: string }) { + return ( + + {label} + + + + + ); +} + +function normaliseWorkspaceKey(path: string): string { + return path.replaceAll('\\', '/').replace(/\/+$/g, '').toLowerCase(); +} + +function createWorkspaceSettings(workingDirectory: string, defaultPersonalities: AiCastPersonalityType[] = []): AiWorkspaceSettingsType { + return { + workingDirectory, + contextPaths: [], + houseStyleNotes: '', + defaultCampaignBrief: '', + castPersonalities: cloneDefaultCastPersonalities(defaultPersonalities), + }; +} + +function cloneDefaultCastPersonalities(defaultPersonalities: AiCastPersonalityType[]): AiCastPersonalityType[] { + return defaultPersonalities.map((personality) => ({ + ...personality, + castIds: [...personality.castIds], + speakerIds: [...personality.speakerIds], + })); +} + +function makePersonalityId(prefix = 'custom'): string { + return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +function createCustomCastPersonality(): AiCastPersonalityType { + return { + id: makePersonalityId(), + label: 'New personality', + castIds: [], + speakerIds: [], + rules: '', + enabled: true, + defaultKey: '', + }; +} + +function cloneCastPersonality(personality: AiCastPersonalityType): AiCastPersonalityType { + return { + ...personality, + id: makePersonalityId('copy'), + label: `${personality.label || 'Personality'} Copy`, + castIds: [...personality.castIds], + speakerIds: [...personality.speakerIds], + defaultKey: '', + }; +} + +function normaliseIdTags(values: string[]): string[] { + const seenValues = new Set(); + const normalisedValues: string[] = []; + + values + .map((value) => value.trim()) + .filter(Boolean) + .forEach((value) => { + const key = value.toLowerCase(); + if (seenValues.has(key)) return; + seenValues.add(key); + normalisedValues.push(value); + }); + + return normalisedValues; +} + +function matchesPersonalitySearch(personality: AiCastPersonalityType, searchTerm: string): boolean { + const trimmedSearchTerm = searchTerm.trim().toLowerCase(); + if (trimmedSearchTerm === '') return true; + + return [personality.label, personality.defaultKey, ...personality.castIds, ...personality.speakerIds] + .filter(Boolean) + .some((value) => value.toLowerCase().includes(trimmedSearchTerm)); +} + +function AiDraftModal({ globalModalId, mode, selectedNodeId }: Props) { + const dataStore = useStore('data'); + const defStore = useStore('def'); + const modalStore = useStore('modal'); + const nodeStore = useStore('node'); + + const [settings, setSettings] = useState(emptySettings); + const [workspaceSettings, setWorkspaceSettings] = useState(null); + const [brief, setBrief] = useState(''); + const [draft, setDraft] = useState(null); + const [validation, setValidation] = useState({ errors: [], warnings: [] }); + const [modelCatalog, setModelCatalog] = useState(null); + const [diagnosticsPath, setDiagnosticsPath] = useState(''); + const [draftRunResult, setDraftRunResult] = useState(null); + const [activeTab, setActiveTab] = useState('draft'); + const [personalitySearch, setPersonalitySearch] = useState(''); + const [selectedPersonalityId, setSelectedPersonalityId] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isLoadingModels, setIsLoadingModels] = useState(false); + const [isSavingConfiguration, setIsSavingConfiguration] = useState(false); + + const { workingDirectory, unsavedActiveConversationAsset } = dataStore; + const isVisible = modalStore.options.get(globalModalId)?.isVisible === true; + const selectedNode = useMemo(() => { + if (!selectedNodeId) return nodeStore.activeNode; + return nodeStore.getNode(selectedNodeId); + }, [selectedNodeId, nodeStore.activeNode]); + const providerName = settings.selectedProvider || 'codex'; + const providerUi = getProviderUi(providerName); + const castPersonalities = workspaceSettings?.castPersonalities || []; + const filteredCastPersonalities = useMemo( + () => castPersonalities.filter((personality) => matchesPersonalitySearch(personality, personalitySearch)), + [castPersonalities, personalitySearch], + ); + const selectedPersonality = useMemo( + () => castPersonalities.find((personality) => personality.id === selectedPersonalityId) || castPersonalities[0] || null, + [castPersonalities, selectedPersonalityId], + ); + const selectedDefaultPersonality = selectedPersonality?.defaultKey + ? settings.defaultCastPersonalities.find((personality) => personality.defaultKey === selectedPersonality.defaultKey) + : null; + + const canGenerate = workingDirectory != null && (mode === 'fullConversation' || selectedNode != null); + const canAccept = draft != null && validation.errors.length === 0; + + const getCachedModelCatalog = (targetSettings: AiSettingsType, targetProviderName: string) => { + const catalog = targetSettings.modelCatalogs ? targetSettings.modelCatalogs[targetProviderName] : null; + if (catalog == null || !catalog.models || catalog.models.length === 0) return null; + return { + ...catalog, + fromCache: true, + }; + }; + + const loadModelCatalog = async (targetSettings: AiSettingsType, forceRefresh = false) => { + console.log(`${AI_DEBUG_PREFIX} loadModelCatalog start`, { + targetProvider: targetSettings.selectedProvider, + targetCodexModel: targetSettings.codexModel, + forceRefresh, + }); + + setIsLoadingModels(true); + try { + const result = await getAiModels(targetSettings, forceRefresh); + setModelCatalog(result); + console.log(`${AI_DEBUG_PREFIX} loadModelCatalog success`, { + provider: result.provider, + modelCount: result.models.length, + fromCache: result.fromCache, + currentStateCodexModel: settings.codexModel, + returnedModelSlugs: result.models.map((model) => model.slug), + }); + } catch (error) { + console.log(`${AI_DEBUG_PREFIX} loadModelCatalog error`, error); + setModelCatalog({ + success: false, + error: error instanceof Error ? error.message : 'Could not load provider models.', + provider: targetSettings.selectedProvider || 'unknown', + defaultModel: '', + fromCache: false, + models: [], + }); + } finally { + setIsLoadingModels(false); + } + }; + + useEffect(() => { + modalStore.setTitle(getModeTitle(mode), globalModalId); + modalStore.setWidth(mode === 'fullConversation' ? '74vw' : '86vw', globalModalId); + modalStore.setShowOkButton(false, globalModalId); + modalStore.setShowCancelButton(true, globalModalId); + modalStore.setCancelLabel('Close', globalModalId); + }, []); + + const loadConfiguration = async (refreshModelsForSettingsTab = false) => { + console.log(`${AI_DEBUG_PREFIX} loadConfiguration start`, { + refreshModelsForSettingsTab, + activeTab, + workingDirectory, + previousCodexModel: settings.codexModel, + }); + + try { + const loadedSettings = await getAiSettings(); + console.log(`${AI_DEBUG_PREFIX} loadConfiguration loaded`, { + loadedSelectedProvider: loadedSettings.selectedProvider, + loadedCodexModel: loadedSettings.codexModel, + loadedModelCatalogKeys: Object.keys(loadedSettings.modelCatalogs || {}), + }); + + setSettings(loadedSettings); + + const loadedProviderName = loadedSettings.selectedProvider || 'codex'; + setModelCatalog(getCachedModelCatalog(loadedSettings, loadedProviderName)); + + if (workingDirectory) { + const key = normaliseWorkspaceKey(workingDirectory); + setWorkspaceSettings(loadedSettings.workspaces[key] || createWorkspaceSettings(workingDirectory, loadedSettings.defaultCastPersonalities)); + } + + if (refreshModelsForSettingsTab && activeTab === 'settings') { + void loadModelCatalog(loadedSettings, true); + } + } catch (error) { + console.log(`${AI_DEBUG_PREFIX} loadConfiguration error`, error); + void message.error(error instanceof Error ? error.message : 'Could not load AI settings.'); + } + }; + + useEffect(() => { + if (!isVisible) return; + + void loadConfiguration(true); + }, [isVisible, workingDirectory]); + + useEffect(() => { + if (draft == null) { + setValidation({ errors: [], warnings: [] }); + return; + } + + setValidation(validateAiDraft(draft, defStore.operations, mode, selectedNode)); + }, [draft, mode, selectedNode, defStore.operations]); + + useEffect(() => { + if (workspaceSettings == null) return; + if (workspaceSettings.castPersonalities.some((personality) => personality.id === selectedPersonalityId)) return; + setSelectedPersonalityId(workspaceSettings.castPersonalities[0]?.id || ''); + }, [workspaceSettings, selectedPersonalityId]); + + const updateSettings = (patch: Partial) => { + console.log(`${AI_DEBUG_PREFIX} updateSettings`, { + patch, + previousCodexModel: settings.codexModel, + nextCodexModel: patch.codexModel ?? settings.codexModel, + }); + + setSettings((previousSettings) => ({ + ...previousSettings, + ...patch, + })); + }; + + const updateWorkspaceSettings = (patch: Partial) => { + setWorkspaceSettings((previousSettings) => ({ + ...(previousSettings || createWorkspaceSettings(workingDirectory || '', settings.defaultCastPersonalities)), + ...patch, + })); + }; + + const updateCastPersonalities = (nextPersonalities: AiCastPersonalityType[]) => { + updateWorkspaceSettings({ castPersonalities: nextPersonalities }); + }; + + const updateSelectedPersonality = (patch: Partial) => { + if (selectedPersonality == null) return; + + updateCastPersonalities( + castPersonalities.map((personality) => + personality.id === selectedPersonality.id + ? { + ...personality, + ...patch, + } + : personality, + ), + ); + }; + + const addCastPersonality = () => { + const personality = createCustomCastPersonality(); + updateCastPersonalities([...castPersonalities, personality]); + setSelectedPersonalityId(personality.id); + }; + + const duplicateSelectedPersonality = () => { + if (selectedPersonality == null) return; + + const personality = cloneCastPersonality(selectedPersonality); + updateCastPersonalities([...castPersonalities, personality]); + setSelectedPersonalityId(personality.id); + }; + + const deleteSelectedPersonality = () => { + if (selectedPersonality == null) return; + + const nextPersonalities = castPersonalities.filter((personality) => personality.id !== selectedPersonality.id); + updateCastPersonalities(nextPersonalities); + setSelectedPersonalityId(nextPersonalities[0]?.id || ''); + }; + + const restoreSelectedDefaultPersonality = () => { + if (selectedPersonality == null || selectedDefaultPersonality == null) return; + + updateSelectedPersonality({ + label: selectedDefaultPersonality.label, + castIds: [...selectedDefaultPersonality.castIds], + speakerIds: [...selectedDefaultPersonality.speakerIds], + rules: selectedDefaultPersonality.rules, + enabled: selectedDefaultPersonality.enabled, + defaultKey: selectedDefaultPersonality.defaultKey, + }); + }; + + const restoreMissingDefaultPersonalities = () => { + const existingDefaultKeys = new Set(castPersonalities.map((personality) => personality.defaultKey).filter(Boolean)); + const missingDefaults = cloneDefaultCastPersonalities(settings.defaultCastPersonalities).filter( + (personality) => !existingDefaultKeys.has(personality.defaultKey), + ); + + if (missingDefaults.length === 0) { + void message.info('All default cast personalities are already present.'); + return; + } + + updateCastPersonalities([...castPersonalities, ...missingDefaults]); + setSelectedPersonalityId(missingDefaults[0].id); + }; + + const addContextPath = (path: string) => { + const existingPaths = workspaceSettings?.contextPaths || []; + if (existingPaths.includes(path)) return; + updateWorkspaceSettings({ contextPaths: [...existingPaths, path] }); + }; + + const openContextPathPicker = () => { + modalStore.setModelContent( + AiContextPathPicker, + { + initialPath: workingDirectory || undefined, + onSelectPath: addContextPath, + }, + 'global2', + ); + }; + + const openDraftArtifact = async (title: string, path: string) => { + if (!path) { + void message.warning('No AI diagnostics file is available yet.'); + return; + } + + try { + const artifact = await getAiDraftArtifact(path); + if (!artifact.success) { + void message.error(artifact.error || 'Could not read AI diagnostics file.'); + return; + } + + modalStore.setModelContent( + AiDraftArtifactViewer, + { + title, + path: artifact.path, + content: artifact.content, + truncated: artifact.truncated, + }, + 'global2', + ); + } catch (error) { + void message.error(error instanceof Error ? error.message : 'Could not read AI diagnostics file.'); + } + }; + + const saveConfiguration = async (showSuccessMessage = false) => { + console.log(`${AI_DEBUG_PREFIX} saveConfiguration start`, { + showSuccessMessage, + selectedProvider: settings.selectedProvider, + codexModel: settings.codexModel, + hasWorkspaceSettings: workspaceSettings != null, + }); + + setIsSavingConfiguration(true); + + try { + const updatedSettings = await saveAiSettings(settings); + let savedSettings = updatedSettings; + console.log(`${AI_DEBUG_PREFIX} saveConfiguration after saveAiSettings`, { + savedSelectedProvider: savedSettings.selectedProvider, + savedCodexModel: savedSettings.codexModel, + }); + + if (workspaceSettings != null) { + savedSettings = await saveAiWorkspaceSettings(workspaceSettings); + console.log(`${AI_DEBUG_PREFIX} saveConfiguration after saveAiWorkspaceSettings`, { + savedSelectedProvider: savedSettings.selectedProvider, + savedCodexModel: savedSettings.codexModel, + }); + } + + setSettings(savedSettings); + const savedProviderName = savedSettings.selectedProvider || 'codex'; + setModelCatalog(getCachedModelCatalog(savedSettings, savedProviderName)); + + if (showSuccessMessage) { + void message.success('AI settings saved'); + } + } catch (error) { + console.log(`${AI_DEBUG_PREFIX} saveConfiguration error`, error); + void message.error(error instanceof Error ? error.message : 'Could not save AI settings.'); + throw error; + } finally { + console.log(`${AI_DEBUG_PREFIX} saveConfiguration finally`, { + codexModelAtFinally: settings.codexModel, + }); + setIsSavingConfiguration(false); + modalStore.setIsLoading(false, globalModalId); + modalStore.setDisableOk(false, globalModalId); + } + }; + + useEffect(() => { + const isConfigurationTab = activeTab === 'settings' || activeTab === 'cast-personalities'; + + modalStore.setShowOkButton(isConfigurationTab, globalModalId); + modalStore.setOkLabel('Save AI Settings', globalModalId); + modalStore.setDisableOk(isSavingConfiguration, globalModalId); + modalStore.setIsLoading(isSavingConfiguration, globalModalId); + modalStore.setLoadingLabel('Saving', globalModalId); + modalStore.setOnOk(() => { + void saveConfiguration(true); + }, globalModalId); + }, [activeTab, settings, workspaceSettings, isSavingConfiguration]); + + const generateDraft = async () => { + if (!canGenerate || workingDirectory == null) { + void message.warning('Open a conversation folder and select a node when required before asking AI.'); + return; + } + + setIsLoading(true); + setDraft(null); + setDiagnosticsPath(''); + setDraftRunResult(null); + + try { + await saveConfiguration(); + const result = await createAiDraft({ + mode, + brief, + workingDirectory, + conversationJson: JSON.stringify(toJS(unsavedActiveConversationAsset), null, 2), + selectedNodeJson: JSON.stringify(toJS(selectedNode), null, 2), + definitionsJson: JSON.stringify(toJS({ operations: defStore.operations, presets: defStore.presets, tags: defStore.tags }), null, 2), + }); + + setDiagnosticsPath(result.diagnosticsDirectory); + setDraftRunResult(result); + + if (!result.success) { + void message.error(result.error || 'AI draft generation failed.'); + return; + } + + setDraft(parseAiDraft(result.draftJson)); + void message.success('AI draft generated'); + } catch (error) { + void message.error(error instanceof Error ? error.message : 'AI draft generation failed.'); + } finally { + setIsLoading(false); + } + }; + + const acceptDraft = async () => { + if (draft == null || workingDirectory == null) return; + + try { + const acceptedDraft = buildAcceptedDraft(draft, mode, workingDirectory, selectedNode); + + if (acceptedDraft.kind === 'fullConversation') { + const roundTripResult = await validateConversationRoundTrip(acceptedDraft.conversationAsset); + if (!roundTripResult.success) { + void message.error(`Generated conversation failed serialisation validation: ${roundTripResult.error}`); + return; + } + + runInAction(() => { + dataStore.updateActiveConversation(acceptedDraft.conversationAsset); + dataStore.setConversationDirty(true); + nodeStore.setRebuild(true); + }); + } else if (acceptedDraft.kind === 'nodeText') { + if (selectedNode == null) return; + + runInAction(() => { + nodeStore.setNodeText(selectedNode, acceptedDraft.text); + nodeStore.setRebuild(true); + }); + } else { + const activeConversation = dataStore.unsavedActiveConversationAsset; + if (activeConversation == null) return; + + runInAction(() => { + const { parentElementNode, nodes } = acceptedDraft.patch; + addNodes(activeConversation, nodes); + + if (parentElementNode.type === 'root') { + updateRootNode(activeConversation, parentElementNode); + } else { + const parentPromptNode = nodeStore.getNode(parentElementNode.parentId) as PromptNodeType; + updateResponseNode(activeConversation, parentPromptNode, parentElementNode); + } + + nodes.forEach((node) => updatePromptNode(activeConversation, node)); + dataStore.setConversationDirty(true); + nodeStore.setRebuild(true); + }); + } + + void message.success(mode === 'fullConversation' ? 'AI conversation draft is ready to save' : 'AI suggestion accepted'); + modalStore.closeModal(globalModalId); + } catch (error) { + void message.error(error instanceof Error ? error.message : 'Could not accept AI draft.'); + } + }; + + const contextPathsText = (workspaceSettings?.contextPaths || []).join('\n'); + const selectedNodeLabel = selectedNode ? `${selectedNode.type} ${getId(selectedNode)}` : 'None'; + const listedModelSlugs = new Set((modelCatalog?.models || []).map((model) => model.slug)); + const shouldShowCustomModel = settings.codexModel !== '' && !listedModelSlugs.has(settings.codexModel); + + console.log(`${AI_DEBUG_PREFIX} render`, { + activeTab, + isVisible, + providerName, + codexModel: settings.codexModel, + modelCatalogProvider: modelCatalog?.provider, + modelCatalogFromCache: modelCatalog?.fromCache, + listedModelSlugs: Array.from(listedModelSlugs), + shouldShowCustomModel, + }); + + const updateProvider = (nextProviderName: string) => { + const previousDefaultCommand = getDefaultCommandForProvider(providerName); + const nextDefaultCommand = getDefaultCommandForProvider(nextProviderName); + const currentCommand = settings.codexCommand || ''; + const shouldUseNextDefault = currentCommand.trim() === '' || currentCommand === previousDefaultCommand; + + const nextSettings = { + ...settings, + selectedProvider: nextProviderName, + codexCommand: shouldUseNextDefault ? nextDefaultCommand : currentCommand, + codexModel: '', + }; + + setSettings(nextSettings); + setModelCatalog(getCachedModelCatalog(nextSettings, nextProviderName)); + }; + + const changeTab = (key: string) => { + console.log(`${AI_DEBUG_PREFIX} changeTab`, { + key, + codexModelBeforeTabChange: settings.codexModel, + }); + + setActiveTab(key); + + if (key === 'settings') { + void loadModelCatalog(settings, true); + } + }; + + return ( +
+ + + {!canGenerate && ( + + )} + + + +
+ + } + > +