feat: Add commands and functionality to save and load chat sessions.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,3 +2,4 @@ bin
|
|||||||
obj
|
obj
|
||||||
.vscode
|
.vscode
|
||||||
publish
|
publish
|
||||||
|
.anchor
|
||||||
@@ -16,6 +16,8 @@ namespace AnchorCli;
|
|||||||
[JsonSerializable(typeof(ModelsResponse))]
|
[JsonSerializable(typeof(ModelsResponse))]
|
||||||
[JsonSerializable(typeof(ModelInfo))]
|
[JsonSerializable(typeof(ModelInfo))]
|
||||||
[JsonSerializable(typeof(ModelPricing))]
|
[JsonSerializable(typeof(ModelPricing))]
|
||||||
|
[JsonSerializable(typeof(Microsoft.Extensions.AI.ChatMessage))]
|
||||||
|
[JsonSerializable(typeof(System.Collections.Generic.List<Microsoft.Extensions.AI.ChatMessage>))]
|
||||||
[JsonSerializable(typeof(AnchorConfig))]
|
[JsonSerializable(typeof(AnchorConfig))]
|
||||||
[JsonSourceGenerationOptions(
|
[JsonSourceGenerationOptions(
|
||||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace AnchorCli;
|
namespace AnchorCli;
|
||||||
|
|
||||||
@@ -69,4 +70,38 @@ internal sealed class ChatSession
|
|||||||
yield return update;
|
yield return update;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task SaveAsync(string filePath, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// Skip the system message when saving (it will be recreated on load)
|
||||||
|
var messagesToSave = History.Skip(1).ToList();
|
||||||
|
|
||||||
|
var options = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
WriteIndented = true
|
||||||
|
};
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(messagesToSave, AppJsonContext.Default.ListChatMessage);
|
||||||
|
await File.WriteAllTextAsync(filePath, json, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task LoadAsync(string filePath, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var json = await File.ReadAllTextAsync(filePath, cancellationToken);
|
||||||
|
|
||||||
|
var options = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||||
|
};
|
||||||
|
|
||||||
|
var messages = JsonSerializer.Deserialize<List<ChatMessage>>(json, AppJsonContext.Default.ListChatMessage)
|
||||||
|
?? new List<ChatMessage>();
|
||||||
|
|
||||||
|
// Keep the system message and append loaded messages
|
||||||
|
var systemMessage = History[0];
|
||||||
|
History.Clear();
|
||||||
|
History.Add(systemMessage);
|
||||||
|
History.AddRange(messages);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
Commands/LoadCommand.cs
Normal file
31
Commands/LoadCommand.cs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
using Spectre.Console;
|
||||||
|
|
||||||
|
namespace AnchorCli.Commands;
|
||||||
|
|
||||||
|
internal class LoadCommand(ChatSession session) : ICommand
|
||||||
|
{
|
||||||
|
public string Name => "load";
|
||||||
|
public string Description => "Load a chat session from a file";
|
||||||
|
|
||||||
|
public async Task ExecuteAsync(string[] args, CancellationToken ct)
|
||||||
|
{
|
||||||
|
string filePath = args.Length > 0 ? args[0] : ".anchor/session.json";
|
||||||
|
|
||||||
|
if (!File.Exists(filePath))
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine($"[yellow]No session file found at {Markup.Escape(filePath)}[/]");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await session.LoadAsync(filePath, ct);
|
||||||
|
AnsiConsole.MarkupLine($"[green]Session loaded from {Markup.Escape(filePath)}[/]");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine($"[red]Failed to load session: {Markup.Escape(ex.Message)}[/]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
31
Commands/SaveCommand.cs
Normal file
31
Commands/SaveCommand.cs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
using Spectre.Console;
|
||||||
|
|
||||||
|
namespace AnchorCli.Commands;
|
||||||
|
|
||||||
|
internal class SaveCommand(ChatSession session) : ICommand
|
||||||
|
{
|
||||||
|
public string Name => "save";
|
||||||
|
public string Description => "Save the current chat session to a file";
|
||||||
|
|
||||||
|
public async Task ExecuteAsync(string[] args, CancellationToken ct)
|
||||||
|
{
|
||||||
|
string filePath = args.Length > 0 ? args[0] : ".anchor/session.json";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var directory = Path.GetDirectoryName(filePath);
|
||||||
|
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
await session.SaveAsync(filePath, ct);
|
||||||
|
AnsiConsole.MarkupLine($"[green]Session saved to {Markup.Escape(filePath)}[/]");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AnsiConsole.MarkupLine($"[red]Failed to save session: {Markup.Escape(ex.Message)}[/]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
42
Program.cs
42
Program.cs
@@ -81,13 +81,13 @@ infoTable.AddRow("[grey]CWD[/]", $"[green]{Markup.Escape(Environment.CurrentDire
|
|||||||
|
|
||||||
if (modelInfo?.Pricing != null)
|
if (modelInfo?.Pricing != null)
|
||||||
|
|
||||||
if (modelInfo?.Pricing != null)
|
if (modelInfo?.Pricing != null)
|
||||||
{
|
{
|
||||||
var inM = tokenTracker.InputPrice * 1_000_000m;
|
var inM = tokenTracker.InputPrice * 1_000_000m;
|
||||||
var outM = tokenTracker.OutputPrice * 1_000_000m;
|
var outM = tokenTracker.OutputPrice * 1_000_000m;
|
||||||
infoTable.AddRow("[grey]Pricing[/]",
|
infoTable.AddRow("[grey]Pricing[/]",
|
||||||
$"[yellow]${inM:F2}[/][dim]/M in[/] [yellow]${outM:F2}[/][dim]/M out[/]");
|
$"[yellow]${inM:F2}[/][dim]/M in[/] [yellow]${outM:F2}[/][dim]/M out[/]");
|
||||||
}
|
}
|
||||||
if (modelInfo != null)
|
if (modelInfo != null)
|
||||||
{
|
{
|
||||||
infoTable.AddRow("[grey]Context[/]",
|
infoTable.AddRow("[grey]Context[/]",
|
||||||
@@ -110,7 +110,7 @@ var openAiClient = new OpenAIClient(new ApiKeyCredential(apiKey), new OpenAIClie
|
|||||||
IChatClient innerClient = openAiClient.GetChatClient(model).AsIChatClient();
|
IChatClient innerClient = openAiClient.GetChatClient(model).AsIChatClient();
|
||||||
|
|
||||||
// ── Tool call logging via Spectre ───────────────────────────────────────
|
// ── Tool call logging via Spectre ───────────────────────────────────────
|
||||||
object consoleLock = new object();
|
object consoleLock = new();
|
||||||
|
|
||||||
void ToolLog(string message)
|
void ToolLog(string message)
|
||||||
{
|
{
|
||||||
@@ -142,12 +142,38 @@ commandRegistry.Register(new StatusCommand(model, endpoint));
|
|||||||
commandRegistry.Register(new CompactCommand(session.Compactor, session.History));
|
commandRegistry.Register(new CompactCommand(session.Compactor, session.History));
|
||||||
commandRegistry.Register(new SetupCommand());
|
commandRegistry.Register(new SetupCommand());
|
||||||
commandRegistry.Register(new ResetCommand(session, tokenTracker));
|
commandRegistry.Register(new ResetCommand(session, tokenTracker));
|
||||||
|
commandRegistry.Register(new SaveCommand(session));
|
||||||
|
commandRegistry.Register(new LoadCommand(session));
|
||||||
|
|
||||||
|
|
||||||
var commandDispatcher = new CommandDispatcher(commandRegistry);
|
var commandDispatcher = new CommandDispatcher(commandRegistry);
|
||||||
|
|
||||||
// ── Run Repl ────────────────────────────────────────────────────────────
|
// ── Run Repl ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Auto-load session if it exists
|
||||||
|
const string sessionPath = ".anchor/session.json";
|
||||||
|
if (File.Exists(sessionPath))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await session.LoadAsync(sessionPath, default);
|
||||||
|
AnsiConsole.MarkupLine($"[dim grey]Auto-loaded previous session.[/]");
|
||||||
|
}
|
||||||
|
catch { /* Ignore load errors on startup */ }
|
||||||
|
}
|
||||||
|
|
||||||
var repl = new ReplLoop(session, tokenTracker, commandDispatcher);
|
var repl = new ReplLoop(session, tokenTracker, commandDispatcher);
|
||||||
await repl.RunAsync();
|
await repl.RunAsync();
|
||||||
|
|
||||||
|
// Auto-save session on clean exit
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var directory = Path.GetDirectoryName(sessionPath);
|
||||||
|
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
}
|
||||||
|
await session.SaveAsync(sessionPath, default);
|
||||||
|
}
|
||||||
|
catch { /* Ignore save errors on exit */ }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user