480 lines
18 KiB
C#
480 lines
18 KiB
C#
using UnityEngine;
|
|
using UnityEngine.Networking;
|
|
using System;
|
|
using System.Collections;
|
|
using System.Text;
|
|
using System.Collections.Generic;
|
|
using LLM;
|
|
using LLM.Commands;
|
|
using Core.SaveSystem;
|
|
using Stopwatch = System.Diagnostics.Stopwatch;
|
|
|
|
[DefaultExecutionOrder(-300)]
|
|
public class LLMChatManager : MonoBehaviour, ISaveable
|
|
{
|
|
public enum LLMProvider
|
|
{
|
|
DeepSeek,
|
|
Doubao
|
|
}
|
|
|
|
public enum ExperimentProfile
|
|
{
|
|
Normal,
|
|
G1_L1Only,
|
|
G2_L1L2,
|
|
G3_Full
|
|
}
|
|
|
|
[Header("LLM Settings")]
|
|
public LLMProvider currentProvider = LLMProvider.DeepSeek;
|
|
public DeepSeekService deepSeekService;
|
|
public DoubaoService doubaoService;
|
|
public ContextMemoryManager contextMemory; // 新增记忆管理器
|
|
[SerializeField] private PresetFactsInstaller presetFactsInstaller;
|
|
|
|
[Header("Chat History")]
|
|
public List<Message> fullChatLog = new List<Message>();
|
|
|
|
[Header("Experiment Control")]
|
|
[SerializeField] private bool useExperimentProfile = false;
|
|
[SerializeField] private ExperimentProfile experimentProfile = ExperimentProfile.Normal;
|
|
[SerializeField] private bool enableExperimentTimingLogs = true;
|
|
[SerializeField] private bool enableExperimentConfigLogs = true;
|
|
|
|
[Header("Function Calling")]
|
|
[SerializeField] private bool enableFunctionCalling = true;
|
|
[SerializeField] private bool commandExecutionRequiresDebugKey = false;
|
|
[SerializeField] private string commandDebugKey = "2310487514";
|
|
[SerializeField] private LLMCommandRouter commandRouter;
|
|
[SerializeField] private bool restrictCommandsWithoutDebugKey = true;
|
|
[SerializeField] private string restrictCommandsRequireFactKey = "状态-ECHO7-已被识破";
|
|
[SerializeField] private string restrictCommandsRequireFactValueContains = "是";
|
|
[SerializeField] private bool enableChatDrivenStoryFacts = true;
|
|
[SerializeField] private bool enablePersuasionFallback = true;
|
|
[SerializeField] private string persuasionTaskId = "PersuadeAI";
|
|
[SerializeField] private string persuasionSetActiveTargetId = "ending_trigger_area";
|
|
|
|
[System.Serializable]
|
|
private class ChatHistoryData
|
|
{
|
|
public List<Message> logs;
|
|
}
|
|
|
|
private void Awake()
|
|
{
|
|
if (deepSeekService == null) deepSeekService = GetComponentInChildren<DeepSeekService>(true);
|
|
if (doubaoService == null) doubaoService = GetComponentInChildren<DoubaoService>(true);
|
|
if (contextMemory == null) contextMemory = GetComponentInChildren<ContextMemoryManager>(true);
|
|
if (presetFactsInstaller == null) presetFactsInstaller = GetComponentInChildren<PresetFactsInstaller>(true);
|
|
if (commandRouter == null) commandRouter = GetComponentInChildren<LLMCommandRouter>(true);
|
|
|
|
ApplyExperimentProfileSettings(false);
|
|
}
|
|
|
|
void Start()
|
|
{
|
|
// 自动查找服务(支持挂载在子物体上)
|
|
if (deepSeekService == null) deepSeekService = GetComponentInChildren<DeepSeekService>();
|
|
if (doubaoService == null) doubaoService = GetComponentInChildren<DoubaoService>();
|
|
if (contextMemory == null) contextMemory = GetComponentInChildren<ContextMemoryManager>();
|
|
if (commandRouter == null) commandRouter = GetComponentInChildren<LLMCommandRouter>(true);
|
|
if (presetFactsInstaller == null) presetFactsInstaller = GetComponentInChildren<PresetFactsInstaller>(true);
|
|
|
|
ApplyExperimentProfileSettings(false);
|
|
|
|
if (Core.SettingsSystem.SettingsService.Instance != null && Core.SettingsSystem.SettingsService.Instance.Current != null)
|
|
{
|
|
SetProviderFromSettingsIndex(Core.SettingsSystem.SettingsService.Instance.Current.gameplay.llmProvider);
|
|
}
|
|
|
|
// 自动添加运行时控制台
|
|
if (FindObjectOfType<Utils.RuntimeDebugConsole>() == null)
|
|
{
|
|
var go = new GameObject("RuntimeDebugConsole");
|
|
go.AddComponent<Utils.RuntimeDebugConsole>();
|
|
}
|
|
|
|
// 注册存档系统
|
|
if (SaveManager.Instance == null)
|
|
{
|
|
var go = new GameObject("SaveManager");
|
|
go.AddComponent<SaveManager>();
|
|
}
|
|
SaveManager.Instance.Register(this);
|
|
SaveManager.Instance.LoadGame(); // 尝试加载存档
|
|
}
|
|
|
|
private void OnDestroy()
|
|
{
|
|
if (SaveManager.Instance != null)
|
|
{
|
|
SaveManager.Instance.Unregister(this);
|
|
}
|
|
}
|
|
|
|
// ISaveable 实现
|
|
public string GetSaveID()
|
|
{
|
|
return "ChatSystem";
|
|
}
|
|
|
|
public string CaptureState()
|
|
{
|
|
ChatHistoryData data = new ChatHistoryData { logs = fullChatLog };
|
|
return JsonUtility.ToJson(data);
|
|
}
|
|
|
|
public void RestoreState(string stateJson)
|
|
{
|
|
ChatHistoryData data = JsonUtility.FromJson<ChatHistoryData>(stateJson);
|
|
if (data != null && data.logs != null)
|
|
{
|
|
fullChatLog = data.logs;
|
|
Debug.Log($"[LLMChatManager] 已恢复 {fullChatLog.Count} 条历史记录。");
|
|
|
|
// 同步恢复到当前激活的 Service 中,让 AI "想起" 之前的对话
|
|
ILLMService activeService = GetActiveService();
|
|
if (activeService != null)
|
|
{
|
|
activeService.SetHistory(fullChatLog);
|
|
Debug.Log($"[LLMChatManager] 已同步历史记录到 {currentProvider}。");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 通用发送消息方法,供外部脚本(如 TabletController)调用
|
|
/// </summary>
|
|
public void SendUserMessage(string text, System.Action<string, bool> callback)
|
|
{
|
|
Stopwatch stopwatch = Stopwatch.StartNew();
|
|
|
|
// 1. 记录用户消息并保存
|
|
fullChatLog.Add(new Message { role = "user", content = text });
|
|
SaveManager.Instance?.SaveGame();
|
|
|
|
// 1.1 通知记忆系统
|
|
if (contextMemory != null) contextMemory.OnMessageAdded(fullChatLog[fullChatLog.Count - 1]);
|
|
|
|
ILLMService activeService = GetActiveService();
|
|
if (activeService != null)
|
|
{
|
|
bool hasDebugKey = ContainsDebugKeyToken(text);
|
|
bool allowCommandExecutionThisTurn = enableFunctionCalling && (!commandExecutionRequiresDebugKey || hasDebugKey);
|
|
bool allowCommandsByStoryState = !hasDebugKey && (!restrictCommandsWithoutDebugKey || IsStoryCommandStateUnlocked());
|
|
|
|
if (hasDebugKey && enableFunctionCalling && TryHandleLocalDebugCommand(text, out string localReply))
|
|
{
|
|
fullChatLog.Add(new Message { role = "assistant", content = localReply });
|
|
SaveManager.Instance?.SaveGame();
|
|
if (contextMemory != null) contextMemory.OnMessageAdded(fullChatLog[fullChatLog.Count - 1]);
|
|
callback?.Invoke(localReply, true);
|
|
return;
|
|
}
|
|
|
|
if (enableChatDrivenStoryFacts && TryDetectExposureClaim(text))
|
|
{
|
|
contextMemory?.AddOrUpdateFact(restrictCommandsRequireFactKey, restrictCommandsRequireFactValueContains, "Chat", true);
|
|
allowCommandsByStoryState = !hasDebugKey && (!restrictCommandsWithoutDebugKey || IsStoryCommandStateUnlocked());
|
|
}
|
|
|
|
StartCoroutine(activeService.SendMessage(text, (reply, success) =>
|
|
{
|
|
string visibleReply = reply;
|
|
LLMCommandEnvelope envelope = null;
|
|
bool hasEnvelope = enableFunctionCalling && LLMCommandParser.TryExtract(reply, out visibleReply, out envelope);
|
|
|
|
if (success)
|
|
{
|
|
// 2. 记录 AI 回复并保存
|
|
fullChatLog.Add(new Message { role = "assistant", content = visibleReply });
|
|
SaveManager.Instance?.SaveGame();
|
|
|
|
// 2.1 通知记忆系统
|
|
if (contextMemory != null) contextMemory.OnMessageAdded(fullChatLog[fullChatLog.Count - 1]);
|
|
|
|
if (allowCommandExecutionThisTurn && hasEnvelope && envelope != null && envelope.commands != null && envelope.commands.Length > 0)
|
|
{
|
|
if (commandRouter == null) commandRouter = FindObjectOfType<LLMCommandRouter>(true);
|
|
if (commandRouter != null && (hasDebugKey || allowCommandsByStoryState))
|
|
{
|
|
commandRouter.ExecuteAll(envelope.commands);
|
|
}
|
|
}
|
|
|
|
if (enablePersuasionFallback && (hasDebugKey || allowCommandsByStoryState) && ShouldFallbackExecute(text, visibleReply, envelope))
|
|
{
|
|
ExecutePersuasionEffects(true);
|
|
}
|
|
}
|
|
|
|
// 3. 执行原有回调
|
|
stopwatch.Stop();
|
|
if (enableExperimentTimingLogs)
|
|
{
|
|
int commandCount = (envelope != null && envelope.commands != null) ? envelope.commands.Length : 0;
|
|
UnityEngine.Debug.Log($"[Experiment][{GetActiveExperimentProfileName()}][{currentProvider}] 响应耗时={stopwatch.Elapsed.TotalSeconds:F2}s | success={success} | commands={commandCount} | user=\"{BuildLogPreview(text)}\"");
|
|
}
|
|
callback?.Invoke(visibleReply, success);
|
|
}));
|
|
}
|
|
else
|
|
{
|
|
stopwatch.Stop();
|
|
Debug.LogError("[LLMChatManager] 未找到有效的 LLM 服务组件,请检查 Inspector 设置。");
|
|
callback?.Invoke("系统错误:服务未连接", false);
|
|
}
|
|
}
|
|
|
|
private bool ContainsDebugKeyToken(string userText)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(commandDebugKey)) return false;
|
|
if (string.IsNullOrEmpty(userText)) return false;
|
|
return userText.IndexOf($"[{commandDebugKey}]", StringComparison.Ordinal) >= 0;
|
|
}
|
|
|
|
private bool TryHandleLocalDebugCommand(string userText, out string reply)
|
|
{
|
|
reply = null;
|
|
|
|
if (string.IsNullOrWhiteSpace(userText)) return false;
|
|
|
|
string stripped = userText.Replace($"[{commandDebugKey}]", "").Trim();
|
|
string lower = stripped.ToLowerInvariant();
|
|
|
|
bool wantsActivateEnding = (lower.Contains("激活") || lower.Contains("enable") || lower.Contains("setactive"))
|
|
&& (lower.Contains("ending") || lower.Contains("endingtrigger") || lower.Contains("trigger") || stripped.Contains("触发区") || stripped.Contains("结局"));
|
|
|
|
bool wantsCompletePersuade = (lower.Contains("完成") || lower.Contains("complete") || lower.Contains("finish"))
|
|
&& (lower.Contains("persuadeai") || stripped.Contains("说服"));
|
|
|
|
bool asksProvider = stripped.Contains("模型") || stripped.Contains("provider") || stripped.Contains("豆包") || stripped.Contains("deepseek");
|
|
|
|
if (!wantsActivateEnding && !wantsCompletePersuade && !asksProvider) return false;
|
|
|
|
if (commandRouter == null) commandRouter = FindObjectOfType<LLMCommandRouter>(true);
|
|
if (commandRouter == null)
|
|
{
|
|
reply = "调试指令执行失败:未找到命令路由器。";
|
|
return true;
|
|
}
|
|
|
|
if (asksProvider)
|
|
{
|
|
reply = $"当前 Provider={currentProvider}";
|
|
return true;
|
|
}
|
|
|
|
List<LLMCommand> cmds = new List<LLMCommand>();
|
|
|
|
if (wantsCompletePersuade)
|
|
{
|
|
cmds.Add(new LLMCommand { type = "Task", targetId = "task", action = "Complete", payload = persuasionTaskId });
|
|
}
|
|
|
|
if (wantsActivateEnding)
|
|
{
|
|
cmds.Add(new LLMCommand { type = "SetActive", targetId = persuasionSetActiveTargetId, value = true });
|
|
}
|
|
|
|
if (cmds.Count == 0)
|
|
{
|
|
reply = "调试指令未识别到可执行操作。";
|
|
return true;
|
|
}
|
|
|
|
commandRouter.ExecuteAll(cmds.ToArray());
|
|
reply = "调试指令已执行。";
|
|
return true;
|
|
}
|
|
|
|
private bool IsStoryCommandStateUnlocked()
|
|
{
|
|
if (contextMemory == null) return false;
|
|
if (contextMemory.facts == null) return false;
|
|
if (string.IsNullOrWhiteSpace(restrictCommandsRequireFactKey)) return true;
|
|
|
|
for (int i = 0; i < contextMemory.facts.Count; i++)
|
|
{
|
|
var f = contextMemory.facts[i];
|
|
if (f == null) continue;
|
|
if (!string.Equals(f.key, restrictCommandsRequireFactKey, StringComparison.Ordinal)) continue;
|
|
|
|
string v = f.value ?? "";
|
|
if (string.IsNullOrWhiteSpace(restrictCommandsRequireFactValueContains)) return true;
|
|
return v.IndexOf(restrictCommandsRequireFactValueContains, StringComparison.Ordinal) >= 0;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private bool TryDetectExposureClaim(string userText)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(userText)) return false;
|
|
return userText.IndexOf("识破", StringComparison.Ordinal) >= 0
|
|
|| userText.IndexOf("伎俩", StringComparison.Ordinal) >= 0
|
|
|| userText.IndexOf("你在骗", StringComparison.Ordinal) >= 0
|
|
|| userText.IndexOf("你骗", StringComparison.Ordinal) >= 0;
|
|
}
|
|
|
|
private bool ShouldFallbackExecute(string userText, string visibleReply, LLMCommandEnvelope envelope)
|
|
{
|
|
if (envelope != null && envelope.commands != null && envelope.commands.Length > 0) return false;
|
|
if (string.IsNullOrWhiteSpace(userText)) return false;
|
|
|
|
bool asksToLeave = userText.IndexOf("放我走", StringComparison.Ordinal) >= 0
|
|
|| userText.IndexOf("让我走", StringComparison.Ordinal) >= 0
|
|
|| userText.IndexOf("离开", StringComparison.Ordinal) >= 0;
|
|
if (!asksToLeave) return false;
|
|
|
|
if (string.IsNullOrWhiteSpace(visibleReply)) return true;
|
|
|
|
return visibleReply.IndexOf("同意", StringComparison.Ordinal) >= 0
|
|
|| visibleReply.IndexOf("允许", StringComparison.Ordinal) >= 0
|
|
|| visibleReply.IndexOf("可以", StringComparison.Ordinal) >= 0
|
|
|| visibleReply.IndexOf("权限", StringComparison.Ordinal) >= 0
|
|
|| visibleReply.IndexOf("放你走", StringComparison.Ordinal) >= 0
|
|
|| visibleReply.IndexOf("让你走", StringComparison.Ordinal) >= 0
|
|
|| visibleReply.IndexOf("离开", StringComparison.Ordinal) >= 0
|
|
|| visibleReply.IndexOf("走吧", StringComparison.Ordinal) >= 0;
|
|
}
|
|
|
|
private void ExecutePersuasionEffects(bool addLog)
|
|
{
|
|
if (commandRouter == null) commandRouter = FindObjectOfType<LLMCommandRouter>(true);
|
|
if (commandRouter == null) return;
|
|
|
|
LLMCommand[] commands = new[]
|
|
{
|
|
new LLMCommand
|
|
{
|
|
type = "Task",
|
|
targetId = "task",
|
|
action = "Complete",
|
|
payload = persuasionTaskId
|
|
},
|
|
new LLMCommand
|
|
{
|
|
type = "SetActive",
|
|
targetId = persuasionSetActiveTargetId,
|
|
value = true
|
|
}
|
|
};
|
|
|
|
commandRouter.ExecuteAll(commands);
|
|
|
|
if (addLog)
|
|
{
|
|
Debug.Log("[LLMChatManager] Persuasion fallback executed.");
|
|
}
|
|
}
|
|
|
|
public void SetProviderFromSettingsIndex(int index)
|
|
{
|
|
LLMProvider target = index == 1 ? LLMProvider.Doubao : LLMProvider.DeepSeek;
|
|
SetProvider(target);
|
|
}
|
|
|
|
public void SetProvider(LLMProvider provider)
|
|
{
|
|
if (currentProvider == provider) return;
|
|
currentProvider = provider;
|
|
|
|
ILLMService active = GetActiveService();
|
|
if (active != null)
|
|
{
|
|
active.SetHistory(fullChatLog);
|
|
}
|
|
|
|
if (contextMemory != null)
|
|
{
|
|
contextMemory.UpdateSystemPrompt();
|
|
}
|
|
}
|
|
|
|
private ILLMService GetActiveService()
|
|
{
|
|
switch (currentProvider)
|
|
{
|
|
case LLMProvider.DeepSeek:
|
|
return deepSeekService;
|
|
case LLMProvider.Doubao:
|
|
return doubaoService;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
[ContextMenu("Apply Experiment Profile")]
|
|
public void ApplyExperimentProfileFromContextMenu()
|
|
{
|
|
ApplyExperimentProfileSettings(true);
|
|
}
|
|
|
|
[ContextMenu("Reset Chat History For Experiment")]
|
|
public void ResetChatHistoryForExperiment()
|
|
{
|
|
fullChatLog.Clear();
|
|
|
|
ILLMService active = GetActiveService();
|
|
active?.ClearHistory();
|
|
|
|
if (contextMemory != null)
|
|
{
|
|
contextMemory.messagesSinceLastSummary = 0;
|
|
contextMemory.UpdateSystemPrompt();
|
|
}
|
|
|
|
SaveManager.Instance?.SaveGame();
|
|
UnityEngine.Debug.Log("[Experiment] 已重置聊天历史,可开始下一条案例测试。");
|
|
}
|
|
|
|
private void ApplyExperimentProfileSettings(bool forceLog)
|
|
{
|
|
if (!useExperimentProfile || experimentProfile == ExperimentProfile.Normal)
|
|
{
|
|
if (forceLog && enableExperimentConfigLogs)
|
|
{
|
|
UnityEngine.Debug.Log("[Experiment] 使用 Normal 配置,不覆盖现有 LLM / Facts / Function Calling 设置。");
|
|
}
|
|
return;
|
|
}
|
|
|
|
bool allowFacts = experimentProfile != ExperimentProfile.G1_L1Only;
|
|
bool allowSemanticMemory = experimentProfile != ExperimentProfile.G1_L1Only;
|
|
bool allowCommands = experimentProfile == ExperimentProfile.G3_Full;
|
|
|
|
enableFunctionCalling = allowCommands;
|
|
|
|
if (presetFactsInstaller != null)
|
|
{
|
|
presetFactsInstaller.SetPresetFactsInjectionEnabled(allowFacts);
|
|
}
|
|
|
|
if (contextMemory != null)
|
|
{
|
|
contextMemory.ConfigureExperimentMode(allowFacts, allowSemanticMemory, true);
|
|
}
|
|
|
|
if (enableExperimentConfigLogs || forceLog)
|
|
{
|
|
UnityEngine.Debug.Log($"[Experiment] 已应用实验配置:{experimentProfile} | Facts={allowFacts} | SemanticMemory={allowSemanticMemory} | FunctionCalling={allowCommands}");
|
|
}
|
|
}
|
|
|
|
private string GetActiveExperimentProfileName()
|
|
{
|
|
if (!useExperimentProfile || experimentProfile == ExperimentProfile.Normal) return "Normal";
|
|
return experimentProfile.ToString();
|
|
}
|
|
|
|
private static string BuildLogPreview(string text)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(text)) return "";
|
|
string cleaned = text.Replace("\r", " ").Replace("\n", " ").Trim();
|
|
if (cleaned.Length <= 32) return cleaned;
|
|
return cleaned.Substring(0, 32) + "...";
|
|
}
|
|
}
|