using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;
namespace AISApp
{
// JSON DTOs for API
public sealed class ChatMessage
{
[JsonPropertyName("role")] public string Role { get; set; } = "";
[JsonPropertyName("content")] public string Content { get; set; } = "";
}
public sealed class ChatRequest
{
[JsonPropertyName("model")] public string Model { get; set; } = "";
[JsonPropertyName("messages")] public List<ChatMessage> Messages { get; set; } = new();
[JsonPropertyName("temperature")] public double Temperature { get; set; } = 0.0;
}
public sealed class ChatChoice
{
[JsonPropertyName("message")] public ChatMessage Message { get; set; } = new();
}
public sealed class ChatResponse
{
[JsonPropertyName("choices")] public List<ChatChoice> Choices { get; set; } = new();
}
public sealed class AIS
{
// -------- Static / constants --------
private static readonly HttpClient Http = new HttpClient();
private static readonly JsonSerializerOptions JsonOpts = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
WriteIndented = false
};
public static readonly Dictionary<string, string> PredefinedModels = new(StringComparer.OrdinalIgnoreCase)
{
["dobby"] = "sentientagi/dobby-mini-unhinged-plus-llama-3.1-8b",
["dolphin"] = "cognitivecomputations/dolphin-mixtral-8x22b",
["dolphin_free"] = "cognitivecomputations/dolphin3.0-mistral-24b:free",
["gemma"] = "google/gemma-3-12b-it",
["gpt-4o-mini"] = "openai/gpt-4o-mini",
["gpt-4.1-nano"] = "openai/gpt-4.1-nano",
["qwen"] = "qwen/qwen3-30b-a3b",
["unslop"] = "thedrummer/unslopnemo-12b",
["euryale"] = "sao10k/l3.3-euryale-70b",
["wizard"] = "microsoft/wizardlm-2-8x22b",
["deepseek"] = "deepseek/deepseek-chat-v3-0324"
};
// -------- Instance state (mirrors Python) --------
public string Name { get; private set; }
public double Temperature { get; private set; }
public double Backoff { get; private set; }
public int MaxRetries { get; private set; }
public bool SafeMode { get; private set; }
public string SystemMessage { get; private set; }
public string Model { get; private set; }
public string BaseUrl { get; private set; }
public string Rel { get; private set; }
public string ApiKey { get; private set; }
public string DbName { get; private set; }
public bool Loaded { get; private set; }
private readonly List<ChatMessage> _messages = new();
private readonly SqliteConnection _conn;
private Dictionary<string, string> Headers => new()
{
["Authorization"] = $"Bearer {ApiKey}",
["Content-Type"] = "application/json"
};
// -------- ctor --------
public AIS(
string? systemMessage = null,
string model = "gemma",
int maxRetries = 3,
double backoff = 1,
string? name = null,
double temperature = 0.0,
string dbName = "tuna_ais_client.db",
bool safeMode = true,
string baseUrl = "openrouter.ai",
string rel = "/api",
string? apiKey = null
)
{
Console.WriteLine($"[INFO] Initializing AIS name={name} model={model} db={dbName} api_key={apiKey}");
Loaded = dbName != ":memory:" && File.Exists(dbName);
DbName = dbName;
// Open connection and create schema
_conn = OpenConnection(dbName);
InitDb();
// If existing DB, hydrate settings first
if (Loaded)
{
temperature = GetSetting("temperature", temperature);
name = GetSetting("name", name ?? "");
backoff = GetSetting("backoff", backoff);
maxRetries = GetSetting("max_retries", maxRetries);
safeMode = GetSetting("safe_mode", safeMode);
systemMessage = GetSetting("system_message", systemMessage ?? "");
model = GetSetting("model", model);
}
if (string.IsNullOrEmpty(systemMessage))
{
systemMessage = "You respond like a calculator; only the result is the answer. Respond only with the result. No explanations or extra words.";
}
SystemMessage = systemMessage;
Name = !string.IsNullOrEmpty(name)
? $"ais_{name}"
: $"ais_{Guid.NewGuid().ToString("N").Substring(0, 8)}";
Backoff = backoff;
SafeMode = safeMode;
MaxRetries = maxRetries;
BaseUrl = baseUrl;
Rel = rel;
ApiKey = apiKey ?? Environment.GetEnvironmentVariable("OPENROUTER_API_KEY")
?? "sk-or-REPLACE_ME"; // keep explicit default like Python had
Temperature = temperature;
Model = PredefinedModels.TryGetValue(model, out var mapped) ? mapped : model;
if (MessagesExist())
{
_messages = LoadMessages();
}
else
{
_messages.Add(new ChatMessage { Role = "system", Content = SystemMessage });
SaveMessage("system", SystemMessage);
}
Console.WriteLine($"[INFO] Initialized AIS name={Name} model={Model} db={DbName}");
}
// -------- DB helpers --------
private static SqliteConnection OpenConnection(string dbName)
{
var cs = dbName == ":memory:" ? "Data Source=:memory:" : $"Data Source={dbName}";
var conn = new SqliteConnection(cs);
conn.Open();
return conn;
}
private void InitDb()
{
using var cmd1 = _conn.CreateCommand();
cmd1.CommandText = @"
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
role TEXT NOT NULL,
content TEXT NOT NULL
)";
cmd1.ExecuteNonQuery();
using var cmd2 = _conn.CreateCommand();
cmd2.CommandText = @"
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
json_value TEXT NOT NULL
)";
cmd2.ExecuteNonQuery();
}
private bool MessagesExist()
{
using var cmd = _conn.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM messages";
var count = Convert.ToInt32(cmd.ExecuteScalar());
return count > 0;
}
private List<ChatMessage> LoadMessages()
{
var list = new List<ChatMessage>();
using var cmd = _conn.CreateCommand();
cmd.CommandText = "SELECT role, content FROM messages ORDER BY id ASC";
using var rdr = cmd.ExecuteReader();
while (rdr.Read())
{
list.Add(new ChatMessage
{
Role = rdr.GetString(0),
Content = rdr.GetString(1)
});
}
return list;
}
private void SaveMessage(string role, string content)
{
using var cmd = _conn.CreateCommand();
cmd.CommandText = "INSERT INTO messages (role, content) VALUES ($r, $c)";
cmd.Parameters.AddWithValue("$r", role);
cmd.Parameters.AddWithValue("$c", content);
cmd.ExecuteNonQuery();
}
// Generic typed setting helpers
private T GetSetting<T>(string name, T defaultValue)
{
using var cmd = _conn.CreateCommand();
cmd.CommandText = "SELECT json_value FROM settings WHERE name = $n LIMIT 1";
cmd.Parameters.AddWithValue("$n", name);
var obj = cmd.ExecuteScalar();
if (obj is string s)
{
try
{
var node = JsonNode.Parse(s);
if (node is null) return defaultValue!;
return node.Deserialize<T>(JsonOpts)!;
}
catch
{
return defaultValue!;
}
}
return defaultValue!;
}
private void SetSetting(string name, object? value)
{
var json = JsonSerializer.Serialize(value, JsonOpts);
using (var upd = _conn.CreateCommand())
{
upd.CommandText = "UPDATE settings SET json_value = $v WHERE name = $n";
upd.Parameters.AddWithValue("$v", json);
upd.Parameters.AddWithValue("$n", name);
var rows = upd.ExecuteNonQuery();
if (rows == 0)
{
using var ins = _conn.CreateCommand();
ins.CommandText = "INSERT INTO settings (name, json_value) VALUES ($n, $v)";
ins.Parameters.AddWithValue("$n", name);
ins.Parameters.AddWithValue("$v", json);
ins.ExecuteNonQuery();
}
}
// Reflectively update instance if property exists (case-insensitive)
var prop = GetType().GetProperty(name, System.Reflection.BindingFlags.Instance |
System.Reflection.BindingFlags.Public |
System.Reflection.BindingFlags.IgnoreCase);
if (prop != null && prop.CanWrite)
{
try
{
var targetVal = value;
if (value is JsonNode node)
{
targetVal = node.Deserialize(prop.PropertyType, JsonOpts);
}
else if (value is string str && prop.PropertyType != typeof(string))
{
try
{
var jn = JsonNode.Parse(str);
if (jn != null) targetVal = jn.Deserialize(prop.PropertyType, JsonOpts);
}
catch { /* ignore */ }
}
prop.SetValue(this, ConvertIfNeeded(targetVal, prop.PropertyType));
}
catch { /* ignore */ }
}
}
private static object? ConvertIfNeeded(object? value, Type targetType)
{
if (value == null) return null;
if (targetType.IsAssignableFrom(value.GetType())) return value;
try { return Convert.ChangeType(value, targetType); }
catch { return value; }
}
private void SaveSettings()
{
SetSetting("name", Name);
SetSetting("temperature", Temperature);
SetSetting("backoff", Backoff);
SetSetting("max_retries", MaxRetries);
SetSetting("safe_mode", SafeMode);
SetSetting("system_message", SystemMessage);
SetSetting("model", Model);
}
// -------- Public API (mirrors Python) --------
public void Append(string messageUser, string messageAssistant, bool save = true)
{
_messages.Add(new ChatMessage { Role = "user", Content = messageUser });
if (save) SaveMessage("user", messageUser);
_messages.Add(new ChatMessage { Role = "assistant", Content = messageAssistant });
if (save) SaveMessage("assistant", messageAssistant);
}
public void Save(string dbPath)
{
if (DbName != ":memory:")
{
Console.WriteLine("[INFO] Already using persistent database.");
return;
}
var dbFile = dbPath.EndsWith(".db", StringComparison.OrdinalIgnoreCase) ? dbPath : dbPath + ".db";
using var dest = new SqliteConnection($"Data Source={dbFile}");
dest.Open();
_conn.BackupDatabase(dest);
dest.Close();
DbName = dbFile;
Console.WriteLine($"[INFO] Converted in-memory DB to persistent DB at {DbName}");
}
public void PatchTime()
{
const string timePrefix = "Current time: `";
if (_messages.Count == 0) return;
var content = _messages[0].Content ?? "";
var nowStr = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
if (content.StartsWith(timePrefix, StringComparison.Ordinal))
{
var end = content.IndexOf('`', timePrefix.Length);
if (end >= 0)
{
var rest = content.Substring(end + 1).TrimStart('\n');
_messages[0].Content = $"{timePrefix}{nowStr}`\n\n{rest}";
}
else
{
_messages[0].Content = $"{timePrefix}{nowStr}`\n\n{content}";
}
}
else
{
_messages[0].Content = $"{timePrefix}{nowStr}`\n\n{content}";
}
// Persist system message update
using var cmd = _conn.CreateCommand();
cmd.CommandText = "UPDATE messages SET content = $c WHERE id = (SELECT MIN(id) FROM messages)";
cmd.Parameters.AddWithValue("$c", _messages[0].Content);
cmd.ExecuteNonQuery();
}
public async Task<object> ChatAsync(string message, double? temperature = null, CancellationToken ct = default)
{
if (!SafeMode)
{
// command handling
if (message.StartsWith("!reset", StringComparison.OrdinalIgnoreCase)) { Reset(); return "true"; }
if (message.StartsWith("!instruct", StringComparison.OrdinalIgnoreCase)) { Instruct(message.Length >= 10 ? message[10..] : ""); return "true"; }
if (message.StartsWith("!undo", StringComparison.OrdinalIgnoreCase)) { return Undo().ToString().ToLowerInvariant(); }
if (message.StartsWith("!get", StringComparison.OrdinalIgnoreCase)) { var key = message[4..].Trim(); return GetRawSettingOr("not found", key); }
if (message.StartsWith("!set", StringComparison.OrdinalIgnoreCase))
{
var rest = message.Length >= 5 ? message[5..] : "";
var idx = rest.IndexOf(' ');
if (idx > 0)
{
var nm = rest[..idx].Trim();
var val = rest[(idx + 1)..].Trim();
SetSetting(nm, TryJson(val));
return "true";
}
}
if (message.StartsWith("!query", StringComparison.OrdinalIgnoreCase)) { return Query(message.Length >= 6 ? message[6..] : ""); }
if (message.StartsWith("!edit", StringComparison.OrdinalIgnoreCase)) { Edit(message.Length >= 5 ? message[5..] : ""); return SystemMessage; }
if (message.StartsWith("!append", StringComparison.OrdinalIgnoreCase))
{
var rest = message.Length >= 7 ? message[7..] : "";
var firstColon = rest.IndexOf(':');
if (firstColon >= 0 && rest.IndexOf(':', firstColon + 1) < 0)
{
var user = rest[..firstColon];
var assistantMessage = rest[(firstColon + 1)..];
Append(user, assistantMessage);
return "true";
}
}
}
PatchTime();
_messages.Add(new ChatMessage { Role = "user", Content = message });
SaveMessage("user", message);
var payload = new ChatRequest
{
Model = Model,
Messages = _messages,
Temperature = temperature ?? Temperature
};
var json = JsonSerializer.Serialize(payload, JsonOpts);
var url = $"https://{BaseUrl}{Rel}/v1/chat/completions";
Console.WriteLine(url);
int attempt = 0;
string assistantResponse = "ERROR";
double backoff = Backoff;
while (attempt < MaxRetries)
{
try
{
using var req = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
foreach (var kv in Headers) req.Headers.TryAddWithoutValidation(kv.Key, kv.Value);
using var resp = await Http.SendAsync(req, ct);
var text = await resp.Content.ReadAsStringAsync(ct);
//var parsed = JsonSerializer.Deserialize<ChatResponse>(text, JsonOpts);
//assistantResponse = parsed?.Choices?[0]?.Message?.Content ?? "ERROR";
var parsed = JsonSerializer.Deserialize<ChatResponse>(text, JsonOpts);
if (parsed?.Choices != null && parsed.Choices.Count > 0)
{
var msg = parsed.Choices[0]?.Message?.Content;
assistantResponse = string.IsNullOrEmpty(msg) ? "ERROR" : msg!;
}
else
{
assistantResponse = text;
// assistantResponse = "ERROR";
}
SaveSettings();
_messages.Add(new ChatMessage { Role = "assistant", Content = assistantResponse });
SaveMessage("assistant", assistantResponse);
return TryJson(assistantResponse);
}
catch (Exception ex)
{
attempt++;
Console.WriteLine($"[WARN] Chat attempt {attempt} failed: {ex.Message}");
if (attempt < MaxRetries)
{
await Task.Delay(TimeSpan.FromSeconds(backoff), ct);
backoff *= 2;
}
else
{
_messages.Add(new ChatMessage { Role = "assistant", Content = "ERROR" });
SaveMessage("assistant", "ERROR");
Console.WriteLine($"[CRITICAL] Failed after {MaxRetries} attempts.");
return "ERROR";
}
}
}
return "ERROR";
}
public object Chat(string message, double? temperature = null)
=> ChatAsync(message, temperature).GetAwaiter().GetResult();
// JSON helper that mirrors Python _try_json (also strips ``` fences)
private static object TryJson(string response)
{
var fixedStr = (response ?? "").Trim();
if (fixedStr.StartsWith("```", StringComparison.Ordinal) && fixedStr.EndsWith("```", StringComparison.Ordinal))
{
var lines = fixedStr.Split('\n');
if (lines.Length >= 2)
{
fixedStr = string.Join("\n", lines, 1, lines.Length - 2).Trim();
}
}
try
{
var node = JsonNode.Parse(fixedStr);
return node ?? response;
}
catch
{
return response;
}
}
private object GetRawSettingOr(string fallback, string key)
{
using var cmd = _conn.CreateCommand();
cmd.CommandText = "SELECT json_value FROM settings WHERE name = $n LIMIT 1";
cmd.Parameters.AddWithValue("$n", key);
var obj = cmd.ExecuteScalar();
return obj is string s ? s : fallback;
}
public void Reset()
{
Console.WriteLine("[INFO] Reset conversation history (keeping system message).");
ChatMessage sys = _messages.Count > 0 ? _messages[0] : new ChatMessage { Role = "system", Content = "" };
_messages.Clear();
_messages.Add(sys);
using var cmd = _conn.CreateCommand();
cmd.CommandText = "DELETE FROM messages WHERE id != (SELECT MIN(id) FROM messages)";
cmd.ExecuteNonQuery();
}
public void Instruct(string instructions)
{
SystemMessage = instructions;
Console.WriteLine($"[INFO] System instructions updated.");
SaveSettings();
if (_messages.Count > 0)
{
_messages[0].Content = instructions;
using var cmd = _conn.CreateCommand();
cmd.CommandText = "UPDATE messages SET content = $c WHERE id = (SELECT MIN(id) FROM messages)";
cmd.Parameters.AddWithValue("$c", instructions);
cmd.ExecuteNonQuery();
}
else
{
_messages.Add(new ChatMessage { Role = "system", Content = instructions });
SaveMessage("system", instructions);
}
Reset();
}
public object Query(string query)
{
var response = Chat(query);
Undo();
return response;
}
private bool DeleteLastMessage()
{
try
{
if (_messages.Count == 0) return false;
_messages.RemoveAt(_messages.Count - 1);
using var cmd = _conn.CreateCommand();
cmd.CommandText = "DELETE FROM messages WHERE id = (SELECT MAX(id) FROM messages)";
cmd.ExecuteNonQuery();
return true;
}
catch (Exception e)
{
Console.WriteLine($"[ERROR] Undo error: {e.Message}");
return false;
}
}
public bool Edit(string change)
{
var prompt = $"Apply this update: `{change}` to `{SystemMessage}` and return the result";
var result = Query(prompt);
var newSys = result is JsonNode jn ? jn.ToJsonString() : result?.ToString() ?? SystemMessage;
Instruct(newSys);
return true;
}
public bool Undo()
{
// delete assistant + user
return DeleteLastMessage() && DeleteLastMessage();
}
public void Repl()
{
Console.WriteLine("[INFO] REPL started. Ctrl+C to quit.");
while (true)
{
try
{
Console.Write("You: ");
var line = Console.ReadLine();
if (string.IsNullOrWhiteSpace(line)) continue;
var response = Chat(line);
var typeName = response switch
{
JsonNode => "JsonNode",
null => "null",
_ => response.GetType().Name
};
Console.WriteLine($"{Name}: {typeName} {FormatForPrint(response)}");
}
catch (OperationCanceledException) { break; }
catch (Exception ex)
{
Console.WriteLine($"[CRITICAL] Unexpected error: {ex.Message}");
Console.WriteLine("AI: ERROR");
}
}
}
private static string FormatForPrint(object? obj)
{
if (obj is JsonNode jn) return jn.ToJsonString(new JsonSerializerOptions { WriteIndented = false });
return obj?.ToString() ?? "null";
}
}
internal static class Program
{
// Equivalent to: if __name__ == '__main__': ai = AIS(model="wizard", safe_mode=False); ai.repl()
private static void Main(string[] args)
{
var ai = new AIS(model: "wizard", safeMode: false);
ai.Repl();
}
}
}