Initial Unity project commit

This commit is contained in:
2026-07-08 19:53:15 +08:00
commit 75f254212e
15657 changed files with 11633879 additions and 0 deletions
@@ -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: