Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"version": "2.0.0",
"tasks": [
{
"label": "Release",
"label": "CT: Release",
"command": "dotnet",
"type": "process",
"args": [
Expand All @@ -15,7 +15,7 @@
"problemMatcher": "$msCompile"
},
{
"label": "Build All",
"label": "CT: Build All",
"command": "dotnet",
"type": "process",
"args": [
Expand All @@ -26,7 +26,7 @@
"problemMatcher": "$msCompile"
},
{
"label": "Build Server",
"label": "CT: Build Server",
"command": "dotnet",
"type": "process",
"args": [
Expand All @@ -37,7 +37,7 @@
"problemMatcher": "$msCompile"
},
{
"label": "UI Install",
"label": "CT: UI Install",
"type": "shell",
"command": "npm install",
"options": {
Expand All @@ -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": [
Expand All @@ -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/"
Expand Down
36 changes: 36 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 10 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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', ... })`.
Expand Down
222 changes: 222 additions & 0 deletions Controllers/AiController.cs
Original file line number Diff line number Diff line change
@@ -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<AiSettings>(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<AiWorkspaceSettings>(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<bool>();
AiSettings settings = data["settings"] == null
? ConfigService.getInstance().GetAiSettings()
: JsonConvert.DeserializeObject<AiSettings>(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<AiDraftRequest>(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<ConversationAsset>(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;
}
}
}
14 changes: 12 additions & 2 deletions Controllers/FileSystemController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@ private ChromelyResponse GetDirectories(ChromelyRequest request) {
IDictionary<string, object> requestParams = request.Parameters;
string path = (string)requestParams["path"];
bool includeFiles = (bool)requestParams["includeFiles"];
List<string> fileExtensions = new List<string>();

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);
Expand All @@ -59,7 +69,7 @@ private ChromelyResponse GetDirectories(ChromelyRequest request) {
List<FsFile> files = new List<FsFile>();

if (includeFiles) {
files = fileSystemService.GetFiles(path);
files = fileSystemService.GetFiles(path, fileExtensions);
}

FsView fsView = new FsView();
Expand Down Expand Up @@ -173,4 +183,4 @@ private ChromelyResponse GetDependencyStatus(ChromelyRequest request) {
return response;
}
}
}
}
Loading
Loading