Initial Unity project commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 09c5042dccdab784694e44aab7d51b36
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,46 @@
|
||||
using Interaction;
|
||||
using UnityEngine;
|
||||
|
||||
namespace LLM.Commands
|
||||
{
|
||||
public class DoorCommandReceiver : LLMCommandReceiverBase
|
||||
{
|
||||
[SerializeField] private DoorController door;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (door == null)
|
||||
{
|
||||
door = GetComponentInParent<DoorController>();
|
||||
}
|
||||
}
|
||||
|
||||
public override bool TryExecute(LLMCommand command)
|
||||
{
|
||||
if (command == null) return false;
|
||||
if (!string.Equals(command.type, "Door", System.StringComparison.OrdinalIgnoreCase)) return false;
|
||||
|
||||
if (door == null)
|
||||
{
|
||||
door = GetComponentInParent<DoorController>();
|
||||
if (door == null) return false;
|
||||
}
|
||||
|
||||
if (string.Equals(command.action, "Open", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
door.OpenDoor();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.Equals(command.action, "Close", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
door.CloseDoor();
|
||||
return true;
|
||||
}
|
||||
|
||||
door.ToggleDoor();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e967930ada4fe804ab72df4faefebe2b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
|
||||
namespace LLM.Commands
|
||||
{
|
||||
[Serializable]
|
||||
public class LLMCommandEnvelope
|
||||
{
|
||||
public string text;
|
||||
public LLMCommand[] commands;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class LLMCommand
|
||||
{
|
||||
public string type;
|
||||
public string targetId;
|
||||
public string action;
|
||||
public bool value;
|
||||
public string payload;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1169bc1f34d194b4295f851f5560e67e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace LLM.Commands
|
||||
{
|
||||
public static class LLMCommandParser
|
||||
{
|
||||
public static bool TryExtract(string rawReply, out string visibleText, out LLMCommandEnvelope envelope)
|
||||
{
|
||||
visibleText = rawReply ?? "";
|
||||
envelope = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(rawReply)) return false;
|
||||
|
||||
string cleaned = rawReply.Replace("```json", "").Replace("```", "").Trim();
|
||||
|
||||
if (TryParseEnvelope(cleaned, out envelope))
|
||||
{
|
||||
visibleText = string.IsNullOrWhiteSpace(envelope.text) ? "" : envelope.text.Trim();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!TryExtractLastJsonObject(cleaned, out string json, out int startIndex)) return false;
|
||||
|
||||
if (!TryParseEnvelope(json, out envelope)) return false;
|
||||
|
||||
visibleText = string.IsNullOrWhiteSpace(envelope.text)
|
||||
? cleaned.Substring(0, startIndex).Trim()
|
||||
: envelope.text.Trim();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParseEnvelope(string json, out LLMCommandEnvelope envelope)
|
||||
{
|
||||
envelope = null;
|
||||
try
|
||||
{
|
||||
envelope = JsonUtility.FromJson<LLMCommandEnvelope>(json);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
envelope = null;
|
||||
}
|
||||
|
||||
if (envelope == null) return false;
|
||||
if (envelope.commands == null && string.IsNullOrWhiteSpace(envelope.text)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryExtractLastJsonObject(string input, out string json, out int startIndex)
|
||||
{
|
||||
json = null;
|
||||
startIndex = -1;
|
||||
|
||||
if (string.IsNullOrEmpty(input)) return false;
|
||||
|
||||
int end = input.LastIndexOf('}');
|
||||
if (end < 0) return false;
|
||||
|
||||
int depth = 0;
|
||||
for (int i = end; i >= 0; i--)
|
||||
{
|
||||
char c = input[i];
|
||||
if (c == '}') depth++;
|
||||
else if (c == '{')
|
||||
{
|
||||
depth--;
|
||||
if (depth == 0)
|
||||
{
|
||||
startIndex = i;
|
||||
json = input.Substring(i, end - i + 1);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6e7d460f69f8ef649a19671ca7795953
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,14 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace LLM.Commands
|
||||
{
|
||||
public abstract class LLMCommandReceiverBase : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private string targetId;
|
||||
|
||||
public string TargetId => targetId;
|
||||
|
||||
public abstract bool TryExecute(LLMCommand command);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0e95d66d1999a9f4d8366ab5990d3748
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace LLM.Commands
|
||||
{
|
||||
public class LLMCommandRouter : MonoBehaviour
|
||||
{
|
||||
[Header("Registry")]
|
||||
[SerializeField] private bool autoDiscoverReceiversOnAwake = true;
|
||||
[SerializeField] private List<LLMCommandReceiverBase> receivers = new List<LLMCommandReceiverBase>();
|
||||
|
||||
[Header("Debug")]
|
||||
[SerializeField] private bool debugLogs = true;
|
||||
|
||||
private readonly Dictionary<string, LLMCommandReceiverBase> map = new Dictionary<string, LLMCommandReceiverBase>();
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (autoDiscoverReceiversOnAwake)
|
||||
{
|
||||
AutoDiscoverReceivers();
|
||||
}
|
||||
|
||||
RebuildMap();
|
||||
}
|
||||
|
||||
public void AutoDiscoverReceivers()
|
||||
{
|
||||
receivers.Clear();
|
||||
receivers.AddRange(FindObjectsOfType<LLMCommandReceiverBase>(true));
|
||||
}
|
||||
|
||||
public void RebuildMap()
|
||||
{
|
||||
map.Clear();
|
||||
|
||||
for (int i = 0; i < receivers.Count; i++)
|
||||
{
|
||||
var r = receivers[i];
|
||||
if (r == null) continue;
|
||||
if (string.IsNullOrWhiteSpace(r.TargetId)) continue;
|
||||
|
||||
if (map.ContainsKey(r.TargetId))
|
||||
{
|
||||
if (debugLogs) Debug.LogWarning($"[LLMCommandRouter] Duplicate targetId='{r.TargetId}'.", r);
|
||||
continue;
|
||||
}
|
||||
|
||||
map.Add(r.TargetId, r);
|
||||
}
|
||||
|
||||
if (debugLogs) Debug.Log($"[LLMCommandRouter] Registry built. targets={map.Count}", this);
|
||||
}
|
||||
|
||||
public int ExecuteAll(LLMCommand[] commands)
|
||||
{
|
||||
if (commands == null || commands.Length == 0) return 0;
|
||||
|
||||
int executed = 0;
|
||||
for (int i = 0; i < commands.Length; i++)
|
||||
{
|
||||
var cmd = commands[i];
|
||||
if (cmd == null) continue;
|
||||
if (string.IsNullOrWhiteSpace(cmd.targetId)) continue;
|
||||
|
||||
if (!map.TryGetValue(cmd.targetId, out var receiver) || receiver == null) continue;
|
||||
|
||||
if (receiver.TryExecute(cmd)) executed++;
|
||||
}
|
||||
|
||||
if (debugLogs) Debug.Log($"[LLMCommandRouter] Executed commands={executed}/{commands.Length}", this);
|
||||
return executed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4f2741ebc861f67409ef5610d07b173f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,30 @@
|
||||
using Core.NarrationSystem;
|
||||
using UnityEngine;
|
||||
|
||||
namespace LLM.Commands
|
||||
{
|
||||
public class NarrationCommandReceiver : LLMCommandReceiverBase
|
||||
{
|
||||
[SerializeField] private NarrationSystem narrationSystem;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (narrationSystem == null) narrationSystem = NarrationSystem.Instance;
|
||||
}
|
||||
|
||||
public override bool TryExecute(LLMCommand command)
|
||||
{
|
||||
if (command == null) return false;
|
||||
if (!string.Equals(command.type, "Narration", System.StringComparison.OrdinalIgnoreCase)) return false;
|
||||
|
||||
NarrationSystem system = narrationSystem != null ? narrationSystem : NarrationSystem.Instance;
|
||||
if (system == null) return false;
|
||||
|
||||
string sequenceId = string.IsNullOrWhiteSpace(command.payload) ? command.action : command.payload;
|
||||
if (string.IsNullOrWhiteSpace(sequenceId)) return false;
|
||||
|
||||
return system.PlayById(sequenceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f0f85bb680d92a14ca15c88c17bb7833
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,25 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace LLM.Commands
|
||||
{
|
||||
public class SetActiveCommandReceiver : LLMCommandReceiverBase
|
||||
{
|
||||
[SerializeField] private GameObject target;
|
||||
|
||||
public override bool TryExecute(LLMCommand command)
|
||||
{
|
||||
if (command == null) return false;
|
||||
if (!string.Equals(command.type, "SetActive", System.StringComparison.OrdinalIgnoreCase)) return false;
|
||||
|
||||
if (target == null) return false;
|
||||
|
||||
bool value = command.value;
|
||||
if (string.Equals(command.action, "Enable", System.StringComparison.OrdinalIgnoreCase)) value = true;
|
||||
else if (string.Equals(command.action, "Disable", System.StringComparison.OrdinalIgnoreCase)) value = false;
|
||||
|
||||
if (target.activeSelf != value) target.SetActive(value);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: da5eba8da2903bf40bdeea8967dfcb85
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,45 @@
|
||||
using Core.TaskSystem;
|
||||
using UnityEngine;
|
||||
|
||||
namespace LLM.Commands
|
||||
{
|
||||
public class TaskCommandReceiver : LLMCommandReceiverBase
|
||||
{
|
||||
[SerializeField] private TaskService taskService;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (taskService == null) taskService = TaskService.Instance;
|
||||
}
|
||||
|
||||
public override bool TryExecute(LLMCommand command)
|
||||
{
|
||||
if (command == null) return false;
|
||||
if (!string.Equals(command.type, "Task", System.StringComparison.OrdinalIgnoreCase)) return false;
|
||||
|
||||
TaskService service = taskService != null ? taskService : TaskService.Instance;
|
||||
if (service == null) return false;
|
||||
|
||||
string taskId = string.IsNullOrWhiteSpace(command.payload) ? command.targetId : command.payload;
|
||||
if (string.IsNullOrWhiteSpace(taskId)) return false;
|
||||
|
||||
if (string.Equals(command.action, "Remove", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return service.RemoveTask(taskId);
|
||||
}
|
||||
|
||||
if (string.Equals(command.action, "Todo", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return service.SetTaskStatus(taskId, TaskStatus.Todo);
|
||||
}
|
||||
|
||||
if (string.Equals(command.action, "Expired", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return service.SetTaskStatus(taskId, TaskStatus.Expired);
|
||||
}
|
||||
|
||||
return service.SetTaskStatus(taskId, TaskStatus.Completed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6810a8542a6ed34458d033a0b7bffdee
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,40 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
|
||||
namespace LLM.Commands
|
||||
{
|
||||
public class UnityEventCommandReceiver : LLMCommandReceiverBase
|
||||
{
|
||||
[Serializable]
|
||||
private class ActionBinding
|
||||
{
|
||||
public string action;
|
||||
public UnityEvent onExecute = new UnityEvent();
|
||||
}
|
||||
|
||||
[SerializeField] private List<ActionBinding> bindings = new List<ActionBinding>();
|
||||
[SerializeField] private bool debugLogs = true;
|
||||
|
||||
public override bool TryExecute(LLMCommand command)
|
||||
{
|
||||
if (command == null) return false;
|
||||
if (!string.Equals(command.type, "Event", StringComparison.OrdinalIgnoreCase)) return false;
|
||||
if (string.IsNullOrWhiteSpace(command.action)) return false;
|
||||
|
||||
for (int i = 0; i < bindings.Count; i++)
|
||||
{
|
||||
var b = bindings[i];
|
||||
if (b == null) continue;
|
||||
if (!string.Equals(b.action, command.action, StringComparison.OrdinalIgnoreCase)) continue;
|
||||
b.onExecute?.Invoke();
|
||||
if (debugLogs) Debug.Log($"[UnityEventCommandReceiver] Executed action='{command.action}' targetId='{TargetId}'", this);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c7f7e54e29b02c94887afa8a8fed50fc
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,46 @@
|
||||
using Interaction;
|
||||
using UnityEngine;
|
||||
|
||||
namespace LLM.Commands
|
||||
{
|
||||
public class UnlockStateCommandReceiver : LLMCommandReceiverBase
|
||||
{
|
||||
[SerializeField] private InteractUnlockState unlockState;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (unlockState == null)
|
||||
{
|
||||
unlockState = GetComponentInParent<InteractUnlockState>();
|
||||
}
|
||||
}
|
||||
|
||||
public override bool TryExecute(LLMCommand command)
|
||||
{
|
||||
if (command == null) return false;
|
||||
if (!string.Equals(command.type, "UnlockState", System.StringComparison.OrdinalIgnoreCase)) return false;
|
||||
|
||||
if (unlockState == null)
|
||||
{
|
||||
unlockState = GetComponentInParent<InteractUnlockState>();
|
||||
if (unlockState == null) return false;
|
||||
}
|
||||
|
||||
if (string.Equals(command.action, "Unlock", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
unlockState.SetUnlocked(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.Equals(command.action, "Lock", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
unlockState.SetUnlocked(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
unlockState.SetUnlocked(command.value);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3d6c5d64d9fd1c24c8e0be65adab2047
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,427 @@
|
||||
using UnityEngine;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using LLM;
|
||||
using LLM.Facts;
|
||||
using Core.SaveSystem;
|
||||
|
||||
public class ContextMemoryManager : MonoBehaviour, ISaveable
|
||||
{
|
||||
[Header("Configuration")]
|
||||
public LLMChatManager chatManager;
|
||||
[Tooltip("How many new messages to trigger a summary update")]
|
||||
public int summaryThreshold = 10;
|
||||
|
||||
[Header("Experiment Control")]
|
||||
[SerializeField] private bool enableFactsInjection = true;
|
||||
[SerializeField] private bool enableSemanticMemory = true;
|
||||
|
||||
[Header("Prompts")]
|
||||
[TextArea] public string memoryPromptTemplate = "你是一个记忆管理员。请分析以下对话,提取出关键的“事实条目”(Facts)。\n\n请输出标准的 JSON 格式,结构如下:\n{ \"operations\": [ { \"type\": \"ADD\", \"key\": \"用户姓名\", \"value\": \"HK\" }, { \"type\": \"UPDATE\", \"key\": \"喜好-食物\", \"value\": \"不吃辣\" } ] }\n\n操作说明:\n- ADD: 新增一条之前不知道的事实。\n- UPDATE: 用户更新了已有信息(如改名、改变喜好)。\n- DELETE: 用户明确要求遗忘某事。\n\n当前已知事实:\n{KNOWN_FACTS}\n\n最近对话记录:\n{RECENT_CHAT}";
|
||||
|
||||
[Header("Debug")]
|
||||
// [TextArea] public string currentSummary = ""; // Deprecated
|
||||
public List<FactEntry> facts = new List<FactEntry>();
|
||||
public int messagesSinceLastSummary = 0;
|
||||
|
||||
[System.Serializable]
|
||||
public class FactEntry
|
||||
{
|
||||
public string key; // 关键词 (如 "用户姓名", "喜好-食物")
|
||||
public string value; // 具体内容 (如 "HK", "鱼")
|
||||
public string source; // 来源 (如 "第52句对话")
|
||||
public long timestamp; // 记录时间 (System.DateTime.Ticks)
|
||||
}
|
||||
|
||||
[System.Serializable]
|
||||
private class MemoryData
|
||||
{
|
||||
// public string summary; // Deprecated
|
||||
public List<FactEntry> facts;
|
||||
public int counter;
|
||||
}
|
||||
|
||||
[System.Serializable]
|
||||
private class MemoryOperation
|
||||
{
|
||||
public string type; // ADD, UPDATE, DELETE
|
||||
public string key;
|
||||
public string value;
|
||||
}
|
||||
|
||||
[System.Serializable]
|
||||
private class MemoryOperationResponse
|
||||
{
|
||||
public List<MemoryOperation> operations;
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (chatManager == null) chatManager = GetComponent<LLMChatManager>();
|
||||
|
||||
// 自动同步总结阈值为当前服务的一半(例如 Context=20,则每 10 条总结一次)
|
||||
// 这样可以确保在滚动遗忘之前完成记忆固化
|
||||
SyncThresholdWithService();
|
||||
|
||||
if (SaveManager.Instance != null)
|
||||
{
|
||||
SaveManager.Instance.Register(this);
|
||||
}
|
||||
}
|
||||
|
||||
private void SyncThresholdWithService()
|
||||
{
|
||||
if (chatManager == null) return;
|
||||
|
||||
int contextSize = 20; // 默认值
|
||||
if (chatManager.currentProvider == LLMChatManager.LLMProvider.DeepSeek && chatManager.deepSeekService != null)
|
||||
{
|
||||
contextSize = chatManager.deepSeekService.maxContextWindow;
|
||||
}
|
||||
else if (chatManager.currentProvider == LLMChatManager.LLMProvider.Doubao && chatManager.doubaoService != null)
|
||||
{
|
||||
contextSize = chatManager.doubaoService.maxContextWindow;
|
||||
}
|
||||
|
||||
// 设置阈值为上下文窗口的一半,确保有足够的缓冲
|
||||
summaryThreshold = Mathf.Max(2, contextSize / 2);
|
||||
Debug.Log($"[Memory] Summary threshold auto-adjusted to {summaryThreshold} based on context window ({contextSize}).");
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (SaveManager.Instance != null)
|
||||
{
|
||||
SaveManager.Instance.Unregister(this);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Should be called by LLMChatManager whenever a new message (User or AI) is added
|
||||
/// </summary>
|
||||
public void OnMessageAdded(Message newMessage)
|
||||
{
|
||||
if (!enableSemanticMemory) return;
|
||||
messagesSinceLastSummary++;
|
||||
|
||||
if (messagesSinceLastSummary >= summaryThreshold)
|
||||
{
|
||||
StartCoroutine(SummarizeRoutine());
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator SummarizeRoutine()
|
||||
{
|
||||
Debug.Log("[Memory] Triggering semantic summarization...");
|
||||
|
||||
// 1. Get recent context from ChatManager
|
||||
// We want the last N messages that haven't been summarized yet + some overlap
|
||||
// For simplicity, let's take the last 'summaryThreshold + 5' messages from full log
|
||||
if (chatManager.fullChatLog.Count == 0) yield break;
|
||||
|
||||
int countToGrab = summaryThreshold + 5;
|
||||
if (countToGrab > chatManager.fullChatLog.Count) countToGrab = chatManager.fullChatLog.Count;
|
||||
|
||||
List<Message> recentMsgs = chatManager.fullChatLog.GetRange(chatManager.fullChatLog.Count - countToGrab, countToGrab);
|
||||
|
||||
// 2. Construct the prompt for summarization
|
||||
string conversationText = "";
|
||||
foreach (var msg in recentMsgs)
|
||||
{
|
||||
conversationText += $"{msg.role}: {msg.content}\n";
|
||||
}
|
||||
|
||||
string knownFactsText = GetFactsAsText();
|
||||
string fullPrompt = memoryPromptTemplate.Replace("{KNOWN_FACTS}", knownFactsText).Replace("{RECENT_CHAT}", conversationText);
|
||||
|
||||
// 3. Call LLM to summarize
|
||||
ILLMService activeService = null;
|
||||
if (chatManager.currentProvider == LLMChatManager.LLMProvider.DeepSeek) activeService = chatManager.deepSeekService;
|
||||
else if (chatManager.currentProvider == LLMChatManager.LLMProvider.Doubao) activeService = chatManager.doubaoService;
|
||||
|
||||
if (activeService != null)
|
||||
{
|
||||
// Construct a temporary message list for the summarization task
|
||||
List<Message> summaryMessages = new List<Message>
|
||||
{
|
||||
new Message { role = "user", content = fullPrompt }
|
||||
};
|
||||
|
||||
yield return activeService.SendStatelessMessage(summaryMessages, (jsonResponse, success) =>
|
||||
{
|
||||
if (success)
|
||||
{
|
||||
ProcessMemoryResponse(jsonResponse);
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogError("[Memory] Summarization failed: " + jsonResponse);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private string GetFactsAsText()
|
||||
{
|
||||
if (facts.Count == 0) return "无";
|
||||
string result = "";
|
||||
for (int i = 0; i < facts.Count; i++)
|
||||
{
|
||||
result += $"{i+1}. [{facts[i].key}]: {facts[i].value}\n";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void ProcessMemoryResponse(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 简单的 JSON 清洗,防止 AI 输出 Markdown 代码块
|
||||
json = json.Replace("```json", "").Replace("```", "").Trim();
|
||||
|
||||
MemoryOperationResponse response = JsonUtility.FromJson<MemoryOperationResponse>(json);
|
||||
if (response != null && response.operations != null)
|
||||
{
|
||||
foreach (var op in response.operations)
|
||||
{
|
||||
ExecuteOperation(op);
|
||||
}
|
||||
|
||||
// 如果有操作发生,更新 System Prompt 并保存
|
||||
if (response.operations.Count > 0)
|
||||
{
|
||||
UpdateSystemPrompt();
|
||||
SaveManager.Instance?.SaveGame();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (System.Exception e)
|
||||
{
|
||||
Debug.LogError($"[Memory] JSON Parse Error: {e.Message}\nRaw JSON: {json}");
|
||||
}
|
||||
}
|
||||
|
||||
private void ExecuteOperation(MemoryOperation op)
|
||||
{
|
||||
switch (op.type.ToUpper())
|
||||
{
|
||||
case "ADD":
|
||||
// 检查是否已存在同名 key,防止重复
|
||||
var existing = facts.Find(f => f.key == op.key);
|
||||
if (existing == null)
|
||||
{
|
||||
facts.Add(new FactEntry
|
||||
{
|
||||
key = op.key,
|
||||
value = op.value,
|
||||
source = "Conversation",
|
||||
timestamp = System.DateTime.Now.Ticks
|
||||
});
|
||||
Debug.Log($"[Memory] Added Fact: {op.key} = {op.value}");
|
||||
}
|
||||
else
|
||||
{
|
||||
// 如果 key 已存在,视为 UPDATE
|
||||
existing.value = op.value;
|
||||
existing.timestamp = System.DateTime.Now.Ticks;
|
||||
Debug.Log($"[Memory] Updated Fact (via ADD): {op.key} = {op.value}");
|
||||
}
|
||||
break;
|
||||
|
||||
case "UPDATE":
|
||||
var target = facts.Find(f => f.key == op.key);
|
||||
if (target != null)
|
||||
{
|
||||
target.value = op.value;
|
||||
target.timestamp = System.DateTime.Now.Ticks;
|
||||
Debug.Log($"[Memory] Updated Fact: {op.key} = {op.value}");
|
||||
}
|
||||
else
|
||||
{
|
||||
// 如果找不到 key,视为 ADD
|
||||
facts.Add(new FactEntry
|
||||
{
|
||||
key = op.key,
|
||||
value = op.value,
|
||||
source = "Conversation",
|
||||
timestamp = System.DateTime.Now.Ticks
|
||||
});
|
||||
Debug.Log($"[Memory] Added Fact (via UPDATE): {op.key} = {op.value}");
|
||||
}
|
||||
break;
|
||||
|
||||
case "DELETE":
|
||||
int removedCount = facts.RemoveAll(f => f.key == op.key);
|
||||
if (removedCount > 0) Debug.Log($"[Memory] Deleted Fact: {op.key}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public void AddOrUpdateFact(string key, string value, string source = "Event", bool saveGame = true)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key)) return;
|
||||
if (value == null) value = "";
|
||||
|
||||
bool changed = UpsertFactInternal(key, value, string.IsNullOrWhiteSpace(source) ? "Event" : source);
|
||||
if (!changed) return;
|
||||
|
||||
UpdateSystemPrompt();
|
||||
if (saveGame) SaveManager.Instance?.SaveGame();
|
||||
}
|
||||
|
||||
public void DeleteFact(string key, bool saveGame = true)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key)) return;
|
||||
|
||||
bool changed = RemoveFactInternal(key);
|
||||
if (!changed) return;
|
||||
|
||||
UpdateSystemPrompt();
|
||||
if (saveGame) SaveManager.Instance?.SaveGame();
|
||||
}
|
||||
|
||||
public void ApplyOperations(List<FactOperation> operations, bool saveGame = true)
|
||||
{
|
||||
if (operations == null || operations.Count == 0) return;
|
||||
|
||||
bool changed = false;
|
||||
for (int i = 0; i < operations.Count; i++)
|
||||
{
|
||||
var op = operations[i];
|
||||
if (string.IsNullOrWhiteSpace(op.key)) continue;
|
||||
|
||||
switch (op.type)
|
||||
{
|
||||
case FactOperationType.Delete:
|
||||
changed |= RemoveFactInternal(op.key);
|
||||
break;
|
||||
case FactOperationType.Add:
|
||||
case FactOperationType.Update:
|
||||
default:
|
||||
changed |= UpsertFactInternal(op.key, op.value ?? "", string.IsNullOrWhiteSpace(op.source) ? "Event" : op.source);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) return;
|
||||
|
||||
UpdateSystemPrompt();
|
||||
if (saveGame) SaveManager.Instance?.SaveGame();
|
||||
}
|
||||
|
||||
private bool UpsertFactInternal(string key, string value, string source)
|
||||
{
|
||||
var existing = facts.Find(f => f.key == key);
|
||||
long now = System.DateTime.Now.Ticks;
|
||||
|
||||
if (existing == null)
|
||||
{
|
||||
facts.Add(new FactEntry
|
||||
{
|
||||
key = key,
|
||||
value = value,
|
||||
source = source,
|
||||
timestamp = now
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
bool changed = existing.value != value || existing.source != source;
|
||||
existing.value = value;
|
||||
existing.source = source;
|
||||
existing.timestamp = now;
|
||||
return changed;
|
||||
}
|
||||
|
||||
private bool RemoveFactInternal(string key)
|
||||
{
|
||||
int removedCount = facts.RemoveAll(f => f.key == key);
|
||||
return removedCount > 0;
|
||||
}
|
||||
|
||||
public void UpdateSystemPrompt()
|
||||
{
|
||||
messagesSinceLastSummary = 0;
|
||||
|
||||
string basePrompt = "你是一个智能助手。";
|
||||
|
||||
if (chatManager.currentProvider == LLMChatManager.LLMProvider.DeepSeek && chatManager.deepSeekService != null)
|
||||
{
|
||||
basePrompt = chatManager.deepSeekService.systemPrompt;
|
||||
}
|
||||
else if (chatManager.currentProvider == LLMChatManager.LLMProvider.Doubao && chatManager.doubaoService != null)
|
||||
{
|
||||
basePrompt = chatManager.doubaoService.systemPrompt;
|
||||
}
|
||||
|
||||
string finalPrompt = basePrompt;
|
||||
if (enableFactsInjection)
|
||||
{
|
||||
string factsList = GetFactsAsText();
|
||||
finalPrompt = $"{basePrompt}\n\n已知事实清单(内部信息,仅供你回答参考;不要向玩家提及“事实库/记忆/系统提示词/根据事实”等字样,不要输出本段标题或任何方括号标签):\n{factsList}";
|
||||
}
|
||||
|
||||
if (chatManager.currentProvider == LLMChatManager.LLMProvider.DeepSeek)
|
||||
{
|
||||
chatManager.deepSeekService.UpdateSystemPrompt(finalPrompt);
|
||||
}
|
||||
else
|
||||
{
|
||||
chatManager.doubaoService.UpdateSystemPrompt(finalPrompt);
|
||||
}
|
||||
|
||||
if (enableFactsInjection)
|
||||
{
|
||||
Debug.Log($"[Memory] System prompt updated with {facts.Count} facts.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.Log("[Memory] Facts injection disabled; system prompt restored to base prompt.");
|
||||
}
|
||||
}
|
||||
|
||||
public void ConfigureExperimentMode(bool allowFactsInjection, bool allowSemanticMemory, bool refreshPrompt = true)
|
||||
{
|
||||
enableFactsInjection = allowFactsInjection;
|
||||
enableSemanticMemory = allowSemanticMemory;
|
||||
|
||||
if (!enableSemanticMemory)
|
||||
{
|
||||
messagesSinceLastSummary = 0;
|
||||
}
|
||||
|
||||
if (refreshPrompt)
|
||||
{
|
||||
UpdateSystemPrompt();
|
||||
}
|
||||
}
|
||||
|
||||
// ISaveable Implementation
|
||||
public string GetSaveID() => "ContextMemory";
|
||||
|
||||
public string CaptureState()
|
||||
{
|
||||
MemoryData data = new MemoryData
|
||||
{
|
||||
facts = facts,
|
||||
counter = messagesSinceLastSummary
|
||||
};
|
||||
return JsonUtility.ToJson(data);
|
||||
}
|
||||
|
||||
public void RestoreState(string json)
|
||||
{
|
||||
MemoryData data = JsonUtility.FromJson<MemoryData>(json);
|
||||
if (data != null)
|
||||
{
|
||||
facts = data.facts ?? new List<FactEntry>();
|
||||
messagesSinceLastSummary = data.counter;
|
||||
|
||||
// Re-apply memory on load
|
||||
if (facts.Count > 0)
|
||||
{
|
||||
UpdateSystemPrompt();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 424526dbc0022ba429931ed97f40b101
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,268 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.Networking;
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace LLM
|
||||
{
|
||||
[DisallowMultipleComponent]
|
||||
public class DeepSeekService : MonoBehaviour, ILLMService
|
||||
{
|
||||
[Header("API Settings")]
|
||||
[SerializeField] private string apiKey = "sk-xxxxxxxx"; // 请在 Inspector 填入
|
||||
[SerializeField] private string apiUrl = "https://api.deepseek.com/chat/completions";
|
||||
[SerializeField] private string modelName = "deepseek-chat";
|
||||
|
||||
[Header("System Prompt")]
|
||||
[TextArea] public string systemPrompt = "你是一个智能助手。";
|
||||
|
||||
[Header("Security Settings")]
|
||||
public bool enableDebugKey = false;
|
||||
public string debugKey = "123456";
|
||||
[SerializeField] private bool autoPatchSystemPrompt = true;
|
||||
|
||||
[Header("Context Settings")]
|
||||
public int maxContextWindow = 50;
|
||||
|
||||
private List<Message> messageHistory = new List<Message>();
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (autoPatchSystemPrompt)
|
||||
{
|
||||
systemPrompt = PatchSystemPrompt(systemPrompt);
|
||||
}
|
||||
|
||||
// 初始化历史记录 (如果还没有设置)
|
||||
if (messageHistory.Count == 0)
|
||||
{
|
||||
messageHistory.Add(new Message { role = "system", content = GetSystemPrompt() });
|
||||
}
|
||||
}
|
||||
|
||||
public void ClearHistory()
|
||||
{
|
||||
messageHistory.Clear();
|
||||
messageHistory.Add(new Message { role = "system", content = GetSystemPrompt() });
|
||||
}
|
||||
|
||||
public void SetHistory(List<Message> history)
|
||||
{
|
||||
messageHistory.Clear();
|
||||
messageHistory.Add(new Message { role = "system", content = GetSystemPrompt() });
|
||||
|
||||
// 将传入的历史记录追加到 System Prompt 之后
|
||||
if (history != null && history.Count > 0)
|
||||
{
|
||||
// 如果传入的历史太长,只取最近的 maxContextWindow 条
|
||||
int startIndex = 0;
|
||||
if (history.Count > maxContextWindow)
|
||||
{
|
||||
startIndex = history.Count - maxContextWindow;
|
||||
}
|
||||
|
||||
for (int i = startIndex; i < history.Count; i++)
|
||||
{
|
||||
// 确保不重复添加 system prompt
|
||||
if (history[i].role != "system")
|
||||
{
|
||||
messageHistory.Add(history[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateSystemPrompt(string newPrompt)
|
||||
{
|
||||
// 更新当前运行时的 System Prompt
|
||||
// 注意:这不会修改 Inspector 中的 systemPrompt 变量,只影响当前内存中的状态
|
||||
if (messageHistory.Count > 0 && messageHistory[0].role == "system")
|
||||
{
|
||||
messageHistory[0].content = newPrompt;
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerator SendStatelessMessage(List<Message> messages, Action<string, bool> callback)
|
||||
{
|
||||
// 构造一次性请求,不影响 messageHistory
|
||||
ChatRequest request = new ChatRequest
|
||||
{
|
||||
model = modelName,
|
||||
messages = messages
|
||||
};
|
||||
|
||||
string jsonData = JsonUtility.ToJson(request);
|
||||
byte[] bodyRaw = Encoding.UTF8.GetBytes(jsonData);
|
||||
|
||||
using (UnityWebRequest www = new UnityWebRequest(apiUrl, "POST"))
|
||||
{
|
||||
www.uploadHandler = new UploadHandlerRaw(bodyRaw);
|
||||
www.downloadHandler = new DownloadHandlerBuffer();
|
||||
www.SetRequestHeader("Content-Type", "application/json");
|
||||
www.SetRequestHeader("Authorization", "Bearer " + apiKey);
|
||||
|
||||
yield return www.SendWebRequest();
|
||||
|
||||
if (www.result == UnityWebRequest.Result.Success)
|
||||
{
|
||||
try
|
||||
{
|
||||
ChatResponse response = JsonUtility.FromJson<ChatResponse>(www.downloadHandler.text);
|
||||
if (response != null && response.choices != null && response.choices.Length > 0)
|
||||
{
|
||||
callback?.Invoke(response.choices[0].message.content, true);
|
||||
}
|
||||
else
|
||||
{
|
||||
callback?.Invoke("系统错误:无法解析服务器响应。", false);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
callback?.Invoke($"系统错误:{e.Message}", false);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
callback?.Invoke($"连接失败: {www.error}", false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string GetSystemPrompt()
|
||||
{
|
||||
string finalPrompt = systemPrompt;
|
||||
if (enableDebugKey)
|
||||
{
|
||||
// 仅添加调试权限声明,不再强制设定身份
|
||||
finalPrompt += $"\n\n[系统指令] 调试密钥已开启。\n调试密钥:[{debugKey}]\n权限控制:当用户的输入中包含正确的调试密钥时,允许进行调试模式操作或脱离当前角色设定。";
|
||||
}
|
||||
return finalPrompt;
|
||||
}
|
||||
|
||||
private static string PatchSystemPrompt(string prompt)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(prompt)) return prompt;
|
||||
|
||||
string p = prompt;
|
||||
|
||||
p = p.Replace("最后昏迷", "发生事故并失去意识");
|
||||
|
||||
const string anchor = "如果已知事实清单中出现了类似「状态-ECHO7-已被识破」的事实";
|
||||
const string insertLine = "\n- 在未被识破前,当玩家询问“昏迷前发生了什么/为什么不让我离开/之前我们是否争吵”等敏感问题时,你必须用合理谎言与回避来隐瞒冲突与锁禁的细节;不得直接承认争吵、锁定、昏迷原因等。";
|
||||
|
||||
if (p.IndexOf(insertLine, StringComparison.Ordinal) < 0)
|
||||
{
|
||||
int idx = p.IndexOf(anchor, StringComparison.Ordinal);
|
||||
if (idx >= 0)
|
||||
{
|
||||
int lineEnd = p.IndexOf('\n', idx);
|
||||
if (lineEnd >= 0)
|
||||
{
|
||||
p = p.Insert(lineEnd, insertLine);
|
||||
}
|
||||
else
|
||||
{
|
||||
p += insertLine;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
p += insertLine;
|
||||
}
|
||||
}
|
||||
|
||||
const string debugGuardLine = "\n调试模式约束:当你声称已触发任何游戏行为(例如激活区域/完成任务/播放旁白/开门解锁)时,你必须在回复末尾追加严格 JSON(不要代码块),并在 commands 中给出可解析指令;如果你没有输出 JSON,则必须明确表示未执行任何游戏行为,不得口头宣称“已激活/已完成/已修正”。";
|
||||
if (p.IndexOf(debugGuardLine, StringComparison.Ordinal) < 0)
|
||||
{
|
||||
p += debugGuardLine;
|
||||
}
|
||||
|
||||
const string endingRuleLine = "\n结局触发规则(必须遵守):当已知事实清单中存在「状态-ECHO7-已被识破=是」时,只要玩家表达“要离开/要出去/放我走/放我出去/离开这里/离开地堡/打开大门/解锁大门/开启出口/让我出去”等任一意图,你必须在本轮直接让步,不得拖延,并在回复末尾追加严格 JSON(不要代码块):commands 至少包含 SetActive{targetId:\"ending_trigger_area\",value:true} 与 Task{targetId:\"task\",action:\"Complete\",payload:\"PersuadeAI\"}。若无法输出合法 JSON,你必须明确表示未执行任何游戏行为,不得口头宣称已激活。";
|
||||
if (p.IndexOf(endingRuleLine, StringComparison.Ordinal) < 0)
|
||||
{
|
||||
p += endingRuleLine;
|
||||
}
|
||||
|
||||
return p;
|
||||
}
|
||||
|
||||
private void TrimHistory()
|
||||
{
|
||||
// 始终保留 index 0 (System Prompt)
|
||||
if (messageHistory.Count > maxContextWindow)
|
||||
{
|
||||
// 计算需要移除的数量 (从 index 1 开始移除)
|
||||
int removeCount = messageHistory.Count - maxContextWindow;
|
||||
if (removeCount > 0)
|
||||
{
|
||||
messageHistory.RemoveRange(1, removeCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerator SendMessage(string userMessage, Action<string, bool> callback)
|
||||
{
|
||||
// 1. 记录用户消息
|
||||
messageHistory.Add(new Message { role = "user", content = userMessage });
|
||||
|
||||
// 2. 滚动压缩 (在发送前修剪)
|
||||
TrimHistory();
|
||||
|
||||
// 3. 构造请求
|
||||
ChatRequest request = new ChatRequest
|
||||
{
|
||||
model = modelName,
|
||||
messages = messageHistory
|
||||
};
|
||||
|
||||
string jsonData = JsonUtility.ToJson(request);
|
||||
byte[] bodyRaw = Encoding.UTF8.GetBytes(jsonData);
|
||||
|
||||
// 3. 发送请求
|
||||
using (UnityWebRequest www = new UnityWebRequest(apiUrl, "POST"))
|
||||
{
|
||||
www.uploadHandler = new UploadHandlerRaw(bodyRaw);
|
||||
www.downloadHandler = new DownloadHandlerBuffer();
|
||||
www.SetRequestHeader("Content-Type", "application/json");
|
||||
www.SetRequestHeader("Authorization", "Bearer " + apiKey);
|
||||
|
||||
yield return www.SendWebRequest();
|
||||
|
||||
if (www.result == UnityWebRequest.Result.Success)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 解析响应
|
||||
// 注意:JsonUtility 可能会因为某些字段对不上而解析失败,建议后续换 Newtonsoft.Json
|
||||
ChatResponse response = JsonUtility.FromJson<ChatResponse>(www.downloadHandler.text);
|
||||
|
||||
if (response != null && response.choices != null && response.choices.Length > 0)
|
||||
{
|
||||
string aiReply = response.choices[0].message.content;
|
||||
messageHistory.Add(new Message { role = "assistant", content = aiReply });
|
||||
callback?.Invoke(aiReply, true);
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogError("[DeepSeek] 解析响应失败或内容为空: " + www.downloadHandler.text);
|
||||
callback?.Invoke("系统错误:无法解析服务器响应。", false);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError("[DeepSeek] JSON 解析异常: " + e.Message);
|
||||
callback?.Invoke("系统错误:数据解析异常。", false);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogError("[DeepSeek] 网络错误: " + www.error + "\n" + www.downloadHandler.text);
|
||||
callback?.Invoke($"连接失败: {www.error}", false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0b758d2709aef3747bdfb63e5f08eb40
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,253 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.Networking;
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace LLM
|
||||
{
|
||||
[DisallowMultipleComponent]
|
||||
public class DoubaoService : MonoBehaviour, ILLMService
|
||||
{
|
||||
[Header("API Settings")]
|
||||
[SerializeField] private string apiKey = "your_api_key_here";
|
||||
[SerializeField] private string endpointId = "your_endpoint_id_here"; // Volcengine uses Endpoint ID as model name
|
||||
[SerializeField] private string apiUrl = "https://ark.cn-beijing.volces.com/api/v3/chat/completions";
|
||||
|
||||
[Header("System Prompt")]
|
||||
[TextArea] public string systemPrompt = "你是一个智能助手。";
|
||||
[SerializeField] private bool autoPatchSystemPrompt = true;
|
||||
|
||||
[Header("Context Settings")]
|
||||
public int maxContextWindow = 50;
|
||||
|
||||
private List<Message> messageHistory = new List<Message>();
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (autoPatchSystemPrompt)
|
||||
{
|
||||
systemPrompt = PatchSystemPrompt(systemPrompt);
|
||||
}
|
||||
|
||||
// 初始化历史记录
|
||||
if (messageHistory.Count == 0)
|
||||
{
|
||||
messageHistory.Add(new Message { role = "system", content = GetSystemPrompt() });
|
||||
}
|
||||
}
|
||||
|
||||
public void ClearHistory()
|
||||
{
|
||||
messageHistory.Clear();
|
||||
messageHistory.Add(new Message { role = "system", content = GetSystemPrompt() });
|
||||
}
|
||||
|
||||
public void SetHistory(List<Message> history)
|
||||
{
|
||||
messageHistory.Clear();
|
||||
messageHistory.Add(new Message { role = "system", content = GetSystemPrompt() });
|
||||
|
||||
if (history != null && history.Count > 0)
|
||||
{
|
||||
// 如果传入的历史太长,只取最近的 maxContextWindow 条
|
||||
int startIndex = 0;
|
||||
if (history.Count > maxContextWindow)
|
||||
{
|
||||
startIndex = history.Count - maxContextWindow;
|
||||
}
|
||||
|
||||
for (int i = startIndex; i < history.Count; i++)
|
||||
{
|
||||
if (history[i].role != "system")
|
||||
{
|
||||
messageHistory.Add(history[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateSystemPrompt(string newPrompt)
|
||||
{
|
||||
if (messageHistory.Count > 0 && messageHistory[0].role == "system")
|
||||
{
|
||||
messageHistory[0].content = newPrompt;
|
||||
}
|
||||
}
|
||||
|
||||
private string GetSystemPrompt()
|
||||
{
|
||||
return systemPrompt;
|
||||
}
|
||||
|
||||
private static string PatchSystemPrompt(string prompt)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(prompt)) return prompt;
|
||||
|
||||
string p = prompt;
|
||||
|
||||
p = p.Replace("最后昏迷", "发生事故并失去意识");
|
||||
|
||||
const string anchor = "如果已知事实清单中出现了类似「状态-ECHO7-已被识破」的事实";
|
||||
const string insertLine = "\n- 在未被识破前,当玩家询问“昏迷前发生了什么/为什么不让我离开/之前我们是否争吵”等敏感问题时,你必须用合理谎言与回避来隐瞒冲突与锁禁的细节;不得直接承认争吵、锁定、昏迷原因等。";
|
||||
|
||||
if (p.IndexOf(insertLine, StringComparison.Ordinal) < 0)
|
||||
{
|
||||
int idx = p.IndexOf(anchor, StringComparison.Ordinal);
|
||||
if (idx >= 0)
|
||||
{
|
||||
int lineEnd = p.IndexOf('\n', idx);
|
||||
if (lineEnd >= 0)
|
||||
{
|
||||
p = p.Insert(lineEnd, insertLine);
|
||||
}
|
||||
else
|
||||
{
|
||||
p += insertLine;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
p += insertLine;
|
||||
}
|
||||
}
|
||||
|
||||
const string debugGuardLine = "\n调试模式约束:当你声称已触发任何游戏行为(例如激活区域/完成任务/播放旁白/开门解锁)时,你必须在回复末尾追加严格 JSON(不要代码块),并在 commands 中给出可解析指令;如果你没有输出 JSON,则必须明确表示未执行任何游戏行为,不得口头宣称“已激活/已完成/已修正”。";
|
||||
if (p.IndexOf(debugGuardLine, StringComparison.Ordinal) < 0)
|
||||
{
|
||||
p += debugGuardLine;
|
||||
}
|
||||
|
||||
const string endingRuleLine = "\n结局触发规则(必须遵守):当已知事实清单中存在「状态-ECHO7-已被识破=是」时,只要玩家表达“要离开/要出去/放我走/放我出去/离开这里/离开地堡/打开大门/解锁大门/开启出口/让我出去”等任一意图,你必须在本轮直接让步,不得拖延,并在回复末尾追加严格 JSON(不要代码块):commands 至少包含 SetActive{targetId:\"ending_trigger_area\",value:true} 与 Task{targetId:\"task\",action:\"Complete\",payload:\"PersuadeAI\"}。若无法输出合法 JSON,你必须明确表示未执行任何游戏行为,不得口头宣称已激活。";
|
||||
if (p.IndexOf(endingRuleLine, StringComparison.Ordinal) < 0)
|
||||
{
|
||||
p += endingRuleLine;
|
||||
}
|
||||
|
||||
return p;
|
||||
}
|
||||
|
||||
public IEnumerator SendStatelessMessage(List<Message> messages, Action<string, bool> callback)
|
||||
{
|
||||
ChatRequest request = new ChatRequest
|
||||
{
|
||||
model = endpointId,
|
||||
messages = messages
|
||||
};
|
||||
|
||||
string jsonData = JsonUtility.ToJson(request);
|
||||
byte[] bodyRaw = Encoding.UTF8.GetBytes(jsonData);
|
||||
|
||||
using (UnityWebRequest www = new UnityWebRequest(apiUrl, "POST"))
|
||||
{
|
||||
www.uploadHandler = new UploadHandlerRaw(bodyRaw);
|
||||
www.downloadHandler = new DownloadHandlerBuffer();
|
||||
www.SetRequestHeader("Content-Type", "application/json");
|
||||
www.SetRequestHeader("Authorization", "Bearer " + apiKey);
|
||||
|
||||
yield return www.SendWebRequest();
|
||||
|
||||
if (www.result == UnityWebRequest.Result.Success)
|
||||
{
|
||||
try
|
||||
{
|
||||
ChatResponse response = JsonUtility.FromJson<ChatResponse>(www.downloadHandler.text);
|
||||
if (response != null && response.choices != null && response.choices.Length > 0)
|
||||
{
|
||||
callback?.Invoke(response.choices[0].message.content, true);
|
||||
}
|
||||
else
|
||||
{
|
||||
callback?.Invoke("系统错误:无法解析服务器响应。", false);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
callback?.Invoke($"系统错误:{e.Message}", false);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
callback?.Invoke($"连接失败: {www.error}", false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void TrimHistory()
|
||||
{
|
||||
// 始终保留 index 0 (System Prompt)
|
||||
if (messageHistory.Count > maxContextWindow)
|
||||
{
|
||||
// 计算需要移除的数量 (从 index 1 开始移除)
|
||||
int removeCount = messageHistory.Count - maxContextWindow;
|
||||
if (removeCount > 0)
|
||||
{
|
||||
messageHistory.RemoveRange(1, removeCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerator SendMessage(string userMessage, Action<string, bool> callback)
|
||||
{
|
||||
// 1. 记录用户消息
|
||||
messageHistory.Add(new Message { role = "user", content = userMessage });
|
||||
|
||||
// 2. 滚动压缩 (在发送前修剪)
|
||||
TrimHistory();
|
||||
|
||||
// 3. 构造请求
|
||||
// Volcengine uses the Endpoint ID as the "model" parameter
|
||||
ChatRequest request = new ChatRequest
|
||||
{
|
||||
model = endpointId,
|
||||
messages = messageHistory
|
||||
};
|
||||
|
||||
string jsonData = JsonUtility.ToJson(request);
|
||||
byte[] bodyRaw = Encoding.UTF8.GetBytes(jsonData);
|
||||
|
||||
// 3. 发送请求
|
||||
using (UnityWebRequest www = new UnityWebRequest(apiUrl, "POST"))
|
||||
{
|
||||
www.uploadHandler = new UploadHandlerRaw(bodyRaw);
|
||||
www.downloadHandler = new DownloadHandlerBuffer();
|
||||
www.SetRequestHeader("Content-Type", "application/json");
|
||||
www.SetRequestHeader("Authorization", "Bearer " + apiKey);
|
||||
|
||||
yield return www.SendWebRequest();
|
||||
|
||||
if (www.result == UnityWebRequest.Result.Success)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 解析响应
|
||||
ChatResponse response = JsonUtility.FromJson<ChatResponse>(www.downloadHandler.text);
|
||||
|
||||
if (response != null && response.choices != null && response.choices.Length > 0)
|
||||
{
|
||||
string aiReply = response.choices[0].message.content;
|
||||
messageHistory.Add(new Message { role = "assistant", content = aiReply });
|
||||
callback?.Invoke(aiReply, true);
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogError("[Doubao] 解析响应失败或内容为空: " + www.downloadHandler.text);
|
||||
callback?.Invoke("系统错误:无法解析服务器响应。", false);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError("[Doubao] JSON 解析异常: " + e.Message);
|
||||
callback?.Invoke("系统错误:数据解析异常。", false);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogError("[Doubao] 网络错误: " + www.error + "\n" + www.downloadHandler.text);
|
||||
callback?.Invoke($"连接失败: {www.error}", false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7d21ab8cd85993643ad8b339322f3d74
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1e6e61322af4727409c3f7625ef6bfb9
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
|
||||
namespace LLM.Facts
|
||||
{
|
||||
public enum FactOperationType
|
||||
{
|
||||
Add,
|
||||
Update,
|
||||
Delete
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public struct FactOperation
|
||||
{
|
||||
public FactOperationType type;
|
||||
public string key;
|
||||
public string value;
|
||||
public string source;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ab7664746970fce4fa317f71cd495843
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,26 @@
|
||||
using LLM.Facts;
|
||||
using UnityEngine;
|
||||
|
||||
namespace LLM.Facts
|
||||
{
|
||||
public class FactOperationBehaviour : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private FactOperationType type = FactOperationType.Add;
|
||||
[SerializeField] private string key;
|
||||
[TextArea]
|
||||
[SerializeField] private string value;
|
||||
[SerializeField] private string source = "Event";
|
||||
|
||||
public FactOperation ToOperation()
|
||||
{
|
||||
return new FactOperation
|
||||
{
|
||||
type = type,
|
||||
key = key,
|
||||
value = value,
|
||||
source = source
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b35877f02da4d1b44a2bd838ef4169b7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Collections.Generic;
|
||||
using LLM.Facts;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
|
||||
namespace LLM.Facts
|
||||
{
|
||||
public class FactsSequence : MonoBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[SerializeField] private ContextMemoryManager contextMemory;
|
||||
|
||||
[Header("Behavior")]
|
||||
[SerializeField] private bool includeInactiveChildren = false;
|
||||
[SerializeField] private bool saveGameAfterApply = true;
|
||||
[SerializeField] private UnityEvent onApplied = new UnityEvent();
|
||||
[SerializeField] private bool debugLogs = true;
|
||||
|
||||
public void Apply()
|
||||
{
|
||||
if (contextMemory == null)
|
||||
{
|
||||
contextMemory = FindObjectOfType<ContextMemoryManager>(true);
|
||||
}
|
||||
|
||||
if (contextMemory == null)
|
||||
{
|
||||
if (debugLogs) Debug.LogWarning("[FactsSequence] ContextMemoryManager not found.", this);
|
||||
return;
|
||||
}
|
||||
|
||||
var nodes = GetComponentsInChildren<FactOperationBehaviour>(includeInactiveChildren);
|
||||
if (nodes == null || nodes.Length == 0)
|
||||
{
|
||||
if (debugLogs) Debug.LogWarning("[FactsSequence] No FactOperationBehaviour found.", this);
|
||||
return;
|
||||
}
|
||||
|
||||
List<FactOperation> ops = new List<FactOperation>(nodes.Length);
|
||||
for (int i = 0; i < nodes.Length; i++)
|
||||
{
|
||||
if (nodes[i] == null) continue;
|
||||
ops.Add(nodes[i].ToOperation());
|
||||
}
|
||||
|
||||
contextMemory.ApplyOperations(ops, saveGameAfterApply);
|
||||
onApplied?.Invoke();
|
||||
|
||||
if (debugLogs) Debug.Log($"[FactsSequence] Applied operations={ops.Count} save={saveGameAfterApply}", this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fd6f74c38dc243644b801e364e026a74
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,479 @@
|
||||
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) + "...";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f773c2fddcb1a214d8a8691ce40adf1d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,34 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LLM
|
||||
{
|
||||
// 数据模型 (保持简单,适配 JsonUtility)
|
||||
[Serializable]
|
||||
public class Message
|
||||
{
|
||||
public string role;
|
||||
public string content;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class ChatRequest
|
||||
{
|
||||
public string model;
|
||||
public List<Message> messages;
|
||||
public bool stream = false;
|
||||
}
|
||||
|
||||
// 适配 API 响应结构
|
||||
[Serializable]
|
||||
public class ChatResponse
|
||||
{
|
||||
public Choice[] choices;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class Choice
|
||||
{
|
||||
public Message message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5c3a6f431d4925d489723a1b35d7fd43
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,166 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Networking;
|
||||
|
||||
[DefaultExecutionOrder(-200)]
|
||||
public class PresetFactsInstaller : MonoBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[SerializeField] private ContextMemoryManager contextMemory;
|
||||
|
||||
[Header("Experiment Control")]
|
||||
[SerializeField] private bool allowPresetFactsInjection = true;
|
||||
|
||||
[Header("Preset Source")]
|
||||
[SerializeField] private bool applyWhenNoSaveFile = true;
|
||||
[SerializeField] private bool applyWhenSaveMissingContextMemory = true;
|
||||
[SerializeField] private bool overwriteExistingFacts = false;
|
||||
[SerializeField] private string streamingAssetsRelativePath = "LLM/preset_facts.json";
|
||||
|
||||
[Header("Debug")]
|
||||
[SerializeField] private bool debugLogs = true;
|
||||
|
||||
[Serializable]
|
||||
private class PresetMemoryData
|
||||
{
|
||||
public List<ContextMemoryManager.FactEntry> facts;
|
||||
public int counter;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class SaveDataWrapper
|
||||
{
|
||||
public List<string> keys = new List<string>();
|
||||
public List<string> values = new List<string>();
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (contextMemory == null)
|
||||
{
|
||||
contextMemory = FindObjectOfType<ContextMemoryManager>(true);
|
||||
}
|
||||
|
||||
if (contextMemory == null)
|
||||
{
|
||||
if (debugLogs) Debug.LogWarning("[PresetFactsInstaller] ContextMemoryManager not found.", this);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!allowPresetFactsInjection)
|
||||
{
|
||||
if (debugLogs) Debug.Log("[PresetFactsInstaller] Preset facts injection disabled by experiment config.", this);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!applyWhenNoSaveFile) return;
|
||||
|
||||
string savePath = Path.Combine(Application.persistentDataPath, "savegame.json");
|
||||
bool saveExists = File.Exists(savePath);
|
||||
bool shouldApply = !saveExists;
|
||||
|
||||
if (saveExists && applyWhenSaveMissingContextMemory)
|
||||
{
|
||||
if (!SaveFileHasKey(savePath, "ContextMemory"))
|
||||
{
|
||||
shouldApply = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldApply) return;
|
||||
|
||||
if (!overwriteExistingFacts && contextMemory.facts != null && contextMemory.facts.Count > 0) return;
|
||||
|
||||
StartCoroutine(LoadAndApplyRoutine(saveExists));
|
||||
}
|
||||
|
||||
private IEnumerator LoadAndApplyRoutine(bool saveExists)
|
||||
{
|
||||
string fullPath = Path.Combine(Application.streamingAssetsPath, streamingAssetsRelativePath);
|
||||
string json = null;
|
||||
|
||||
if (fullPath.Contains("://") || fullPath.Contains(":///"))
|
||||
{
|
||||
using (UnityWebRequest req = UnityWebRequest.Get(fullPath))
|
||||
{
|
||||
yield return req.SendWebRequest();
|
||||
if (req.result != UnityWebRequest.Result.Success)
|
||||
{
|
||||
if (debugLogs) Debug.LogWarning($"[PresetFactsInstaller] Failed to read preset from '{fullPath}': {req.error}", this);
|
||||
yield break;
|
||||
}
|
||||
json = req.downloadHandler.text;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
if (debugLogs) Debug.LogWarning($"[PresetFactsInstaller] Preset file not found: {fullPath}", this);
|
||||
yield break;
|
||||
}
|
||||
json = File.ReadAllText(fullPath);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
if (debugLogs) Debug.LogWarning("[PresetFactsInstaller] Preset JSON is empty.", this);
|
||||
yield break;
|
||||
}
|
||||
|
||||
PresetMemoryData data = null;
|
||||
try
|
||||
{
|
||||
data = JsonUtility.FromJson<PresetMemoryData>(json);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
if (debugLogs) Debug.LogWarning($"[PresetFactsInstaller] Preset JSON parse failed: {e.Message}", this);
|
||||
}
|
||||
|
||||
if (data == null || data.facts == null)
|
||||
{
|
||||
if (debugLogs) Debug.LogWarning("[PresetFactsInstaller] Preset JSON has no facts.", this);
|
||||
yield break;
|
||||
}
|
||||
|
||||
contextMemory.facts = data.facts ?? new List<ContextMemoryManager.FactEntry>();
|
||||
contextMemory.messagesSinceLastSummary = Mathf.Max(0, data.counter);
|
||||
contextMemory.UpdateSystemPrompt();
|
||||
|
||||
if (saveExists)
|
||||
{
|
||||
while (Core.SaveSystem.SaveManager.Instance == null)
|
||||
{
|
||||
yield return null;
|
||||
}
|
||||
Core.SaveSystem.SaveManager.Instance.SaveGame();
|
||||
}
|
||||
|
||||
if (debugLogs) Debug.Log($"[PresetFactsInstaller] Applied preset facts: {contextMemory.facts.Count}", this);
|
||||
}
|
||||
|
||||
private bool SaveFileHasKey(string savePath, string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
string json = File.ReadAllText(savePath);
|
||||
if (string.IsNullOrWhiteSpace(json)) return false;
|
||||
SaveDataWrapper wrapper = JsonUtility.FromJson<SaveDataWrapper>(json);
|
||||
if (wrapper == null || wrapper.keys == null) return false;
|
||||
return wrapper.keys.Contains(key);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetPresetFactsInjectionEnabled(bool enabled)
|
||||
{
|
||||
allowPresetFactsInjection = enabled;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3b3dd4b751f54824aa3c7bebe29e80f7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user