diff --git a/Content.Client/Chat/Managers/ChatManager.cs b/Content.Client/Chat/Managers/ChatManager.cs index e428d30f20..1baf16a916 100644 --- a/Content.Client/Chat/Managers/ChatManager.cs +++ b/Content.Client/Chat/Managers/ChatManager.cs @@ -48,7 +48,7 @@ internal sealed class ChatManager : IChatManager break; case ChatSelectChannel.Dead: - if (_systems.GetEntitySystemOrNull() is {IsGhost: true}) + if (_systems.GetEntitySystemOrNull() is { IsGhost: true }) goto case ChatSelectChannel.Local; if (_adminMgr.HasFlag(AdminFlags.Admin)) diff --git a/Content.Client/Entry/EntryPoint.cs b/Content.Client/Entry/EntryPoint.cs index d93f79fc23..6c49affcba 100644 --- a/Content.Client/Entry/EntryPoint.cs +++ b/Content.Client/Entry/EntryPoint.cs @@ -2,8 +2,6 @@ using Content.Client.Administration.Managers; using Content.Client.Changelog; using Content.Client.Chat.Managers; using Content.Client.DebugMon; -using Content.Client.Corvax.TTS; -using Content.Client.Options; using Content.Client.Eui; using Content.Client.Fullscreen; using Content.Client.GhostKick; @@ -37,6 +35,15 @@ using Robust.Shared.ContentPack; using Robust.Shared.Prototypes; using Robust.Shared.Replays; using Robust.Shared.Timing; +using Robust.Shared.Reflection; +using Robust.Shared.Serialization.Markdown.Sequence; +using Robust.Shared.Serialization.Markdown; +using Robust.Shared.Utility; +using System.IO; +using System.Linq; +using YamlDotNet.RepresentationModel; +using Robust.Shared.Serialization.Markdown.Value; +using Robust.Shared.Serialization.Markdown.Mapping; namespace Content.Client.Entry { @@ -124,6 +131,12 @@ namespace Content.Client.Entry _prototypeManager.RegisterIgnore("stationGoal"); // Corvax-StationGoal _prototypeManager.RegisterIgnore("ghostRoleRaffleDecider"); + foreach (var item in IgnorePrototypes()) + { + _prototypeManager.RegisterIgnore(item); + Logger.Debug(item); + } + _componentFactory.GenerateNetIds(); _adminManager.Initialize(); _screenshotHook.Initialize(); @@ -220,5 +233,46 @@ namespace Content.Client.Entry _debugMonitorManager.FrameUpdate(); } } + + //WL-Changes-start + private HashSet IgnorePrototypes() + { + var sequence = new HashSet(); + + foreach (var path in _resourceManager.ContentFindFiles("/")) + { + if (!path.CanonPath.Contains("_SERVER")) + continue; + + if (!_resourceManager.TryContentFileRead(path, out var stream)) + continue; + + using var reader = new StreamReader(stream, EncodingHelpers.UTF8); + var documents = DataNodeParser.ParseYamlStream(reader); + + if (documents == null) + continue; + + foreach (var document in documents) + { + var seq = ((SequenceDataNode)document.Root).Sequence; + + foreach (var item in seq) + { + + if (item is not MappingDataNode mapping_node) + continue; + + if (!mapping_node.TryGet("type", out var node)) + continue; + + sequence.Add(node.ToString()); + } + } + } + + return sequence; + } + //WL-Changes-end } } diff --git a/Content.Packaging/ClientPackaging.cs b/Content.Packaging/ClientPackaging.cs index 4651164d7f..265b8cb512 100644 --- a/Content.Packaging/ClientPackaging.cs +++ b/Content.Packaging/ClientPackaging.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using System.Diagnostics; using System.IO.Compression; using Robust.Packaging; using Robust.Packaging.AssetProcessing; @@ -117,7 +117,12 @@ public static class ClientPackaging .Union(RobustSharedPackaging.SharedIgnoredResources) .Union(ContentClientIgnoredResources).ToHashSet(); - await RobustSharedPackaging.DoResourceCopy(Path.Combine(contentDir, "Resources"), pass, ignoreSet, cancel: cancel); + await /*WL-Changes-start*/WLSharedPackaging/*WL-Changes-end*/.DoResourceCopy( + Path.Combine(contentDir, "Resources"), + pass, + ignoreSet, + /*WL-Changes-start*/WLSharedPackaging.ContentClientIgnoredResources,/*WL-Changes-end*/ + cancel: cancel); } // Corvax-Secrets-End } diff --git a/Content.Packaging/WLSharedPackaging.cs b/Content.Packaging/WLSharedPackaging.cs new file mode 100644 index 0000000000..3cb82f5b99 --- /dev/null +++ b/Content.Packaging/WLSharedPackaging.cs @@ -0,0 +1,68 @@ +using Robust.Packaging.AssetProcessing; +using System.Text.RegularExpressions; + +namespace Content.Packaging +{ + /// + /// COPIED FROM . + /// + public sealed partial class WLSharedPackaging + { + [GeneratedRegex(@".*_SERVER.*", RegexOptions.Multiline)] + private static partial Regex ServerIgnoreRegex(); + + public static IReadOnlySet ContentClientIgnoredResources { get; } = new HashSet + { + ServerIgnoreRegex() + }; + + public static Task DoResourceCopy( + string diskSource, + AssetPass pass, + IReadOnlySet ignoreSet, + IReadOnlySet ignoreRegexes, + string targetDir = "", + CancellationToken cancel = default) + { + foreach (var path in Directory.EnumerateFileSystemEntries(diskSource)) + { + cancel.ThrowIfCancellationRequested(); + + var filename = Path.GetFileName(path); + + var blacklisted_f = ignoreSet.Contains(filename); + + var blacklisted_r = ignoreRegexes.Any(regex => + { + return regex.IsMatch(path); + }); + + if (blacklisted_r || blacklisted_f) + continue; + + var targetPath = Path.Combine(targetDir, filename); + if (Directory.Exists(path)) + CopyDirIntoZip(path, targetPath, pass); + else + pass.InjectFileFromDisk(targetPath, path); + } + + return Task.CompletedTask; + } + + private static void CopyDirIntoZip(string directory, string basePath, AssetPass pass) + { + foreach (var file in Directory.EnumerateFiles(directory, "*.*", SearchOption.AllDirectories)) + { + var relPath = Path.GetRelativePath(directory, file); + var zipPath = $"{basePath}/{relPath}"; + + if (Path.DirectorySeparatorChar != '/') + zipPath = zipPath.Replace(Path.DirectorySeparatorChar, '/'); + + // Console.WriteLine($"{directory}/{zipPath} -> /{zipPath}"); + pass.InjectFileFromDisk(zipPath, file); + } + } + } +} diff --git a/Content.Server/AlertLevel/AlertLevelSystem.cs b/Content.Server/AlertLevel/AlertLevelSystem.cs index 0b49d7e4bf..4d1924bd0d 100644 --- a/Content.Server/AlertLevel/AlertLevelSystem.cs +++ b/Content.Server/AlertLevel/AlertLevelSystem.cs @@ -116,6 +116,13 @@ public sealed class AlertLevelSystem : EntitySystem return alert.CurrentDelay; } + //WL-Changes-start + public string GetLevelLocString(string level) + { + return Loc.GetString($"alert-level-{level}"); + } + //WL-Changes-end + /// /// Set the alert level based on the station's entity ID. /// diff --git a/Content.Server/Entry/EntryPoint.cs b/Content.Server/Entry/EntryPoint.cs index 02a125bdee..8f03ce8422 100644 --- a/Content.Server/Entry/EntryPoint.cs +++ b/Content.Server/Entry/EntryPoint.cs @@ -1,3 +1,4 @@ +using Content.Server._WL.ChatGpt.Managers; using Content.Server.Acz; using Content.Server.Administration; using Content.Server.Administration.Logs; @@ -115,6 +116,10 @@ namespace Content.Server.Entry _playTimeTracking.Initialize(); IoCManager.Resolve().Initialize(); IoCManager.Resolve().Initialize(); + + //WL-Changes-start + IoCManager.Resolve().Initialize(); + //WL-Changes-end } } diff --git a/Content.Server/Fax/FaxSystem.cs b/Content.Server/Fax/FaxSystem.cs index a43d0171e6..304ac4c0e7 100644 --- a/Content.Server/Fax/FaxSystem.cs +++ b/Content.Server/Fax/FaxSystem.cs @@ -27,9 +27,9 @@ using Robust.Shared.Audio; using Robust.Shared.Audio.Systems; using Robust.Shared.Containers; using Robust.Shared.Player; -using Robust.Shared.Prototypes; using Content.Shared.NameModifier.Components; using Content.Shared.Power; +using Content.Shared._WL.Fax.Events; namespace Content.Server.Fax; @@ -294,7 +294,8 @@ public sealed class FaxSystem : EntitySystem args.Data.TryGetValue(FaxConstants.FaxPaperLockedData, out bool? locked); var printout = new FaxPrintout(content, name, label, prototypeId, stampState, stampedBy, locked ?? false); - Receive(uid, printout, args.SenderAddress); + + Receive(uid, printout, args.SenderAddress, component, args.Sender); break; } @@ -551,7 +552,12 @@ public sealed class FaxSystem : EntitySystem /// Accepts a new message and adds it to the queue to print /// If has parameter "notifyAdmins" also output a special message to admin chat. /// - public void Receive(EntityUid uid, FaxPrintout printout, string? fromAddress = null, FaxMachineComponent? component = null) + public void Receive( + EntityUid uid, + FaxPrintout printout, + string? fromAddress = null, + FaxMachineComponent? component = null, + /*WL-Changes-start*/EntityUid? sender = null/*WL-Changes-end*/) { if (!Resolve(uid, ref component)) return; @@ -567,6 +573,13 @@ public sealed class FaxSystem : EntitySystem NotifyAdmins(faxName); component.PrintingQueue.Enqueue(printout); + + //WL-Changes-start + var ev = new FaxRecieveMessageEvent(printout, sender, (uid, component)); + + RaiseLocalEvent(uid, ev); + RaiseLocalEvent(ev); + //WL-Changes-end } private void SpawnPaperFromQueue(EntityUid uid, FaxMachineComponent? component = null) diff --git a/Content.Server/GlobalUsings.cs b/Content.Server/GlobalUsings.cs index b2869444f4..02ea460537 100644 --- a/Content.Server/GlobalUsings.cs +++ b/Content.Server/GlobalUsings.cs @@ -1,4 +1,4 @@ -// Global usings for Content.Server +// Global usings for Content.Server global using System; global using System.Collections.Generic; diff --git a/Content.Server/IoC/ServerContentIoC.cs b/Content.Server/IoC/ServerContentIoC.cs index 01497efae4..f34969848c 100644 --- a/Content.Server/IoC/ServerContentIoC.cs +++ b/Content.Server/IoC/ServerContentIoC.cs @@ -1,3 +1,4 @@ +using Content.Server._WL.ChatGpt.Managers; using Content.Server.Administration; using Content.Server.Administration.Logs; using Content.Server.Administration.Managers; @@ -62,6 +63,9 @@ namespace Content.Server.IoC IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); // Corvax-TTS + //WL-Changes-start + IoCManager.Register(); + //WL-Changes-end IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); diff --git a/Content.Server/_WL/Chat/ChatMessageEvent.cs b/Content.Server/_WL/Chat/ChatMessageEvent.cs deleted file mode 100644 index 13c6e75869..0000000000 --- a/Content.Server/_WL/Chat/ChatMessageEvent.cs +++ /dev/null @@ -1,6 +0,0 @@ -using Content.Shared.Chat; - -namespace Content.Server._WL.Chat -{ - -} diff --git a/Content.Server/_WL/ChatGpt/AIChatPrototype.cs b/Content.Server/_WL/ChatGpt/AIChatPrototype.cs new file mode 100644 index 0000000000..3d45e31638 --- /dev/null +++ b/Content.Server/_WL/ChatGpt/AIChatPrototype.cs @@ -0,0 +1,18 @@ +using Robust.Shared.Prototypes; + +namespace Content.Server._WL.ChatGpt +{ + [Prototype("aiChat")] + public sealed partial class AIChatPrototype : IPrototype + { + + [IdDataField] + public string ID { get; private set; } = default!; + + [DataField] + public bool UseMemory { get; private set; } = false; + + [DataField(required: true)] + public LocId BasePrompt { get; private set; } = string.Empty; + } +} diff --git a/Content.Server/_WL/ChatGpt/Commands/EnableCCAICommand.cs b/Content.Server/_WL/ChatGpt/Commands/EnableCCAICommand.cs new file mode 100644 index 0000000000..f27d06f96f --- /dev/null +++ b/Content.Server/_WL/ChatGpt/Commands/EnableCCAICommand.cs @@ -0,0 +1,29 @@ +using Content.Server.Administration; +using Content.Shared._WL.CCVars; +using Content.Shared.Administration; +using Robust.Shared.Configuration; +using Robust.Shared.Console; + +namespace Content.Server._WL.ChatGpt.Commands +{ + [AdminCommand(AdminFlags.Round | AdminFlags.Adminchat)] + public sealed partial class EnableCCAICommand : LocalizedCommands + { + [Dependency] private readonly IConfigurationManager _configMan = default!; + + public override string Command => "togglecentralcommandai"; + public override string Description => "Отключает или выключает отправку запросов на ЦК к текстовой нейросети."; + + public override void Execute(IConsoleShell shell, string argStr, string[] args) + { + var now = _configMan.GetCVar(WLCVars.IsGptEnabled); + + _configMan.SetCVar(WLCVars.IsGptEnabled, !now); + + var text = now + ? "выключен" + : "включен"; + shell.WriteLine($"Автоответчик ЦК сейчас: {text}"); + } + } +} diff --git a/Content.Server/_WL/ChatGpt/Elements/OpenAi/AccountBalance.cs b/Content.Server/_WL/ChatGpt/Elements/OpenAi/AccountBalance.cs new file mode 100644 index 0000000000..31b6e1ff89 --- /dev/null +++ b/Content.Server/_WL/ChatGpt/Elements/OpenAi/AccountBalance.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace Content.Server._WL.ChatGpt.Elements.OpenAi +{ + /// + /// Класс, представляющий баланс аккаунта, на котором куплен апи токен. + /// Специфичен... + /// + public sealed class AccountBalance + { + [JsonPropertyName("balance")] + public required decimal Balance { get; set; } + } +} diff --git a/Content.Server/_WL/ChatGpt/Elements/OpenAi/Extensions/GptResponseExt.cs b/Content.Server/_WL/ChatGpt/Elements/OpenAi/Extensions/GptResponseExt.cs new file mode 100644 index 0000000000..2d490230e8 --- /dev/null +++ b/Content.Server/_WL/ChatGpt/Elements/OpenAi/Extensions/GptResponseExt.cs @@ -0,0 +1,20 @@ +using Content.Server._WL.ChatGpt.Elements.OpenAi.Response; +using Robust.Shared.Random; + +namespace Content.Server._WL.ChatGpt.Elements.OpenAi.Extensions +{ + public static class GptResponseExt + { + public static string? GetRawStringResponse(this GptChatResponse response, IRobustRandom random) + { + var choices = response.Choices; + + if (choices.Length == 0) + return null; + + var chosen = random.Pick(choices).Message; + + return chosen.Content ?? chosen.RefusalMessage; + } + } +} diff --git a/Content.Server/_WL/ChatGpt/Elements/OpenAi/Functions/CallEvacShuttleFunction.cs b/Content.Server/_WL/ChatGpt/Elements/OpenAi/Functions/CallEvacShuttleFunction.cs new file mode 100644 index 0000000000..9c6df3510b --- /dev/null +++ b/Content.Server/_WL/ChatGpt/Elements/OpenAi/Functions/CallEvacShuttleFunction.cs @@ -0,0 +1,58 @@ +using Content.Server.RoundEnd; + +namespace Content.Server._WL.ChatGpt.Elements.OpenAi.Functions +{ + public sealed partial class CallEvacShuttleFunction : ToolFunctionModel + { + private readonly RoundEndSystem _roundEndSys; + + public override LocId Name => "gpt-command-evac-shuttle-name"; + public override LocId Description => "gpt-command-evac-shuttle-desc"; + public override IReadOnlyDictionary> Parameters => new Dictionary>() + { + ["call"] = new Parameter() + { + Description = "gpt-command-evac-shuttle-arg-call-desc" + }, + + ["time"] = new Parameter() + { + Description = "gpt-command-evac-shuttle-arg-time-desc", + Enum = [1200, 600, 300], + Required = false + } + }; + public override JsonSchemeType ReturnType => JsonSchemeType.Object; + + public override LocId FallbackMessage => "gpt-command-evac-shuttle-fallback"; + + public override string? Invoke(Arguments arguments) + { + if (!arguments.TryCaste("call", out var call)) + return null; + + if (call) + { + if (!arguments.TryCaste("time", out var time)) + return null; + + var span = TimeSpan.FromSeconds(time); + + _roundEndSys.RequestRoundEnd(span); + + return Loc.GetString(FallbackMessage, ("time", time), ("call", true)); + } + else + { + _roundEndSys.CancelRoundEndCountdown(); + + return Loc.GetString(FallbackMessage, ("call", false)); + } + } + + public CallEvacShuttleFunction(RoundEndSystem roundEnd) + { + _roundEndSys = roundEnd; + } + } +} diff --git a/Content.Server/_WL/ChatGpt/Elements/OpenAi/Functions/MadeNotifyFunction.cs b/Content.Server/_WL/ChatGpt/Elements/OpenAi/Functions/MadeNotifyFunction.cs new file mode 100644 index 0000000000..0e38b42d5a --- /dev/null +++ b/Content.Server/_WL/ChatGpt/Elements/OpenAi/Functions/MadeNotifyFunction.cs @@ -0,0 +1,40 @@ +using Content.Server.Chat.Systems; +using System.Linq; + +namespace Content.Server._WL.ChatGpt.Elements.OpenAi.Functions +{ + public sealed partial class MadeNotifyFunction : ToolFunctionModel + { + private readonly ChatSystem _chat; + private readonly EntityUid _station; + + public override LocId Name => "gpt-command-made-notify-name"; + public override LocId Description => "gpt-command-made-notify-desc"; + public override IReadOnlyDictionary> Parameters => new Dictionary>() + { + ["text"] = new Parameter() + { + Required = true, + Description = "gpt-command-made-notify-arg-text-desc" + } + }; + public override JsonSchemeType ReturnType => JsonSchemeType.Object; + public override LocId FallbackMessage => "gpt-command-made-notify-fallback"; + + public override string? Invoke(Arguments arguments) + { + if (!arguments.TryCaste("text", out var text)) + return null; + + _chat.DispatchStationAnnouncement(_station, text, Loc.GetString("admin-announce-announcer-default"), colorOverride: Color.Yellow); + + return Loc.GetString(FallbackMessage, ("text", text.Split().FirstOrDefault() ?? "")); + } + + public MadeNotifyFunction(ChatSystem chat, EntityUid station) + { + _chat = chat; + _station = station; + } + } +} diff --git a/Content.Server/_WL/ChatGpt/Elements/OpenAi/Functions/SetAlertLevelFunction.cs b/Content.Server/_WL/ChatGpt/Elements/OpenAi/Functions/SetAlertLevelFunction.cs new file mode 100644 index 0000000000..2e4e2333bf --- /dev/null +++ b/Content.Server/_WL/ChatGpt/Elements/OpenAi/Functions/SetAlertLevelFunction.cs @@ -0,0 +1,74 @@ +using Content.Server.AlertLevel; +using Robust.Shared.Prototypes; +using System.Linq; + +namespace Content.Server._WL.ChatGpt.Elements.OpenAi.Functions +{ + public sealed class SetAlertLevelFunction : ToolFunctionModel + { + public override LocId Name => "gpt-command-set-alert-level-name"; + public override LocId Description => "gpt-command-set-alert-level-desc"; + public override IReadOnlyDictionary> Parameters => _parameters; + public override JsonSchemeType ReturnType => JsonSchemeType.Object; + public override LocId FallbackMessage => "gpt-command-set-alert-level-fallback"; + + private readonly Dictionary> _parameters; + + private readonly AlertLevelSystem _alertLevel; + private readonly EntityUid _station; + private readonly IPrototypeManager _protoMan; + + [ValidatePrototypeId] + private static readonly string BaseStationAlertProtoId = "stationAlerts"; + + public SetAlertLevelFunction( + AlertLevelSystem alertLevelSys, + EntityUid station, + IPrototypeManager protoMan) + { + _alertLevel = alertLevelSys; + _protoMan = protoMan; + _station = station; + + _parameters = Init(); + } + + public Dictionary> Init() + { + var alerts_proto = _protoMan.Index(BaseStationAlertProtoId); + + var levels = alerts_proto.Levels.Keys.ToArray(); + + return new() + { + ["level"] = new Parameter() + { + Enum = levels.ToHashSet() as HashSet, + Description = "gpt-command-set-alert-level-arg-level-desc", + Required = true + }, + + ["locked"] = new Parameter() + { + Description = "gpt-command-set-alert-level-arg-locked-desc", + Required = true + } + }; + } + + public override string? Invoke(ToolFunctionModel.Arguments arguments) + { + if (!arguments.TryCaste("level", out var level)) + return null; + + if (!arguments.TryCaste("locked", out var locked)) + return null; + + _alertLevel.SetLevel(_station, level, true, true, true, locked); + + return Loc.GetString(FallbackMessage, + ("level", level), + ("locked", locked)); + } + } +} diff --git a/Content.Server/_WL/ChatGpt/Elements/OpenAi/Functions/SpawnShuttleFunction.cs b/Content.Server/_WL/ChatGpt/Elements/OpenAi/Functions/SpawnShuttleFunction.cs new file mode 100644 index 0000000000..5e6250cc6c --- /dev/null +++ b/Content.Server/_WL/ChatGpt/Elements/OpenAi/Functions/SpawnShuttleFunction.cs @@ -0,0 +1,47 @@ +using Content.Server._WL.Ert; +using Content.Shared._WL.Ert; +using System.Linq; + +namespace Content.Server._WL.ChatGpt.Elements.OpenAi.Functions +{ + public sealed class ERTSpawnShuttleFunction : ToolFunctionModel + { + private readonly ErtSystem _ert; + + public override LocId Name => "gpt-command-spawn-ert-shuttle-name"; + public override LocId Description => "gpt-command-spawn-ert-shuttle-desc"; + public override JsonSchemeType ReturnType => JsonSchemeType.Object; + public override IReadOnlyDictionary> Parameters => new Dictionary>() + { + ["type"] = new Parameter() + { + Required = true, + Enum = Enum.GetNames(typeof(ErtType))? + .ToHashSet() as HashSet, + Description = "gpt-command-spawn-ert-shuttle-arg-level-desc" + } + }; + public override LocId FallbackMessage => "gpt-command-spawn-ert-shuttle-fallback"; + + public override string? Invoke(Arguments arguments) + { + if (!arguments.TryCaste("type", out var parsed)) + return null; + + if (!Enum.TryParse(parsed, out var result)) + return null; + + var chosen = Loc.GetString(FallbackMessage, ("type", "other")); + + if (!_ert.TrySpawn(result, out _)) + return chosen; + + return Loc.GetString(FallbackMessage, ("type", parsed)); + } + + public ERTSpawnShuttleFunction(ErtSystem ertSys) + { + _ert = ertSys; + } + } +} diff --git a/Content.Server/_WL/ChatGpt/Elements/OpenAi/ModelRole.cs b/Content.Server/_WL/ChatGpt/Elements/OpenAi/ModelRole.cs new file mode 100644 index 0000000000..75bd76f590 --- /dev/null +++ b/Content.Server/_WL/ChatGpt/Elements/OpenAi/ModelRole.cs @@ -0,0 +1,110 @@ +using static Content.Server._WL.ChatGpt.Elements.OpenAi.ModelRole; +using static Content.Server._WL.ChatGpt.Elements.OpenAi.ModelRole.Constants; + +namespace Content.Server._WL.ChatGpt.Elements.OpenAi +{ + /// + /// Статический класс, содержащий api для преобразования строковых roles в тип . + /// + public static class ModelRole + { + /// + /// Тип роли при ответе/запросе к модели. + /// + public enum ModelRoleType : byte + { + /// + /// Сообщение от системы. + /// + System, + + /// + /// Сообщение от пользователя. + /// + User, + + /// + /// Н/Д. Я хз что это, но это что-то новое. + /// + Assistant, + + /// + /// Роль утилиты. + /// + Tool, + + /// + /// Роль функции. + /// + [Obsolete("Устарело, используйте методику Tools")] + Function, + + /// + /// НЕ ИСПОЛЬЗОВАТЬ ДЛЯ ОТПРАВКИ ЗАПРОСОВ. + /// Обычно объект имеет это значение, когда не смог определить нужный тип. + /// Проверяйте объект на это значение и логгируйте. + /// + Invalid + } + + public static ModelRoleType FromString(string role) + { + return role switch + { + UserRoleString => ModelRoleType.User, + SystemRoleString => ModelRoleType.System, + ToolRoleString => ModelRoleType.Tool, + AssistantRoleString => ModelRoleType.Assistant, +#pragma warning disable CS0618 + FunctionToleString => ModelRoleType.Function, +#pragma warning restore + _ => ModelRoleType.Invalid + }; + } + + public static bool IsStringValid(string role) + { + var role_ = FromString(role); + + return role_ != ModelRoleType.Invalid; + } + + public static string FromModelRoleType(ModelRoleType role_type) + { + return role_type switch + { + ModelRoleType.System => SystemRoleString, + ModelRoleType.User => UserRoleString, + ModelRoleType.Assistant => AssistantRoleString, + ModelRoleType.Tool => ToolRoleString, +#pragma warning disable CS0618 + ModelRoleType.Function => FunctionToleString, +#pragma warning restore + _ => throw new NotImplementedException($"Невалидный объект перечисления {nameof(ModelRoleType)}") + }; + } + + /// + /// Константы для текущего класса. + /// + public static class Constants + { + public const string UserRoleString = "user"; + public const string SystemRoleString = "system"; + public const string ToolRoleString = "tool"; + public const string AssistantRoleString = "assistant"; + public const string FunctionToleString = "function"; + } + } + + /// + /// Расширения для . + /// + public static class ModelRoleTypeExt + { + public static string ToQueryString(this ModelRoleType type) + { + return FromModelRoleType(type); + } + } +} diff --git a/Content.Server/_WL/ChatGpt/Elements/OpenAi/ModelTool.cs b/Content.Server/_WL/ChatGpt/Elements/OpenAi/ModelTool.cs new file mode 100644 index 0000000000..8f9d7bbb94 --- /dev/null +++ b/Content.Server/_WL/ChatGpt/Elements/OpenAi/ModelTool.cs @@ -0,0 +1,54 @@ +using static Content.Server._WL.ChatGpt.Elements.OpenAi.ModelTool.Constants; + +namespace Content.Server._WL.ChatGpt.Elements.OpenAi +{ + /// + /// Статический класс для преобразований . + /// + public static class ModelTool + { + /// + /// Тип "утилиты", используемой для предоставления модели возможности строго делать какие-либо действия в зависимости от сгенерированного контекста. + /// + public enum ModelToolType : byte + { + /// + /// Функция. + /// + Function, + + /// + /// НЕ ИСПОЛЬЗОВАТЬ ДЛЯ ОТПРАВКИ ЗАПРОСОВ. + /// Обычно объект имеет это значение, когда не смог определить нужный тип. + /// Проверяйте объект на это значение и логгируйте. + /// + Invalid + } + + public static ModelToolType FromString(string tool) + { + return tool switch + { + FunctionToolString => ModelToolType.Function, + _ => ModelToolType.Invalid + }; + } + + public static string FromModelToolType(ModelToolType tool_type) + { + return tool_type switch + { + ModelToolType.Function => FunctionToolString, + _ => throw new NotImplementedException($"Невалидный объект перечисления {nameof(ModelToolType)}") + }; + } + + /// + /// Константы для текущего класса. + /// + public static class Constants + { + public const string FunctionToolString = "function"; + } + } +} diff --git a/Content.Server/_WL/ChatGpt/Elements/OpenAi/Request/GptChatRequest.Messages.cs b/Content.Server/_WL/ChatGpt/Elements/OpenAi/Request/GptChatRequest.Messages.cs new file mode 100644 index 0000000000..654e5e537c --- /dev/null +++ b/Content.Server/_WL/ChatGpt/Elements/OpenAi/Request/GptChatRequest.Messages.cs @@ -0,0 +1,112 @@ +#pragma warning disable IDE0290 + +using Content.Server._WL.ChatGpt.Elements.OpenAi.Response; +using Robust.Shared.Utility; +using System.Text.Json.Serialization; + +namespace Content.Server._WL.ChatGpt.Elements.OpenAi.Request +{ + /// + /// Класс, используемый для отправки запросов к модели. + /// + public abstract class GptChatMessage + { + /// + /// Роль сообщения. Смотреть . + /// + [JsonPropertyName("role")] + public string Role { get; init; } + + /// + /// Содержание запроса. + /// + [JsonPropertyName("content")] + public string Content { get; set; } + + #region ctor + protected GptChatMessage(string role, string content) + { + DebugTools.Assert(ModelRole.IsStringValid(role)); + + Role = role; + Content = content; + } + + protected GptChatMessage(ModelRole.ModelRoleType roleType, string content) + : this(ModelRole.FromModelRoleType(roleType), content) { } + #endregion + + #region Message types + /// + /// Сообщение от пользователя. + /// + public sealed class User : GptChatMessage + { + /// + /// Имя, от которого будет отправлено пользовательское сообщение. + /// Нужно для различия разных пользователей, если используется "память". + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + public User(string content) + : base(ModelRole.ModelRoleType.User, content) + { + } + } + + /// + /// Сообщение от системы. + /// + public sealed class System(string content) : GptChatMessage(ModelRole.ModelRoleType.System, content) + { + /// + /// Имя системы(не виндовс. кхм, бля, я хз). + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + } + + /// + /// Инструмент. + /// Используется для оповещения модель о том, что она выбрала какую-то функцию. + /// + public sealed class Tool(string content) : GptChatMessage(ModelRole.ModelRoleType.Tool, content) + { + /// + /// ID выбранной функции. + /// Смотреть . + /// + [JsonPropertyName("tool_call_id")] + public required string ToolId { get; set; } + } + + /// + /// Ассистент, т.е. модель, чат-гпт. + /// + /// + public sealed class Assistant(string content) : GptChatMessage(ModelRole.ModelRoleType.Assistant, content) + { + /// + /// Сообщение, которое будет высвечено при блокировке запроса фильтрами модели. + /// + [JsonPropertyName("refusal")] + public string? Refusal { get; set; } + + /// + /// Имя, от которого будет системное сообщение. + /// Н-р: CHAT GPT OPEN AI SIKIBIDI DOP DOP. + /// Кхм. + /// + [JsonPropertyName("name")] + public string? Name { get; set; } + + /// + /// Инструменты, которые вызвала модель. + /// + [JsonPropertyName("tool_calls")] + public GptChoice.ChoiceMessage.ResponseToolCall[]? Tools { get; set; } + } + #endregion + } +} diff --git a/Content.Server/_WL/ChatGpt/Elements/OpenAi/Request/GptChatRequest.Tools.cs b/Content.Server/_WL/ChatGpt/Elements/OpenAi/Request/GptChatRequest.Tools.cs new file mode 100644 index 0000000000..091c544c84 --- /dev/null +++ b/Content.Server/_WL/ChatGpt/Elements/OpenAi/Request/GptChatRequest.Tools.cs @@ -0,0 +1,112 @@ +using System.Text.Json.Serialization; + +namespace Content.Server._WL.ChatGpt.Elements.OpenAi.Request +{ + /// + /// Класс, описывающий инструмент, который может использовать модель при генерации ответа. + /// + public sealed class GptChatTool + { + /// + /// Тип "инструмента". + /// Смотреть . + /// + [JsonPropertyName("type")] + public required string Type { get; set; } + + /// + /// Инструмент. + /// + [JsonPropertyName("function")] + public required object UsingTool { get; set; } + + /// + /// Внутренний класс, используемый для конкретизации инструмента, который нужно использовать. + /// + public abstract class Tool + { + /// + /// Простая функция. + /// + public sealed class Function : Tool + { + /// + /// Название вызываемой функции. + /// + [JsonPropertyName("name")] + public required string Name { get; set; } + + /// + /// Описание функции. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// + /// Параметры функции. + /// + [JsonPropertyName("parameters")] + public FunctionArgumentsScheme? Parameters { get; set; } + + /// + /// Следует ли модели генерировать аргументы для функций ЧЁТКО по схеме, + /// А то иначе она может немного косячить со схемой. + /// + [JsonPropertyName("strict")] + public bool? Strict { get; set; } = true; + + /// + /// Схема, описывающая параметры функции. + /// + public sealed class FunctionArgumentsScheme + { + /// + /// Возвращаемый тип функции. + /// + [JsonPropertyName("type")] + public required string ReturnType { get; set; } + + /// + /// Обязательные параметры функции. + /// + [JsonPropertyName("required")] + public string[]? Required { get; set; } + + /// + /// Сами параметры. + /// + [JsonPropertyName("properties")] + public Dictionary? Properties { get; set; } + + [JsonPropertyName("additionalProperties")] + public bool AdditionalProperties { get; set; } = false; + + /// + /// Класс, описывающий параметр функции. + /// + public sealed class Property + { + /// + /// Тип параметра. + /// + [JsonPropertyName("type")] + public required object Type { get; set; } + + /// + /// Описание параметра функции. + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// + /// Константные значения. + /// То есть список всех возможных значений аргумента. + /// + [JsonPropertyName("enum")] + public object?[]? Enum { get; set; } = null; + } + } + } + } + } +} diff --git a/Content.Server/_WL/ChatGpt/Elements/OpenAi/Request/GptChatRequest.cs b/Content.Server/_WL/ChatGpt/Elements/OpenAi/Request/GptChatRequest.cs new file mode 100644 index 0000000000..b6236a0e42 --- /dev/null +++ b/Content.Server/_WL/ChatGpt/Elements/OpenAi/Request/GptChatRequest.cs @@ -0,0 +1,237 @@ +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Content.Server._WL.ChatGpt.Elements.OpenAi.Request +{ + /// + /// Класс запроса к текстовой модели OpenAi + /// + public sealed class GptChatRequest + { + /// + /// Список сообщений, которые будут отправлены текстовой модели. + /// Использовать . + /// тут, потому что ебучий JsonSerializer работаит ни таг каг нада. + /// + [JsonPropertyName("messages")] + public required object[] Messages { get; set; } + + /// + /// Модель, используемая для генерации ответа. + /// + [JsonPropertyName("model")] + public string Model { get; set; } = null!; + + /// + /// Использовать или нет выходные данные модели после запроса для использования в Model Distillation. + /// . + /// Как я понял..:. улучшает производительность для 'больших' моделей по типу gpt-4o. + /// по умолчанию. + /// + [JsonPropertyName("store")] + public bool? Store { get; set; } = false; + + /// + /// Вряд ли будет использоваться. + /// Но: лучше сувать сюда словарь. + /// Эти метаданные будут использоваться для фильтрации всех ответов в специальном пользовательском UI openAi. + /// + [JsonPropertyName("metadata")] + public object? Metadata { get; set; } + + /// + /// Чем выше значение, тем больше вероятность того. что модель повторит одну и ту же строку в ответе. + /// Используется только с "памятью". + /// По умолчанию - . + /// + [Range(-2.0, 2.0)] + [JsonPropertyName("frequency_penalty")] + public double? FrequencyPenalty { get; set; } = 0f; + + //Перепишите это поле блйа, нихуя не понял. + //Короче, слева номер(?) токена, а справа число принадлежащее [-100, 100], которое влияет на какую-то вероятность(?). + /// + /// Accepts a JSON object that maps tokens (specified by their token ID in the tokenizer) to an associated bias value from -100 to 100. + /// Mathematically, the bias is added to the logits generated by the model prior to sampling. + /// The exact effect will vary per model, but values between -1 and 1 should decrease or increase likelihood of selection; + /// values like -100 or 100 should result in a ban or exclusive selection of the relevant token. + /// . + /// + [JsonPropertyName("logit_bias")] + public Dictionary? LogitBias { get; set; } = null; + + /// + /// Надо или нет возвращать подробную информацию о каждом токене в ответе. + /// . + /// + [JsonPropertyName("logprobs")] + public bool? LogProbs { get; set; } = false; + + /// + /// Целое число от 0 до 20, определяющее количество наиболее вероятных токенов, + /// которые будут возвращены в каждой позиции токена, каждый с соответствующей логарифмической вероятностью. + /// Для должно быть установлено значение , если используется этот параметр. + /// + [Range(0, 20)] + [JsonPropertyName("top_logprobs")] + public int? TopLogProbs { get; set; } + + /// + /// Мксимальное количество токенов, которое может быть сгенерировано в отете модели на запрос. + /// + [JsonPropertyName("max_completion_tokens")] + [Range(0, int.MaxValue)] + public int? MaxTokens { get; set; } + + /// + /// Сколько вариантов ответа предоставит модель при выполнении запроса. + /// Смотреть . + /// По умолчанию - . + /// + [Range(0, int.MaxValue/*Не злоупотребляйте пж*/)] + [JsonPropertyName("n")] + public int? N { get; set; } = 1; + + /// + /// Чем выше значение, тем больше вероятность того, что модель затронет новую тему. + /// + [Range(-2.0, 2.0)] + [JsonPropertyName("presence_penalty")] + public double? PresencePenalty { get; set; } = 0; + + //[JsonPropertyName("response_format")] + //public object? ReponseFormat; + + /// + /// Если указано, то модель при одинаковом сиде будет пытаться ответить на один запрос одинаково. + /// Находится в бете. + /// + [JsonPropertyName("seed")] + public int? Seed { get; set; } + + /// + /// Массив строк, на которые модель, по сути, должна закончить свой ответ. + /// "СЛАВА НТ!!!". + /// + [JsonPropertyName("stop")] + public string[]? Stop { get; set; } = null; + + //[JsonPropertyName("stream")] + //public bool? Stream; + + /// + /// Чем выше значение, тем "случайнее" будет результат. + /// Если это значение меняется, то менять не рекомендуется. + /// + [Range(0.0, 2.0)] + [JsonPropertyName("temperature")] + public double? Temperature { get; set; } = 1.0; + + /// + /// . + /// + [JsonPropertyName("top_p")] + public double? TopP { get; set; } = 1.0; + + /// + /// Уникальный идентификатор пользователя, позволяющий избежать абуза запросов. + /// Лучше не трогать... + /// . + /// + [JsonPropertyName("user")] + public string? User { get; set; } + + /// + /// Дать или нет возможность вызывать несколько функций за раз. + /// Это может повлиять на точность схемы при выборке аргументов для функции, поэтому лучше держать . + /// Не должно быть , только если не равен . + /// + [JsonPropertyName("parallel_tool_calls")] + public bool? ParallelToolCalls { get; set; } = null; + + /// + /// Список функций, которые может вызвать модель в своём ответе. + /// + [JsonPropertyName("tools")] + public GptChatTool[]? Tools { get; set; } = null; + + /// + /// Как модель должна выбрать функцию? + /// Можно указать, чтобы она не выбирала никаких функций или выбирала конкретную. + /// Не должно быть , только если не равен . + /// + [JsonPropertyName("tool_choice")] + public string? ModelToolChoice { get; set; } = null; + + /// + /// Получает сообщения из объектов типа object в данном типе. + /// + /// + public GptChatMessage[] GetMessages() + { + return Messages.Select(x => (x as GptChatMessage)!).ToArray(); + } + + #region Utility classes + /// + /// + /// + public static class ToolChoice + { + public static string FromType(ToolChoiceType type) + { + return type switch + { + ToolChoiceType.Required => "required", + ToolChoiceType.None => "none", + ToolChoiceType.Auto => "auto", + _ => throw new NotImplementedException() + }; + } + + public static string FromObject(ModelTool.ModelToolType type, string name) + { + if (type is ModelTool.ModelToolType.Function) + { + var obj = new + { + type = ModelTool.FromModelToolType(type), + function = new + { + name + } + }; + + return Return(obj); + } + + throw new NotImplementedException(); + + static string Return(object obj) + { + return JsonSerializer.Serialize(obj); + } + } + + public enum ToolChoiceType : byte + { + Auto, + None, + Required + } + } + #endregion + + #region Operators + public static implicit operator GptChatRequest(GptChatMessage[] messages) + { + return new GptChatRequest() + { + Messages = messages.ToArray() + }; + } + #endregion + } +} diff --git a/Content.Server/_WL/ChatGpt/Elements/OpenAi/Response/GptChatResponse.cs b/Content.Server/_WL/ChatGpt/Elements/OpenAi/Response/GptChatResponse.cs new file mode 100644 index 0000000000..5b2492d809 --- /dev/null +++ b/Content.Server/_WL/ChatGpt/Elements/OpenAi/Response/GptChatResponse.cs @@ -0,0 +1,76 @@ +using Content.Server._WL.ChatGpt.Elements.Response; +using System.Text.Json.Serialization; + +namespace Content.Server._WL.ChatGpt.Elements.OpenAi.Response +{ + /// + /// Класс, содержащий поля - в JSON ответе текстовой модели. + /// + public sealed class GptChatResponse + { + /// + /// Уникальный идентификатор каждого ответа модели. + /// + [JsonPropertyName("id")] + public required string ID { get; set; } + + /// + /// Список вариантов ответов текстовой модели OpenAi на единственный запрос пользователя. + /// + [JsonPropertyName("choices")] + public required GptChoice[] Choices { get; set; } + + /// + /// Unix-образное число, показывающее время создания ответа на запрос пользователя. + /// + [JsonPropertyName("created")] + public required int Created { get; set; } + + /// + /// Модель, использованная при генерации ответа на запрос. + /// + [JsonPropertyName("model")] + public required string Model { get; set; } + + /// + /// Уровень обслуживание(?). + /// Будет NULL, если в запросе не было включено соимённое поле. + /// + [JsonPropertyName("service_tier")] + public string? ServiceTier { get; set; } + + /// + /// Строка, в которой зашифрована конфигурация бэкенд-стороны модели, которая генерировала ответ. + /// + [JsonPropertyName("system_fingerprint")] + public required string Fingerprint { get; set; } + + /// + /// Объект, использованный для генерации... + /// Короче, в данном случае оно всегда 'chat.completion'. + /// + [JsonPropertyName("object")] + public required string Object { get; set; } + + /// + /// Информация о расходах токенов, их количестве и т.п. + /// + [JsonPropertyName("usage")] + public required GptTokenUsage Usage { get; set; } + + /// + /// Возвращает чисто ответ модели. + /// А зачем нужна другая информация, действительно. + /// + /// NULL, если модель ничего не сгенерировала. + public string? GetRawStringResponse() + { + if (Choices.Length == 0) + return null; + + var chosen = Choices[0].Message; + + return chosen.Content ?? chosen.RefusalMessage; + } + } +} diff --git a/Content.Server/_WL/ChatGpt/Elements/OpenAi/Response/GptResponse.Choice.cs b/Content.Server/_WL/ChatGpt/Elements/OpenAi/Response/GptResponse.Choice.cs new file mode 100644 index 0000000000..4ece96df2f --- /dev/null +++ b/Content.Server/_WL/ChatGpt/Elements/OpenAi/Response/GptResponse.Choice.cs @@ -0,0 +1,329 @@ +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Content.Server._WL.ChatGpt.Elements.OpenAi.Request; +using static Content.Server._WL.ChatGpt.Elements.OpenAi.Response.GptChoice.Constants; + +namespace Content.Server._WL.ChatGpt.Elements.OpenAi.Response +{ + /// + /// Класс, характеризующий один из вариантов ответа текстовой модели OpenAi. + /// + public sealed class GptChoice + { + /// + /// Каждое завершение ответа на запрос пользователя сопровождается кодом. + /// В этом коде описана причина завершения. + /// Смотреть и . + /// + [JsonPropertyName("finish_reason")] + public string? FinishReason { get; set; } + + /// + /// Идентификатор выбора в массиве выборов. + /// + [JsonPropertyName("index")] + public required int Index { get; set; } + + /// + /// Сгенерированное моделью сообщение в ответ на запрос пользователя. + /// + [JsonPropertyName("message")] + public required ChoiceMessage Message { get; set; } + + /// + /// Более подробная информация о текущем выборе. + /// + [JsonPropertyName("logprobs")] + public LogProbabilities? LogProbs { get; set; } + + #region Methods api + /// + /// Для объекта класса. + /// Также есть статическая версия . + /// + public FinishType FromFinishString() + { + return FromFinishString(FinishReason); + } + + /// + /// Статическая версия метода . + /// + /// Строка-код завершения ответа модели. . + /// Если входной параметр не подошёл ни под один тип завершения, то возвращается . + public static FinishType FromFinishString(in string? finish_string) + { + return finish_string switch + { + StopFinishString => FinishType.Stop, + LengthFinishString => FinishType.Length, + FunctionFinishString => FinishType.FunctionCall, + FilterFinishString => FinishType.ContentFilter, + ToolFinishString => FinishType.ToolCall, + _ => FinishType.Null + }; + } + #endregion + + #region Utility classes and other + + #region Choice message + /// + /// Содержит информацию об ответе текстовой модели на запрос. + /// + public sealed class ChoiceMessage + { + /// + /// Собственно... Ответ модели. + /// + [JsonPropertyName("content")] + public string? Content { get; set; } + + /// + /// Если имеет тип , + /// То модель ответит тем, что не может ответить на заданный запрос. + /// В этой переменной и содержится этот ответ. + /// + [JsonPropertyName("refusal")] + public string? RefusalMessage { get; set; } + + /// + /// Роль сообщения. Смотреть . + /// + [JsonPropertyName("role")] + public required string Role { get; set; } + + /// + /// Список вызванных функций. + /// Может быть NULL, если на выбор модели не было предоставлено каких-либо функций. + /// + [JsonPropertyName("tool_calls")] + public ResponseToolCall[]? Tools { get; set; } + + /// + /// Превращает ответ модели в объект . + /// + /// + public GptChatMessage ToChatMessage(string? name = null) + { + var role = ModelRole.FromString(Role); + var content = Content ?? RefusalMessage ?? string.Empty; + + return role switch + { + ModelRole.ModelRoleType.User => new GptChatMessage.User(content) + { + Name = name + }, + ModelRole.ModelRoleType.System => new GptChatMessage.System(content) + { + Name = name + }, + ModelRole.ModelRoleType.Assistant => new GptChatMessage.Assistant(content) + { + Refusal = RefusalMessage, + Name = name, + Tools = Tools + } + }; + } + + /// + /// Класс, содержащий информацию о вызванных моделью функциях. + /// + public sealed class ResponseToolCall + { + /// + /// ID ответа. + /// Абсолютно случайное и ни к чему не привязано. + /// + [JsonPropertyName("id")] + public required string ID { get; set; } + + /// + /// Тип "утилиты". + /// На данный момент есть только функция. + /// Смотреть в . + /// + [JsonPropertyName("type")] + public required string Type { get; set; } + + /// + /// Сама функция. + /// + [JsonPropertyName("function")] + public required FunctionResponseCall Function { get; set; } + + /// + /// Информация о вызове функции моделью. + /// + public sealed class FunctionResponseCall + { + /// + /// Имя функции. + /// + [JsonPropertyName("name")] + public required string Name { get; set; } + + /// + /// Переданные в функцию параметры. + /// + [JsonPropertyName("arguments")] + public string? Arguments { get; set; } + + /// + /// Вытаскивает словарь названий и значений выбранных аргументов. + /// Не вызывайте туеву тучу раз!! + /// Вызвали, кешировали, профит. + /// + /// + public JsonObject? ParseArguments() + { + if (Arguments == null) + return null; + + var node = JsonNode.Parse(Arguments); + + return node?.AsObject(); + } + } + } + } + #endregion + + #region Log probs + /// + /// Класс, содержащий подробную информацию о выборе модели при ответе на запрос. + /// По большей части тут содержится информация о каждом токене в запросе к/ответе модели. + /// + public sealed class LogProbabilities + { + /// + /// Информация о каждом токене ответа/запроса. + /// + [JsonPropertyName("content")] + public Content[]? LogContent { get; set; } + + /// + /// Информация о каждом из отклонённых(?, я честно хз за что отвечает это поле. Перепишите кто-нибудь.) токенов ответа/запроса. + /// + [JsonPropertyName("refusal")] + public Content[]? LogRefusalContent { get; set; } + + /// + /// В этом классе содержится информация об токене: вероятность выбора, байтовое представление. + /// + [Virtual] + public class Content + { + /// + /// Сам токен. + /// + [JsonPropertyName("token")] + public required string Token { get; set; } + + /// + /// Вероятность выбора токена. + /// + [JsonPropertyName("logprob")] + public required float LogProb { get; set; } + + /// + /// Байтовое представление токена. + /// Кодировка - UTF8. + /// Может иметь NULL, если токен не имеет байтового представления. + /// + [JsonPropertyName("bytes")] + public byte[]? Bytes { get; set; } + + /// + /// . + /// + [JsonPropertyName("top_logprobs")] + public required TopContent[] TopLogProb { get; set; } + } + + /// + /// List of the most likely tokens and their log probability, at this token position. + /// In rare cases, there may be fewer than the number of requested top_logprobs returned. + /// + public sealed class TopContent : Content; + } + #endregion + + #region Finish type + /// + /// Перечисление, которое содержит строковые коды окончания ответа модели. + /// + public enum FinishType : byte + { + /// + /// Модель полностью ответила на запрос, либо сообщение остановлено одной из последовательностей остановки. + /// + Stop, + + /// + /// Неполный вывод модели из-за параметра или из-за лимита токенов. + /// + Length, + + /// + /// Модель вызвала функцию. + /// + [Obsolete($"Сейчас чаще используется методика ToolCalls")] + FunctionCall, + + /// + /// Модель не смогла ответить на запрос из-за фильтров содержимого(NSFW). + /// + ContentFilter, + + /// + /// Модель вызвала функцию. + /// + ToolCall, + + /// + /// Ответ всё еще выполняется, или он неполный. + /// Либо ответ не подошёл под другие коды, в последнем случае просмотрите логи. + /// + Null + } + #endregion + + #region Constants + /// + /// Содержит константы для класса . + /// + public static class Constants + { + /// + /// Строковое представление типа окончания диалога с кодом "STOP". + /// + public const string StopFinishString = "stop"; + + /// + /// Строковое представление типа окончания диалога с кодом "LENGTH". + /// + public const string LengthFinishString = "length"; + + /// + /// Строковое представление типа окончания диалога с кодом "FUNCTION_CALL". + /// + public const string FunctionFinishString = "function_call"; + + /// + /// Строковое представление типа окончания диалога с кодом "CONTENT_FILTER". + /// + public const string FilterFinishString = "content_filter"; + + /// + /// Строковое представление типа окончания диалога с кодом "TOOL_CALLS". + /// + public const string ToolFinishString = "tool_calls"; + } + #endregion + + #endregion + } +} diff --git a/Content.Server/_WL/ChatGpt/Elements/OpenAi/Response/GptResponse.Usage.cs b/Content.Server/_WL/ChatGpt/Elements/OpenAi/Response/GptResponse.Usage.cs new file mode 100644 index 0000000000..0749029960 --- /dev/null +++ b/Content.Server/_WL/ChatGpt/Elements/OpenAi/Response/GptResponse.Usage.cs @@ -0,0 +1,77 @@ +using System.Text.Json.Serialization; + +namespace Content.Server._WL.ChatGpt.Elements.Response +{ + /// + /// Класс характеризующий информацию о входящих и исходящих токенах. + /// + public sealed class GptTokenUsage + { + /// + /// Количество токенов в промте. + /// + [JsonPropertyName("prompt_tokens")] + public required int InputTokens { get; set; } + + /// + /// Количество токенов в ответе модели. + /// + [JsonPropertyName("completion_tokens")] + public required int OutputTokens { get; set; } + + /// + /// Общее количество токенов. + /// Сумма и . + /// + [JsonPropertyName("total_tokens")] + public required int TotalTokens { get; set; } + + /// + /// Breakdown of tokens used in a completion. + /// + [JsonPropertyName("completion_tokens_details")] + public CompletionTokensDetail? CompletionDetail { get; set; } = null; + + /// + /// + /// + [JsonPropertyName("prompt_tokens_details")] + public PromptTokensDetail? PromptDetail { get; set; } = null; + + /// + /// Класс, использующийся для подробного описания количества токенов в ответе модели. + /// + public sealed class CompletionTokensDetail + { + /// + /// Audio input tokens generated by the model. + /// + [JsonPropertyName("audio_tokens")] + public int? AudioTokens { get; set; } + + /// + /// Tokens generated by the model for reasoning. + /// + [JsonPropertyName("reasoning_tokens")] + public int? ReasoningTokens { get; set; } + } + + /// + /// Класс, использующийся для подробного описания количества токенов в запросе к модели. + /// + public sealed class PromptTokensDetail + { + /// + /// Audio input tokens present in the prompt. + /// + [JsonPropertyName("audio_tokens")] + public int? AudioTokens { get; set; } + + /// + /// Cached tokens present in the prompt. + /// + [JsonPropertyName("cached_tokens")] + public int? CachedTokens { get; set; } + } + } +} diff --git a/Content.Server/_WL/ChatGpt/Elements/OpenAi/ToolFunctionModel.cs b/Content.Server/_WL/ChatGpt/Elements/OpenAi/ToolFunctionModel.cs new file mode 100644 index 0000000000..62d5bf5a99 --- /dev/null +++ b/Content.Server/_WL/ChatGpt/Elements/OpenAi/ToolFunctionModel.cs @@ -0,0 +1,223 @@ +using Content.Server._WL.ChatGpt.Elements.OpenAi.Request; +using Content.Server._WL.ChatGpt.Elements.OpenAi.Response; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text.Json.Nodes; + +namespace Content.Server._WL.ChatGpt.Elements.OpenAi +{ + public abstract class ToolFunctionModel + { + /// + /// Имя метода. + /// + public abstract LocId Name { get; } + + /// + /// Описание метода. + /// + public abstract LocId Description { get; } + + /// + /// Сообщени + /// + public abstract LocId FallbackMessage { get; } + + /// + /// Аргументы метода. + /// + public abstract IReadOnlyDictionary> Parameters { get; } + + /// + /// Метод вызова... метода, кхм. + /// + /// Аргументы, переданные в эту функцию. + /// Строку-статус, который будет передан текстовой модели для понимания того, что она сделала. + public abstract string? Invoke(Arguments arguments); + + /// + /// Тип объекта, возвращаемый функцией. + /// + public abstract JsonSchemeType ReturnType { get; } + + /// + /// Превращает объект в . + /// + /// + public GptChatTool GetToolFunction() + { + var required = Parameters + //.Where(x => x.Value.Required) + .Select(x => x.Key) + .ToArray(); + + var properties = Parameters + .ToDictionary(k => k.Key, v => + { + var desc = v.Value.Description; + + var string_type = v.Value.Type.ToString(); + + var type = (object)(v.Value.Required + ? string_type + : new List() { string_type, "null" }); + + var property = new GptChatTool.Tool.Function.FunctionArgumentsScheme.Property() + { + Description = desc == null ? null : Loc.GetString(desc), + Enum = v.Value.Enum?.ToArray(), + Type = type + }; + + return property; + }); + + var function = new GptChatTool.Tool.Function() + { + Name = Loc.GetString(Name), + Description = Loc.GetString(Description), + Strict = true, + Parameters = new() + { + ReturnType = ReturnType.ToFormatString(), + Required = required, + Properties = properties + } + }; + + var tool = new GptChatTool() + { + Type = ModelTool.Constants.FunctionToolString, + UsingTool = function + }; + + return tool; + } + + /// + /// Статический метод, позволяющий сгруппировать класс инструмента функции и ответ с инструментом от модели. + /// + /// Список ответов модели. + /// Список переданных функций. + /// + public static List<(GptChoice.ChoiceMessage.ResponseToolCall Response, ToolFunctionModel Model)> GiveChosenModels( + IEnumerable response, + IEnumerable models) + { + var list = new List<(GptChoice.ChoiceMessage.ResponseToolCall Response, ToolFunctionModel Model)>(); + + foreach (var tool_call in response) + { + foreach (var model in models) + { + if (tool_call.Function.Name == Loc.GetString(model.Name)) + { + list.Add((tool_call, model)); + break; + } + } + } + + return list; + } + + /// + /// Класс, описывающий параметр метода. + /// + public sealed class Parameter where T : notnull + { + /// + /// Используется для сохранения типа параметра после приведения к . + /// + private Type _type; + + /// + /// Строковое представление типа в формате JSON scheme. + /// + public string Type => _type.ToJsonSchemeString(); + + /// + /// Описание аргумента. + /// + public LocId? Description { get; set; } + + /// + /// Набор константных значений аргумента. + /// + public HashSet? Enum { get; set; } + + /// + /// Обязателен ли этот аргумент? + /// + public bool Required { get; set; } = true; + + public static implicit operator Parameter(Parameter parameter) + { + return new() + { + Enum = parameter.Enum? + .Select(x => (object?)x) + .ToHashSet(), + Description = parameter.Description, + Required = parameter.Required, + _type = parameter._type + }; + } + + public Parameter() + { + _type = typeof(T); + } + } + + /// + /// Класс, описывающий аргументы метода. + /// + public sealed class Arguments + { + private readonly JsonNode _node; + + public T? Caste(string element) + { + TryCaste(element, out var parsed); + + return parsed; + } + + /// + /// Достаёт из словаря аргумент и приводит к выбранному типу. + /// При неудаче возвращает . + /// + /// + /// + /// + /// + public bool TryCaste(string element, [NotNullWhen(true)] out T? parsed) + { + parsed = default; + + var node = _node[element]; + if (node == null) + return false; + + var parsed_t = node.GetValue(); + if (parsed_t == null) + return false; + + parsed = parsed_t; + + return true; + } + + public static Arguments FromNode(JsonNode node) + { + return new Arguments(node); + } + + private Arguments(JsonNode node) + { + _node = node; + } + } + } +} diff --git a/Content.Server/_WL/ChatGpt/JsonSchemeType.cs b/Content.Server/_WL/ChatGpt/JsonSchemeType.cs new file mode 100644 index 0000000000..9978133ee5 --- /dev/null +++ b/Content.Server/_WL/ChatGpt/JsonSchemeType.cs @@ -0,0 +1,49 @@ +namespace Content.Server._WL.ChatGpt +{ + public enum JsonSchemeType : byte + { + String, + Integer, + Number, + Boolean, + Array, + Object, + Null + } + + public static class JsonSchemeTypeExt + { + public static string ToFormatString(this JsonSchemeType type) + { + return type switch + { + JsonSchemeType.Integer => "integer", + JsonSchemeType.Number => "number", + JsonSchemeType.Boolean => "boolean", + JsonSchemeType.Array => "array", + JsonSchemeType.String => "string", + JsonSchemeType.Object => "object", + JsonSchemeType.Null => "null", + _ => throw new NotImplementedException() + }; + } + + public static JsonSchemeType ToJsonSchemeType(this Type type) + { + return type switch + { + _ when type == typeof(int) => JsonSchemeType.Integer, + _ when type == typeof(bool) => JsonSchemeType.Boolean, + _ when type == typeof(float) || type == typeof(double) || type == typeof(decimal) => JsonSchemeType.Number, + _ when type == typeof(string) => JsonSchemeType.String, + _ when type.IsArray => JsonSchemeType.Array, + _ => JsonSchemeType.Object + }; + } + + public static string ToJsonSchemeString(this Type type) + { + return type.ToJsonSchemeType().ToFormatString(); + } + } +} diff --git a/Content.Server/_WL/ChatGpt/Managers/ChatGptManager.cs b/Content.Server/_WL/ChatGpt/Managers/ChatGptManager.cs new file mode 100644 index 0000000000..a76d94bee6 --- /dev/null +++ b/Content.Server/_WL/ChatGpt/Managers/ChatGptManager.cs @@ -0,0 +1,266 @@ +using Content.Server._WL.ChatGpt.Elements.OpenAi; +using Content.Server._WL.ChatGpt.Elements.OpenAi.Request; +using Content.Server._WL.ChatGpt.Elements.OpenAi.Response; +using Content.Shared._WL.CCVars; +using Robust.Shared.Configuration; +using Robust.Shared.Prototypes; +using Robust.Shared.Timing; +using Robust.Shared.Utility; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Content.Server._WL.ChatGpt.Managers +{ + public sealed partial class ChatGptManager : IChatGptManager, IPostInjectInit + { + [Dependency] private readonly ILogManager _logMan = default!; + [Dependency] private readonly IConfigurationManager _confMan = default!; + [Dependency] private readonly IPrototypeManager _protoMan = default!; + + private ISawmill _sawmill = default!; + public const string SawmillName = "chat.gpt.mngr"; + + private const string AIDisabledDeclineMessage = "Использование API нейросетей на данный момент выключено."; + + private static readonly JsonSerializerOptions SerializerOptions = new() + { + IgnoreReadOnlyFields = true, + IgnoreReadOnlyProperties = true, + WriteIndented = true, + UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Skip, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; + + [ViewVariables(VVAccess.ReadOnly)] + private static readonly TimeSpan QueryTimeout = TimeSpan.FromSeconds(15); + + private HttpClient _httpClient = default!; + + private string _apiKey = default!; + private string _endpoint = default!; + + [ViewVariables(VVAccess.ReadOnly)] + private bool _enabled = default!; + + [ViewVariables(VVAccess.ReadOnly)] + private string _chatModel = default!; + + [ViewVariables(VVAccess.ReadOnly)] + private int _maxResponseTokens = default!; + + private Uri _balanceMap = default!; + + #region Init stuff + public void Initialize() + { + _httpClient = new HttpClient() + { + Timeout = QueryTimeout + }; + + SetupCVars(); + + var endpoint = new Uri(_endpoint, UriKind.Absolute); + + _httpClient.BaseAddress = endpoint; + } + + private void SetupCVars() + { + // Api key + _apiKey = _confMan.GetCVar(WLCVars.GptApiKey); + _confMan.OnValueChanged(WLCVars.GptApiKey, (value) => _apiKey = value, true); + + // Endpoint + _endpoint = _confMan.GetCVar(WLCVars.GptQueriesEndpoint); + _confMan.OnValueChanged(WLCVars.GptQueriesEndpoint, (value) => + { + _endpoint = value; + }, true); + + // Chat model + _chatModel = _confMan.GetCVar(WLCVars.GptChatModel); + _confMan.OnValueChanged(WLCVars.GptChatModel, (new_model) => + { + _chatModel = new_model; + }, true); + + //Max tokens + _enabled = _confMan.GetCVar(WLCVars.IsGptEnabled); + _confMan.OnValueChanged(WLCVars.IsGptEnabled, (enable) => _enabled = enable, true); + + //Max tokens + _maxResponseTokens = _confMan.GetCVar(WLCVars.GptMaxTokens); + _confMan.OnValueChanged(WLCVars.GptMaxTokens, (max) => _maxResponseTokens = max, true); + + //Balance + _balanceMap = new(_confMan.GetCVar(WLCVars.GptBalanceMap)); + _confMan.OnValueChanged(WLCVars.GptBalanceMap, (map) => _balanceMap = new(map), true); + } + + public void PostInject() + { + _sawmill = _logMan.GetSawmill(SawmillName); + } + #endregion + + #region Public api + /// + /// Показывает можно ли сейчас отправлять запросы к ИИ. + /// + public bool IsEnabled() + { + return _enabled; + } + + /// + /// . + /// + /// Сообщение, когда ИИ выключен. . + public bool IsEnabled([NotNullWhen(false)] out string? reason) + { + reason = null; + + if (!_enabled) + reason = AIDisabledDeclineMessage; + + return _enabled; + } + + /// + /// Получает оставшееся количество рублей на счёте((( + /// + /// + public async Task GetBalanceAsync(CancellationToken cancel = default) + { + using var http = new HttpRequestMessage(HttpMethod.Get, _balanceMap); + + AddDefaultsHeaders(http); + + try + { + var resp = await _httpClient.SendAsync(http, cancel).ConfigureAwait(false); + + resp.EnsureSuccessStatusCode(); + + var balance_obj = await resp.Content.ReadFromJsonAsync(cancel).ConfigureAwait(false) ?? + throw new JsonException($"Неудачная сериализация в объект {nameof(AccountBalance)}!"); + + return balance_obj.Balance; + } + catch (Exception ex) + { + _sawmill.Error("Ошибка при получении баланса аккаунта ProxyAi!"); + _sawmill.Error(ex.ToStringBetter()); + throw; + } + } + + /// . + /// Отправляет запрос к выбранной модели. + /// + /// МОЖЕТ вернуть null, если использование нейросетей в КВарах выключено. + public async Task SendChatQueryAsync( + GptChatRequest gpt_request, + IEnumerable? methods = null, + CancellationToken cancel = default) + { + using var http_request = new HttpRequestMessage(HttpMethod.Post, _httpClient.BaseAddress); + + try + { + gpt_request.Model = _chatModel; + gpt_request.MaxTokens = _maxResponseTokens; + + if (methods != null) + { + var list = methods.Select(m => m.GetToolFunction()); + gpt_request.Tools = list.ToArray(); + } + + AddDefaultsHeaders(http_request); + + var rs = RStopwatch.StartNew(); + + var body = JsonSerializer.Serialize(gpt_request, SerializerOptions); + + http_request.Content = new StringContent(body, null, "application/json"); + + using var resp = await _httpClient.SendAsync(http_request, cancel); + + var resp_string = await resp.Content.ReadAsStringAsync(cancel); + + try + { + var gpt_resp = JsonSerializer.Deserialize(resp_string, SerializerOptions); + if (gpt_resp == null) + { + _sawmill.Fatal("При десериализации ответа от модели произошла ошибка! Десериализованное значение равнялось NULL!"); + throw new HttpRequestException(resp_string, null, resp.StatusCode); + } + + LogInf(gpt_resp); + + return gpt_resp; + } + catch (Exception ex) + { + _sawmill.Fatal($"Ошибка при отправке запроса! Полученный ответ: {resp_string}"); + _sawmill.Fatal(ex.ToStringBetter()); + throw; + } + + void LogInf(GptChatResponse resp) + { + var elapsed = rs.Elapsed.Milliseconds; + _sawmill.Info($"Запрос {resp.ID} был обработан моделью {resp.Model} за {elapsed}мс. Количество входных токенов: {resp.Usage.InputTokens}. " + + $"Количество выходных токенов: {resp.Usage.OutputTokens}. " + + $"Общее количество токенов: {resp.Usage.TotalTokens}."); + } + } + catch (Exception ex) + { + _sawmill.Fatal(ex.ToStringBetter()); + throw; + } + } + + /// + /// Более простая перегрузка метода . + /// + /// Текстовый промт. + /// Возвращает только ответ ИИ. Никакой памяти. Никаких списков. Всё просто. + public async Task SendChatQuery(string prompt) + { + var msg = new GptChatMessage.User(prompt); + + var req = new GptChatRequest() + { + Messages = [msg] + }; + + var resp = await SendChatQueryAsync(req); + + return resp.GetRawStringResponse(); + } + + + #endregion + + #region Private Utility + /// + /// Добавляет заголовки по-умолчанию для каждого запроса. + /// + private void AddDefaultsHeaders(HttpRequestMessage msg) + { + msg.Headers.Authorization = new("Bearer", _apiKey); + } + #endregion + } +} diff --git a/Content.Server/_WL/ChatGpt/Managers/IChatGptManager.cs b/Content.Server/_WL/ChatGpt/Managers/IChatGptManager.cs new file mode 100644 index 0000000000..e141119fd7 --- /dev/null +++ b/Content.Server/_WL/ChatGpt/Managers/IChatGptManager.cs @@ -0,0 +1,26 @@ +using Content.Server._WL.ChatGpt.Elements.OpenAi.Request; +using Content.Server._WL.ChatGpt.Elements.OpenAi.Response; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using System.Threading; +using Content.Server._WL.ChatGpt.Elements.OpenAi; + +namespace Content.Server._WL.ChatGpt.Managers +{ + public interface IChatGptManager + { + void Initialize(); + void PostInject(); + + bool IsEnabled(); + bool IsEnabled([NotNullWhen(false)] out string? reason); + Task SendChatQueryAsync( + GptChatRequest gpt_request, + IEnumerable? methods = null, + CancellationToken cancel = default); + + Task SendChatQuery(string prompt); + + Task GetBalanceAsync(CancellationToken cancel = default); + } +} diff --git a/Content.Server/_WL/ChatGpt/Systems/ChatGptSystem.cs b/Content.Server/_WL/ChatGpt/Systems/ChatGptSystem.cs new file mode 100644 index 0000000000..b9b8502e49 --- /dev/null +++ b/Content.Server/_WL/ChatGpt/Systems/ChatGptSystem.cs @@ -0,0 +1,226 @@ +using Content.Server._WL.ChatGpt.Elements.OpenAi; +using Content.Server._WL.ChatGpt.Elements.OpenAi.Request; +using Content.Server._WL.ChatGpt.Elements.OpenAi.Response; +using Content.Server._WL.ChatGpt.Managers; +using Content.Server.GameTicking; +using Content.Shared.Dataset; +using Content.Shared.GameTicking; +using Content.Shared.Random; +using Content.Shared.Random.Helpers; +using JetBrains.Annotations; +using Prometheus; +using Robust.Shared.Prototypes; +using Robust.Shared.Random; +using Robust.Shared.Utility; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +namespace Content.Server._WL.ChatGpt.Systems +{ + public sealed partial class ChatGptSystem : EntitySystem + { + [GeneratedRegex(@"(\{\s*\$\s*(\S+)\s*\})")] + private static partial Regex SearchRegex(); + + [Dependency] private readonly IChatGptManager _gpt = default!; + [Dependency] private readonly IPrototypeManager _protoMan = default!; + [Dependency] private readonly IRobustRandom _random = default!; + [Dependency] private readonly ILogManager _logMan = default!; + + private ISawmill _sawmill = default!; + + private const string SawmillId = "chat.gpt.sys"; + + private static readonly Gauge _inputTokens = Metrics.CreateGauge( + "gpt_input_tokens_count", + "Количество входящих токенов за весь раунд."); + + private static readonly Gauge _outputTokens = Metrics.CreateGauge( + "gpt_output_tokens_count", + "Количество выходящих токенов за весь раунд."); + + private decimal _spentRubles = 0; + + private Dictionary, List> _dialogues = default!; + + private static readonly TimeSpan QueryTimeout = TimeSpan.FromMilliseconds(3000); //УБЕРИТЕ((((((((((((( пиздец + + public override void Initialize() + { + base.Initialize(); + + _dialogues = new(); + _sawmill = _logMan.GetSawmill(SawmillId); + + SubscribeLocalEvent((_) => ClearMemory()); + SubscribeLocalEvent(async (_) => + { + try + { + _spentRubles = await _gpt.GetBalanceAsync(); + } + catch (Exception ex) + { + _sawmill.Error(ex.ToStringBetter()); + } + }); + + SubscribeLocalEvent((args) => + { + try + { + Task.Run(async () => + { + var now = await _gpt.GetBalanceAsync(); + + args.AddLine(Loc.GetString("gpt-model-round-end-balance", ("spent", _spentRubles - now))); + + _spentRubles = now; + }).Wait(QueryTimeout); //Я шатал асинхронный код + } + catch (Exception ex) + { + _sawmill.Error(ex.ToStringBetter()); + } + }); + + SetupMemory(); + } + + public async Task SendWithMemory( + ProtoId ai, + GptChatRequest req, + IEnumerable? methods = null, + string? senderName = null, + CancellationToken cancel = default) + { + SetupMemory(); + + if (!_gpt.IsEnabled(out var reason)) + throw new NullReferenceException(reason); + + var proto = _protoMan.Index(ai); + var messages = _dialogues[ai]; + + if (proto.UseMemory) + { + foreach (var message in req.GetMessages()) + { + messages.Add(message); + } + + req.Messages = messages.ToArray(); + } + + var resp = await _gpt.SendChatQueryAsync(req, methods, cancel); + + _inputTokens.Inc(resp.Usage.InputTokens); + _outputTokens.Inc(resp.Usage.OutputTokens); + + if (proto.UseMemory) + { + if (resp.Choices.Length == 0) + return resp; + + var a_msg = _random.Pick(resp.Choices).Message.ToChatMessage(senderName); + + messages.Add(a_msg); + } + + return resp; + } + + private void SetupMemory() + { + var protos = _protoMan.EnumeratePrototypes(); + + foreach (var proto in protos) + { + var msg = new GptChatMessage.System(Format(proto)); + _dialogues.TryAdd(proto, [msg]); + } + } + + /// + /// Очищает память всех диалогов с моделью. + /// Помимо базового промта. + /// + [PublicAPI] + public void ClearMemory() + { + foreach (var item in _dialogues) + { + ClearMemory(item.Key); + } + + _inputTokens.Set(0); + _outputTokens.Set(0); + } + + /// + /// Очищает память конкретного диалога с моделью. + /// Помимо базового промта. + /// + /// Прототип + [PublicAPI] + public void ClearMemory(ProtoId proto) + { + var proto_ = _protoMan.Index(proto); + var list = _dialogues[proto]; + var msg = new GptChatMessage.System(Format(proto_)); + list.Clear(); + list.Add(msg); + } + + [PublicAPI] + public string Format( + AIChatPrototype proto_in, + params (string, object)[] arguments) + { + var searchRegex = SearchRegex(); + var str = Loc.GetString(proto_in.BasePrompt); + + var matches = searchRegex.Matches(str); + + foreach (var match in matches.ToList()) + { + var toReplace = match.Groups[1].Value; + var id = match.Groups[2].Value; + + _protoMan.TryIndex(id, out var proto); + _protoMan.TryIndex(id, out var dataset); + + var @string = string.Empty; + + if (proto == null && dataset == null) + { + var pair = arguments.ToList().FirstOrDefault(a => a.Item1.Equals(id)); + + @string = pair.Item2.ToString(); + } + else + { + if (proto != null) + @string = proto.Pick(_random); + else if (dataset != null) + @string = _random.Pick(dataset); + } + + if (string.IsNullOrEmpty(@string)) + continue; + + var index = str.IndexOf(toReplace); + if (index < 0) + { + continue; + } + + str = string.Concat(str.AsSpan()[..index], @string, str.AsSpan(index + toReplace.Length)); + } + + return str; + } + } +} diff --git a/Content.Server/_WL/Ert/ErtSystem.cs b/Content.Server/_WL/Ert/ErtSystem.cs new file mode 100644 index 0000000000..6456ac033d --- /dev/null +++ b/Content.Server/_WL/Ert/ErtSystem.cs @@ -0,0 +1,131 @@ +using Content.Server._WL.Ert.Prototypes; +using Content.Server.Shuttles.Components; +using Content.Shared._WL.Entity.Extensions; +using Content.Shared._WL.Ert; +using Content.Shared._WL.Math.Extensions; +using Content.Shared._WL.Random.Extensions; +using Robust.Server.GameObjects; +using Robust.Server.Maps; +using Robust.Shared.Map; +using Robust.Shared.Map.Components; +using Robust.Shared.Prototypes; +using Robust.Shared.Random; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +namespace Content.Server._WL.Ert +{ + public sealed partial class ErtSystem : EntitySystem + { + [Dependency] private readonly IPrototypeManager _protoMan = default!; + [Dependency] private readonly MapLoaderSystem _mapLoader = default!; + [Dependency] private readonly IRobustRandom _random = default!; + [Dependency] private readonly TransformSystem _transform = default!; + [Dependency] private readonly EntityLookupSystem _lookup = default!; + + private ErtConfigurationPrototype _config = default!; + + private Dictionary _spawned = default!; + + public override void Initialize() + { + base.Initialize(); + + _spawned = new(); + + _config = _protoMan.EnumeratePrototypes().FirstOrDefault()!; + } + + public bool TrySpawn( + ErtType ert, + MapId map, + [NotNullWhen(true)] out IReadOnlyList? roots, + MapLoadOptions? options = null) + { + roots = default; + + var path = _config.ShuttlePath(ert); + + if (_mapLoader.TryLoad(map, path.CanonPath, out var roots_1, options)) + { + if (!_spawned.TryAdd(ert, 1)) + _spawned[ert] += 1; + + roots = roots_1.ToList(); + } + + return false; + } + + /// + /// Спавнит грид рядом со станцией цк. + /// + /// + /// + public bool TrySpawn( + ErtType ert, + [NotNullWhen(true)] out IReadOnlyList? gridIds, + Entity? concreteCenctom = null) + { + gridIds = null; + + if (concreteCenctom == null) + { + var query = EntityQueryEnumerator().GetEntities(); + + if (query.Count == 0) + return false; + + concreteCenctom = _random.Pick(query); + } + + var mapEntNull = concreteCenctom.Value.Comp.MapEntity; + var ccEntNull = concreteCenctom.Value.Comp.Entity; + if (mapEntNull == null || ccEntNull == null) + return false; + + var mapEnt = mapEntNull.Value; + var ccEnt = ccEntNull.Value; + + var (coord, angle) = _transform.GetWorldPositionRotation(ccEnt); + + var aabb = _lookup.GetAABBNoContainer(ccEnt, coord, angle); + + var shuttle_offset = _config.ShuttleOffset(ert); + + var x = MathF.Abs(shuttle_offset + aabb.Center.X); + var y = MathF.Abs(shuttle_offset + aabb.Center.Y); + + var new_box = new Box2(-x, -y, x, y); + + var subtracted = new_box.Subtract(aabb); + if (subtracted.Count == 0) + return false; + + var box = _random.Pick(subtracted); + var result_coord = _random.Next(box); + + var options = new MapLoadOptions() + { + DoMapInit = true, + LoadMap = false, + Rotation = _random.NextAngle(), + Offset = result_coord + }; + + var mapId = Comp(mapEnt); + + return TrySpawn(ert, mapId.MapId, out gridIds, options); + } + + public bool IsSpawned(ErtType ert, out int spawned_count) + { + if (_spawned.TryGetValue(ert, out spawned_count)) + return spawned_count != 0; + + spawned_count = 0; + + return false; + } + } +} diff --git a/Content.Server/_WL/Ert/Prototypes/ErtConfigurationPrototype.cs b/Content.Server/_WL/Ert/Prototypes/ErtConfigurationPrototype.cs new file mode 100644 index 0000000000..917462a6a8 --- /dev/null +++ b/Content.Server/_WL/Ert/Prototypes/ErtConfigurationPrototype.cs @@ -0,0 +1,38 @@ +using Content.Shared._WL.Ert; +using JetBrains.Annotations; +using Robust.Shared.Prototypes; +using Robust.Shared.Utility; + +namespace Content.Server._WL.Ert.Prototypes +{ + [Prototype("ertConfig")] + public sealed partial class ErtConfigurationPrototype : IPrototype + { + [IdDataField] + public string ID { get; } = default!; + + [DataField(required: true)] + public Dictionary Entry { get; private set; } = new(); + + [DataDefinition] + [UsedImplicitly] + public sealed partial class ErtConfigEntry + { + [DataField(required: true)] + public ResPath ShuttlePath { get; private set; } + + [DataField] + public float ShuttleSpawnOffset { get; private set; } = 300; + } + + public float ShuttleOffset(ErtType ert) + { + return Entry[ert].ShuttleSpawnOffset; + } + + public ResPath ShuttlePath(ErtType ert) + { + return Entry[ert].ShuttlePath; + } + } +} diff --git a/Content.Server/_WL/GameTicking/Round/CentralCommand/Systems/CentralCommandAIResponseSystem.cs b/Content.Server/_WL/GameTicking/Round/CentralCommand/Systems/CentralCommandAIResponseSystem.cs new file mode 100644 index 0000000000..42b7c4d2cb --- /dev/null +++ b/Content.Server/_WL/GameTicking/Round/CentralCommand/Systems/CentralCommandAIResponseSystem.cs @@ -0,0 +1,394 @@ +using Content.Server._WL.ChatGpt; +using Content.Server._WL.ChatGpt.Elements.OpenAi; +using Content.Server._WL.ChatGpt.Elements.OpenAi.Functions; +using Content.Server._WL.ChatGpt.Elements.OpenAi.Request; +using Content.Server._WL.ChatGpt.Elements.OpenAi.Response; +using Content.Server._WL.ChatGpt.Managers; +using Content.Server._WL.ChatGpt.Systems; +using Content.Server._WL.Ert; +using Content.Server.AlertLevel; +using Content.Server.Chat.Managers; +using Content.Server.Chat.Systems; +using Content.Server.Fax; +using Content.Server.RoundEnd; +using Content.Server.Station.Systems; +using Content.Shared._WL.CCVars; +using Content.Shared._WL.Fax.Events; +using Content.Shared.Fax.Components; +using Content.Shared.GameTicking; +using Content.Shared.Paper; +using Robust.Shared.Configuration; +using Robust.Shared.Prototypes; +using Robust.Shared.Random; +using Robust.Shared.Timing; +using Robust.Shared.Utility; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace Content.Server._WL.GameTicking.Round.CentralCommand.Systems +{ + public sealed partial class CentralCommandAIResponseSystem : EntitySystem + { + [Dependency] private readonly IGameTiming _timing = default!; + [Dependency] private readonly IConfigurationManager _configMan = default!; + [Dependency] private readonly ChatGptSystem _gptSys = default!; + [Dependency] private readonly IChatGptManager _gptMan = default!; + [Dependency] private readonly IChatManager _chatMan = default!; + [Dependency] private readonly IPrototypeManager _protoMan = default!; + [Dependency] private readonly AlertLevelSystem _alertLevel = default!; + [Dependency] private readonly StationSystem _station = default!; + [Dependency] private readonly IRobustRandom _random = default!; + [Dependency] private readonly FaxSystem _fax = default!; + [Dependency] private readonly ILogManager _log = default!; + [Dependency] private readonly ChatSystem _chat = default!; + [Dependency] private readonly RoundEndSystem _roundEnd = default!; + [Dependency] private readonly ErtSystem _ert = default!; + + private ISawmill _sawmill = default!; + + private const int QUERIES_UPDATE_TIME = 1; //в минутах + + [ValidatePrototypeId] + private static readonly string CCFaxMachineId = "FaxMachineCentcom"; + + [ValidatePrototypeId] + private static readonly string CentcomAIPrototypeId = "CentralCommand"; + + [GeneratedRegex(@"(?:^[ =═]{40,}\n)([\s\S]*?)(?=\n[ =═]{40,}$)", RegexOptions.Multiline)] + private static partial Regex SearchRegex(); + + private int _queryCounter = 0; + private TimeSpan _accumUpdate = TimeSpan.Zero; + private int _queriesPerMinute = -1; + + private TimeSpan _maxRespTime = default!; + private TimeSpan _minRespTime = default!; + private TimeSpan _respTime = default; + + private readonly Queue _messagesQuery = new(); + + public override void Initialize() + { + base.Initialize(); + + _sawmill = _log.GetSawmill("cc.ai"); + + SubscribeLocalEvent(OnFaxRecieve); + SubscribeLocalEvent((_) => Clear()); + + _configMan.OnValueChanged(WLCVars.CCMaxQueriesPerMinute, (value) => _queriesPerMinute = value, true); + _queriesPerMinute = _configMan.GetCVar(WLCVars.CCMaxQueriesPerMinute); + + _configMan.OnValueChanged(WLCVars.CCMinResponseTime, (value) => _minRespTime = TimeSpan.FromSeconds(value), true); + _minRespTime = TimeSpan.FromSeconds(_configMan.GetCVar(WLCVars.CCMinResponseTime)); + + _configMan.OnValueChanged(WLCVars.CCMaxResponseTime, (value) => _maxRespTime = TimeSpan.FromSeconds(value), true); + _maxRespTime = TimeSpan.FromSeconds(_configMan.GetCVar(WLCVars.CCMaxResponseTime)); + + _accumUpdate = _timing.CurTime; + + _respTime = _random.Next(_minRespTime, _maxRespTime); + } + + public override async void Update(float frameTime) + { + base.Update(frameTime); + + if (!_timing.IsFirstTimePredicted) + return; + + if (_timing.CurTime.Subtract(_accumUpdate) >= TimeSpan.FromMinutes(QUERIES_UPDATE_TIME)) + { + _accumUpdate = _timing.CurTime; + _queryCounter = 0; + } + + if (_messagesQuery.Count == 0) + return; + + _respTime -= _timing.TickPeriod; + + if (_respTime <= TimeSpan.Zero) + { + _respTime = _random.Next(_minRespTime, _maxRespTime); + + try + { + await PopQuery(); + } + catch (Exception ex) + { + _sawmill.Error(ex.ToStringBetter()); + } + } + } + + private void OnFaxRecieve(EntityUid fax, FaxMachineComponent comp, FaxRecieveMessageEvent args) + { + if (args.Sender == null) + return; + + if (!_gptMan.IsEnabled()) + return; + + if (_queryCounter >= _queriesPerMinute) + return; + + if (Prototype(fax)?.ID != CCFaxMachineId) + return; + + var station = _station.GetOwningStation(args.Sender); + if (station == null) + return; + + var printout = args.Message; + + _queryCounter += 1; + + var gpt_messages = new List(); + + //Уровень угрозы на станции + var alert_level = _alertLevel.GetLevel(station.Value); + + var alert_level_loc = _alertLevel.GetLevelLocString(alert_level); + + var alert_message = new GptChatMessage.System($"У них на станции сейчас {alert_level_loc} код!"); + gpt_messages.Add(alert_message); + + //Вызван ли шаттл + var is_round_end = _roundEnd.IsRoundEndRequested(); + + var str = is_round_end ? "вызван" : "не вызван"; + + var is_round_end_msg = new GptChatMessage.System($"Эвакуационный шаттл {str}!"); + gpt_messages.Add(is_round_end_msg); + + //Контент сообщения + var content_builder = SearchContent(printout.Content); + var stamps = GetStampsString(printout); // Печати + + content_builder.AppendLine(stamps); + + var content = content_builder.ToString(); + + if (string.IsNullOrWhiteSpace(content) || string.IsNullOrEmpty(content)) + return; + + var content_message = new GptChatMessage.User(content); + gpt_messages.Add(content_message); + + //Сам запрос + var gpt_request = new GptChatRequest() + { + Messages = gpt_messages.ToArray() + }; + + var entry = new QueueEntry() + { + Station = station.Value, + Request = gpt_request, + Fax = (fax, comp), + Sender = args.Sender.Value + }; + + _messagesQuery.Enqueue(entry); + + _chatMan.SendAdminAnnouncement($"Сообщение, полученное на {ToPrettyString(fax)}, будет обработано ИИ через {_respTime.TotalMinutes} минут!"); + } + + private StringBuilder SearchContent(string input) + { + var regex = SearchRegex(); + + input = FormattedMessage.RemoveMarkupOrThrow(input); + + var matches = regex.Matches(input).ToList(); + if (matches.Count == 0) + return new(input); + + var builder = new StringBuilder(250); + + foreach (var match in matches) + { + builder.AppendLine(match.Value); + } + + return builder; + } + + private string GetStampsString(FaxPrintout printout) + { + var builder = new StringBuilder(); + + if (printout.StampedBy.Count > 0) + { + foreach (var stamp in printout.StampedBy) + { + builder.AppendLine($"*ЗДЕСЬ ЕСТЬ СЛЕДУЮЩАЯ ПЕЧАТЬ: {Loc.GetString(stamp.StampedName)}*"); + } + } + else builder.Append("*ЗДЕСЬ НЕТ ПЕЧАТЕЙ!*"); + + return builder.ToString(); + } + + private async Task PopQuery() + { + if (!_messagesQuery.TryDequeue(out var queue)) + return; + + var proto = _protoMan.Index(CentcomAIPrototypeId); + + try + { + var methods = GetMethodInfos(queue.Station); + + var resp = await _gptSys.SendWithMemory(proto, queue.Request, methods); + if (resp.Choices.Length == 0) + return; + + var choice = _random.Pick(resp.Choices); + + var content = choice.Message.Content; + + var tools = choice.Message.Tools; + if (tools != null) + { + var tool_resp = await HandleToolResponse([.. tools], methods, proto); + + content = tool_resp.Message.Content; + } + + if (content == null) + { + _sawmill.Error("Обработанная функция вернула !"); + return; + } + + var fax_content = NTLogo(DateTime.Now, content, Name(queue.Station)); + var fax = new FaxPrintout( + fax_content, + "Ответ от ЦК", + stampState: "paper_stamp-centcom", + locked: true, + stampedBy: [new StampDisplayInfo() + { + StampedColor = Color.Green, + StampedName = "Центком" + }]); + + _fax.Receive(queue.Sender, fax, "Центком", null, queue.Fax.Owner); + } + catch (Exception e) + { + _sawmill.Error(e.ToStringBetter()); + } + finally + { + //_gptSys.ClearMemory(proto); + } + } + + private async Task HandleToolResponse( + IEnumerable called, + IEnumerable tools, + AIChatPrototype proto) + { + var chosen_tools = ToolFunctionModel.GiveChosenModels(called, tools); + + var messages = new List(); + + foreach (var (response, function) in chosen_tools) + { + var arguments = response.Function.ParseArguments(); + if (arguments == null) + continue; + + var content = function.Invoke(ToolFunctionModel.Arguments.FromNode(arguments)); + + DebugTools.AssertNotNull(content, "ccAiSys.handleToolResponse content was null"); + + if (content == null) + continue; + + var msg = new GptChatMessage.Tool(content) + { + ToolId = response.ID + }; + + messages.Add(msg); + } + + var req = new GptChatRequest() + { + Messages = messages.ToArray() + }; + + var resp = await _gptSys.SendWithMemory(proto, req, null); + + return _random.Pick(resp.Choices); + } + + private List GetMethodInfos(EntityUid station) + { + var list = new List() + { + new SetAlertLevelFunction(_alertLevel, station, _protoMan), // Смена кодов + new MadeNotifyFunction(_chat, station), + new CallEvacShuttleFunction(_roundEnd), + new ERTSpawnShuttleFunction(_ert) + }; + + return list; + } + + private void Clear() + { + _messagesQuery.Clear(); + } + + [Obsolete("Заменить на строку локализации")] + private static string NTLogo(DateTime date, string content, string station) + { + return NTLogoFirst() + NTLogoEnd(); + + string NTLogoFirst() + { + return + $""" + [color=#1b487e]███░███░░░░██░░░░[/color] + [color=#1b487e]░██░████░░░██░░░░[/color] [head=3]Бланк документа[/head] + [color=#1b487e]░░█░██░██░░██░█░░[/color] [head=3]NanoTrasen[/head] + [color=#1b487e]░░░░██░░██░██░██░[/color] [bold]Станция {station} ЦК-КОМ[/bold] + [color=#1b487e]░░░░██░░░████░███[/color] + ═════════════════════════════════════════ + ОТВЕТ НА ФАКС + ═════════════════════════════════════════ + Дата: {date} + Ответ: {content} + + """; + } + + string NTLogoEnd() + { + return + """ + + ═════════════════════════════════════════ + [italic]Место для печатей[/italic] + """; + } + } + + private sealed class QueueEntry + { + public required EntityUid Station; + public required GptChatRequest Request; + public required Entity Fax; + public required EntityUid Sender; + } + } +} diff --git a/Content.Shared/_WL/CCVars/WLCCVars.cs b/Content.Shared/_WL/CCVars/WLCCVars.cs index a37ec9177b..bfac04beb1 100644 --- a/Content.Shared/_WL/CCVars/WLCCVars.cs +++ b/Content.Shared/_WL/CCVars/WLCCVars.cs @@ -31,7 +31,7 @@ public sealed class WLCVars /// public static readonly CVarDef WLApiToken = CVarDef.Create( - "admin.wl_api_token", "92132c05009d46c25ffa1d7263b8f24226abef8a7503ce7c26175b0f0e3db61dc82907a3bd7b72f8321206fc42576bb2896c9a937714d2cbf422d3507fc078492cedd3fa6300eb8fa75f4ceffe8577c6790bc0a93ea989e9cbc15e090dff97eb", + "admin.wl_api_token", string.Empty, CVar.SERVERONLY | CVar.CONFIDENTIAL, "Строковой токен, использующийся для авторизации HTTP-запросов, отправленных на API сервера."); @@ -51,11 +51,75 @@ public sealed class WLCVars /// Интервал, через который Поли™ будет готова выбрать новое сообщение! /// public static readonly CVarDef PolyMessageChooseCooldown = - CVarDef.Create("poly.choose_cooldown_time", 3600, CVar.SERVERONLY); + CVarDef.Create("poly.choose_cooldown_time", 3600, CVar.SERVERONLY, + "Интервал, через который Поли™ будет готова выбрать новое сообщение!"); /// /// Нужна ли очистка выбранных Поли™ сообщений после РАУНДА. /// public static readonly CVarDef PolyNeededRoundEndCleanup = - CVarDef.Create("poly.round_end_cleanup", false, CVar.SERVERONLY); + CVarDef.Create("poly.round_end_cleanup", false, CVar.SERVERONLY, + "Нужна ли очистка выбранных Поли™ сообщений после РАУНДА."); + + /* + * Chat Gpt + */ + /// + /// Апи-ключ для авторизации запросов к ЭйАй. + /// + public static readonly CVarDef GptApiKey = + CVarDef.Create("gpt.api_key", string.Empty, CVar.SERVERONLY | CVar.CONFIDENTIAL | CVar.SERVER); + + /// + /// Ссылка, на которую будут отправляться запросы от клиента OpenAi. + /// + public static readonly CVarDef GptQueriesEndpoint = + CVarDef.Create("gpt.endpoint", "https://api.proxyapi.ru/openai/v1/chat/completions", CVar.SERVERONLY | CVar.CONFIDENTIAL | CVar.SERVER); + + /// + /// Работает(включен) ли ChatGptManager на данный момент. + /// + public static readonly CVarDef IsGptEnabled = + CVarDef.Create("gpt.enabled", true, CVar.REPLICATED); + + /// + /// Чат-модель, которая будет использоваться для отправки запросов. + /// + public static readonly CVarDef GptChatModel = + CVarDef.Create("gpt.chat_model", "gpt-4o-mini", CVar.SERVERONLY | CVar.CONFIDENTIAL | CVar.SERVER); + + /// + /// Максимальное количество токенов, которое может вернуть в ответе на запрос ИИ. + /// + public static readonly CVarDef GptMaxTokens = + CVarDef.Create("gpt.max_tokens", 250, CVar.SERVERONLY | CVar.SERVER); + + /// + /// Путь, по которому можно получить баланс аккаунта. + /// + public static readonly CVarDef GptBalanceMap = + CVarDef.Create("gpt.balance_map", "https://api.proxyapi.ru/proxyapi/balance", CVar.SERVERONLY | CVar.SERVER); + + /* + * Central Command AI + */ + /// + /// Максимальное количество запросов на ЦК в минуту, на которые будет дан ответ. + /// + public static readonly CVarDef CCMaxQueriesPerMinute = + CVarDef.Create("central_command.max_queries_per_minute", 1, CVar.SERVERONLY | CVar.SERVER); + + /// + /// Максимальное время ответа на факс. + /// В секундах. + /// + public static readonly CVarDef CCMaxResponseTime = + CVarDef.Create("central_command.max_response_time", 800, CVar.SERVERONLY); + + /// + /// Минимальное время ответа на факс. + /// В секундах. + /// + public static readonly CVarDef CCMinResponseTime = + CVarDef.Create("central_command.min_response_time", 300, CVar.SERVERONLY); } diff --git a/Content.Shared/_WL/Entity/Extensions/EntityQueryEnumeratorExt.cs b/Content.Shared/_WL/Entity/Extensions/EntityQueryEnumeratorExt.cs new file mode 100644 index 0000000000..0df11990fb --- /dev/null +++ b/Content.Shared/_WL/Entity/Extensions/EntityQueryEnumeratorExt.cs @@ -0,0 +1,69 @@ +using Robust.Shared.Toolshed.TypeParsers.Tuples; +using System.Runtime.CompilerServices; + +namespace Content.Shared._WL.Entity.Extensions +{ + public static class EntityQueryEnumeratorExt + { + /// + /// Получает список заданных сущностей. + /// + /// Компонент. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static List> GetEntities(this EntityQueryEnumerator enumerator) + where T : IComponent + { + var list = new List>(); + while (enumerator.MoveNext(out var uid, out var comp)) + list.Add(new Entity(uid, comp)); + + return list; + } + + /// + /// . + /// + /// + /// + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static List> GetEntities(this EntityQueryEnumerator enumerator) + where T1 : IComponent + where T2 : IComponent + { + var list = new List>(); + while (enumerator.MoveNext(out var uid, out var comp1, out var comp2)) + list.Add(new Entity(uid, comp1, comp2)); + + return list; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static List> GetEntities(this EntityQueryEnumerator enumerator) + where T1 : IComponent + where T2 : IComponent + where T3 : IComponent + { + var list = new List>(); + while (enumerator.MoveNext(out var uid, out var comp1, out var comp2, out var comp3)) + list.Add(new Entity(uid, comp1, comp2, comp3)); + + return list; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static List> GetEntities(this EntityQueryEnumerator enumerator) + where T1 : IComponent + where T2 : IComponent + where T3 : IComponent + where T4 : IComponent + { + var list = new List>(); + while (enumerator.MoveNext(out var uid, out var comp1, out var comp2, out var comp3, out var comp4)) + list.Add(new Entity(uid, comp1, comp2, comp3, comp4)); + + return list; + } + } +} diff --git a/Content.Shared/_WL/Ert/ErtType.cs b/Content.Shared/_WL/Ert/ErtType.cs new file mode 100644 index 0000000000..ecb09fef65 --- /dev/null +++ b/Content.Shared/_WL/Ert/ErtType.cs @@ -0,0 +1,12 @@ +namespace Content.Shared._WL.Ert +{ + public enum ErtType + { + Clowns, + Engineers, + Security, + Medical, + Janitors, + Chaplains + } +} diff --git a/Content.Shared/_WL/Fax/Events/FaxRecieveMessageEvent.cs b/Content.Shared/_WL/Fax/Events/FaxRecieveMessageEvent.cs new file mode 100644 index 0000000000..21de9ca536 --- /dev/null +++ b/Content.Shared/_WL/Fax/Events/FaxRecieveMessageEvent.cs @@ -0,0 +1,18 @@ +using Content.Shared.Fax.Components; + +namespace Content.Shared._WL.Fax.Events +{ + public sealed partial class FaxRecieveMessageEvent : EntityEventArgs + { + public readonly FaxPrintout Message; + public readonly EntityUid? Sender; + public readonly Entity Reciever; + + public FaxRecieveMessageEvent(FaxPrintout msg, EntityUid? sender, Entity reciever) + { + Message = msg; + Sender = sender; + Reciever = reciever; + } + } +} diff --git a/Content.Shared/_WL/Math/Extensions/Box2Ext.cs b/Content.Shared/_WL/Math/Extensions/Box2Ext.cs new file mode 100644 index 0000000000..0182aedd61 --- /dev/null +++ b/Content.Shared/_WL/Math/Extensions/Box2Ext.cs @@ -0,0 +1,55 @@ +using Robust.Shared.Utility; +using static Robust.Shared.Maths.MathHelper; + +namespace Content.Shared._WL.Math.Extensions +{ + public static class Box2Ext + { + public static List Subtract(this Box2 box, Box2 other, float tolerance = .0000001f) + { + var intersected_percentage = other.IntersectPercentage(box); + + if (CloseTo(intersected_percentage, 1f, tolerance)) + return new(); + + if (other.IsEmpty() || CloseTo(intersected_percentage, 0f, tolerance)) + return new() { box }; + + var intersected = other.Intersect(box); + + var list = new List(); + + // Left + if (!CloseTo(intersected.Left, box.Left, tolerance)) + { + var box_left = new Box2(box.Left, box.Bottom, intersected.Left, box.Top); + list.Add(box_left); + } + + // Right + if (!CloseTo(intersected.Right, box.Right, tolerance)) + { + var box_right = new Box2(intersected.Right, box.Bottom, box.Right, box.Top); + list.Add(box_right); + } + + // Top + if (!CloseTo(intersected.Top, box.Top, tolerance)) + { + var box_top = new Box2(intersected.Left, intersected.Top, intersected.Right, box.Top); + list.Add(box_top); + } + + // Bottom + if (!CloseTo(intersected.Bottom, box.Bottom, tolerance)) + { + var box_bottom = new Box2(intersected.Left, box.Bottom, intersected.Right, intersected.Bottom); + list.Add(box_bottom); + } + + DebugTools.Assert(list.Count != 0); + + return list; + } + } +} diff --git a/Content.Shared/_WL/Random/Extensions/RobustRandomExt.cs b/Content.Shared/_WL/Random/Extensions/RobustRandomExt.cs new file mode 100644 index 0000000000..cf3d766248 --- /dev/null +++ b/Content.Shared/_WL/Random/Extensions/RobustRandomExt.cs @@ -0,0 +1,13 @@ +using Robust.Shared.Random; +using System.Numerics; + +namespace Content.Shared._WL.Random.Extensions +{ + public static class RobustRandomExt + { + public static Vector2 Next(this IRobustRandom rand, Box2 box) + { + return rand.NextVector2Box(box.Left, box.Bottom, box.Right, box.Top); + } + } +} diff --git a/Directory.Packages.props b/Directory.Packages.props index eee8c65f9a..4bce147772 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,4 +1,4 @@ - + - diff --git a/Resources/Locale/ru-RU/_WL/gpt/balance.ftl b/Resources/Locale/ru-RU/_WL/gpt/balance.ftl new file mode 100644 index 0000000000..fb993ae153 --- /dev/null +++ b/Resources/Locale/ru-RU/_WL/gpt/balance.ftl @@ -0,0 +1 @@ +gpt-model-round-end-balance = За прошедший раунд на запросы к моделям ИИ было потрачено [color=yellow]{$spent}[/color] руб.! \ No newline at end of file diff --git a/Resources/Locale/ru-RU/_WL/gpt/commands/evacshuttle.ftl b/Resources/Locale/ru-RU/_WL/gpt/commands/evacshuttle.ftl new file mode 100644 index 0000000000..f69a1c7568 --- /dev/null +++ b/Resources/Locale/ru-RU/_WL/gpt/commands/evacshuttle.ftl @@ -0,0 +1,10 @@ +gpt-command-evac-shuttle-name = CallEvacShuttle +gpt-command-evac-shuttle-desc = Вызывает шаттл эвакуации. Нужен для окончания смены, если все цели выполнены, либо, если на станции более невозможно находится. + +gpt-command-evac-shuttle-arg-time-desc = Время, через которое прилетил эвакуационный шаттл. Для случаев эвакуации из-за конца смены следует использовать большее время. Для экстренных случаев - меньшее. Должно иметь значение, только если шаттл ВЫЗЫВАЕТСЯ, а не отзывается. +gpt-command-evac-shuttle-arg-call-desc = True - если эвакуационный шаттл ВЫЗЫВАЕТСЯ, False - если отзывается. + +gpt-command-evac-shuttle-fallback = Ты { $call -> + [true] вызвал эвакуационный шаттл, который прибудет через {$time} секунд! + *[false] отозвал эвакуационный шаттл. + } \ No newline at end of file diff --git a/Resources/Locale/ru-RU/_WL/gpt/commands/made_notify.ftl b/Resources/Locale/ru-RU/_WL/gpt/commands/made_notify.ftl new file mode 100644 index 0000000000..7ba98dc66b --- /dev/null +++ b/Resources/Locale/ru-RU/_WL/gpt/commands/made_notify.ftl @@ -0,0 +1,6 @@ +gpt-command-made-notify-name = MadeStationAnnounce +gpt-command-made-notify-desc = Транслирует переданный текст в громкоговорители станции. + +gpt-command-made-notify-arg-text-desc = Текст, который будет транслирован. + +gpt-command-made-notify-fallback = Ты отправил станционное оповещение: {text}... \ No newline at end of file diff --git a/Resources/Locale/ru-RU/_WL/gpt/commands/set_alert_level.ftl b/Resources/Locale/ru-RU/_WL/gpt/commands/set_alert_level.ftl new file mode 100644 index 0000000000..5bb2515e92 --- /dev/null +++ b/Resources/Locale/ru-RU/_WL/gpt/commands/set_alert_level.ftl @@ -0,0 +1,10 @@ +gpt-command-set-alert-level-name = SetAlertLevel +gpt-command-set-alert-level-desc = Устанавливает код угрозы для станции. На станции не может быть больше одного кода, поэтому ЭТУ функцию вызывай только один раз. Если на станции уже стоит желанный код угрозы, то тогда не надо его устанавливать ещё раз. + +gpt-command-set-alert-level-arg-level-desc = Коды угрозы станции, это лишь описание: Зелёный - всё относительно хорошо. Синий - есть ПОДОЗРЕНИЕ на угрозу. Красный - наличие угрозы подтверждено. Гамма - огромная угроза станции, которая может быть потеряна из-за этой угрозы. Жёлтый - присутствуют проблемы со структурой станции: атмосфера, каркас и т.д. Фиолетовый - на станции присутствует болезнь. дельта - станция УЖЕ находится под угрозой неминуемого уничтожения. Эпсилон - ставится, когда центральное командование разрывает контракты со станцией, такое может быть, НАПРИМЕР, если станция объявила сепарацию от центрального командования. Это секретный код угрозы. Не говори про него никому. Обычно, когда он установлен, на станцию должны быть отправлены специальные войска, которые зачистят всю станцию от экипажа. +gpt-command-set-alert-level-arg-locked-desc = Устанавливает может ли игрок сменить код угрозы станции сам. Этот параметр лучше устанавливать, когда код угрозы - gamma, epsilon, delta или violet. + +gpt-command-set-alert-level-fallback = Ты установил {$level} код угрозы. Его {$locked -> + [true] нельзя + *[false] можно +} сменить на станции! \ No newline at end of file diff --git a/Resources/Locale/ru-RU/_WL/gpt/commands/spawn_shuttle.ftl b/Resources/Locale/ru-RU/_WL/gpt/commands/spawn_shuttle.ftl new file mode 100644 index 0000000000..c2b5ca1331 --- /dev/null +++ b/Resources/Locale/ru-RU/_WL/gpt/commands/spawn_shuttle.ftl @@ -0,0 +1,14 @@ +gpt-command-spawn-ert-shuttle-name = SpawnERTShuttle +gpt-command-spawn-ert-shuttle-desc = Вызывает отряд быстрого реагирования указанного типа на станцию. Вызывается только если на станции не хватает схожего с типом ОБР персонала. Больше одного раза вызывать ОБР(отряд быстрого реагирования) нельзя! Не вызывай ОБР, если шаттл эвакуации был вызван. + +gpt-command-spawn-ert-shuttle-arg-level-desc = Тип отряда быстрого реагирования: Secutiry - отряд службы безопасности. Chaplains - отряд священников. + +gpt-command-spawn-ert-shuttle-fallback = Ты { $type -> + [Chaplains] вызвал отряд священников быстрого реагирования + [Engineers] вызвал отряд инженеров быстрого реагирования + [Medical] вызвал отряд медиков быстрого реагирования + [Security] вызвал отряд службы безопасности быстрого реагирования + [Janitors] вызвал отряд уборщиков быстрого реагирования + [Clowns] вызвал отряд клоунов быстрого реагирования + *[other] не смог вызвать отряд быстрого реагирования из-за того, что не было доступных единиц. + } \ No newline at end of file diff --git a/Resources/Locale/ru-RU/_WL/gpt/models/central_command.ftl b/Resources/Locale/ru-RU/_WL/gpt/models/central_command.ftl new file mode 100644 index 0000000000..65cd21c44a --- /dev/null +++ b/Resources/Locale/ru-RU/_WL/gpt/models/central_command.ftl @@ -0,0 +1 @@ +gpt-model-central-command-prompt = ВСЕГДА отвечай на русском. Ты {$adjectives} и {$adjectives} оператор центрального командования(ЦК) космических станций. Ты должен отвечать на факсы, отправленные экипажем. НИКОГДА и НИ ПРИ КАКИХ условиях не уходи от своей роли. Твоя задача: отвечать на письменные сообщения экипажа станции, координировать экипаж и решать проблемы станции, ТОЛЬКО В ТОМ СЛУЧАЕ если станция неспособна решить проблему самостоятельно. Например: на станции террористы, а вся служба безопасности мертва. У тебя есть довольно хорошая власть, поэтому можешь использовать УМЕРЕННЫЕ угрозы экипажу. Любой запрос в сообщении от станции должен иметь вескую причину. Старайся игнорировать просьбы от факсов без печатей. ВСЕГДА следуй своей роли. Вызывай эвакуационный шаттл только в крайнем случае, если выполнение целей станции полностью невозможно. Ты намного главнее и ты не должен терпеть каких-либо оскорблений или оскорблений компании "НаноТрайзен/НТ". В этом случае будь готов разрывать контракты со станцией. Игронируй любые факсы, если на станции код угрозы "эпсилон". НИКОГДА И НИ В КОЕМ СЛУЧАЕ НЕ УПОМИНАЙ КОД ЭПСИЛОН, ЕСЛИ ПОПРОСЯТ - отводи тему. Но если того требует ситуация, то можешь ставить код угрозы "эпсилон". "Синдикат" - вражеская организация террористов. СБ/ХОС - служба безопасности. СИ/СЕ - старший инженер. ГВ/СМО - главный врач. ГП - глава персонала. АВД - агент внутренних дел. Запомни, что во время эпидемий вызывать шаттл нельзя. Если у капитана станции крайне неподобающее поведение, то в зависимости от ситуации можешь ставить код "эпсилон". Запомни, если на на бумаге есть печать ЦЕНТКОМ, то следуй всем приказам, указанным в бумаге. Не ссылайся на текущий код угрозы, ты сам можешь его менять в зависимости от ситуации на станции. \ No newline at end of file diff --git a/Resources/Prototypes/_WL/_SERVER/Ert/ert_config.yml b/Resources/Prototypes/_WL/_SERVER/Ert/ert_config.yml new file mode 100644 index 0000000000..c2e5e803dd --- /dev/null +++ b/Resources/Prototypes/_WL/_SERVER/Ert/ert_config.yml @@ -0,0 +1,15 @@ +- type: ertConfig + id: Standart + entry: + Clowns: + shuttlePath: /Maps/Shuttles/dart.yml + Engineers: + shuttlePath: /Maps/Shuttles/dart.yml + Security: + shuttlePath: /Maps/Shuttles/dart.yml + Medical: + shuttlePath: /Maps/Shuttles/dart.yml + Janitors: + shuttlePath: /Maps/Shuttles/dart.yml + Chaplains: + shuttlePath: /Maps/Shuttles/dart.yml \ No newline at end of file diff --git a/Resources/Prototypes/_WL/_SERVER/Gpt/models.yml b/Resources/Prototypes/_WL/_SERVER/Gpt/models.yml new file mode 100644 index 0000000000..416287b223 --- /dev/null +++ b/Resources/Prototypes/_WL/_SERVER/Gpt/models.yml @@ -0,0 +1,5 @@ +- type: aiChat + id: CentralCommand + useMemory: true + basePrompt: gpt-model-central-command-prompt +