Players can hold natural-language conversations with NPCs that have a persona, backed by the Anthropic Messages API (default model claude-haiku-4-5, disabled by default). - Personas come from hand-authored templates in Data/ai-personas.json (matched by exact NPC name or class name walking the inheritance chain) or from per-NPC personas set in game with [SetPersona and persisted through world saves via a GenericPersistence module. - A greeting near a persona NPC, or speaking its name, starts a conversation; farewells, walking away, or two minutes of silence end it. Speech is observed passively through EventSink.Speech, so bank, guard, vendor and pet keywords are unaffected. - Requests are dispatched with async/await: HTTP I/O runs off-thread and continuations marshal back to the game loop through the EventLoopContext, so the game thread never blocks on the network. Replies are dropped if the session ended, the NPC was deleted, or the player disconnected while the request was in flight. - Cost controls: per-player cooldown and per-minute caps, a server-wide concurrent request cap, bounded history and input/output lengths - all configurable under aiConversation.* settings. - Replies are sanitized and split into ~120-char chunks spoken at ~900ms intervals; failures retry once on 429/5xx/timeout, then fall back to a canned in-character line. - Staff commands: [SetPersona, [RemovePersona, [PersonaInfo (GM) and [AIChat status|on|off|reload|end (Admin) with token usage stats. - The API key is only read from an environment variable (ANTHROPIC_API_KEY by default); ships with persona examples, a feature README and unit tests for the pure logic. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GPKWo5V4JXt22nQtFnat41
49 lines
1.3 KiB
C#
49 lines
1.3 KiB
C#
using System.Collections.Generic;
|
|
|
|
namespace Server.Engines.AIConversation;
|
|
|
|
/// <summary>
|
|
/// Bounded per-session message history. Oldest turns are dropped first, and
|
|
/// the head is re-aligned so the first message sent to the API is always a
|
|
/// user turn (the Messages API rejects histories that open with "assistant").
|
|
/// </summary>
|
|
public class ConversationHistory
|
|
{
|
|
private readonly List<ChatTurn> _turns = new();
|
|
private readonly int _maxTurns;
|
|
|
|
public ConversationHistory(int maxTurns) => _maxTurns = maxTurns;
|
|
|
|
public IReadOnlyList<ChatTurn> Turns => _turns;
|
|
|
|
public int Count => _turns.Count;
|
|
|
|
public void Add(ChatRole role, string text)
|
|
{
|
|
_turns.Add(new ChatTurn(role, text));
|
|
Trim();
|
|
}
|
|
|
|
private void Trim()
|
|
{
|
|
if (_turns.Count > _maxTurns)
|
|
{
|
|
_turns.RemoveRange(0, _turns.Count - _maxTurns);
|
|
}
|
|
|
|
var firstUser = 0;
|
|
|
|
while (firstUser < _turns.Count && _turns[firstUser].Role != ChatRole.User)
|
|
{
|
|
firstUser++;
|
|
}
|
|
|
|
if (firstUser > 0)
|
|
{
|
|
_turns.RemoveRange(0, firstUser);
|
|
}
|
|
}
|
|
|
|
/// <summary>Snapshot for handing to a background request.</summary>
|
|
public ChatTurn[] ToArray() => _turns.ToArray();
|
|
}
|