using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.WebSockets;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using SwarmUI.Accounts;
using SwarmUI.Utils;
using SwarmUI.WebAPI;
namespace Mrleo1nid.SwarmAssistent;
/// Ollama transport: model listing, /api/chat calls and the two chat API endpoints.
public partial class SwarmAssistentExtension
{
const int DefaultNumCtxFallback = 16384;
public async Task AssistentListModels(Session session, string baseUrl)
{
string root = NormalizeBaseUrl(baseUrl);
try
{
using HttpResponseMessage resp = await HttpClient.GetAsync($"{root}/api/tags");
string body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
return new JObject { ["error"] = $"Ollama /api/tags HTTP {(int)resp.StatusCode}: {Clip(body, 400)}" };
}
JObject parsed = JObject.Parse(body);
JArray all = [];
foreach (JToken m in parsed["models"] as JArray ?? [])
{
string name = m["name"]?.ToString() ?? m["model"]?.ToString() ?? "";
if (!string.IsNullOrWhiteSpace(name))
{
all.Add(name);
}
}
JObject roles = Config?.LoadOllamaRoles() ?? new JObject();
HashSet chatSet = new(
(roles["chat"] as JArray)?.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s)) ?? [],
StringComparer.OrdinalIgnoreCase);
HashSet memSet = new(
(roles["memory"] as JArray)?.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s)) ?? [],
StringComparer.OrdinalIgnoreCase);
// Heuristic fallbacks when sidecar missing
if (chatSet.Count == 0 && memSet.Count == 0)
{
foreach (JToken t in all)
{
string n = t.ToString();
if (LooksLikeEmbedModel(n))
{
memSet.Add(n);
}
else
{
chatSet.Add(n);
}
}
}
else
{
// Keep only tags that exist; anything unlabeled goes to chat if not memory
foreach (JToken t in all)
{
string n = t.ToString();
if (memSet.Contains(n) || LooksLikeEmbedModel(n))
{
memSet.Add(n);
chatSet.Remove(n);
}
else if (chatSet.Count == 0 || chatSet.Contains(n))
{
chatSet.Add(n);
}
else if (!memSet.Contains(n))
{
chatSet.Add(n);
}
}
}
JArray models = new(all.Select(t => t.ToString()).Where(n => chatSet.Contains(n) && !memSet.Contains(n) && !LooksLikeEmbedModel(n)));
JArray memoryModels = new(all.Select(t => t.ToString()).Where(n => memSet.Contains(n) || LooksLikeEmbedModel(n)).Distinct(StringComparer.OrdinalIgnoreCase).ToList());
if (memoryModels.Count == 0)
{
string fallback = Config?.LoadAssistant(Config.DefaultPersonaId())["embed_model"]?.ToString() ?? "nomic-embed-text";
if (all.Any(t => string.Equals(t.ToString(), fallback, StringComparison.OrdinalIgnoreCase)
|| t.ToString().StartsWith(fallback.Split(':')[0], StringComparison.OrdinalIgnoreCase)))
{
memoryModels.Add(all.Select(t => t.ToString()).First(n =>
string.Equals(n, fallback, StringComparison.OrdinalIgnoreCase)
|| n.StartsWith(fallback.Split(':')[0], StringComparison.OrdinalIgnoreCase)));
}
}
string preferred = roles["default_chat"]?.ToString()?.Trim() ?? "";
if (string.IsNullOrWhiteSpace(preferred) || !models.Any(t => string.Equals(t.ToString(), preferred, StringComparison.OrdinalIgnoreCase)))
{
preferred = PickSeniorChatModel(models.Select(t => t.ToString()).ToList());
}
return new JObject
{
["success"] = true,
["base_url"] = root,
["models"] = models,
["memory_models"] = memoryModels,
["preferred"] = preferred ?? "",
};
}
catch (Exception ex)
{
return new JObject { ["error"] = $"Ollama unreachable at {root}: {ex.Message}" };
}
}
static bool LooksLikeEmbedModel(string name)
{
string n = (name ?? "").ToLowerInvariant();
return n.Contains("embed") || n.Contains("nomic") || n.Contains("bge-") || n.Contains("minilm") || n.Contains("e5-");
}
/// Prefer larger param tags (32b > 8b > 7b), then instruct / qwen3.
static string PickSeniorChatModel(IList names)
{
if (names is null || names.Count == 0)
{
return "";
}
return names.OrderByDescending(ChatModelSeniority).ThenBy(n => n, StringComparer.OrdinalIgnoreCase).First();
}
static long ChatModelSeniority(string name)
{
string n = (name ?? "").ToLowerInvariant();
long score = 0;
System.Text.RegularExpressions.Match m = System.Text.RegularExpressions.Regex.Match(n, @"(?:^|[:\-/])(\d+)\s*b\b");
if (m.Success && long.TryParse(m.Groups[1].Value, out long bil))
{
score += bil * 1_000_000;
}
if (n.Contains("instruct"))
{
score += 50_000;
}
if (n.Contains("qwen3"))
{
score += 20_000;
}
if (n.Contains("thinking") || n.EndsWith(":latest"))
{
score -= 10_000;
}
return score;
}
async Task<(string reply, JObject raw)> CallOllamaChat(
string root,
string modelName,
List ollamaMessages,
bool stream,
Func onDelta,
string personaId = null)
{
int numCtx = Config.LoadAssistant(AssistentConfig.SafeId(personaId) ?? Config.DefaultPersonaId())["num_ctx"]?.Value()
?? DefaultNumCtxFallback;
int numPredict = Config.LoadAssistant(AssistentConfig.SafeId(personaId) ?? Config.DefaultPersonaId())["num_predict"]?.Value()
?? 3072;
if (numPredict < 512)
{
numPredict = 512;
}
JObject payload = new()
{
["model"] = modelName,
["stream"] = stream,
["messages"] = new JArray(ollamaMessages),
["options"] = new JObject
{
["num_ctx"] = numCtx,
// Without this, Ollama defaults can cut mid-prompt / mid-skill_load fence.
["num_predict"] = numPredict,
},
["keep_alive"] = "15m",
// Qwen3-VL instruct still thinks by default; that holds the WS open after
// the visible greeting and leaves the composer looking locked.
["think"] = false,
};
using StringContent content = new(payload.ToString(Newtonsoft.Json.Formatting.None), Encoding.UTF8, "application/json");
using HttpRequestMessage req = new(HttpMethod.Post, $"{root}/api/chat") { Content = content };
using HttpResponseMessage resp = await HttpClient.SendAsync(req, stream
? HttpCompletionOption.ResponseHeadersRead
: HttpCompletionOption.ResponseContentRead);
if (!resp.IsSuccessStatusCode)
{
string errBody = await resp.Content.ReadAsStringAsync();
throw new Exception($"Ollama /api/chat HTTP {(int)resp.StatusCode}: {Clip(errBody, 800)}");
}
if (!stream)
{
string body = await resp.Content.ReadAsStringAsync();
JObject parsed = JObject.Parse(body);
string reply = parsed["message"]?["content"]?.ToString() ?? parsed["response"]?.ToString() ?? "";
if (TryTruncateAtCompleteFence(reply, out string cut))
{
reply = cut;
}
return (reply, parsed);
}
StringBuilder full = new();
await using Stream streamBody = await resp.Content.ReadAsStreamAsync();
using StreamReader reader = new(streamBody, Encoding.UTF8);
JObject last = null;
while (true)
{
string line = await reader.ReadLineAsync();
if (line is null)
{
break;
}
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
JObject chunk = JObject.Parse(line);
last = chunk;
string delta = chunk["message"]?["content"]?.ToString() ?? "";
if (!string.IsNullOrEmpty(delta))
{
full.Append(delta);
// Closed ```json``` patch → stop reading. Models otherwise ramble («Готово!», 2nd aspect…).
if (TryTruncateAtCompleteFence(full.ToString(), out string cut))
{
string extra = full.Length > cut.Length ? full.ToString(cut.Length, full.Length - cut.Length) : "";
full.Clear();
full.Append(cut);
if (onDelta is not null)
{
// Only forward the part of this delta that stays inside the fence.
int keep = delta.Length - extra.Length;
if (keep > 0)
{
await onDelta(delta.Substring(0, keep));
}
}
break;
}
if (onDelta is not null)
{
await onDelta(delta);
}
}
if (chunk["done"]?.Value() == true)
{
break;
}
}
return (full.ToString(), last ?? new JObject());
}
/// SwarmUI hands the whole request body over as the JObject param, so every field is read flat off it.
static void ExtractChatPayload(JObject raw, ref string baseUrl, ref string model, ref string pack, ref bool includeBase, out JArray userMessages, out string contextJson, out string persona, out JArray skills)
{
JObject whole = raw ?? [];
if (string.IsNullOrWhiteSpace(baseUrl))
{
baseUrl = whole["base_url"]?.ToString() ?? whole["baseUrl"]?.ToString();
}
if (string.IsNullOrWhiteSpace(model))
{
model = whole["model"]?.ToString();
}
if (string.IsNullOrWhiteSpace(pack))
{
pack = whole["pack"]?.ToString();
}
if (whole["includeBase"] is not null)
{
includeBase = whole.Value("includeBase") ?? includeBase;
}
userMessages = whole["messages"] as JArray;
contextJson = whole["context_json"]?.ToString();
persona = whole["persona"]?.ToString() ?? "neutral";
skills = whole["skills"] as JArray;
}
/// Shared validation for both chat endpoints. Returns an error message, or null when the request is usable.
static string ValidateChatRequest(string modelName, JArray userMessages)
{
if (string.IsNullOrWhiteSpace(modelName))
{
return "model is required";
}
if (userMessages is null || userMessages.Count == 0)
{
return "messages required";
}
return null;
}
/// Extract Ollama prompt token count when present.
static int? ReadPromptEvalCount(JObject raw)
{
if (raw is null)
{
return null;
}
int? n = raw["prompt_eval_count"]?.Value();
if (n is null || n <= 0)
{
n = raw["promptEvalCount"]?.Value();
}
return n is > 0 ? n : null;
}
/// Proxy to Ollama /api/chat (non-stream), with optional ask hops.
public async Task AssistentChat(Session session, string baseUrl, string model, string pack, bool includeBase, JObject raw)
{
ExtractChatPayload(raw, ref baseUrl, ref model, ref pack, ref includeBase, out JArray userMessages, out string contextJson, out string persona, out JArray skills);
string root = NormalizeBaseUrl(baseUrl);
string modelName = (model ?? "").Trim();
string invalid = ValidateChatRequest(modelName, userMessages);
if (invalid is not null)
{
return new JObject { ["error"] = invalid };
}
string packName = (pack ?? "ordinary").Trim();
string embedModel = raw?["embed_model"]?.ToString() ?? Config.LoadSettings()["embed_model"]?.ToString();
try
{
(string reply, JObject parsed, JArray civitai, int systemChars, JObject systemLayers) = await RunChatWithHops(
session, root, modelName, packName, includeBase, contextJson, userMessages, personaId: persona, skillIds: skills, embedModel: embedModel);
JObject result = new()
{
["success"] = true,
["reply"] = reply,
["model"] = modelName,
["pack"] = packName,
["persona"] = persona,
["raw"] = parsed,
["civitai_results"] = civitai,
["system_chars"] = systemChars,
["system_layers"] = systemLayers,
};
int? promptEval = ReadPromptEvalCount(parsed);
if (promptEval is not null)
{
result["prompt_eval_count"] = promptEval.Value;
}
return result;
}
catch (Exception ex)
{
return new JObject { ["error"] = $"Ollama chat failed: {ex.Message}" };
}
}
/// WebSocket streaming chat (Ollama stream:true) + ask hops.
public async Task AssistentChatWS(Session session, WebSocket ws, string baseUrl, string model, string pack, bool includeBase, JObject raw)
{
ExtractChatPayload(raw, ref baseUrl, ref model, ref pack, ref includeBase, out JArray userMessages, out string contextJson, out string persona, out JArray skills);
string root = NormalizeBaseUrl(baseUrl);
string modelName = (model ?? "").Trim();
string invalid = ValidateChatRequest(modelName, userMessages);
if (invalid is not null)
{
await ws.SendJson(new JObject { ["error"] = invalid }, API.WebsocketTimeout);
return null;
}
string packName = (pack ?? "ordinary").Trim();
string embedModel = raw?["embed_model"]?.ToString() ?? Config.LoadSettings()["embed_model"]?.ToString();
try
{
if (ws.State == WebSocketState.Open)
{
await ws.SendJson(new JObject
{
["phase"] = "waiting_ollama",
["notice"] = "Waiting for Ollama…",
}, API.WebsocketTimeout);
}
async Task OnDelta(string delta)
{
if (ws.State == WebSocketState.Open)
{
await ws.SendJson(new JObject { ["delta"] = delta }, API.WebsocketTimeout);
}
}
async Task OnHopStart(int hop)
{
if (ws.State == WebSocketState.Open && hop > 0)
{
await ws.SendJson(new JObject
{
["clear_stream"] = true,
["hop"] = hop + 1,
["notice"] = "Ask hop — refining…",
}, API.WebsocketTimeout);
}
}
(string reply, JObject parsed, JArray civitai, int systemChars, JObject systemLayers) = await RunChatWithHops(
session, root, modelName, packName, includeBase, contextJson, userMessages, OnDelta, OnHopStart, persona, skills, embedModel);
JObject done = new()
{
["success"] = true,
["done"] = true,
["reply"] = reply,
["model"] = modelName,
["pack"] = packName,
["persona"] = persona,
["raw"] = parsed,
["civitai_results"] = civitai,
["system_chars"] = systemChars,
["system_layers"] = systemLayers,
};
int? promptEval = ReadPromptEvalCount(parsed);
if (promptEval is not null)
{
done["prompt_eval_count"] = promptEval.Value;
}
await ws.SendJson(done, API.WebsocketTimeout);
}
catch (Exception ex)
{
await ws.SendJson(new JObject { ["error"] = $"Ollama chat failed: {ex.Message}" }, API.WebsocketTimeout);
}
return null;
}
}